diff --git a/.babelrc b/.babelrc index 7273d21244..20a7bcc4e2 100644 --- a/.babelrc +++ b/.babelrc @@ -1,9 +1,15 @@ { "plugins": [ - "transform-flow-strip-types" + "@babel/plugin-transform-flow-strip-types" ], "presets": [ - "es2015", - "stage-0" - ] + "@babel/preset-typescript", + ["@babel/preset-env", { + "targets": { + "node": "18" + }, + "exclude": ["proposal-dynamic-import"] + }] + ], + "sourceMaps": "inline" } diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..693f3e35b5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +node_modules +npm-debug.log +*.md +Dockerfile +.dockerignore +.gitignore +.travis.yml +.istanbul.yml +.git +.github + +# Build folder +lib/ + +# Tests +spec/ +# Keep local dependencies used to CI tests +!spec/dependencies/ + +# IDEs +.idea/ diff --git a/.flowconfig b/.flowconfig index c13f93b6a4..955444c1c0 100644 --- a/.flowconfig +++ b/.flowconfig @@ -7,3 +7,5 @@ [libs] [options] +suppress_comment= \\(.\\|\n\\)*\\@flow-disable-next +esproposal.optional_chaining=enable diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..23b2493344 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +* text=auto eol=lf + +*.js text +*.html text +*.less text +*.json text +*.css text +*.xml text +*.md text +*.txt text +*.yml text +*.sql text +*.sh text + +*.png binary \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index b6904e95a8..0000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,67 +0,0 @@ -Please read the following instructions carefully. - -Check out https://github.com/ParsePlatform/parse-server/issues/1271 for an ideal bug report. -The closer your issue report is to that one, the more likely we are to be able to help, and the more likely we will be to fix the issue quickly! - -Many members of the community use Stack Overflow and Server Fault to ask questions. -Read through the existing questions or ask your own! -- Stack Overflow: http://stackoverflow.com/questions/tagged/parse.com -- Server Fault: https://serverfault.com/tags/parse - -For database migration help, please file a bug report at https://parse.com/help#report - -Make sure these boxes are checked before submitting your issue -- thanks for reporting issues back to Parse Server! - -- [ ] You've met the prerequisites: https://github.com/ParsePlatform/parse-server/wiki/Parse-Server-Guide#prerequisites. - -- [ ] You're running the latest version of Parse Server: https://github.com/ParsePlatform/parse-server/releases - -- [ ] You've searched through existing issues: https://github.com/ParsePlatform/Parse-Server/issues?utf8=%E2%9C%93&q=is%3Aissue Chances are that your issue has been reported or resolved before. - -- [ ] You have filled out every section below. Issues without sufficient information are more likely to be closed. - --- - -### Issue Description - -[DELETE EVERYTHING ABOVE THIS LINE BEFORE SUBMITTING YOUR ISSUE] - -Describe your issue in as much detail as possible. - -[FILL THIS OUT] - -### Steps to reproduce - -Please include a detailed list of steps that reproduce the issue. Include curl commands when applicable. - -1. [FILL THIS OUT] -2. [FILL THIS OUT] -3. [FILL THIS OUT] - -#### Expected Results - -[FILL THIS OUT] - -#### Actual Outcome - -[FILL THIS OUT] - -### Environment Setup - -- **Server** - - parse-server version: [FILL THIS OUT] - - Operating System: [FILL THIS OUT] - - Hardware: [FILL THIS OUT] - - Localhost or remote server? (AWS, Heroku, Azure, Digital Ocean, etc): [FILL THIS OUT] - -- **Database** - - MongoDB version: [FILL THIS OUT] - - Storage engine: [FILL THIS OUT] - - Hardware: [FILL THIS OUT] - - Localhost or remote server? (AWS, mLab, ObjectRocket, Digital Ocean, etc): [FILL THIS OUT] - -### Logs/Trace - -You can turn on additional logging by configuring VERBOSE=1 in your environment. - -[FILL THIS OUT] diff --git a/.github/ISSUE_TEMPLATE/---1-report-an-issue.md b/.github/ISSUE_TEMPLATE/---1-report-an-issue.md new file mode 100644 index 0000000000..d31ad8bcff --- /dev/null +++ b/.github/ISSUE_TEMPLATE/---1-report-an-issue.md @@ -0,0 +1,46 @@ +--- +name: "\U0001F41B Report an issue" +about: A feature of Parse Server is not working as expected. +title: '' +labels: '' +assignees: '' + +--- + +### New Issue Checklist + +- Report security issues [confidentially](https://github.com/parse-community/parse-server/security/policy). +- Any contribution is under this [license](https://github.com/parse-community/parse-server/blob/alpha/LICENSE). +- Before posting search [existing issues](https://github.com/parse-community/parse-server/issues?q=is%3Aissue). + +### Issue Description + + +### Steps to reproduce + + +### Actual Outcome + + +### Expected Outcome + + +### Environment + + +Server +- Parse Server version: `FILL_THIS_OUT` +- Operating system: `FILL_THIS_OUT` +- Local or remote host (AWS, Azure, Google Cloud, Heroku, Digital Ocean, etc): `FILL_THIS_OUT` + +Database +- System (MongoDB or Postgres): `FILL_THIS_OUT` +- Database version: `FILL_THIS_OUT` +- Local or remote host (MongoDB Atlas, mLab, AWS, Azure, Google Cloud, etc): `FILL_THIS_OUT` + +Client +- SDK (iOS, Android, JavaScript, PHP, Unity, etc): `FILL_THIS_OUT` +- SDK version: `FILL_THIS_OUT` + +### Logs + diff --git a/.github/ISSUE_TEMPLATE/---2-feature-request.md b/.github/ISSUE_TEMPLATE/---2-feature-request.md new file mode 100644 index 0000000000..f5d9fdf370 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/---2-feature-request.md @@ -0,0 +1,29 @@ +--- +name: "\U0001F4A1 Request a feature" +about: Suggest new functionality or an enhancement of existing functionality. +title: '' +labels: '' +assignees: '' + +--- + +### New Feature / Enhancement Checklist + +- Report security issues [confidentially](https://github.com/parse-community/parse-server/security/policy). +- Any contribution is under this [license](https://github.com/parse-community/parse-server/blob/alpha/LICENSE). +- Before posting search [existing issues](https://github.com/parse-community/parse-server/issues?q=is%3Aissue). + +### Current Limitation + + +### Feature / Enhancement Description + + +### Example Use Case + + +### Alternatives / Workarounds + + +### 3rd Party References + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..e5a8c3caa9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: 🙋đŸŊâ€â™€ī¸ Getting help with code + url: https://stackoverflow.com/questions/tagged/parse-platform + about: Get help with code-level questions on Stack Overflow. + - name: 🙋 Getting general help + url: https://community.parseplatform.org + about: Get help with other questions on our Community Forum. diff --git a/.github/MigrationPhases.png b/.github/MigrationPhases.png deleted file mode 100644 index dfaca26604..0000000000 Binary files a/.github/MigrationPhases.png and /dev/null differ diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..ebfc2b23c8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +# Dependabot dependency updates +# Docs: https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "npm" + # Location of package-lock.json + directory: "/" + # Check daily for updates + schedule: + interval: "daily" + commit-message: + # Set commit message prefix + prefix: "refactor" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..4c7cc7c321 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## Pull Request + +- Report security issues [confidentially](https://github.com/parse-community/parse-server/security/policy). +- Any contribution is under this [license](https://github.com/parse-community/parse-server/blob/alpha/LICENSE). + +## Issue + + +## Approach + + +## Tasks + + +- [ ] Add tests +- [ ] Add changes to documentation (guides, repository pages, code comments) +- [ ] Add [security check](https://github.com/parse-community/parse-server/blob/master/CONTRIBUTING.md#security-checks) +- [ ] Add new Parse Error codes to Parse JS SDK diff --git a/.github/workflows/ci-automated-check-environment.yml b/.github/workflows/ci-automated-check-environment.yml new file mode 100644 index 0000000000..937e9d8351 --- /dev/null +++ b/.github/workflows/ci-automated-check-environment.yml @@ -0,0 +1,83 @@ +# This checks whether there are new CI environment versions available, e.g. MongoDB, Node.js; +# a pull request is created if there are any available. + +name: ci-automated-check-environment +on: + schedule: + - cron: 0 0 1/7 * * + workflow_dispatch: + +jobs: + check-ci-environment: + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - name: Checkout default branch + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v2 + with: + node-version: 20 + cache: 'npm' + - name: Install dependencies + run: npm ci + - name: CI Environments Check + run: npm run ci:check + create-pr: + needs: check-ci-environment + if: failure() + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - name: Checkout default branch + uses: actions/checkout@v4 + - name: Compose branch name for PR + id: branch + run: echo "::set-output name=name::ci-bump-environment" + - name: Create branch + run: | + git config --global user.email ${{ github.actor }}@users.noreply.github.com + git config --global user.name ${{ github.actor }} + git checkout -b ${{ steps.branch.outputs.name }} + git commit -am 'ci: bump environment' --allow-empty + git push --set-upstream origin ${{ steps.branch.outputs.name }} + - name: Create or update PR + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const head = '${{ steps.branch.outputs.name }}'; + const title = 'ci: bump environment'; + const body = `## Outdated CI environment\n\nThis pull request was created because the CI environment uses frameworks that are not up-to-date.\nYou can see which frameworks need to be upgraded in the [logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).\n\n*âš ī¸ Use \`Squash and merge\` to merge this pull request.*`; + + // Check for existing open PR + const pulls = await github.rest.pulls.list({ + owner, + repo, + head: `${owner}:${head}`, + state: 'open', + }); + + if (pulls.data.length > 0) { + const prNumber = pulls.data[0].number; + await github.rest.pulls.update({ + owner, + repo, + pull_number: prNumber, + title, + body, + }); + core.info(`Updated PR #${prNumber}`); + } else { + const pr = await github.rest.pulls.create({ + owner, + repo, + title, + body, + head, + base: (await github.rest.repos.get({ owner, repo })).data.default_branch, + }); + core.info(`Created PR #${pr.data.number}`); + } diff --git a/.github/workflows/ci-performance.yml b/.github/workflows/ci-performance.yml new file mode 100644 index 0000000000..47ba3e0f14 --- /dev/null +++ b/.github/workflows/ci-performance.yml @@ -0,0 +1,260 @@ +name: ci-performance +on: + pull_request: + branches: + - alpha + - beta + - release + - 'release-[0-9]+.x.x' + - next-major + paths-ignore: + - '**.md' + - 'docs/**' + +env: + NODE_VERSION: 24.11.0 + MONGODB_VERSION: 8.0.4 + +permissions: + contents: read + +jobs: + performance-check: + name: Benchmarks + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout PR branch (for benchmark script) + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 1 + + - name: Save PR benchmark script + run: | + mkdir -p /tmp/pr-benchmark + cp -r benchmark /tmp/pr-benchmark/ || echo "No benchmark directory" + cp package.json /tmp/pr-benchmark/ || true + + - name: Checkout base branch + uses: actions/checkout@v4 + with: + ref: ${{ github.base_ref }} + fetch-depth: 1 + clean: true + + - name: Restore PR benchmark script + run: | + if [ -d "/tmp/pr-benchmark/benchmark" ]; then + rm -rf benchmark + cp -r /tmp/pr-benchmark/benchmark . + fi + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies (base) + run: npm ci + + - name: Build Parse Server (base) + run: npm run build + + - name: Run baseline benchmarks + id: baseline + env: + NODE_ENV: production + run: | + set -o pipefail + echo "Running baseline benchmarks..." + if [ ! -f "benchmark/performance.js" ]; then + echo "âš ī¸ Benchmark script not found - this is expected for new features" + echo "Skipping baseline benchmark" + echo '[]' > baseline.json + echo "Baseline: N/A (no benchmark script)" > baseline-output.txt + exit 0 + fi + taskset -c 0 npm run benchmark 2>&1 | tee baseline-output.txt || npm run benchmark 2>&1 | tee baseline-output.txt || true + # Extract JSON from output (everything between first [ and last ]) + sed -n '/^\[/,/^\]/p' baseline-output.txt > baseline.json || echo '[]' > baseline.json + continue-on-error: true + + - name: Save baseline results to temp location + run: | + mkdir -p /tmp/benchmark-results + cp baseline.json /tmp/benchmark-results/ || echo '[]' > /tmp/benchmark-results/baseline.json + cp baseline-output.txt /tmp/benchmark-results/ || echo 'No baseline output' > /tmp/benchmark-results/baseline-output.txt + + - name: Upload baseline results + uses: actions/upload-artifact@v4 + with: + name: baseline-benchmark + path: | + /tmp/benchmark-results/baseline.json + /tmp/benchmark-results/baseline-output.txt + retention-days: 7 + + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 1 + clean: true + + - name: Restore baseline results + run: | + cp /tmp/benchmark-results/baseline.json ./ || echo '[]' > baseline.json + cp /tmp/benchmark-results/baseline-output.txt ./ || echo 'No baseline output' > baseline-output.txt + + - name: Setup Node.js (PR) + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies (PR) + run: npm ci + + - name: Build Parse Server (PR) + run: npm run build + + - name: Run PR benchmarks + id: pr-bench + env: + NODE_ENV: production + run: | + set -o pipefail + echo "Running PR benchmarks..." + taskset -c 0 npm run benchmark 2>&1 | tee pr-output.txt || npm run benchmark 2>&1 | tee pr-output.txt || true + # Extract JSON from output (everything between first [ and last ]) + sed -n '/^\[/,/^\]/p' pr-output.txt > pr.json || echo '[]' > pr.json + continue-on-error: true + + - name: Upload PR results + uses: actions/upload-artifact@v4 + with: + name: pr-benchmark + path: | + pr.json + pr-output.txt + retention-days: 7 + + - name: Verify benchmark files exist + run: | + echo "Checking for benchmark result files..." + if [ ! -f baseline.json ] || [ ! -s baseline.json ]; then + echo "âš ī¸ baseline.json is missing or empty, creating empty array" + echo '[]' > baseline.json + fi + if [ ! -f pr.json ] || [ ! -s pr.json ]; then + echo "âš ī¸ pr.json is missing or empty, creating empty array" + echo '[]' > pr.json + fi + echo "baseline.json size: $(wc -c < baseline.json) bytes" + echo "pr.json size: $(wc -c < pr.json) bytes" + + - name: Compare benchmark results + id: compare + run: | + set -o pipefail + node -e " + const fs = require('fs'); + + let baseline, pr; + try { + baseline = JSON.parse(fs.readFileSync('baseline.json', 'utf8')); + pr = JSON.parse(fs.readFileSync('pr.json', 'utf8')); + } catch (e) { + console.log('âš ī¸ Could not parse benchmark results'); + process.exit(0); + } + + // Handle case where baseline doesn't exist (new feature) + if (!Array.isArray(baseline) || baseline.length === 0) { + if (!Array.isArray(pr) || pr.length === 0) { + console.log('âš ī¸ Benchmark results are empty or invalid'); + process.exit(0); + } + console.log('# Performance Benchmark Results\n'); + console.log('> â„šī¸ Baseline not available - this appears to be a new feature\n'); + console.log('| Benchmark | Value | Details |'); + console.log('|-----------|-------|---------|'); + pr.forEach(result => { + console.log(\`| \${result.name} | \${result.value.toFixed(2)} ms | \${result.extra} |\`); + }); + console.log(''); + console.log('✅ **New benchmarks established for this feature.**'); + process.exit(0); + } + + if (!Array.isArray(pr) || pr.length === 0) { + console.log('âš ī¸ PR benchmark results are empty or invalid'); + process.exit(0); + } + + console.log('# Performance Comparison\n'); + console.log('| Benchmark | Baseline | PR | Change | Status |'); + console.log('|-----------|----------|----|---------| ------ |'); + + let hasRegression = false; + let hasImprovement = false; + + baseline.forEach(baseResult => { + const prResult = pr.find(p => p.name === baseResult.name); + if (!prResult) { + console.log(\`| \${baseResult.name} | \${baseResult.value.toFixed(2)} ms | N/A | - | âš ī¸ Missing |\`); + return; + } + + const baseValue = parseFloat(baseResult.value); + const prValue = parseFloat(prResult.value); + const change = ((prValue - baseValue) / baseValue * 100); + const changeStr = change > 0 ? \`+\${change.toFixed(1)}%\` : \`\${change.toFixed(1)}%\`; + + let status = '✅'; + if (change > 50) { + status = '❌ Much Slower'; + hasRegression = true; + } else if (change > 25) { + status = 'âš ī¸ Slower'; + hasRegression = true; + } else if (change < -25) { + status = '🚀 Faster'; + hasImprovement = true; + } + + console.log(\`| \${baseResult.name} | \${baseValue.toFixed(2)} ms | \${prValue.toFixed(2)} ms | \${changeStr} | \${status} |\`); + }); + + console.log(''); + if (hasRegression) { + console.log('âš ī¸ **Performance regressions detected.** Please review the changes.'); + process.exitCode = 1; + } else if (hasImprovement) { + console.log('🚀 **Performance improvements detected!** Great work!'); + } else { + console.log('✅ **No significant performance changes.**'); + } + " | tee comparison.md + + - name: Upload comparison + uses: actions/upload-artifact@v4 + with: + name: benchmark-comparison + path: comparison.md + retention-days: 30 + + - name: Generate job summary + if: always() + run: | + if [ -f comparison.md ]; then + cat comparison.md >> $GITHUB_STEP_SUMMARY + else + echo "âš ī¸ Benchmark comparison not available" >> $GITHUB_STEP_SUMMARY + fi +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..64a113cc88 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,311 @@ +name: ci +on: + push: + branches: [release, alpha, beta, next-major, 'release-[0-9]+.x.x'] + pull_request: + branches: + - '**' + paths-ignore: + - '**/**.md' +env: + NODE_VERSION: 24.11.0 + PARSE_SERVER_TEST_TIMEOUT: 20000 +permissions: + actions: write +jobs: + check-code-analysis: + name: Code Analysis + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: ['javascript'] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + source-root: src + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + check-ci: + name: Node Engine Check + timeout-minutes: 15 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - name: Install prod dependencies + run: npm ci + - name: Remove dev dependencies + run: ./ci/uninstallDevDeps.sh @actions/core + - name: CI Node Engine Check + run: npm run ci:checkNodeEngine + check-lint: + name: Lint + timeout-minutes: 15 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - name: Cache Node.js modules + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}- + - name: Install dependencies + run: npm ci + - run: npm run lint + check-definitions: + name: Check Definitions + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - name: Cache Node.js modules + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}- + - name: Install dependencies + run: npm ci + - name: CI Definitions Check + run: npm run ci:definitionsCheck + check-circular: + name: Circular Dependencies + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - name: Cache Node.js modules + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}- + - name: Install dependencies + run: npm ci + - run: npm run madge:circular + check-docs: + name: Docs + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - name: Cache Node.js modules + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}- + - name: Install dependencies + run: npm ci + - name: Build source + run: npm run build + - name: Generate docs + run: npm run docs + check-docker: + name: Docker Build + timeout-minutes: 15 + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Set up QEMU + id: qemu + uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Build docker image + uses: docker/build-push-action@v3 + with: + context: . + platforms: linux/amd64, linux/arm64/v8 + check-lock-file-version: + name: NPM Lock File Version + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check NPM lock file version + run: | + version=$(node -e "console.log(require('./package-lock.json').lockfileVersion)") + if [ "$version" != "2" ]; then + echo "::error::Expected lockfileVersion 2, got $version" + exit 1 + fi + check-types: + name: Check Types + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: npm ci + - name: Build types + run: npm run build:types + - name: Test Types + run: npm run test:types + check-mongo: + strategy: + matrix: + include: + - name: MongoDB 7, ReplicaSet + MONGODB_VERSION: 7.0.16 + MONGODB_TOPOLOGY: replset + NODE_VERSION: 24.11.0 + - name: MongoDB 8, ReplicaSet + MONGODB_VERSION: 8.0.4 + MONGODB_TOPOLOGY: replset + NODE_VERSION: 24.11.0 + - name: Redis Cache + PARSE_SERVER_TEST_CACHE: redis + MONGODB_VERSION: 8.0.4 + MONGODB_TOPOLOGY: standalone + NODE_VERSION: 24.11.0 + - name: Node 20 + MONGODB_VERSION: 8.0.4 + MONGODB_TOPOLOGY: standalone + NODE_VERSION: 20.19.0 + - name: Node 22 + MONGODB_VERSION: 8.0.4 + MONGODB_TOPOLOGY: standalone + NODE_VERSION: 22.12.0 + fail-fast: false + name: ${{ matrix.name }} + timeout-minutes: 20 + runs-on: ubuntu-latest + services: + redis: + image: redis + ports: + - 6379:6379 + env: + MONGODB_VERSION: ${{ matrix.MONGODB_VERSION }} + MONGODB_TOPOLOGY: ${{ matrix.MONGODB_TOPOLOGY }} + MONGODB_STORAGE_ENGINE: ${{ matrix.MONGODB_STORAGE_ENGINE }} + PARSE_SERVER_TEST_CACHE: ${{ matrix.PARSE_SERVER_TEST_CACHE }} + NODE_VERSION: ${{ matrix.NODE_VERSION }} + steps: + - name: Fix usage of insecure GitHub protocol + run: sudo git config --system url."https://github".insteadOf "git://github" + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.NODE_VERSION }} + - name: Cache Node.js modules + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}- + - name: Install dependencies + run: npm ci + - run: npm run coverage:mongodb + env: + CI: true + - name: Upload code coverage + uses: codecov/codecov-action@v4 + with: + # Set to `true` once codecov token bug is fixed; https://github.com/parse-community/parse-server/issues/9129 + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} + check-postgres: + strategy: + matrix: + include: + - name: PostgreSQL 16, PostGIS 3.5 + POSTGRES_IMAGE: postgis/postgis:16-3.5 + NODE_VERSION: 24.11.0 + - name: PostgreSQL 17, PostGIS 3.5 + POSTGRES_IMAGE: postgis/postgis:17-3.5 + NODE_VERSION: 24.11.0 + - name: PostgreSQL 18, PostGIS 3.6 + POSTGRES_IMAGE: postgis/postgis:18-3.6 + NODE_VERSION: 24.11.0 + fail-fast: false + name: ${{ matrix.name }} + timeout-minutes: 20 + runs-on: ubuntu-latest + services: + redis: + image: redis + ports: + - 6379:6379 + postgres: + image: ${{ matrix.POSTGRES_IMAGE }} + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + PARSE_SERVER_TEST_DB: postgres + PARSE_SERVER_TEST_DATABASE_URI: postgres://postgres:postgres@localhost:5432/parse_server_postgres_adapter_test_database + NODE_VERSION: ${{ matrix.NODE_VERSION }} + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.NODE_VERSION }} + - name: Cache Node.js modules + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-${{ matrix.NODE_VERSION }}- + - name: Install dependencies + run: npm ci + - run: | + bash scripts/before_script_postgres_conf.sh + bash scripts/before_script_postgres.sh + - run: npm run coverage + env: + CI: true + - name: Upload code coverage + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true diff --git a/.github/workflows/release-automated.yml b/.github/workflows/release-automated.yml new file mode 100644 index 0000000000..58bcc400be --- /dev/null +++ b/.github/workflows/release-automated.yml @@ -0,0 +1,126 @@ +name: release-automated +on: + push: + branches: [ release, alpha, beta, next-major, 'release-[0-9]+.x.x' ] +jobs: + release: + runs-on: ubuntu-latest + outputs: + current_tag: ${{ steps.tag.outputs.current_tag }} + trigger_branch: ${{ steps.branch.outputs.trigger_branch }} + steps: + - name: Determine trigger branch name + id: branch + run: echo "::set-output name=trigger_branch::${GITHUB_REF#refs/*/}" + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org/ + - name: Cache Node.js modules + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + - run: npm ci + - run: npx semantic-release + env: + GH_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Determine tag on current commit + id: tag + run: echo "::set-output name=current_tag::$(git describe --tags --abbrev=0 --exact-match || echo '')" + + docker: + needs: release + if: needs.release.outputs.current_tag != '' + env: + REGISTRY: docker.io + IMAGE_NAME: parseplatform/parse-server + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Determine branch name + id: branch + run: echo "::set-output name=branch_name::${GITHUB_REF#refs/*/}" + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ needs.release.outputs.current_tag }} + - name: Set up QEMU + id: qemu + uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Log into Docker Hub + if: github.event_name != 'pull_request' + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + flavor: | + latest=${{ steps.branch.outputs.branch_name == 'release' }} + tags: | + type=semver,pattern={{version}},value=${{ needs.release.outputs.current_tag }} + - name: Build and push Docker image + uses: docker/build-push-action@v3 + with: + context: . + platforms: linux/amd64, linux/arm64/v8 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + docs: + needs: release + if: needs.release.outputs.current_tag != '' && github.ref == 'refs/heads/release' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: 18.20.4 + - name: Cache Node.js modules + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + - name: Generate Docs + run: | + echo $SOURCE_TAG + npm ci + ./release_docs.sh + env: + SOURCE_TAG: ${{ needs.release.outputs.current_tag }} + - name: Configure Pages + uses: actions/configure-pages@v5 + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v4 + with: + path: ./docs + - name: Deploy to GitHub Pages + id: deploy + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release-manual-docker.yml b/.github/workflows/release-manual-docker.yml new file mode 100644 index 0000000000..3b7ee8c0ab --- /dev/null +++ b/.github/workflows/release-manual-docker.yml @@ -0,0 +1,57 @@ +# Trigger this workflow only to manually create a Docker release; this should only be used +# in extraordinary circumstances, as Docker releases are normally created automatically as +# part of the automated release workflow. + +name: release-manual-docker +on: + workflow_dispatch: + inputs: + ref: + default: '' + description: 'Reference (tag / SHA):' +env: + REGISTRY: docker.io + IMAGE_NAME: parseplatform/parse-server +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Determine branch name + id: branch + run: echo "::set-output name=branch_name::${GITHUB_REF#refs/*/}" + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.ref }} + - name: Set up QEMU + id: qemu + uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Log into Docker Hub + if: github.event_name != 'pull_request' + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + flavor: | + latest=${{ steps.branch.outputs.branch_name == 'release' && github.event.inputs.ref == '' }} + tags: | + type=semver,enable=true,pattern={{version}},value=${{ github.event.inputs.ref }} + type=raw,enable=${{ github.event.inputs.ref == '' }},value=latest + - name: Build and push Docker image + uses: docker/build-push-action@v3 + with: + context: . + platforms: linux/amd64, linux/arm64/v8 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/release-manual-docs.yml b/.github/workflows/release-manual-docs.yml new file mode 100644 index 0000000000..e9141a93f6 --- /dev/null +++ b/.github/workflows/release-manual-docs.yml @@ -0,0 +1,55 @@ +# Trigger this workflow only to manually create a docs release; this should only be used +# in extraordinary circumstances, as docs releases are normally created automatically as +# part of the automated release workflow. + +name: release-manual-docs +on: + workflow_dispatch: + inputs: + ref: + default: '' + description: 'Reference (tag / SHA):' + required: true +jobs: + docs: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.ref }} + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: 18.20.4 + - name: Cache Node.js modules + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + - name: Generate Docs + run: | + echo $SOURCE_TAG + npm ci + ./release_docs.sh + env: + SOURCE_TAG: ${{ github.event.inputs.ref }} + - name: Configure Pages + uses: actions/configure-pages@v5 + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v4 + with: + path: ./docs + - name: Deploy to GitHub Pages + id: deploy + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release-prepare-monthly.yml b/.github/workflows/release-prepare-monthly.yml new file mode 100644 index 0000000000..948991d36b --- /dev/null +++ b/.github/workflows/release-prepare-monthly.yml @@ -0,0 +1,70 @@ +name: release-prepare-monthly +on: + schedule: + # Runs at midnight UTC on the 1st of every month + - cron: '0 0 1 * *' + workflow_dispatch: +jobs: + create-release-pr: + runs-on: ubuntu-latest + steps: + - name: Check if running on the original repository + run: | + if [ "$GITHUB_REPOSITORY_OWNER" != "parse-community" ]; then + echo "This is a forked repository. Exiting." + exit 1 + fi + - name: Checkout working branch + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Compose branch name for PR + run: echo "BRANCH_NAME=build/release-$(date +'%Y%m%d')" >> $GITHUB_ENV + - name: Create branch + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "GitHub Actions" + git checkout -b ${{ env.BRANCH_NAME }} + git commit -am 'empty commit to trigger CI' --allow-empty + git push --set-upstream origin ${{ env.BRANCH_NAME }} + - name: Create or update PR + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.RELEASE_GITHUB_TOKEN }} + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const head = '${{ env.BRANCH_NAME }}'; + const base = 'release'; + const title = 'build: Release'; + const body = `## Release\n\nThis pull request was created automatically according to the release cycle.\n\n> [!WARNING]\n> Only use \`Merge Commit\` to merge this pull request. Do not use \`Rebase and Merge\` or \`Squash and Merge\`.`; + + // Check for existing open PR + const pulls = await github.rest.pulls.list({ + owner, + repo, + head: `${owner}:${head}`, + state: 'open', + }); + + if (pulls.data.length > 0) { + const prNumber = pulls.data[0].number; + await github.rest.pulls.update({ + owner, + repo, + pull_number: prNumber, + title, + body, + }); + core.info(`Updated PR #${prNumber}`); + } else { + const pr = await github.rest.pulls.create({ + owner, + repo, + title, + body, + head, + base, + }); + core.info(`Created PR #${pr.data.number}`); + } diff --git a/.gitignore b/.gitignore index 4415a5c02c..b5b69e3891 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,11 @@ lib-cov # Coverage directory used by tools like istanbul coverage +.nyc_output + +# docs output +out +docs # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt @@ -23,6 +28,9 @@ coverage # Compiled binary addons (http://nodejs.org/api/addons.html) build/Release +# build folder for automated releases +latest + # Dependency directory # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git node_modules @@ -38,12 +46,21 @@ node_modules # Babel.js lib/ +# types/* once we have full typescript support, we can generate types from the typescript files +!types/tsconfig.json # cache folder .cache +.eslintcache # Mac DS_Store files .DS_Store # Folder created by FileSystemAdapter /files + +# Redis Dump +dump.rdb + +# AI agents +.claude diff --git a/.istanbul.yml b/.istanbul.yml deleted file mode 100644 index 8bb4ab0d88..0000000000 --- a/.istanbul.yml +++ /dev/null @@ -1,2 +0,0 @@ -instrumentation: - excludes: ["**/spec/**", "**/PostgresStorageAdapter.js"] diff --git a/.madgerc b/.madgerc new file mode 100644 index 0000000000..4b9aa24bc2 --- /dev/null +++ b/.madgerc @@ -0,0 +1,10 @@ +{ + "detectiveOptions": { + "ts": { + "skipTypeImports": true + }, + "es6": { + "skipTypeImports": true + } + } +} diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000000..36526782d6 --- /dev/null +++ b/.npmignore @@ -0,0 +1,2 @@ +types/tests.ts +types/eslint.config.mjs diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000000..9075659573 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20.15.0 diff --git a/.nycrc b/.nycrc new file mode 100644 index 0000000000..82a1fc5f1e --- /dev/null +++ b/.nycrc @@ -0,0 +1,10 @@ +{ + "reporter": [ + "lcov", + "text-summary" + ], + "exclude": [ + "**/spec/**" + ] +} + diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000000..31fa426fac --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +semi: true +trailingComma: "es5" +singleQuote: true +arrowParens: "avoid" +printWidth: 100 \ No newline at end of file diff --git a/.releaserc.js b/.releaserc.js new file mode 100644 index 0000000000..49ace56f8d --- /dev/null +++ b/.releaserc.js @@ -0,0 +1,131 @@ +/** + * Semantic Release Config + */ + +const { readFile } = require('fs').promises; +const { resolve } = require('path'); + +// For ES6 modules use: +// import { readFile } from 'fs/promises'; +// import { resolve, dirname } from 'path'; +// import { fileURLToPath } from 'url'; + +// Get env vars +const ref = process.env.GITHUB_REF; +const serverUrl = process.env.GITHUB_SERVER_URL; +const repository = process.env.GITHUB_REPOSITORY; +const repositoryUrl = serverUrl + '/' + repository; + +// Declare params +const resourcePath = './.releaserc/'; +const templates = { + main: { file: 'template.hbs', text: undefined }, + header: { file: 'header.hbs', text: undefined }, + commit: { file: 'commit.hbs', text: undefined }, + footer: { file: 'footer.hbs', text: undefined }, +}; + +// Declare semantic config +async function config() { + + // Get branch + const branch = ref?.split('/')?.pop()?.split('-')[0] || '(current branch could not be determined)'; + // eslint-disable-next-line no-console + console.log(`Running on branch: ${branch}`); + + // Set changelog file + const changelogFileSuffix = branch.match(/release-\d+\.x\.x/) ? 'release' : branch; + const changelogFile = `./changelogs/CHANGELOG_${changelogFileSuffix}.md`; + // eslint-disable-next-line no-console + console.log(`Changelog file output to: ${changelogFile}`); + + // Load template file contents + await loadTemplates(); + + const config = { + branches: [ + 'release', + { name: 'alpha', prerelease: true }, + // { name: 'beta', prerelease: true }, + // Long-Term-Support branch + { name: 'release-9.x.x', range: '9.x.x', channel: '9.x.x' }, + ], + dryRun: false, + debug: true, + ci: true, + tagFormat: '${version}', + plugins: [ + ['@semantic-release/commit-analyzer', { + preset: 'angular', + releaseRules: [ + { type: 'docs', scope: 'README', release: 'patch' }, + { scope: 'no-release', release: false }, + ], + parserOpts: { + noteKeywords: ['BREAKING CHANGE'], + }, + }], + ['@semantic-release/release-notes-generator', { + preset: 'angular', + parserOpts: { + noteKeywords: ['BREAKING CHANGE'] + }, + writerOpts: { + commitsSort: ['subject', 'scope'], + mainTemplate: templates.main.text, + headerPartial: templates.header.text, + commitPartial: templates.commit.text, + footerPartial: templates.footer.text, + }, + }], + ['@semantic-release/changelog', { + 'changelogFile': changelogFile, + }], + ['@semantic-release/npm', { + 'npmPublish': true, + }], + ['@semantic-release/git', { + assets: [changelogFile, 'package.json', 'package-lock.json', 'npm-shrinkwrap.json'], + }], + ['@semantic-release/github', { + successComment: getReleaseComment(), + labels: ['type:ci'], + releasedLabels: ['state:released<%= nextRelease.channel ? `-\${nextRelease.channel}` : "" %>'] + }], + // Back-merge module runs last because if it fails it should not impede the release process + [ + "@saithodev/semantic-release-backmerge", + { + "backmergeBranches": [ + // { from: 'beta', to: 'alpha' }, + // { from: 'release', to: 'beta' }, + { from: 'release', to: 'alpha' }, + ] + } + ], + ], + }; + + return config; +} + +async function loadTemplates() { + for (const template of Object.keys(templates)) { + + // For ES6 modules use: + // const fileUrl = import.meta.url; + // const __dirname = dirname(fileURLToPath(fileUrl)); + + const filePath = resolve(__dirname, resourcePath, templates[template].file); + const text = await readFile(filePath, 'utf-8'); + templates[template].text = text; + } +} + +function getReleaseComment() { + const url = repositoryUrl + '/releases/tag/${nextRelease.gitTag}'; + const comment = '🎉 This change has been released in version [${nextRelease.version}](' + url + ')'; + return comment; +} + +module.exports = config(); diff --git a/.releaserc/commit.hbs b/.releaserc/commit.hbs new file mode 100644 index 0000000000..e10a0d9012 --- /dev/null +++ b/.releaserc/commit.hbs @@ -0,0 +1,61 @@ +*{{#if scope}} **{{scope}}:** +{{~/if}} {{#if subject}} + {{~subject}} +{{~else}} + {{~header}} +{{~/if}} + +{{~!-- commit link --}} {{#if @root.linkReferences~}} + ([{{shortHash}}]( + {{~#if @root.repository}} + {{~#if @root.host}} + {{~@root.host}}/ + {{~/if}} + {{~#if @root.owner}} + {{~@root.owner}}/ + {{~/if}} + {{~@root.repository}} + {{~else}} + {{~@root.repoUrl}} + {{~/if}}/ + {{~@root.commit}}/{{hash}})) +{{~else}} + {{~shortHash}} +{{~/if}} + +{{~!-- commit references --}} +{{~#if references~}} + , closes + {{~#each references}} {{#if @root.linkReferences~}} + [ + {{~#if this.owner}} + {{~this.owner}}/ + {{~/if}} + {{~this.repository}}#{{this.issue}}]( + {{~#if @root.repository}} + {{~#if @root.host}} + {{~@root.host}}/ + {{~/if}} + {{~#if this.repository}} + {{~#if this.owner}} + {{~this.owner}}/ + {{~/if}} + {{~this.repository}} + {{~else}} + {{~#if @root.owner}} + {{~@root.owner}}/ + {{~/if}} + {{~@root.repository}} + {{~/if}} + {{~else}} + {{~@root.repoUrl}} + {{~/if}}/ + {{~@root.issue}}/{{this.issue}}) + {{~else}} + {{~#if this.owner}} + {{~this.owner}}/ + {{~/if}} + {{~this.repository}}#{{this.issue}} + {{~/if}}{{/each}} +{{~/if}} + diff --git a/.releaserc/footer.hbs b/.releaserc/footer.hbs new file mode 100644 index 0000000000..575df456e5 --- /dev/null +++ b/.releaserc/footer.hbs @@ -0,0 +1,11 @@ +{{#if noteGroups}} +{{#each noteGroups}} + +### {{title}} + +{{#each notes}} +* {{#if commit.scope}}**{{commit.scope}}:** {{/if}}{{text}} ([{{commit.shortHash}}]({{commit.shortHash}})) +{{/each}} +{{/each}} + +{{/if}} diff --git a/.releaserc/header.hbs b/.releaserc/header.hbs new file mode 100644 index 0000000000..fc781c4b51 --- /dev/null +++ b/.releaserc/header.hbs @@ -0,0 +1,25 @@ +{{#if isPatch~}} + ## +{{~else~}} + # +{{~/if}} {{#if @root.linkCompare~}} + [{{version}}]( + {{~#if @root.repository~}} + {{~#if @root.host}} + {{~@root.host}}/ + {{~/if}} + {{~#if @root.owner}} + {{~@root.owner}}/ + {{~/if}} + {{~@root.repository}} + {{~else}} + {{~@root.repoUrl}} + {{~/if~}} + /compare/{{previousTag}}...{{currentTag}}) +{{~else}} + {{~version}} +{{~/if}} +{{~#if title}} "{{title}}" +{{~/if}} +{{~#if date}} ({{date}}) +{{/if}} diff --git a/.releaserc/template.hbs b/.releaserc/template.hbs new file mode 100644 index 0000000000..63610bdcb7 --- /dev/null +++ b/.releaserc/template.hbs @@ -0,0 +1,14 @@ +{{> header}} + +{{#each commitGroups}} + +{{#if title}} +### {{title}} + +{{/if}} +{{#each commits}} +{{> commit root=@root}} +{{/each}} +{{/each}} + +{{> footer}} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ee210a2a84..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,40 +0,0 @@ -language: node_js -node_js: -- '4.3' -- '6.1' -services: - - postgresql -addons: - postgresql: '9.4' -before_script: - - ls -al "$HOME/.mongodb/versions" - - psql -c 'create database parse_server_postgres_adapter_test_database;' -U postgres -env: - global: - - COVERAGE_OPTION='./node_modules/.bin/istanbul cover' - matrix: - - PARSE_SERVER_TEST_DB=postgres - - MONGODB_VERSION=2.6.11 - - MONGODB_VERSION=3.0.8 - - MONGODB_VERSION=3.2.6 -matrix: - fast_finish: true, -branches: - only: - - master - - /^[0-9]+.[0-9]+.[0-9]+(-.*)?$/ -cache: - directories: - - "$HOME/.mongodb/versions" -after_script: "./node_modules/.bin/codecov" -deploy: - provider: npm - email: - secure: T3LwSJFcu632SDfCoavcKL39NktWLEOOFzonAjeHAglmMyDp9hvU8xHwQ4JJy2CRA03c+ezAe2gH3Va+jfxvh1JtFUan+p88vaCHHKuNwPSp4WZBPt1ZTDmG+ACG6j+ZWHK+eP7hLvtlCi/M9/v4/bVojpm7v01LLiM+MRhnE9E7eLemQC4zb6RVtf2oGQ3mX51kMeya218kEm5bsQPpRJElI9jwletFa8qYK5AFgaCHpll059RRHQTTE9MTNcN58P/Kf6Qb3KGpKNoaHTXoOD4U6LcXLWvdHONVB6AzGefxg2b/dvtaO4nd5lDooMBR3u9kWpopXkyAuf+fO/8je9JTxO6CDUtDjHRcR2mCEXWX6rculIAbNXOv1ccRYCTQ8aFtYnFlMSb1+QIAdyT6HHmeT5ktk3+XJRuEv0PJJvqHTo+j7tPngjiv5sPNutgGFlOFO90omTbzEkIT/D/LsgXbWm2QKYWejbLiuSINg+FiFiZN90doCf4aCpm2y1Q/9GzvK+eXcpqzPCGxOykE7EuPZK3+S/ZU2oaWL2uSpbKPtC0qxebrneR307aVEa7C85HCkTMMBzE4tjUr8h5HhLqtWvqmDcnBo3PfQhf9cEO6jQLd3qhEVJmcwKz/yOCfgPXlCbRqiKk7j94perpNCIalXtMI9ySYjJNjHsB4600= - api_key: - secure: WLjhHVAdUkDy6UdNWveTpZqphw9olN0alCpyPpU0cJjlN/hk7YxPP+YHOSVnMZaRZoHM0LL11jPKLf970nymBfvJHDWxKk71c/5xyTX8pBtSxmGmGy23a5g2VrHVMd2JxsI7NEAH500tlFz+01t4E3Steo0NpAkHR3Q51kj01gQy8IumsksfhRc9uvjZ7zjm99Yk4L+cxyei79v4esfpx7Bgm0HTfAAAG/19F+r0hvvFiV517SREDU/YtcX/rIjVepEy1uiLKEohnmYtVIRSA3Hjh6VlHVhdj2WDd/dYrxi/Ioysq2zOM9pZVLamO9asB1e6JrTEipWE9jSZXxsbnfFzuNLxcOjKv1wd3OzQKF/7pGKGiWRTv5Xm19D/FrXoE/ULs6bHcC/Ke8Gs2RxPlOCdvAFehCkyf5P4HOPCQdR7o8Yiuvt+5JWKBflElNbSd4nWgwUOo3Yv8vC4Vj53fwmM+Uqfu3IgYFWktYxCm4RWIKMfB/gtmjcF6QYFfjvEMwAvRfBV81kuynCsnubXWzQeBE/b3JOhBfpGciKCjKfy+tS6bZfFjCtQV98hMMiCPre8Y7PahHDYc65wU9Ake+ZE+dDaSbeV3DZ5JeifLJHzKW2J2dWeRANkOaiSwO9VBC9/rht5ulK5qQ1pB2+sKDToAaiKy6RMlB/HgIoYqsU= - on: - tags: true - all_branches: true - condition: "$MONGODB_VERSION = '3.0.8'" - repo: ParsePlatform/parse-server diff --git a/2.3.0.md b/2.3.0.md deleted file mode 100644 index fe58835498..0000000000 --- a/2.3.0.md +++ /dev/null @@ -1,82 +0,0 @@ -# Upgrading Parse Server to version 2.3.0 - -Parse Server version 2.3.0 begins using unique indexes to ensure User's username and email are unique. This is not a backwards incompatable change, but it may in some cases cause a significant performance regression until the index finishes building. Building the unique index before upgrading your Parse Server version will eliminate the performance impact, and is a recommended step before upgrading any app to Parse Server 2.3.0. New apps starting with version 2.3.0 do not need to take any steps before beginning their project. - -If you are using MongoDB in Cluster or Replica Set mode, we recommend reading Mongo's [documentation on index building](https://docs.mongodb.com/v3.0/tutorial/build-indexes-on-replica-sets/) first. If you are not using these features, you can execute the following commands from the Mongo shell to build the unique index. You may also want to create a backup first. - -```js -// Select the database that your Parse App uses -use parse; - -// Select the collection your Parse App uses for users. For migrated apps, this probably includes a collectionPrefix. -var coll = db['your_prefix:_User']; - -// You can check if the indexes already exists by running coll.getIndexes() -coll.getIndexes(); - -// The indexes you want should look like this. If they already exist, you can skip creating them. -{ - "v" : 1, - "unique" : true, - "key" : { - "username" : 1 - }, - "name" : "username_1", - "ns" : "parse.your_prefix:_User", - "background" : true, - "sparse" : true -} - -{ - "v" : 1, - "unique" : true, - "key" : { - "email" : 1 - }, - "name" : "email_1", - "ns" : "parse.your_prefix:_User", - "background" : true, - "sparse" : true -} - -// Create the username index. -// "background: true" is mandatory and avoids downtime while the index builds. -// "sparse: true" is also mandatory because Parse Server uses sparse indexes. -coll.ensureIndex({ username: 1 }, { background: true, unique: true, sparse: true }); - -// Create the email index. -// "background: true" is still mandatory. -// "sparse: true" is also mandatory both because Parse Server uses sparse indexes, and because email addresses are not required by the Parse API. -coll.ensureIndex({ email: 1 }, { background: true, unique: true, sparse: true }); -``` - -There are some issues you may run into during this process: - -## Mongo complains that the index already exists, but with different options - -In this case, you will need to remove the incorrect index. If your app relies on the existence of the index in order to be performant, you can create a new index, with "-1" for the direction of the field, so that it counts as different options. Then, drop the conflicting index, and create the unique index. - -## There is already non-unique data in the username or email field - -This is possible if you have explicitly set some user's emails to null. If this is bogus data, and those null fields shoud be unset, you can unset the null emails with this command. If your app relies on the difference between null and unset emails, you will need to upgrade your app to treat null and unset emails the same before building the index and upgrading to Parse Server 2.3.0. - -```js -coll.update({ email: { $exists: true, $eq: null } }, { $unset: { email: '' } }, { multi: true }) -``` - -## There is already non-unique data in the username or email field, and it's not nulls - -This is possible due to a race condition in previous versions of Parse Server. If you have this problem, it is unlikely that you have a lot of rows with duplicate data. We recommend you clean up the data manually, by removing or modifying the offending rows. - -This command, can be used to find the duplicate data: - -```js -coll.aggregate([ - {$match: {"username": {"$ne": null}}}, - {$group: {_id: "$username", uniqueIds: {$addToSet: "$_id"}, count: {$sum: 1}}}, - {$match: {count: {"$gt": 1}}}, - {$project: {id: "$uniqueIds", username: "$_id", _id : 0} }, - {$unwind: "$id" }, - {$out: '_duplicates'} // Save the list of duplicates to a new, "_duplicates" collection. Remove this line to just output the list. -], {allowDiskUse:true}) -``` diff --git a/6.0.0.md b/6.0.0.md new file mode 100644 index 0000000000..780be79955 --- /dev/null +++ b/6.0.0.md @@ -0,0 +1,78 @@ +# Parse Server 6 Migration Guide + +This document only highlights specific changes that require a longer explanation. For a full list of changes in Parse Server 6 please refer to the [changelog](https://github.com/parse-community/parse-server/blob/alpha/CHANGELOG.md). + +--- + +- [Incompatible git protocol with Node 14](#incompatible-git-protocol-with-node-14) +- [Import Statement](#import-statement) +- [Asynchronous Initialization](#asynchronous-initialization) + +--- + +## Incompatible git protocol with Node 14 + +Parse Server 6 uses the Node Package Manger (npm) package lock file version 2. While version 2 is supposed to be backwards compatible with version 1, you may still encounter errors due to incompatible git protocols that cannot be interpreted correctly by npm bundled with Node 14. + +If you are encountering issues installing Parse Server on Node 14 because of dependency references in the package lock file using the `ssh` protocol, configure git to use the `https` protocol instead: + +``` +sudo git config --system url."https://github".insteadOf "ssh://git@github" +``` + +Alternatively you could manually replace the dependency URLs in the package lock file. + +âš ī¸ You could also delete the package lock file and recreate it with Node 14. Keep in mind that doing so you are not using an official version of Parse Server anymore. You may be using dependencies that have not been tested as part of the Parse Server release process. + +## Import Statement + +The import and initialization syntax has been simplified with more intuitive naming and structure. + +*Parse Server 5:* +```js +// Returns a Parse Server instance +const ParseServer = require('parse-server'); + +// Returns a Parse Server express middleware +const { ParseServer } = require('parse-server'); +``` + +*Parse Server 6:* +```js +// Both return a Parse Server instance +const ParseServer = require('parse-server'); +const { ParseServer } = require('parse-server'); +``` + +To get the express middleware in Parse Server 6, configure the Parse Server instance, start Parse Server and use its `app` property. See [Asynchronous Initialization](#asynchronous-initialization) for more details. + +## Asynchronous Initialization + +Previously, it was possible to mount Parse Server before it was fully started up and ready to receive requests. This could result in undefined behavior, such as Parse Objects could be saved before Cloud Code was registered. To prevent this, Parse Server 6 requires to be started asynchronously before being mounted. + +*Parse Server 5:* +```js +// 1. Import Parse Server +const { ParseServer } = require('parse-server'); + +// 2. Create a Parse Server instance as express middleware +const server = new ParseServer(config); + +// 3. Mount express middleware +app.use("/parse", server); +``` + +*Parse Server 6:* +```js +// 1. Import Parse Server +const ParseServer = require('parse-server'); + +// 2. Create a Parse Server instance +const server = new ParseServer(config); + +// 3. Start up Parse Server asynchronously +await server.start(); + +// 4. Mount express middleware +app.use("/parse", server.app); +``` diff --git a/8.0.0.md b/8.0.0.md new file mode 100644 index 0000000000..ab41c0cf29 --- /dev/null +++ b/8.0.0.md @@ -0,0 +1,44 @@ +# Parse Server 8 Migration Guide + +This document only highlights specific changes that require a longer explanation. For a full list of changes in Parse Server 8 please refer to the [changelog](https://github.com/parse-community/parse-server/blob/alpha/CHANGELOG.md). + +--- + +- [Email Verification](#email-verification) +- [Database Indexes](#database-indexes) + +--- + +## Email Verification + +In order to remove sensitive information (PII) from technical logs, the `Parse.User.username` field has been removed from the email verification process. This means the username will no longer be used and the already existing verification token, that is internal to Parse Server and associated with the user, will be used instead. This makes use of the fact that an expired verification token is not deleted from the database by Parse Server, despite being expired, and can therefore be used to identify a user. + +This change affects how verification emails with expired tokens are handled. When opening a verification link that contains an expired token, the page that the user is redirected to will no longer provide the `username` as a URL query parameter. Instead, the URL query parameter `token` will be provided. + +The request to re-send a verification email changed to sending a `POST` request to the endpoint `/resend_verification_email` with `token` in the body, instead of `username`. If you have customized the HTML pages for email verification either for the `PagesRouter` in `/public/` or the deprecated `PublicAPIRouter` in `/public_html/`, you need to adapt the form request in your custom pages. See the example pages in these aforementioned directories for how the forms must be set up. + +> [!WARNING] +> An expired verification token is not automatically deleted from the database by Parse Server even though it has expired. If you have implemented a custom clean-up logic that removes expired tokens, this will break the form request to re-send a verification email as the expired token won't be found and cannot be associated with any user. In that case you'll have to implement your custom process to re-send a verification email. + +> [!IMPORTANT] +> Parse Server does not keep a history of verification tokens but only stores the most recently generated verification token in the database. Every time Parse Server generates a new verification token, the currently stored token is replaced. If a user opens a link with an expired token, and that token has already been replaced in the database, Parse Server cannot associate the expired token with any user. In this case, another way has to be offered to the user to re-send a verification email. To mitigate this issue, set the Parse Server option `emailVerifyTokenReuseIfValid: true` and set `emailVerifyTokenValidityDuration` to a longer duration, which ensures that the currently stored verification token is not replaced too soon. + +Related pull request: + +- https://github.com/parse-community/parse-server/pull/8488 + +## Database Indexes + +As part of the email verification and password reset improvements in Parse Server 8, the queries used for these operations have changed to use tokens instead of username/email fields. To ensure optimal query performance, Parse Server now automatically creates indexes on the following fields during server initialization: + +- `_User._email_verify_token`: used for email verification queries +- `_User._perishable_token`: used for password reset queries + +These indexes are created automatically when Parse Server starts, similar to how indexes for `username` and `email` fields are created. No manual intervention is required. + +> [!WARNING] +> If you have a large existing user base, the index creation may take some time during the first server startup after upgrading to Parse Server 8. The server logs will indicate when index creation is complete or if any errors occur. If you have any concerns regarding a potential database performance impact during index creation, you could create these indexes manually in a controlled procedure before upgrading Parse Server. + +Related pull request: + +- https://github.com/parse-community/parse-server/pull/9893 diff --git a/9.0.0.md b/9.0.0.md new file mode 100644 index 0000000000..80ba6cf0a8 --- /dev/null +++ b/9.0.0.md @@ -0,0 +1,56 @@ +# Parse Server 9 Migration Guide + +This document only highlights specific changes that require a longer explanation. For a full list of changes in Parse Server 9 please refer to the [changelog](https://github.com/parse-community/parse-server/blob/alpha/CHANGELOG.md). + +--- +- [Route Path Syntax and Rate Limiting](#route-path-syntax-and-rate-limiting) +--- + +## Route Path Syntax and Rate Limiting +Parse Server 9 standardizes the route pattern syntax across cloud routes and rate-limiting to use the new **path-to-regexp v8** style. This update introduces validation and a clear deprecation error for the old wildcard route syntax. + +### Key Changes +- **Standardization**: All route paths now use the path-to-regexp v8 syntax, which provides better consistency and security. +- **Validation**: Added validation to ensure route paths conform to the new syntax. +- **Deprecation**: Old wildcard route syntax is deprecated and will trigger a clear error message. + +### Migration Steps + +#### Path Syntax Examples + +Update your rate limit configurations to use the new path-to-regexp v8 syntax: + +| Old Syntax (deprecated) | New Syntax (v8) | +|------------------------|-----------------| +| `/functions/*` | `/functions/*path` | +| `/classes/*` | `/classes/*path` | +| `/*` | `/*path` | +| `*` | `*path` | + +**Before:** +```javascript +rateLimit: { + requestPath: '/functions/*', + requestTimeWindow: 10000, + requestCount: 100 +} +``` + +**After:** +```javascript +rateLimit: { + requestPath: '/functions/*path', + requestTimeWindow: 10000, + requestCount: 100 +} +``` + +- Review your custom cloud routes and ensure they use the new path-to-regexp v8 syntax. +- Update any rate-limiting configurations to use the new route path format. +- Test your application to ensure all routes work as expected with the new syntax. + +> [!Note] +> Consult the [path-to-regexp v8 docs](https://github.com/pillarjs/path-to-regexp) and the [Express 5 migration guide](https://expressjs.com/en/guide/migrating-5.html#path-syntax) for more details on the new path syntax. + +### Related Pull Request +- [#9942](https://github.com/parse-community/parse-server/pull/9942) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71fde16fd1..867c8162fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,417 +1,37 @@ -## Parse Server Changelog +# Changelog -### [2.2.17](https://github.com/ParsePlatform/parse-server/tree/2.2.17) (07/23/2016) -[Full Changelog](https://github.com/ParsePlatform/parse-server/compare/2.2.16...2.2.17) +Changelogs are separated by release type for better overview. -* Cloud code logs [\#2370](https://github.com/ParsePlatform/parse-server/pull/2370) ([flovilmart](https://github.com/flovilmart)) -* Make sure \_PushStatus operations are run in order [\#2367](https://github.com/ParsePlatform/parse-server/pull/2367) ([flovilmart](https://github.com/flovilmart)) -* Typo fix for error message when can't ensure uniqueness of user email addresses [\#2360](https://github.com/ParsePlatform/parse-server/pull/2360) ([AndrewLane](https://github.com/AndrewLane)) -* LiveQuery constrains matching fix [\#2357](https://github.com/ParsePlatform/parse-server/pull/2357) ([simonas-notcat](https://github.com/simonas-notcat)) -* Fix typo in logging for commander parseConfigFile [\#2352](https://github.com/ParsePlatform/parse-server/pull/2352) ([AndrewLane](https://github.com/AndrewLane)) -* Fix minor typos in test names [\#2351](https://github.com/ParsePlatform/parse-server/pull/2351) ([acinader](https://github.com/acinader)) -* Makes sure we don't strip authData or session token from users using masterKey [\#2348](https://github.com/ParsePlatform/parse-server/pull/2348) ([flovilmart](https://github.com/flovilmart)) -* Run coverage with istanbul [\#2340](https://github.com/ParsePlatform/parse-server/pull/2340) ([flovilmart](https://github.com/flovilmart)) -* Run next\(\) after successfully sending data to the client [\#2338](https://github.com/ParsePlatform/parse-server/pull/2338) ([blacha](https://github.com/blacha)) -* Cache all the mongodb/version folder [\#2336](https://github.com/ParsePlatform/parse-server/pull/2336) ([flovilmart](https://github.com/flovilmart)) -* updates usage of setting: emailVerifyTokenValidityDuration [\#2331](https://github.com/ParsePlatform/parse-server/pull/2331) ([cherukumilli](https://github.com/cherukumilli)) -* Update Mongodb client to 2.2.4 [\#2329](https://github.com/ParsePlatform/parse-server/pull/2329) ([flovilmart](https://github.com/flovilmart)) -* Allow usage of analytics adapter [\#2327](https://github.com/ParsePlatform/parse-server/pull/2327) ([deashay](https://github.com/deashay)) -* Fix flaky tests [\#2324](https://github.com/ParsePlatform/parse-server/pull/2324) ([flovilmart](https://github.com/flovilmart)) -* don't serve null authData values [\#2320](https://github.com/ParsePlatform/parse-server/pull/2320) ([yuzeh](https://github.com/yuzeh)) -* Fix null relation problem [\#2319](https://github.com/ParsePlatform/parse-server/pull/2319) ([flovilmart](https://github.com/flovilmart)) -* Clear the connectionPromise upon close or error [\#2314](https://github.com/ParsePlatform/parse-server/pull/2314) ([flovilmart](https://github.com/flovilmart)) -* Report validation errors with correct error code [\#2299](https://github.com/ParsePlatform/parse-server/pull/2299) ([flovilmart](https://github.com/flovilmart)) -* Parses correctly Parse.Files and Dates when sent to Cloud Code Functions [\#2297](https://github.com/ParsePlatform/parse-server/pull/2297) ([flovilmart](https://github.com/flovilmart)) -* Adding proper generic Not Implemented. [\#2292](https://github.com/ParsePlatform/parse-server/pull/2292) ([vitaly-t](https://github.com/vitaly-t)) -* Adds schema caching capabilities \(5s by default\) [\#2286](https://github.com/ParsePlatform/parse-server/pull/2286) ([flovilmart](https://github.com/flovilmart)) -* add digits oauth provider [\#2284](https://github.com/ParsePlatform/parse-server/pull/2284) ([ranhsd](https://github.com/ranhsd)) -* Improve installations query [\#2281](https://github.com/ParsePlatform/parse-server/pull/2281) ([flovilmart](https://github.com/flovilmart)) -* Adding request headers to cloud functions fixes \#1461 [\#2274](https://github.com/ParsePlatform/parse-server/pull/2274) ([blacha](https://github.com/blacha)) -* Creates a new sessionToken when updating password [\#2266](https://github.com/ParsePlatform/parse-server/pull/2266) ([flovilmart](https://github.com/flovilmart)) -* Add Gitter chat link to the README. [\#2264](https://github.com/ParsePlatform/parse-server/pull/2264) ([nlutsenko](https://github.com/nlutsenko)) -* Restores ability to include non pointer keys [\#2263](https://github.com/ParsePlatform/parse-server/pull/2263) ([flovilmart](https://github.com/flovilmart)) -* Allow next middleware handle error in handleParseErrors [\#2260](https://github.com/ParsePlatform/parse-server/pull/2260) ([mejcz](https://github.com/mejcz)) -* Exposes the ClientSDK infos if available [\#2259](https://github.com/ParsePlatform/parse-server/pull/2259) ([flovilmart](https://github.com/flovilmart)) -* Adds support for multiple twitter auths options [\#2256](https://github.com/ParsePlatform/parse-server/pull/2256) ([flovilmart](https://github.com/flovilmart)) -* validate\_purchase fix for SANDBOX requests [\#2253](https://github.com/ParsePlatform/parse-server/pull/2253) ([valeryvaskabovich](https://github.com/valeryvaskabovich)) +## ✅ [Stable Releases][log_release] -### 2.2.16 (7/10/2016) +> ### “Stable for production!” -* New: Expose InMemoryCacheAdapter publicly, thanks to [Steven Shipton](https://github.com/steven-supersolid) -* New: Add ability to prevent login with unverified email, thanks to [Diwakar Cherukumilli](https://github.com/cherukumilli) -* Improved: Better error message for incorrect type, thanks to [Andrew Lane](https://github.com/AndrewLane) -* Improved: Better error message for permission denied, thanks to [Blayne Chard](https://github.com/blacha) -* Improved: Update authData on login, thanks to [Florent Vilmart](https://github.com/flovilmart) -* Improved: Ability to not check for old files on Parse.com, thanks to [OzgeAkin](https://github.com/OzgeAkin) -* Fix: Issues with email adapter validation, thanks to [Tyler Brock](https://github.com/TylerBrock) -* Fix: Issues with nested $or queries, thanks to [Florent Vilmart](https://github.com/flovilmart) +These are the official, stable releases that you can use in your production environments. -### 2.2.15 (6/30/2016) +Details: +- Stability: *stable* +- NPM channel: `@latest` +- Branch: [release][branch_release] +- Purpose: official release +- Suitable environment: production -* Fix: Type in description for Parse.Error.INVALID_QUERY, thanks to [Andrew Lane](https://github.com/AndrewLane) -* Improvement: Stop requiring verifyUserEmails for password reset functionality, thanks to [Tyler Brock](https://github.com/TylerBrock) -* Improvement: Kill without validation, thanks to [Drew Gross](https://github.com/drew-gross) -* Fix: Deleting a file does not delete from fs.files, thanks to [David Keita](https://github.com/maninga) -* Fix: Postgres stoage adapter fix, thanks to [Vitaly Tomilov](https://github.com/vitaly-t) -* Fix: Results invalid session when providing an invalid session token, thanks to [Florent Vilmart](https://github.com/flovilmart) -* Fix: issue creating an anonymous user, thanks to [Hussam Moqhim](https://github.com/hmoqhim) -* Fix: make http response serializable, thanks to [Florent Vilmart](https://github.com/flovilmart) -* New: Add postmark email adapter alternative [Glenn Reyes](https://github.com/glennreyes) +## đŸ”Ĩ [Alpha Releases][log_alpha] -### 2.2.14 (6/25/2016) +> ### “If you are curious to see what's next!” -* Hotfix: Fix Parse.Cloud.HTTPResponse serialization +These releases contain the latest development changes, but you should be prepared for anything, including sudden breaking changes or code refactoring. Use this branch to contribute to the project and open pull requests. -### 2.2.13 (6/12/2016) +Details: +- Stability: *unstable* +- NPM channel: `@alpha` +- Branch: [alpha][branch_alpha] +- Purpose: product development +- Suitable environment: experimental -* Hotfix: Pin version of deepcopy -### 2.2.12 (6/9/2016) - -* New: Custom error codes in cloud code response.error, thanks to [Jeremy Pease](https://github.com/JeremyPlease) -* Fix: Crash in beforeSave when response is not an object, thanks to [Tyler Brock](https://github.com/TylerBrock) -* Fix: Allow "get" on installations -* Fix: Fix overly restrictive Class Level Permissions, thanks to [Florent Vilmart](https://github.com/flovilmart) -* Fix: Fix nested date parsing in Cloud Code, thanks to [Marco Cheung](https://github.com/Marco129) -* Fix: Support very old file formats from Parse.com - -### 2.2.11 (5/31/2016) - -* Security: Censor user password in logs, thanks to [Marco Cheung](https://github.com/Marco129) -* New: Add PARSE_SERVER_LOGS_FOLDER env var for setting log folder, thanks to [KartikeyaRokde](https://github.com/KartikeyaRokde) -* New: Webhook key support, thanks to [Tyler Brock](https://github.com/TylerBrock) -* Perf: Add cache adapter and default caching of certain objects, thanks to [Blayne Chard](https://github.com/blacha) -* Improvement: Better error messages for schema type mismatches, thanks to [Jeremy Pease](https://github.com/JeremyPlease) -* Improvement: Better error messages for reset password emails -* Improvement: Webhook key support in CLI, thanks to [Tyler Brock](https://github.com/TylerBrock) -* Fix: Remove read only fields when using beforeSave, thanks to [Tyler Brock](https://github.com/TylerBrock) -* Fix: Use content type provided by JS SDK, thanks to [Blayne Chard](https://github.com/blacha) and [Florent Vilmart](https://github.com/flovilmart) -* Fix: Tell the dashboard the stored push data is available, thanks to [Jeremy Pease](https://github.com/JeremyPlease) -* Fix: Add support for HTTP Basic Auth, thanks to [Hussam Moqhim](https://github.com/hmoqhim) -* Fix: Support for MongoDB version 3.2.6, (note: do not use MongoDB 3.2 with migrated apps that still have traffic on Parse.com), thanks to [Tyler Brock](https://github.com/TylerBrock) -* Fix: Prevent `pm2` from crashing when push notifications fail, thanks to [benishak](https://github.com/benishak) -* Fix: Add full list of default _Installation fields, thanks to [Jeremy Pease](https://github.com/JeremyPlease) -* Fix: Strip objectId out of hooks responses, thanks to [Tyler Brock](https://github.com/TylerBrock) -* Fix: Fix external webhook response format, thanks to [Tyler Brock](https://github.com/TylerBrock) -* Fix: Fix beforeSave when object is passed to `success`, thanks to [Madhav Bhagat](https://github.com/codebreach) -* Fix: Remove use of deprecated APIs, thanks to [Emad Ehsan](https://github.com/emadehsan) -* Fix: Crash when multiple Parse Servers on the same machine try to write to the same logs folder, thanks to [Steven Shipton](https://github.com/steven-supersolid) -* Fix: Various issues with key names in `Parse.Object`s -* Fix: Treat Bytes type properly -* Fix: Caching bugs that caused writes by masterKey or other session token to not show up to users reading with a different session token -* Fix: Pin mongo driver version, preventing a regression in version 2.1.19 -* Fix: Various issues with pointer fields not being treated properly -* Fix: Issues with pointed getting un-fetched due to changes in beforeSave -* Fix: Fixed crash when deleting classes that have CLPs - -### 2.2.10 (5/15/2016) - -* Fix: Write legacy ACLs to Mongo so that clients that still go through Parse.com can read them, thanks to [Tyler Brock](https://github.com/TylerBrock) and [carmenlau](https://github.com/carmenlau) -* Fix: Querying installations with limit = 0 and count = 1 now works, thanks to [ssk7833](https://github.com/ssk7833) -* Fix: Return correct error when violating unique index, thanks to [Marco Cheung](https://github.com/Marco129) -* Fix: Allow unsetting user's email, thanks to [Marco Cheung](https://github.com/Marco129) -* New: Support for Node 6.1 - -### 2.2.9 (5/9/2016) - -* Fix: Fix a regression that caused Parse Server to crash when a null parameter is passed to a Cloud function - -### 2.2.8 (5/8/2016) - -* New: Support for Pointer Permissions -* New: Expose logger in Cloud Code -* New: Option to revoke sessions on password reset -* New: Option to expire inactive sessions -* Perf: Improvements in ACL checking query -* Fix: Issues when sending pushes to list of devices that contains invalid values -* Fix: Issues caused by using babel-polyfill outside of Parse Server, but in the same express app -* Fix: Remove creation of extra session tokens -* Fix: Return authData when querying with master key -* Fix: Bugs when deleting webhooks -* Fix: Ignore _RevocableSession header, which might be sent by the JS SDK -* Fix: Issues with querying via URL params -* Fix: Properly encode "Date" parameters to cloud code functions - - -### 2.2.7 (4/15/2016) - -* Adds support for --verbose and verbose option when running ParseServer [\#1414](https://github.com/ParsePlatform/parse-server/pull/1414) ([flovilmart](https://github.com/flovilmart)) -* Adds limit = 0 as a valid parameter for queries [\#1493](https://github.com/ParsePlatform/parse-server/pull/1493) ([seijiakiyama](https://github.com/seijiakiyama)) -* Makes sure we preserve Installations when updating a token \(\#1475\) [\#1486](https://github.com/ParsePlatform/parse-server/pull/1486) ([flovilmart](https://github.com/flovilmart)) -* Hotfix for tests [\#1503](https://github.com/ParsePlatform/parse-server/pull/1503) ([flovilmart](https://github.com/flovilmart)) -* Enable logs [\#1502](https://github.com/ParsePlatform/parse-server/pull/1502) ([drew-gross](https://github.com/drew-gross)) -* Do some triple equals for great justice [\#1499](https://github.com/ParsePlatform/parse-server/pull/1499) ([TylerBrock](https://github.com/TylerBrock)) -* Apply credential stripping to all untransforms for \_User [\#1498](https://github.com/ParsePlatform/parse-server/pull/1498) ([TylerBrock](https://github.com/TylerBrock)) -* Checking if object has defined key for Pointer constraints in liveQuery [\#1487](https://github.com/ParsePlatform/parse-server/pull/1487) ([simonas-notcat](https://github.com/simonas-notcat)) -* Remove collection prefix and default mongo URI [\#1479](https://github.com/ParsePlatform/parse-server/pull/1479) ([drew-gross](https://github.com/drew-gross)) -* Store collection prefix in mongo adapter, and clean up adapter interface [\#1472](https://github.com/ParsePlatform/parse-server/pull/1472) ([drew-gross](https://github.com/drew-gross)) -* Move field deletion logic into mongo adapter [\#1471](https://github.com/ParsePlatform/parse-server/pull/1471) ([drew-gross](https://github.com/drew-gross)) -* Adds support for Long and Double mongodb types \(fixes \#1316\) [\#1470](https://github.com/ParsePlatform/parse-server/pull/1470) ([flovilmart](https://github.com/flovilmart)) -* Schema.js database agnostic [\#1468](https://github.com/ParsePlatform/parse-server/pull/1468) ([flovilmart](https://github.com/flovilmart)) -* Remove console.log [\#1465](https://github.com/ParsePlatform/parse-server/pull/1465) ([drew-gross](https://github.com/drew-gross)) -* Push status nits [\#1462](https://github.com/ParsePlatform/parse-server/pull/1462) ([flovilmart](https://github.com/flovilmart)) -* Fixes \#1444 [\#1451](https://github.com/ParsePlatform/parse-server/pull/1451) ([flovilmart](https://github.com/flovilmart)) -* Removing sessionToken and authData from \_User objects included in a query [\#1450](https://github.com/ParsePlatform/parse-server/pull/1450) ([simonas-notcat](https://github.com/simonas-notcat)) -* Move mongo field type logic into mongoadapter [\#1432](https://github.com/ParsePlatform/parse-server/pull/1432) ([drew-gross](https://github.com/drew-gross)) -* Prevents \_User lock out when setting ACL on signup or afterwards [\#1429](https://github.com/ParsePlatform/parse-server/pull/1429) ([flovilmart](https://github.com/flovilmart)) -* Update .travis.yml [\#1428](https://github.com/ParsePlatform/parse-server/pull/1428) ([flovilmart](https://github.com/flovilmart)) -* Adds relation fields to objects [\#1424](https://github.com/ParsePlatform/parse-server/pull/1424) ([flovilmart](https://github.com/flovilmart)) -* Update .travis.yml [\#1423](https://github.com/ParsePlatform/parse-server/pull/1423) ([flovilmart](https://github.com/flovilmart)) -* Sets the defaultSchemas keys in the SchemaCollection [\#1421](https://github.com/ParsePlatform/parse-server/pull/1421) ([flovilmart](https://github.com/flovilmart)) -* Fixes \#1417 [\#1420](https://github.com/ParsePlatform/parse-server/pull/1420) ([drew-gross](https://github.com/drew-gross)) -* Untransform should treat Array's as nested objects [\#1416](https://github.com/ParsePlatform/parse-server/pull/1416) ([blacha](https://github.com/blacha)) -* Adds X-Parse-Push-Status-Id header [\#1412](https://github.com/ParsePlatform/parse-server/pull/1412) ([flovilmart](https://github.com/flovilmart)) -* Schema format cleanup [\#1407](https://github.com/ParsePlatform/parse-server/pull/1407) ([drew-gross](https://github.com/drew-gross)) -* Updates the publicServerURL option [\#1397](https://github.com/ParsePlatform/parse-server/pull/1397) ([flovilmart](https://github.com/flovilmart)) -* Fix exception with non-expiring session tokens. [\#1386](https://github.com/ParsePlatform/parse-server/pull/1386) ([0x18B2EE](https://github.com/0x18B2EE)) -* Move mongo schema format related logic into mongo adapter [\#1385](https://github.com/ParsePlatform/parse-server/pull/1385) ([drew-gross](https://github.com/drew-gross)) -* WIP: Huge performance improvement on roles queries [\#1383](https://github.com/ParsePlatform/parse-server/pull/1383) ([flovilmart](https://github.com/flovilmart)) -* Removes GCS Adapter from provided adapters [\#1339](https://github.com/ParsePlatform/parse-server/pull/1339) ([flovilmart](https://github.com/flovilmart)) -* DBController refactoring [\#1228](https://github.com/ParsePlatform/parse-server/pull/1228) ([flovilmart](https://github.com/flovilmart)) -* Spotify authentication [\#1226](https://github.com/ParsePlatform/parse-server/pull/1226) ([1nput0utput](https://github.com/1nput0utput)) -* Expose DatabaseAdapter to simplify application tests [\#1121](https://github.com/ParsePlatform/parse-server/pull/1121) ([steven-supersolid](https://github.com/steven-supersolid)) - -### 2.2.6 (4/5/2016) - -* Important Fix: Disables find on installation from clients [\#1374](https://github.com/ParsePlatform/parse-server/pull/1374) ([flovilmart](https://github.com/flovilmart)) -* Adds missing options to the CLI [\#1368](https://github.com/ParsePlatform/parse-server/pull/1368) ([flovilmart](https://github.com/flovilmart)) -* Removes only master on travis [\#1367](https://github.com/ParsePlatform/parse-server/pull/1367) ([flovilmart](https://github.com/flovilmart)) -* Auth.\_loadRoles should not query the same role twice. [\#1366](https://github.com/ParsePlatform/parse-server/pull/1366) ([blacha](https://github.com/blacha)) - -### 2.2.5 (4/4/2016) - -* Improves config loading and tests [\#1363](https://github.com/ParsePlatform/parse-server/pull/1363) ([flovilmart](https://github.com/flovilmart)) -* Adds travis configuration to deploy NPM on new version tags [\#1361](https://github.com/ParsePlatform/parse-server/pull/1361) ([gfosco](https://github.com/gfosco)) -* Inject the default schemas properties when loading it [\#1357](https://github.com/ParsePlatform/parse-server/pull/1357) ([flovilmart](https://github.com/flovilmart)) -* Adds console transport when testing with VERBOSE=1 [\#1351](https://github.com/ParsePlatform/parse-server/pull/1351) ([flovilmart](https://github.com/flovilmart)) -* Make notEqual work on relations [\#1350](https://github.com/ParsePlatform/parse-server/pull/1350) ([flovilmart](https://github.com/flovilmart)) -* Accept only bool for $exists in LiveQuery [\#1315](https://github.com/ParsePlatform/parse-server/pull/1315) ([drew-gross](https://github.com/drew-gross)) -* Adds more options when using CLI/config [\#1305](https://github.com/ParsePlatform/parse-server/pull/1305) ([flovilmart](https://github.com/flovilmart)) -* Update error message [\#1297](https://github.com/ParsePlatform/parse-server/pull/1297) ([drew-gross](https://github.com/drew-gross)) -* Properly let masterKey add fields [\#1291](https://github.com/ParsePlatform/parse-server/pull/1291) ([flovilmart](https://github.com/flovilmart)) -* Point to \#1271 as how to write a good issue report [\#1290](https://github.com/ParsePlatform/parse-server/pull/1290) ([drew-gross](https://github.com/drew-gross)) -* Adds ability to override mount with publicServerURL for production uses [\#1287](https://github.com/ParsePlatform/parse-server/pull/1287) ([flovilmart](https://github.com/flovilmart)) -* Single object queries to use include and keys [\#1280](https://github.com/ParsePlatform/parse-server/pull/1280) ([jeremyjackson89](https://github.com/jeremyjackson89)) -* Improves report for Push error in logs and \_PushStatus [\#1269](https://github.com/ParsePlatform/parse-server/pull/1269) ([flovilmart](https://github.com/flovilmart)) -* Removes all stdout/err logs while testing [\#1268](https://github.com/ParsePlatform/parse-server/pull/1268) ([flovilmart](https://github.com/flovilmart)) -* Matching queries with doesNotExist constraint [\#1250](https://github.com/ParsePlatform/parse-server/pull/1250) ([andrecardoso](https://github.com/andrecardoso)) -* Added session length option for session tokens to server configuration [\#997](https://github.com/ParsePlatform/parse-server/pull/997) ([Kenishi](https://github.com/Kenishi)) -* Regression test for \#1259 [\#1286](https://github.com/ParsePlatform/parse-server/pull/1286) ([drew-gross](https://github.com/drew-gross)) -* Regression test for \#871 [\#1283](https://github.com/ParsePlatform/parse-server/pull/1283) ([drew-gross](https://github.com/drew-gross)) -* Add a test to repro \#701 [\#1281](https://github.com/ParsePlatform/parse-server/pull/1281) ([drew-gross](https://github.com/drew-gross)) -* Fix for \#1334: using relative cloud code files broken [\#1353](https://github.com/ParsePlatform/parse-server/pull/1353) ([airdrummingfool](https://github.com/airdrummingfool)) -* Fix Issue/1288 [\#1346](https://github.com/ParsePlatform/parse-server/pull/1346) ([flovilmart](https://github.com/flovilmart)) -* Fixes \#1271 [\#1295](https://github.com/ParsePlatform/parse-server/pull/1295) ([drew-gross](https://github.com/drew-gross)) -* Fixes issue \#1302 [\#1314](https://github.com/ParsePlatform/parse-server/pull/1314) ([flovilmart](https://github.com/flovilmart)) -* Fixes bug related to include in queries [\#1312](https://github.com/ParsePlatform/parse-server/pull/1312) ([flovilmart](https://github.com/flovilmart)) - - -### 2.2.4 (3/29/2016) - -* Hotfix: fixed imports issue for S3Adapter, GCSAdapter, FileSystemAdapter [\#1263](https://github.com/ParsePlatform/parse-server/pull/1263) ([drew-gross](https://github.com/drew-gross) -* Fix: Clean null authData values on _User update [\#1199](https://github.com/ParsePlatform/parse-server/pull/1199) ([yuzeh](https://github.com/yuzeh)) - -### 2.2.3 (3/29/2016) - -* Fixed bug with invalid email verification link on email update. [\#1253](https://github.com/ParsePlatform/parse-server/pull/1253) ([kzielonka](https://github.com/kzielonka)) -* Badge update supports increment as well as Increment [\#1248](https://github.com/ParsePlatform/parse-server/pull/1248) ([flovilmart](https://github.com/flovilmart)) -* Config/Push Tested with the dashboard. [\#1235](https://github.com/ParsePlatform/parse-server/pull/1235) ([drew-gross](https://github.com/drew-gross)) -* Better logging with winston [\#1234](https://github.com/ParsePlatform/parse-server/pull/1234) ([flovilmart](https://github.com/flovilmart)) -* Make GlobalConfig work like parse.com [\#1210](https://github.com/ParsePlatform/parse-server/pull/1210) ([framp](https://github.com/framp)) -* Improve flattening of results from pushAdapter [\#1204](https://github.com/ParsePlatform/parse-server/pull/1204) ([flovilmart](https://github.com/flovilmart)) -* Push adapters are provided by external packages [\#1195](https://github.com/ParsePlatform/parse-server/pull/1195) ([flovilmart](https://github.com/flovilmart)) -* Fix flaky test [\#1188](https://github.com/ParsePlatform/parse-server/pull/1188) ([drew-gross](https://github.com/drew-gross)) -* Fixes problem affecting finding array pointers [\#1185](https://github.com/ParsePlatform/parse-server/pull/1185) ([flovilmart](https://github.com/flovilmart)) -* Moves Files adapters to external packages [\#1172](https://github.com/ParsePlatform/parse-server/pull/1172) ([flovilmart](https://github.com/flovilmart)) -* Mark push as enabled in serverInfo endpoint [\#1164](https://github.com/ParsePlatform/parse-server/pull/1164) ([drew-gross](https://github.com/drew-gross)) -* Document email adapter [\#1144](https://github.com/ParsePlatform/parse-server/pull/1144) ([drew-gross](https://github.com/drew-gross)) -* Reset password fix [\#1133](https://github.com/ParsePlatform/parse-server/pull/1133) ([carmenlau](https://github.com/carmenlau)) - -### 2.2.2 (3/23/2016) - -* Important Fix: Mounts createLiveQueryServer, fix babel induced problem [\#1153](https://github.com/ParsePlatform/parse-server/pull/1153) (flovilmart) -* Move ParseServer to it's own file [\#1166](https://github.com/ParsePlatform/parse-server/pull/1166) (flovilmart) -* Update README.md * remove deploy buttons * replace with community links [\#1139](https://github.com/ParsePlatform/parse-server/pull/1139) (drew-gross) -* Adds bootstrap.sh [\#1138](https://github.com/ParsePlatform/parse-server/pull/1138) (flovilmart) -* Fix: Do not override username [\#1142](https://github.com/ParsePlatform/parse-server/pull/1142) (flovilmart) -* Fix: Add pushId back to GCM payload [\#1168](https://github.com/ParsePlatform/parse-server/pull/1168) (wangmengyan95) - -### 2.2.1 (3/22/2016) - -* New: Add FileSystemAdapter file adapter [\#1098](https://github.com/ParsePlatform/parse-server/pull/1098) (dtsolis) -* New: Enabled CLP editing [\#1128](https://github.com/ParsePlatform/parse-server/pull/1128) (drew-gross) -* Improvement: Reduces the number of connections to mongo created [\#1111](https://github.com/ParsePlatform/parse-server/pull/1111) (flovilmart) -* Improvement: Make ParseServer a class [\#980](https://github.com/ParsePlatform/parse-server/pull/980) (flovilmart) -* Fix: Adds support for plain object in $add, $addUnique, $remove [\#1114](https://github.com/ParsePlatform/parse-server/pull/1114) (flovilmart) -* Fix: Generates default CLP, freezes objects [\#1132](https://github.com/ParsePlatform/parse-server/pull/1132) (flovilmart) -* Fix: Properly sets installationId on creating session with 3rd party auth [\#1110](https://github.com/ParsePlatform/parse-server/pull/1110) (flovilmart) - -### 2.2.0 (3/18/2016) - -* New Feature: Real-time functionality with Live Queries! [\#1092](https://github.com/ParsePlatform/parse-server/pull/1092) (wangmengyan95) -* Improvement: Push Status API [\#1004](https://github.com/ParsePlatform/parse-server/pull/1004) (flovilmart) -* Improvement: Allow client operations on Roles [\#1068](https://github.com/ParsePlatform/parse-server/pull/1068) (flovilmart) -* Improvement: Add URI encoding to mongo auth parameters [\#986](https://github.com/ParsePlatform/parse-server/pull/986) (bgw) -* Improvement: Adds support for apps key in config file, but only support single app for now [\#979](https://github.com/ParsePlatform/parse-server/pull/979) (flovilmart) -* Documentation: Getting Started and Configuring Parse Server [\#988](https://github.com/ParsePlatform/parse-server/pull/988) (hramos) -* Fix: Various edge cases with REST API [\#1066](https://github.com/ParsePlatform/parse-server/pull/1066) (flovilmart) -* Fix: Makes sure the location in results has the proper objectId [\#1065](https://github.com/ParsePlatform/parse-server/pull/1065) (flovilmart) -* Fix: Third-party auth is properly removed when unlinked [\#1081](https://github.com/ParsePlatform/parse-server/pull/1081) (flovilmart) -* Fix: Clear the session-user cache when changing \_User objects [\#1072](https://github.com/ParsePlatform/parse-server/pull/1072) (gfosco) -* Fix: Bug related to subqueries on unfetched objects [\#1046](https://github.com/ParsePlatform/parse-server/pull/1046) (flovilmart) -* Fix: Properly urlencode parameters for email validation and password reset [\#1001](https://github.com/ParsePlatform/parse-server/pull/1001) (flovilmart) -* Fix: Better sanitization/decoding of object data for afterSave triggers [\#992](https://github.com/ParsePlatform/parse-server/pull/992) (flovilmart) -* Fix: Changes default encoding for httpRequest [\#892](https://github.com/ParsePlatform/parse-server/pull/892) (flovilmart) - -### 2.1.6 (3/11/2016) - -* Improvement: Full query support for badge Increment \(\#931\) [\#983](https://github.com/ParsePlatform/parse-server/pull/983) (flovilmart) -* Improvement: Shutdown standalone parse server gracefully [\#958](https://github.com/ParsePlatform/parse-server/pull/958) (raulr) -* Improvement: Add database options to ParseServer constructor and pass to MongoStorageAdapter [\#956](https://github.com/ParsePlatform/parse-server/pull/956) (steven-supersolid) -* Improvement: AuthData logic refactor [\#952](https://github.com/ParsePlatform/parse-server/pull/952) (flovilmart) -* Improvement: Changed FileLoggerAdapterSpec to fail gracefully on Windows [\#946](https://github.com/ParsePlatform/parse-server/pull/946) (aneeshd16) -* Improvement: Add new schema collection type and replace all usages of direct mongo collection for schema operations. [\#943](https://github.com/ParsePlatform/parse-server/pull/943) (nlutsenko) -* Improvement: Adds CLP API to Schema router [\#898](https://github.com/ParsePlatform/parse-server/pull/898) (flovilmart) -* Fix: Cleans up authData null keys on login for android crash [\#978](https://github.com/ParsePlatform/parse-server/pull/978) (flovilmart) -* Fix: Do master query for before/afterSaveHook [\#959](https://github.com/ParsePlatform/parse-server/pull/959) (wangmengyan95) -* Fix: re-add shebang [\#944](https://github.com/ParsePlatform/parse-server/pull/944) (flovilmart) -* Fix: Added test command for Windows support [\#886](https://github.com/ParsePlatform/parse-server/pull/886) (aneeshd16) - -### 2.1.5 (3/9/2016) - -* New: FileAdapter for Google Cloud Storage [\#708](https://github.com/ParsePlatform/parse-server/pull/708) (mcdonamp) -* Improvement: Minimize extra schema queries in some scenarios. [\#919](https://github.com/ParsePlatform/parse-server/pull/919) (Marco129) -* Improvement: Move DatabaseController and Schema fully to adaptive mongo collection. [\#909](https://github.com/ParsePlatform/parse-server/pull/909) (nlutsenko) -* Improvement: Cleanup PushController/PushRouter, remove raw mongo collection access. [\#903](https://github.com/ParsePlatform/parse-server/pull/903) (nlutsenko) -* Improvement: Increment badge the right way [\#902](https://github.com/ParsePlatform/parse-server/pull/902) (flovilmart) -* Improvement: Migrate ParseGlobalConfig to new database storage API. [\#901](https://github.com/ParsePlatform/parse-server/pull/901) (nlutsenko) -* Improvement: Improve delete flow for non-existent \_Join collection [\#881](https://github.com/ParsePlatform/parse-server/pull/881) (Marco129) -* Improvement: Adding a role scenario test for issue 827 [\#878](https://github.com/ParsePlatform/parse-server/pull/878) (gfosco) -* Improvement: Test empty authData block on login for \#413 [\#863](https://github.com/ParsePlatform/parse-server/pull/863) (gfosco) -* Improvement: Modified the npm dev script to support Windows [\#846](https://github.com/ParsePlatform/parse-server/pull/846) (aneeshd16) -* Improvement: Move HooksController to use MongoCollection instead of direct Mongo access. [\#844](https://github.com/ParsePlatform/parse-server/pull/844) (nlutsenko) -* Improvement: Adds public\_html and views for packaging [\#839](https://github.com/ParsePlatform/parse-server/pull/839) (flovilmart) -* Improvement: Better support for windows builds [\#831](https://github.com/ParsePlatform/parse-server/pull/831) (flovilmart) -* Improvement: Convert Schema.js to ES6 class. [\#826](https://github.com/ParsePlatform/parse-server/pull/826) (nlutsenko) -* Improvement: Remove duplicated instructions [\#816](https://github.com/ParsePlatform/parse-server/pull/816) (hramos) -* Improvement: Completely migrate SchemasRouter to new MongoCollection API. [\#794](https://github.com/ParsePlatform/parse-server/pull/794) (nlutsenko) -* Fix: Do not require where clause in $dontSelect condition on queries. [\#925](https://github.com/ParsePlatform/parse-server/pull/925) (nlutsenko) -* Fix: Make sure that ACLs propagate to before/after save hooks. [\#924](https://github.com/ParsePlatform/parse-server/pull/924) (nlutsenko) -* Fix: Support params option in Parse.Cloud.httpRequest. [\#912](https://github.com/ParsePlatform/parse-server/pull/912) (carmenlau) -* Fix: Fix flaky Parse.GeoPoint test. [\#908](https://github.com/ParsePlatform/parse-server/pull/908) (nlutsenko) -* Fix: Handle legacy \_client\_permissions key in \_SCHEMA. [\#900](https://github.com/ParsePlatform/parse-server/pull/900) (drew-gross) -* Fix: Fixes bug when querying equalTo on objectId and relation [\#887](https://github.com/ParsePlatform/parse-server/pull/887) (flovilmart) -* Fix: Allow crossdomain on filesRouter [\#876](https://github.com/ParsePlatform/parse-server/pull/876) (flovilmart) -* Fix: Remove limit when counting results. [\#867](https://github.com/ParsePlatform/parse-server/pull/867) (gfosco) -* Fix: beforeSave changes should propagate to the response [\#865](https://github.com/ParsePlatform/parse-server/pull/865) (gfosco) -* Fix: Delete relation field when \_Join collection not exist [\#864](https://github.com/ParsePlatform/parse-server/pull/864) (Marco129) -* Fix: Related query on non-existing column [\#861](https://github.com/ParsePlatform/parse-server/pull/861) (gfosco) -* Fix: Update markdown in .github/ISSUE\_TEMPLATE.md [\#859](https://github.com/ParsePlatform/parse-server/pull/859) (igorshubovych) -* Fix: Issue with creating wrong \_Session for Facebook login [\#857](https://github.com/ParsePlatform/parse-server/pull/857) (tobernguyen) -* Fix: Leak warnings in tests, use mongodb-runner from node\_modules [\#843](https://github.com/ParsePlatform/parse-server/pull/843) (drew-gross) -* Fix: Reversed roles lookup [\#841](https://github.com/ParsePlatform/parse-server/pull/841) (flovilmart) -* Fix: Improves loading of Push Adapter, fix loading of S3Adapter [\#833](https://github.com/ParsePlatform/parse-server/pull/833) (flovilmart) -* Fix: Add field to system schema [\#828](https://github.com/ParsePlatform/parse-server/pull/828) (Marco129) - -### 2.1.4 (3/3/2016) - -* New: serverInfo endpoint that returns server version and info about the server's features -* Improvement: Add support for badges on iOS -* Improvement: Improve failure handling in cloud code http requests -* Improvement: Add support for queries on pointers and relations -* Improvement: Add support for multiple $in clauses in a query -* Improvement: Add allowClientClassCreation config option -* Improvement: Allow atomically setting subdocument keys -* Improvement: Allow arbitrarily deeply nested roles -* Improvement: Set proper content-type in S3 File Adapter -* Improvement: S3 adapter auto-creates buckets -* Improvement: Better error messages for many errors -* Performance: Improved algorithm for validating client keys -* Experimental: Parse Hooks and Hooks API -* Experimental: Email verification and password reset emails -* Experimental: Improve compatability of logs feature with Parse.com -* Fix: Fix for attempting to delete missing classes via schemas API -* Fix: Allow creation of system classes via schemas API -* Fix: Allow missing where cause in $select -* Fix: Improve handling of invalid object ids -* Fix: Replace query overwriting existing query -* Fix: Propagate installationId in cloud code triggers -* Fix: Session expiresAt is now a Date instead of a string -* Fix: Fix count queries -* Fix: Disallow _Role objects without names or without ACL -* Fix: Better handling of invalid types submitted -* Fix: beforeSave will not be triggered for attempts to save with invalid authData -* Fix: Fix duplicate device token issues on Android -* Fix: Allow empty authData on signup -* Fix: Allow Master Key Headers (CORS) -* Fix: Fix bugs if JavaScript key was not provided in server configuration -* Fix: Parse Files on objects can now be stored without URLs -* Fix: allow both objectId or installationId when modifying installation -* Fix: Command line works better when not given options - -### 2.1.3 (2/24/2016) - -* Feature: Add initial support for in-app purchases -* Feature: Better error messages when attempting to run the server on a port that is already in use or without a server URL -* Feature: Allow customization of max file size -* Performance: Faster saves if not using beforeSave triggers -* Fix: Send session token in response to current user endpoint -* Fix: Remove triggers for _Session collection -* Fix: Improve compatability of cloud code beforeSave hook for newly created object -* Fix: ACL creation for master key only objects -* Fix: Allow uploading files without Content-Type -* Fix: Add features to http requrest to match Parse.com -* Fix: Bugs in development script when running from locations other than project root -* Fix: Can pass query constraints in URL -* Fix: Objects with legacy "_tombstone" key now don't cause issues. -* Fix: Allow nested keys in objects to begin with underscores -* Fix: Allow correct headers for CORS - -### 2.1.2 (2/19/2016) - -* Change: The S3 file adapter constructor requires a bucket name -* Fix: Parse Query should throw if improperly encoded -* Fix: Issue where roles were not used in some requests -* Fix: serverURL will no longer default to api.parse.com/1 - -### 2.1.1 (2/18/2016) - -* Experimental: Schemas API support for DELETE operations -* Fix: Session token issue fetching Users -* Fix: Facebook auth validation -* Fix: Invalid error when deleting missing session - -### 2.1.0 (2/17/2016) - -* Feature: Support for additional OAuth providers -* Feature: Ability to implement custom OAuth providers -* Feature: Support for deleting Parse Files -* Feature: Allow querying roles -* Feature: Support for logs, extensible via Log Adapter -* Feature: New Push Adapter for sending push notifications through OneSignal -* Feature: Tighter default security for Users -* Feature: Pass parameters to cloud code in query string -* Feature: Disable anonymous users via configuration. -* Experimental: Schemas API support for PUT operations -* Fix: Prevent installation ID from being added to User -* Fix: Becoming a user works properly with sessions -* Fix: Including multiple object when some object are unavailable will get all the objects that are available -* Fix: Invalid URL for Parse Files -* Fix: Making a query without a limit now returns 100 results -* Fix: Expose installation id in cloud code -* Fix: Correct username for Anonymous users -* Fix: Session token issue after fetching user -* Fix: Issues during install process -* Fix: Issue with Unity SDK sending _noBody - -### 2.0.8 (2/11/2016) - -* Add: support for Android and iOS push notifications -* Experimental: cloud code validation hooks (can mark as non-experimental after we have docs) -* Experimental: support for schemas API (GET and POST only) -* Experimental: support for Parse Config (GET and POST only) -* Fix: Querying objects with equality constraint on array column -* Fix: User logout will remove session token -* Fix: Various files related bugs -* Fix: Force minimum node version 4.3 due to security issues in earlier version -* Performance Improvement: Improved caching +[log_release]: https://github.com/parse-community/parse-server/blob/release/changelogs/CHANGELOG_release.md +[log_beta]: https://github.com/parse-community/parse-server/blob/beta/changelogs/CHANGELOG_beta.md +[log_alpha]: https://github.com/parse-community/parse-server/blob/alpha/changelogs/CHANGELOG_alpha.md +[branch_release]: https://github.com/parse-community/parse-server/tree/release +[branch_beta]: https://github.com/parse-community/parse-server/tree/beta +[branch_alpha]: https://github.com/parse-community/parse-server/tree/alpha diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..5f6271c8e5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at codeofconduct@parseplatform.org. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ca0afdcb19..41a2f87de0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,15 +1,697 @@ -### Contributing to Parse Server +# Contributing to Parse Server -#### Pull Requests Welcome! +## Table of Contents +- [Contributing](#contributing) + - [Issue vs. Pull Request](#issue-vs-pull-request) + - [Scope](#scope) + - [Templates](#templates) +- [Why Contributing?](#why-contributing) +- [Contribution FAQs](#contribution-faqs) + - [Reviewer Role](#reviewer-role) + - [Review Feedback](#review-feedback) + - [Merge Readiness](#merge-readiness) + - [Review Validity](#review-validity) + - [Code Ownership](#code-ownership) + - [Access Permissions](#access-permissions) + - [New Private Repository](#new-private-repository) + - [New Public Repository](#new-public-repository) +- [Environment Setup](#environment-setup) + - [Recommended Tools](#recommended-tools) + - [Setting up your local machine](#setting-up-your-local-machine) + - [Good to Know](#good-to-know) + - [Troubleshooting](#troubleshooting) + - [Please Do's](#please-dos) + - [TypeScript Tests](#typescript-tests) + - [Test against Postgres](#test-against-postgres) + - [Postgres with Docker](#postgres-with-docker) + - [Performance Testing](#performance-testing) + - [Adding Tests](#adding-tests) + - [Adding Benchmarks](#adding-benchmarks) + - [Benchmark Guidelines](#benchmark-guidelines) +- [Breaking Changes](#breaking-changes) + - [Deprecation Policy](#deprecation-policy) +- [Feature Considerations](#feature-considerations) + - [Security Checks](#security-checks) + - [Add Security Check](#add-security-check) + - [Wording Guideline](#wording-guideline) + - [Parse Error](#parse-error) + - [Parse Server Configuration](#parse-server-configuration) +- [Pull Request](#pull-request) + - [Commit Message](#commit-message) + - [Breaking Change](#breaking-change) +- [Merging](#merging) + - [Breaking Change](#breaking-change-1) + - [Reverting](#reverting) + - [Security Vulnerability](#security-vulnerability) + - [Local Testing](#local-testing) + - [Environment](#environment) + - [Merging](#merging-1) +- [Releasing](#releasing) + - [General Considerations](#general-considerations) + - [Major Release / Long-Term-Support](#major-release--long-term-support) + - [Preparing Release](#preparing-release) + - [Publishing Release (forward-merge):](#publishing-release-forward-merge) + - [Publishing Hotfix (back-merge):](#publishing-hotfix-back-merge) + - [Publishing Major Release (Yearly Release)](#publishing-major-release-yearly-release) +- [Versioning](#versioning) +- [Code of Conduct](#code-of-conduct) -We really want Parse to be yours, to see it grow and thrive in the open source community. +## Contributing -##### Please Do's +Before you start to code, please open a [new issue](https://github.com/parse-community/parse-server/issues/new/choose) to describe your idea, or search for and continue the discussion in an [existing issue](https://github.com/parse-community/parse-server/issues). -* Take testing seriously! Aim to increase the test coverage with every pull request. -* Run the tests for the file you are working on with `npm test spec/MyFile.spec.js` -* Run the tests for the whole project and look at the coverage report to make sure your tests are exhaustive by running `npm test` and looking at (project-root)/lcov-report/parse-server/FileUnderTest.js.html +> âš ī¸ Please do not post a security vulnerability on GitHub or in the Parse Community Forum. Instead, follow the [Parse Community Security Policy](https://github.com/parse-community/parse-server/security/policy). -##### Code of Conduct +Please completely fill out any templates to provide essential information about your new feature or the bug you discovered. -This project adheres to the [Open Code of Conduct](http://todogroup.org/opencodeofconduct/#Parse Server/fjm@fb.com). By participating, you are expected to honor this code. +Together we will plan out the best conceptual approach for your contribution, so that your and our time is invested in the best possible approach. The discussion often reveals how to leverage existing features of Parse Server to reach your goal with even less effort and in a more sustainable way. + +When you are ready to code, you can find more information about opening a pull request in the [GitHub docs](https://help.github.com/articles/creating-a-pull-request/). + +Whether this is your first contribution or you are already an experienced contributor, the Parse Community has your back – don't hesitate to ask for help! + +### Issue vs. Pull Request + +An issue is required to be linked in every pull request. We understand that no-one likes to create an issue for something that appears to be a simple pull request, but here is why this is beneficial for everyone: + +- An issue get more visibility than a pull request as issues can be pinned, receive bounties and it is primarily the issue list that people browse through rather than the more technical pull request list. Visibility is a key aspect so others can weigh in on issues and contribute their opinion. +- The discussion in the issue is different from the discussion in the pull request. The issue discussion is focused on the issue and how to address it, whereas the discussion in the pull request is focused on a specific implemention. An issue may even have multiple pull requests because either the issue requires multiple implementations or multiple pull requests are opened to compare and test different approaches to later decide for one. +- High-level conceptual discussions about the issue should be still available, even if a pull request is closed because its appraoch was discarded. If these discussions are in the pull request instead, they can easily become fragmented over multiple pull requests and issues, which can make it very hard to make sense of all aspects of an issue. + +### Scope + +An issue and pull request must limit its scope on a distinct issue. Pull requests can only contain changes that are required to address the scoped issue. While it may seem quick and easy to add unrelated changes to the pull request, it can cause singificant complications after merging. Some of the reasons are: + +- A pull request corresponds to a single changelog entry. A changelog entry should not describe multiple unrelated changes in one entry for better readability. +- A pull request creates a distinct commit; having an individual commit for each limited scope makes it easier for others to go back in the commit history and debug. Bugs are generally more difficult to identify and fix if there are various unrelated changes merged at once. +- If a pull request needs to be reverted, unrelated changes will be reverted as well. That makes it more complex and time consuming to revert, having to consider its effects and possibly publishing a broken release or requiring a follow-up pull request with code manipulation. + +### Templates + +You are required to use and completely fill out the templates for new issues and pull requests. We understand that no-one enjoys filling out forms, but here is why this is beneficial for everyone: + +- It may take you 30 seconds longer, but will save even more time for everyone else trying to understand your issue. +- It helps to fix issues and merge pull requests faster as reviewers spend less time trying to understand your issue. +- It makes investigations easier when others try to understand your issue and code changes made even years later. + +## Why Contributing? + +Buy cheap, buy twice. What? No, this is not the Economics 101 class, but the same is true for contributing. + +There are two ways of writing a feature or fixing a bug. Sometimes the quick solution is to just write a Cloud Code function that does what you want. Contributing by making the change directly in Parse Server may take a bit longer, but it actually saves you much more time in the long run. + +Consider the benefits you get: + +- #### 🚀 Higher efficiency + Your code is examined for efficiency and interoperability with existing features by the community. +- #### 🛡 Stronger security + Your code is scrutinized for bugs and vulnerabilities and automated checks help to identify security issues that may arise in the future. +- #### đŸ§Ŧ Continuous improvement + If your feature is used by others it is likely to be continuously improved and extended by the community. +- #### 💝 Giving back + You give back to the community that contributed to make the Parse Platform become what it is today and for future developers to come. +- #### 🧑‍🎓 Improving yourself + You learn to better understand the inner workings of Parse Server, which will help you to write more efficient and resilient code for your own application. + +Most importantly, with every contribution you improve your skills so that future contributions take even less time and you get all the benefits above for free — easy choice, right? + +## Contribution FAQs + +### Reviewer Role + +> *Instead of writing review comments back-and-forth, why doesn't the reviewer just write the code themselves?* + +A reviewer is already helping you to make a code contribution through their review. A reviewer *may* even help you to write code by actually writing it for you, but is not obliged to do so. + +GitHub allows reviewers to suggest and write code changes as part of the review feedback. These code suggestions are likely to contain mistakes due to the lack of code syntax checks when writing code directly on GitHub. You should therefore always review these suggestions before accepting them, ideally in an IDE. If you merge a code suggestion and the CI then fails, take another look at the code change before asking the reviewer for help. + +### Review Feedback + +> *It takes too much effort to incorporate the review feedback, why why can't you just merge my pull request?* + +If you are a new contributor, it's naturally a learning experience for you and therefore takes longer. We welcome contributors of any experience levels and gladly support you in getting familiar with the code base and our quality standards and contribution requirements. In return we expect you to be open to and appreciative of the reviewers' feedback. + +In a large pull request, it can be a significant effort to bring it over the finish line. Luckily this is a collaborative environment and others are free to jump in to contribute to the pull request to share the effort. You can either give others access to your fork or they can open their own pull request based on your previous work. + +If you are out of resources stay calm, explain your personal constraints (expertise or time) and ask for help. Wasting time by complaining about the amount of review comments will neither use your own time in a meaningful way, nor the time of others who read your complaint. + +This is a collaborative enviroment in which everyone works on a common goal - to get a pull request ready for merging. Reviewers are working *with* you to get your pull request ready, *not against you*. + +**â—ī¸ Always be mindful that the reviewers' efforts are an integral part of code contribution. Their review is as important as your written code and their review time is a valuable as your coding time.** + +### Merge Readiness + +> *The feature already works, why do you request more changes instead of just merging my pull request?* + +A feature may work for your own use case or in your own environment, but that doesn't necessarily mean that it's ready for merging. Aside from code quality and code style requirements, reviewers also review based on strategic and architectural considerations. It's often easy to just get a feature to work, but it needs to be also maintained in the future, robust therefore well tested and validated, intuitive for other developers to use, well documented, and not cause a forseeable breaking change in the near future. + +### Review Validity + +> *The reviewer has never worked on the issue and was never part of any previous discussion, why would I care about their opinion?* + +It's contrary to an open, collaborative environment to expect others to be involved in an issue or discussion since its beginning. Such a mindset would close out any new views, which are important for a differentiated discussion. + +> *The reviewer doesn't have any expertise in that matter, why would I care about their opinion?* + +Your arguments must focus on the issue, not on your assumption of someone else's personal experience. We will take immediate and appropriate action in case of personal attacks, regardless of your previous contributions. Personal attacks are not permissible. If you became a victim of personal attacks, you can privately [report](https://docs.github.com/en/communities/maintaining-your-safety-on-github/reporting-abuse-or-spam) the GitHub comment to the Parse Platform PMC. + +### Code Ownership + +> *Can I open a new pull request based on another author's pull request?* + +If your pull request contains work from someone else then you are required to get their permission to use their work in your pull request. Please make sure to observe the [license](LICENSE) for more details. In addition, as an appreciative gesture you should clearly mention that your pull request is based on another pull request with a link in the top-most comment of your pull request. To avoid this issue we encourage contributors to collaborate on a single pull request to preserve the commit history and clearly identify each author's contribution. To do so, you can review the other author's pull request and submit your code suggestions, or ask the original author to grant you write access to their repository to also be able to make commits directly to their pull request. + +### Access Permissions + +> *Can I get write access to the repository to make changes faster?* + +Keeping our products safe and secure is one of your top priorities. Our security policy mandates that write access to repositories is only provided to as few people as necessary. All usual contributions can be made via public pull requests. If you think you need write access, contact the repository team and explain in detail what the constraint is that you are trying to overcome. We want to make contributing for you as easy as possible. If there are any bottlenecks that are slowing you down we are happy to receive your feedback to see where we can improve. + +### New Private Repository + +> *Can I get a new private repository within the Parse Platform organization to work on some stuff?* + +Private repositories are not provided unless there is a significant constraint or requirement that makes it necessary. For example, when collaborating on fixing a security vulnerability we provide private repositories to allow collaborators to share sensitive information within a select group. + +### New Public Repository + +> *Can I get a new public repository within the Parse Platform organization to work on some stuff?* + +First of all, we appreciate your contribution. In rare cases, where we consider it beneficial to the advancement of the repository, a new public repository for a specific purpose may be provided, for example for increased visibility or to provide the organization's GitHub ressources. In other cases, we encourage you to start your contribution in a personal repository of your own GitHub account, and later transfer it to the Parse Platform organization. We will be happy to assist you in the repository transfer. + +## Environment Setup + +### Recommended Tools + +* [Visual Studio Code](https://code.visualstudio.com), the popular IDE. +* [Jasmine Test Explorer](https://marketplace.visualstudio.com/items?itemName=hbenl.vscode-jasmine-test-adapter), a very practical test exploration plugin which let you run, debug and see the test results inline. + +### Setting up your local machine + +* [Fork](https://github.com/parse-community/parse-server) this project and clone the fork on your local machine: + +```sh +$ git clone https://github.com/parse-community/parse-server +$ cd parse-server # go into the clone directory +$ npm install # install all the node dependencies +$ code . # launch vscode +$ npm run watch # run babel watching for local file changes +``` + +> To launch VS Code from the terminal with the `code` command you first need to follow the [launching from the command line section](https://code.visualstudio.com/docs/setup/mac#_launching-from-the-command-line) in the VS Code setup documentation. + +Once you have babel running in watch mode, you can start making changes to parse-server. + +### Good to Know + +* The `lib/` folder is not committed, so never make changes in there. +* Always make changes to files in the `src/` folder. +* All the tests should point to sources in the `lib/` folder. +* The `lib/` folder is produced by `babel` using either the `npm run build`, `npm run watch`, or the `npm run prepare` step. +* The `npm run prepare` step is automatically invoked when your package depends on forked parse-server installed via git for example using `npm install --save git+https://github.com/[username]/parse-server#[branch/commit]`. +* The tests are run against a single server instance. You can change the server configurations using `await reconfigureServer({ ... some configuration })` found in `spec/helper.js`. +* The tests are ran at random. +* Caches and Configurations are reset after every test. +* Users are logged out after every test. +* Cloud Code hooks are removed after every test. +* Database is deleted after every test (indexes are not removed for speed) +* Tests are located in the `spec` folder +* For better test reporting enable `PARSE_SERVER_LOG_LEVEL=debug` + +### Troubleshooting + +*Question*: I modify the code in the src folder but it doesn't seem to have any effect.
+*Answer*: Check that `npm run watch` is running + +*Question*: How do I use breakpoints and debug step by step?
+*Answer*: The easiest way is to install [Jasmine Test Explorer](https://marketplace.visualstudio.com/items?itemName=hbenl.vscode-test-explorer), it will let you run selectively tests and debug them. + +*Question*: How do I deploy my forked version on my servers?
+*Answer*: In your `package.json`, update the `parse-server` dependency to `https://github.com/[username]/parse-server#[branch/commit]`. Run `npm install`, commit the changes and deploy to your servers. + +*Question*: How do I deploy my forked version using docker?
+*Answer*: In your `package.json`, update the `parse-server` dependency to `https://github.com/[username]/parse-server#[branch/commit]`. Make sure the `npm install` step in your `Dockerfile` is running under non-privileged user for the ``npm run prepare`` step to work correctly. For official node images from hub.docker.com that non-privileged user is `node` with `/home/node` working directory. + + +### Please Do's + +* Begin by reading the [Development Guide](http://docs.parseplatform.org/parse-server/guide/#development-guide) to learn how to get started running the parse-server. +* Take testing seriously! Aim to increase the test coverage with every pull request. To obtain the test coverage of the project, run: `npm run coverage` +* Run the tests for the file you are working on with the following command: `npm test spec/MyFile.spec.js` +* Run the tests for the whole project to make sure the code passes all tests. This can be done by running the test command for a single file but removing the test file argument. The results can be seen at */coverage/lcov-report/index.html*. +* Lint your code by running `npm run lint` to make sure the code is not going to be rejected by the CI. +* **Do not** publish the *lib* folder. +* Mocks belong in the `spec/support` folder. +* Please consider if any changes to the [docs](http://docs.parseplatform.org) are needed or add additional sections in the case of an enhancement or feature. + +#### TypeScript Tests + +Type tests are located in [/types/tests.ts](/types/tests.ts) and are responsible for ensuring that the type generation for each class is behaving as expected. Types are generated by manually running the script `npm run build:types`. The generated types are `.d.ts` files located in [/types](/types) and must not be manually changed after generation. + +> [!CAUTION] +> An exemption are type changes to `src/Options/index.js` which must be manually updated in `types/Options/index.d.ts`, as these types are not generated via a script. + +When developing type definitions you can run `npm run watch:ts` in order to rebuild your changes automatically upon each save. Use `npm run test:types` in order to run types tests against generated `.d.ts` files. + +### Test against Postgres + +If your pull request introduces a change that may affect the storage or retrieval of objects, you may want to make sure it plays nice with Postgres. + +* You'll need to have postgres running on your machine and setup [appropriately](https://github.com/parse-community/parse-server/blob/master/scripts/before_script_postgres.sh) or use [`Docker`](#postgres-with-docker) +* Run the tests against the postgres database with: + ``` + PARSE_SERVER_TEST_DB=postgres PARSE_SERVER_TEST_DATABASE_URI=postgres://postgres:password@localhost:5432/parse_server_postgres_adapter_test_database npm run testonly + ``` +* The Postgres adapter has a special debugger that traces all the sql commands. You can enable it with setting the environment variable `PARSE_SERVER_LOG_LEVEL=debug` +* If your feature is intended to only work with MongoDB, you should disable PostgreSQL-specific tests with: + + - `describe_only_db('mongo')` // will create a `describe` that runs only on mongoDB + - `it_only_db('mongo')` // will make a test that only runs on mongo + - `it_exclude_dbs(['postgres'])` // will make a test that runs against all DB's but postgres +* Similarly, if your feature is intended to only work with PostgreSQL, you should disable MongoDB-specific tests with: + + - `describe_only_db('postgres')` // will create a `describe` that runs only on postgres + - `it_only_db('postgres')` // will make a test that only runs on postgres + - `it_exclude_dbs(['mongo'])` // will make a test that runs against all DB's but mongo + +* If your feature is intended to work with MongoDB and PostgreSQL, you can include or exclude tests more granularly with: + + - `it_only_mongodb_version('>=4.4')` // will test with any version of Postgres but only with version >=4.4 of MongoDB; accepts semver notation to specify a version range + - `it_only_postgres_version('>=13')` // will test with any version of Mongo but only with version >=13 of Postgres; accepts semver notation to specify a version range + +#### Postgres with Docker + +[PostGIS images (select one with v2.2 or higher) on docker hub](https://hub.docker.com/r/postgis/postgis) is based off of the official [postgres](https://hub.docker.com/_/postgres) image and will work out-of-the-box (as long as you create a user with the necessary extensions for each of your Parse databases; see below). To launch the compatible Postgres instance, copy and paste the following line into your shell: + +``` +docker run -d --name parse-postgres -p 5432:5432 -e POSTGRES_PASSWORD=password --rm postgis/postgis:17-3.5-alpine && sleep 20 && docker exec -it parse-postgres psql -U postgres -c 'CREATE DATABASE parse_server_postgres_adapter_test_database;' && docker exec -it parse-postgres psql -U postgres -c 'CREATE EXTENSION pgcrypto; CREATE EXTENSION postgis;' -d parse_server_postgres_adapter_test_database && docker exec -it parse-postgres psql -U postgres -c 'CREATE EXTENSION postgis_topology;' -d parse_server_postgres_adapter_test_database +``` +To stop the Postgres instance: + +``` +docker stop parse-postgres +``` + +You can also use the [postgis/postgis:17-3.5-alpine](https://hub.docker.com/r/postgis/postgis) image in a Dockerfile and copy this [script](https://github.com/parse-community/parse-server/blob/master/scripts/before_script_postgres.sh) to the image by adding the following lines: + +``` +#Install additional scripts. These are run in abc order during initial start +COPY ./scripts/setup-dbs.sh /docker-entrypoint-initdb.d/setup-dbs.sh +RUN chmod +x /docker-entrypoint-initdb.d/setup-dbs.sh +``` + +Note that the script above will ONLY be executed during initialization of the container with no data in the database, see the official [Postgres image](https://hub.docker.com/_/postgres) for details. If you want to use the script to run again be sure there is no data in the /var/lib/postgresql/data of the container. + +### Performance Testing + +Parse Server includes an automated performance benchmarking system that runs on every pull request to detect performance regressions and track improvements over time. + +#### Adding Tests + +You should consider adding performance benchmarks if your contribution: + +- **Introduces a performance-critical feature**: Features that will be frequently used in production environments, such as new query operations, authentication methods, or data processing functions. +- **Modifies existing critical paths**: Changes to core functionality like object CRUD operations, query execution, user authentication, file operations, or Cloud Code execution. +- **Has potential performance impact**: Any change that affects database operations, network requests, data parsing, caching mechanisms, or algorithmic complexity. +- **Optimizes performance**: If your PR specifically aims to improve performance, adding benchmarks helps verify the improvement and prevents future regressions. + +#### Adding Benchmarks + +Performance benchmarks are located in [`benchmark/performance.js`](benchmark/performance.js). To add a new benchmark: + +1. **Identify the operation to benchmark**: Determine the specific operation you want to measure (e.g., a new query type, a new API endpoint). + +2. **Create a benchmark function**: Follow the existing patterns in `benchmark/performance.js`: + ```javascript + async function benchmarkNewFeature() { + return measureOperation('Feature Name', async () => { + // Your operation to benchmark + const result = await someOperation(); + }, ITERATIONS); + } + ``` + +3. **Add to benchmark suite**: Register your benchmark in the `runBenchmarks()` function: + ```javascript + console.error('Running New Feature benchmark...'); + await cleanupDatabase(); + results.push(await benchmarkNewFeature()); + ``` + +4. **Test locally**: Run the benchmarks locally to verify they work: + ```bash + npm run benchmark:quick # Quick test with 10 iterations + npm run benchmark # Full test with 10,000 iterations + ``` + +For new features where no baseline exists, the CI will establish new benchmarks that future PRs will be compared against. + +#### Benchmark Guidelines + +- **Keep benchmarks focused**: Each benchmark should test a single, well-defined operation. +- **Use realistic data**: Test with data that reflects real-world usage patterns. +- **Clean up between runs**: Use `cleanupDatabase()` to ensure consistent test conditions. +- **Consider iteration count**: Use fewer iterations for expensive operations (see `ITERATIONS` environment variable). +- **Document what you're testing**: Add clear comments explaining what the benchmark measures and why it's important. + +## Breaking Changes + +Breaking changes should be avoided whenever possible. For a breaking change to be accepted, the benefits of the change have to clearly outweigh the costs of developers having to adapt their deployments. If a breaking change is only cosmetic it will likely be rejected and preferred to become obsolete organically during the course of further development, unless it is required as part of a larger change. Breaking changes should follow the [Deprecation Policy](#deprecation-policy). + +Please consider that Parse Server is just one component in a stack that requires attention. A breaking change requires resources and effort to adapt an environment. An unnecessarily high frequency of breaking changes can have detrimental side effects such as: +- "upgrade fatigue" where developers run old versions of Parse Server because they cannot always attend to every update that contains a breaking change +- less secure Parse Server deployments that run on old versions which is contrary to the security evangelism Parse Server intends to facilitate for developers +- less feedback and slower identification of bugs and an overall slow-down of Parse Server development because new versions with breaking changes also include new features we want to get feedback on + +### Deprecation Policy + +If you change or remove an existing feature that would lead to a breaking change, use the following deprecation pattern: + - Make the new feature or change optional, if necessary with a new Parse Server option parameter. + - Use a default value that falls back to existing behavior. + - Add a deprecation definition in `Deprecator/Deprecations.js` that will output a deprecation warning log message on Parse Server launch, for example: + > DeprecationWarning: The Parse Server option 'example' will be removed in a future release. + +For deprecations that can only be determined ad-hoc during runtime, for example Parse Query syntax deprecations, use the `Deprecator.logRuntimeDeprecation()` method. + +Deprecations become breaking changes after notifying developers through deprecation warnings for at least one entire previous major release. For example: + - `4.5.0` is the current version + - `4.6.0` adds a new optional feature and a deprecation warning for the existing feature + - `5.0.0` marks the beginning of logging the deprecation warning for one entire major release + - `6.0.0` makes the breaking change by removing the deprecation warning and making the new feature replace the existing feature + +See the [Deprecation Plan](https://github.com/parse-community/parse-server/blob/master/DEPRECATIONS.md) for an overview of deprecations and planned breaking changes. + +## Feature Considerations +### Security Checks + +The Parse Server security checks feature warns developers about weak security settings in their Parse Server deployment. + +A security check needs to be added for every new feature or enhancement that allows the developer to configure it in a way that weakens security mechanisms or exposes functionality which creates a weak spot for malicious attacks. If you are not sure whether your feature or enhancements requires a security check, feel free to ask. + +For example, allowing public read and write to a class may be useful to simplify development but should be disallowed in a production environment. + +Security checks are added in [CheckGroups](https://github.com/parse-community/parse-server/tree/master/src/Security/CheckGroups). + +#### Add Security Check +Adding a new security check for your feature is easy and fast: +1. Look into [CheckGroups](https://github.com/parse-community/parse-server/tree/master/src/Security/CheckGroups) whether there is an existing `CheckGroup[Category].js` file for the category of check to add. For example, a check regarding the database connection is added to `CheckGroupDatabase.js`. +2. If you did not find a file, duplicate an existing file and replace the category name in `setName()` and the checks in `setChecks()`: + ```js + class CheckGroupNewCategory extends CheckGroup { + setName() { + return 'House'; + } + setChecks() { + return [ + new Check({ + title: 'Door locked', + warning: 'Anyone can enter your house.', + solution: 'Lock the door.', + check: () => { + return; // Example of a passing check + } + }), + new Check({ + title: 'Camera online', + warning: 'Security camera is offline.', + solution: 'Check the camera.', + check: async () => { + throw 1; // Example of a failing check + } + }), + ]; + } + } + ``` + +3. If you added a new file in the previous step, reference the file in [CheckGroups.js](https://github.com/parse-community/parse-server/blob/master/src/Security/CheckGroups/CheckGroups.js), which is the collector of all security checks: + ``` + export { default as CheckGroupNewCategory } from './CheckGroupNewCategory'; + ``` +4. Add a test that covers the new check to [SecurityCheckGroups.js](https://github.com/parse-community/parse-server/blob/master/spec/SecurityCheckGroups.js) for the cases of success and failure. + +#### Wording Guideline +Consider the following when adding a new security check: +- *Group.name*: The category name; ends without period as this is a headline. +- *Check.title*: Is the positive hypothesis that should be checked, for example "Door locked" instead of "Door unlocked"; ends without period as this is a title. +- *Check.warning*: The warning if the test fails; ends with period as this is a description. +- *Check.solution*: The recommended solution if the test fails; ends with period as this is an instruction. +- The wordings must not contain any sensitive information such as keys, as the security report may be exposed in logs. +- The wordings should be concise and not contain verbose explanations, for example "Door locked" instead of "Door has been locked securely". +- Do not use pronouns such as "you" or "your" because log files can have various readers with different roles. Do not use pronouns such as "I" or "me" because although we love it dearly, Parse Server is not a human. + +### Parse Error + +Introducing new Parse Errors requires the following steps: + +1. Research whether an existing Parse Error already covers the error scenario. Keep in mind that reusing an already existing Parse Error does not allow to distinguish between scenarios in which the same error is thrown, so it may be necessary to add a new and more specific Parse Error, even though a more general Parse Error already exists. +âš ī¸ Currently (as of Dec. 2020), there are inconsistencies between the Parse Errors documented in the Parse Guides, coded in the Parse JS SDK and coded in Parse Server, therefore research regarding the availability of error codes has to be conducted in all of these sources. +1. Add the new Parse Error to [/src/ParseError.js](https://github.com/parse-community/Parse-SDK-JS/blob/master/src/ParseError.js) in the Parse JavaScript SDK. This is the primary reference for Parse Errors for the Parse JavaScript SDK and Parse Server. +1. Create a pull request for the Parse JavaScript SDK including the new Parse Errors. The PR needs to be merged and a new Parse JS SDK version needs to be released. +1. Change the Parse JS SDK dependency in [package.json](https://github.com/parse-community/parse-server/blob/master/package.json) of Parse Server to the newly released Parse JS SDK version, so that the new Parse Error is recognized by Parse Server. +1. When throwing the new Parse Error in code, do not hard-code the error code but instead reference the error code from the Parse Error. For example: + ```javascript + throw new Parse.Error(Parse.Error.EXAMPLE_ERROR_CODE, 'Example error message.'); + ``` +1. Choose a descriptive error message that provdes more details about the specific error scenario. Different error messages may be used for the same error code. For example: + ```javascript + throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'The file could not be saved because it exceeded the maximum allowed file size.'); + throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'The file could not be saved because the file format was incorrect.'); + ``` +1. Add the new Parse Error to the [docs](https://github.com/parse-community/docs/blob/gh-pages/_includes/common/errors.md). + +### Parse Server Configuration + +Introducing new [Parse Server configuration][config] parameters requires the following steps: + +1. Add parameters definitions in [/src/Options/index.js][config-index]. +2. If the new parameter does not have one single value but is a parameter group (an object containing multiple sub-parameters): + - add the environment variable prefix for the parameter group to `nestedOptionEnvPrefix` in [/resources/buildConfigDefinition.js](https://github.com/parse-community/parse-server/blob/master/resources/buildConfigDefinition.js) + - add the parameter group type to `nestedOptionTypes` in [/resources/buildConfigDefinition.js](https://github.com/parse-community/parse-server/blob/master/resources/buildConfigDefinition.js) + + For example, take a look at the existing Parse Server `security` parameter. It is a parameter group, because it has multiple sub-parameter such as `checkGroups`. Its interface is defined in [index.js][config-index] as `export interface SecurityOptions`. Therefore, the value to add to `nestedOptionTypes` would be `SecurityOptions`, the value to add to `nestedOptionEnvPrefix` would be `PARSE_SERVER_SECURITY_`. + +3. Execute `npm run definitions` to automatically create the definitions in [/src/Options/Definitions.js][config-def] and [/src/Options/docs.js][config-docs]. +4. Add parameter value validation in [/src/Config.js](https://github.com/parse-community/parse-server/blob/master/src/Config.js). +5. Add test cases to ensure the correct parameter value validation. Parse Server throws an error at launch if an invalid value is set for any configuration parameter. +6. Execute `npm run docs` to generate the documentation in the `/out` directory. Take a look at the documentation whether the description and formatting of the newly introduced parameters is satisfactory. + +## Pull Request + +### Commit Message + +For release automation, the title of pull requests needs to be written in a defined syntax. We loosely follow the [Conventional Commits](https://www.conventionalcommits.org) specification, which defines this syntax: + +``` +: +``` + +The _type_ is the category of change that is made, possible types are: +- `feat` - add a new feature or improve an existing feature +- `fix` - fix a bug +- `refactor` - refactor code without impact on features or performance +- `docs` - add or edit code comments, documentation, GitHub pages +- `style` - edit code style +- `build` - retry failing build and anything build process related +- `perf` - performance optimization +- `ci` - continuous integration +- `test` - tests + +The _summary_ is a short change description in present tense, not capitalized, without period at the end. This summary will also be used as the changelog entry. +- It must be short and self-explanatory for a reader who does not see the details of the full pull request description +- It must not contain abbreviations, e.g. instead of `LQ` write `LiveQuery` +- It must use the correct product and feature names as referenced in the documentation, e.g. instead of `Cloud Validator` use `Cloud Function validation` +- In case of a breaking change, the summary must not contain duplicate information that is also in the [BREAKING CHANGE](#breaking-change) chapter of the pull request description. It must not contain a note that it is a breaking change, as this will be automatically flagged as such if the pull request description contains the BREAKING CHANGE chapter. + +For example: + +``` +feat: add handle to door for easy opening +``` + +Currently, we are not making use of the commit _scope_, which would be written as `(): `, that attributes a change to a specific part of the product. + +### Breaking Change + +If a pull request contains a braking change, the description of the pull request must contain a dedicated chapter at the bottom to indicate this. This is to assist the committer of the pull request to avoid merging a breaking change as non-breaking. + +## Merging + +The following guide is for anyone who merges a contributor pull request into the working branch, the working branch into a release branch, a release branch into another release branch, or any other direct commits such as hotfixes into release branches or the working branch. + +- A contributor pull request must be merged into the working branch using `Squash and Merge`, to create a single commit message that describes the change. +- A release branch or the default branch must be merged into another release branch using `Merge Commit`, to preserve each individual commit message that describes its respective change. +- For changelog generation, only the commit message set when merging the pull request is relevant. The title and description of the GitHub pull request as authored by the contributor have no influence on the changelog generation. However, the title of the GitHub pull request should be used as the commit message. See the following chapters for considerations in special scenarios, e.g. merging a breaking change or reverting a commit. + +### Breaking Change + +If the pull request contains a breaking change, the commit message must contain the phrase `BREAKING CHANGE`, capitalized and without any formatting, followed by a short description of the breaking change and ideally how the developer should address it, all in a single line. This line should contain more details focusing on the "breaking” aspect of the change and is intended to assist the developer in adapting. Keep it concise, as it will become part of the changelog entry, for example: + + ``` + fix: remove handle from door + + BREAKING CHANGE: You cannot open the door anymore by using a handle. See the [#migration guide](http://example.com) for more details. + ``` + Keep in mind that in a repository with release automation, merging such a commit message will trigger a release with a major version increment. + +### Reverting + +If the commit reverts a previous commit, use the prefix `revert:`, followed by the header of the reverted commit. In the body of the commit message add `This reverts commit .`, where the hash is the SHA of the commit being reverted. For example: + + ``` + revert: fix: remove handle from door + + This reverts commit 1234567890abcdef. + ``` + +âš ī¸ A `revert` prefix will *always* trigger a release. Generally, a commit that did not trigger a release when it was initially merged should also not trigger a release when it is reverted. For example, do not use the `revert` prefix when reverting a commit that has a `ci` prefix: + + ``` + ci: add something + ``` + is reverted with: + ``` + ci: remove something + ``` + instead of: + ``` + revert: ci: add something + + This reverts commit 1234567890abcdef. + ``` + +### Security Vulnerability + +#### Local Testing + +Fixes for security vulnerabilities are developed in private forks with a closed audience, inaccessible to the public. A current GitHub limitation does not allow to run CI tests on pull requests in private forks. Whether a pull requests fully passes all CI tests can only be determined by publishing the fix as a public pull request and running the CI. This means the fix and implicitly information about the vulnerability are made accessible to the public. This increases the risk that a vulnerability fix is published, but then cannot be merged immediately due to a CI issue. To mitigate that risk, before publishing a vulnerability fix, the following tests needs to be run locally and pass: + +- `npm run test` to test with MongoDB +- `npm run test:postgres:testonly` to test with Postgres +- `npm run madge:circular` to detect circular dependencies +- `npm run lint` to check lint compliance +- `npm run definitions` to update the Parse Server options definitions + +> [!CAUTION] +> It is essential to run `npm run build` *after* switching to a different branch or making a commit and *before* running any tests. Otherwise the tests may run on the build from a different branch or on a build that does not reflect the most recent commits. + +#### Environment + +A reported vulnerability may have already been fixed since it was reported, either due to a targeted fix or as side-effect of other code changed. To verify that a vulnerability exists, tests need to be run in an environment that uses the latest commit of the development branch of Parse Server. + +> [!NOTE] +> Do not use the latest alpha version for testing as it may be behind the latest commit of the development branch. + +Vulnerability test must only be conducted in environments for which the tester can ensure that no unauthorized 3rd party has potentially access to. This is to ensure a vulnerability stays confidential and is not exposed prematurely to the public. + +You must not test a vulnerability using any 3rd party APIs that provide Parse Server as a hosted service (SaaS) as this may expose the vulnerability to an unauthorized 3rd party and the effects of the vulnerability may cause issues on the provider's side. + +> [!CAUTION] +> Utilizing a vulnerability in a third-party service, even for testing or development purposes, can result in legal repercussions. You are solely accountable for any damage arising from such actions and agree to indemnify Parse Platform against any liabilities or claims resulting from your actions. + +#### Merging + +A current GitHub limitation does not allow to customize the commit message when merging pull requests of a private fork that was created to fix a security vulnerability. Our release automation framework demands a specific commit message syntax which therefore cannot be met. This prohibits to follow the process that GitHub suggest, which is to merge a pull request from a private fork directly to a public branch. Instead, after [local testing](#local-testing), a public pull request needs to be created with the code fix copied over from the private pull request. + +This creates a risk that a vulnerability is indirectly disclosed by publishing a pull request with the fix, but the fix cannot be merged due to a CI issue. To mitigate that risk, the pull request title and description should be kept marginal or generic, not hinting to a vulnerability or giving any details about the vulnerability, until the pull request has been successfully merged. + +## Releasing + +### General Considerations + +- The `package-lock.json` file has to be deleted and recreated by npm from scratch in regular intervals using the `npm i` command. It is not enough to only update the file via automated security pull requests (e.g. dependabot, snyk), that can create inconsistencies between sub-dependencies of a dependency and increase the chances of vulnerabilities. The file should be recreated once every release cycle which is usually monthly. + +### Major Release / Long-Term-Support + +While the current major version is published on branch `release`, a Long-Term-Support (LTS) version is published on branch `release-#.x.x`, for example `release-4.x.x` for the Parse Server 4.x LTS branch. + +Only the previous major version is under LTS. Older major versions are no longer maintained and their `release-#.x.x` branches are frozen; no further changes will be made. If you need features or fixes on an older branch, fork it and backport changes in your own branch. + +### Preparing Release + +The following changes are done in the `alpha` branch, before publishing the last `beta` version that will eventually become the major release. This way the changes trickle naturally through all branches and code consistency is ensured among branches. + +- Make sure all [deprecations](https://github.com/parse-community/parse-server/blob/alpha/DEPRECATIONS.md) are reflected in code, old code is removed and the deprecations table is updated. +- Add the future LTS branch `release-#.x.x` to the branch list in [release.config.js](https://github.com/parse-community/parse-server/blob/alpha/release.config.js) so that the branch will later be recognized for release automation. + +### Publishing Release (forward-merge): + +1. Create new temporary branch `build` on branch `beta`. +2. Create PR to merge `build` into `release`: + - PR title: `build: release` + - PR description: (leave empty) +3. Resolve any conflicts: + - For conflicts regarding the package version in `package.json` and `package-lock.json` it doesn't matter which version is chosen, as the version will be set by auto-release in a commit after merging. However, for both files the same version should be chosen when resolving the conflict. +4. Merge PR with a "merge commit", do not "squash and merge": + - Commit message: (use PR title) + - Description: (leave empty) +5. Wait for GitHub Action `release-automated` to finish: + - If GitHub Action fails, investigate why; manual correction may be needed. +6. Pull all remote branches into local branches. +7. Delete temporary branch `build`. +8. Create new temporary branch `build` on branch `alpha`. +9. Create PR to merge `build` into `beta`: + - PR title: `build: release` + - PR description: (leave empty) +8. Repeat steps 3-7 for PR from step 9. + +### Publishing Hotfix (back-merge): + +1. Create PR to merge hotfix PR into `release`: + - Merge PR following the same rules as any PR would be merged into the working branch `alpha`. +2. Wait for GitHub Action `release-automated` to finish: + - GitHub Action will fail with error `! [rejected] HEAD -> beta (non-fast-forward)`; this is expected as auto-release currently cannot fully handle back-merging; docker will not publish the new release, so this has to be done manually using the GitHub workflow `release-manual-docker` and entering the version tag that has been created by auto-release. +3. Pull all remote branches into local branches. +4. Create a new temporary branch `backmerge` on branch `release`. +5. Create PR to merge `backmerge` into `beta`: + - PR title: `refactor: ` where `` is the commit summary of step 1. The commit type needs to be `refactor`, otherwise the commit will show in the changelog of the `release` branch, once the `beta` branch is merged into release; this would a duplicate entry because the same changelog entry has already been generated when the PR was merged into the `release` branch in step 1. + - PR description: (leave empty) +6. Resolve any conflicts: + - During back-merging, usually all changes are preserved; current changes come from the hotfix in the `release` branch, the incoming changes come from the `beta` branch usually being ahead of the `release` branch. This makes back-merging so complex and bug-prone and is the main reason why it should be avoided if possible. +7. Merge PR with "squash and merge", do not do a "merge commit": + - Commit message: (use PR title) + - Description: (leave empty) + + â„šī¸ Merging this PR will not trigger a release; the back-merge will not appear in changelogs of the `beta`, `alpha` branches; the back-merged fix will be an undocumented change of these branches' next releases; if necessary, the change needs to be added manually to the pre-release changelogs *after* the next pre-releases. +8. Delete temporary branch `backmerge`. +10. Create a new temporary branch `backmerge` on branch `beta`. +11. Repeat steps 4-8 to merge PR into `alpha`. + +âš ī¸ Long-term-support branches are excluded from the processes above and handled individually as they do not have pre-releases branches and are not considered part of the current codebase anymore. It may be necessary to significantly adapt a PR for a LTS branch due to the differences in codebase and CI tests. This adaption should be done in advance before merging any related PR, especially for security fixes, as to not publish a vulnerability while it may still take significant time to adapt the fix for the older codebase of a LTS branch. + +### Publishing Major Release (Yearly Release) + +1. Create LTS branch `release-#.x.x` off the latest version tag on `release` branch. +2. Create temporary branch `build-release` off branch `beta` and create a pull request with `release` as the base branch. +3. Merge branch `build-release` into `release`. Given that there will be breaking changes, a new major release will be created. In the unlikely case that there have been no breaking changes between the previous major release and the upcoming release, a major version increment has to be triggered manually. See the docs of the release automation framework for how to do that. +4. Add newly created LTS branch `release-#.x.x` from step 1 to [Snyk](https://snyk.io) so that Snyk opens pull requests for the LTS branch; remove previously existing LTS branch `release-#.x.x` from Snyk. + +## Versioning + +> The following versioning system is applied since Parse Server 5.0.0 and does not necessarily apply to previous releases. + +Parse Server follows [semantic versioning](https://semver.org) with a flavor of [calendric versioning](https://calver.org). Semantic versioning makes Parse Server easy to upgrade because breaking changes only occur in major releases. Calendric versioning gives an additional sense of how old a Parse Server release is and allows for Long-Term Support of previous major releases. + +Example version: `5.0.0-alpha.1` + +Syntax: `[major]`**.**`[minor]`**.**`[patch]`**-**`[pre-release-label]`**.**`[pre-release-increment]` + +- The `major` version increments with the first release of every year and may include changes that are *not backwards compatible*. +- The `minor` version increments during the year and may include new features or improvements of existing features that are backwards compatible. +- The `patch` version increments during the year and may include bug fixes that are backwards compatible. +- The `pre-release-label` is optional for pre-release versions such as: + - `-alpha` (likely to contain bugs, likely to change in features until release) + - `-beta` (likely to contain bugs, no change in features until release) +- The `[pre-release-increment]` is a number that increments with every new version of a pre-release + +Exceptions: +- The `major` version may increment during the year in the unlikely event that a breaking change is so urgent that it cannot wait for the next yearly release. An example would be a vulnerability fix that leads to an unavoidable breaking change. However, security requirements depend on the application and not every vulnerability may affect every deployment, depending on the features used. Therefore we usually prefer to deprecate insecure functionality and introduce the breaking change following our [deprecation policy](#deprecation-policy). + +## Code of Conduct + +This project adheres to the [Contributor Covenant Code of Conduct](https://github.com/parse-community/parse-server/blob/master/CODE_OF_CONDUCT.md). By participating, you are expected to honor this code. + +[config]: http://parseplatform.org/parse-server/api/master/ParseServerOptions.html +[config-def]: https://github.com/parse-community/parse-server/blob/master/src/Options/Definitions.js +[config-docs]: https://github.com/parse-community/parse-server/blob/master/src/Options/docs.js +[config-index]: https://github.com/parse-community/parse-server/blob/master/src/Options/index.js diff --git a/DEPRECATIONS.md b/DEPRECATIONS.md new file mode 100644 index 0000000000..27ccee409e --- /dev/null +++ b/DEPRECATIONS.md @@ -0,0 +1,34 @@ +# Deprecation Plan + +The following is a list of deprecations, according to the [Deprecation Policy](https://github.com/parse-community/parse-server/blob/master/CONTRIBUTING.md#deprecation-policy). After a feature becomes deprecated, and giving developers time to adapt to the change, the deprecated feature will eventually be changed, leading to a breaking change. Developer feedback during the deprecation period may postpone or even revoke the introduction of the breaking change. + +| ID | Change | Issue | Deprecation [â„šī¸][i_deprecation] | Planned Change [â„šī¸][i_change] | Status [â„šī¸][i_status] | Notes | +|---------|----------------------------------------------------------------------------------------------|------------------------------------------------------------------------|---------------------------------|---------------------------------|-----------------------|-------| +| DEPPS1 | Native MongoDB syntax in aggregation pipeline | [#7338](https://github.com/parse-community/parse-server/issues/7338) | 5.0.0 (2022) | 6.0.0 (2023) | changed | - | +| DEPPS2 | Config option `directAccess` defaults to `true` | [#6636](https://github.com/parse-community/parse-server/pull/6636) | 5.0.0 (2022) | 6.0.0 (2023) | changed | - | +| DEPPS3 | Config option `enforcePrivateUsers` defaults to `true` | [#7319](https://github.com/parse-community/parse-server/pull/7319) | 5.0.0 (2022) | 6.0.0 (2023) | changed | - | +| DEPPS4 | Remove convenience method for http request `Parse.Cloud.httpRequest` | [#7589](https://github.com/parse-community/parse-server/pull/7589) | 5.0.0 (2022) | 6.0.0 (2023) | changed | - | +| DEPPS5 | Config option `allowClientClassCreation` defaults to `false` | [#7925](https://github.com/parse-community/parse-server/pull/7925) | 5.3.0 (2022) | 7.0.0 (2024) | changed | - | +| DEPPS6 | Auth providers disabled by default | [#7953](https://github.com/parse-community/parse-server/pull/7953) | 5.3.0 (2022) | 7.0.0 (2024) | changed | - | +| DEPPS7 | Remove file trigger syntax `Parse.Cloud.beforeSaveFile((request) => {})` | [#7966](https://github.com/parse-community/parse-server/pull/7966) | 5.3.0 (2022) | 7.0.0 (2024) | changed | - | +| DEPPS8 | Login with expired 3rd party authentication token defaults to `false` | [#7079](https://github.com/parse-community/parse-server/pull/7079) | 5.3.0 (2022) | 7.0.0 (2024) | changed | - | +| DEPPS9 | Rename LiveQuery `fields` option to `keys` | [#8389](https://github.com/parse-community/parse-server/issues/8389) | 6.0.0 (2023) | 7.0.0 (2024) | changed | - | +| DEPPS10 | Encode `Parse.Object` in Cloud Function and remove option `encodeParseObjectInCloudFunction` | [#8634](https://github.com/parse-community/parse-server/issues/8634) | 6.2.0 (2023) | 9.0.0 (2026) | changed | - | +| DEPPS11 | Replace `PublicAPIRouter` with `PagesRouter` | [#7625](https://github.com/parse-community/parse-server/issues/7625) | 8.0.0 (2025) | 9.0.0 (2026) | changed | - | +| DEPPS12 | Database option `allowPublicExplain` defaults to `false` | [#7519](https://github.com/parse-community/parse-server/issues/7519) | 8.5.0 (2025) | 9.0.0 (2026) | changed | - | +| DEPPS13 | Config option `enableInsecureAuthAdapters` defaults to `false` | [#9667](https://github.com/parse-community/parse-server/pull/9667) | 8.0.0 (2025) | 9.0.0 (2026) | changed | - | +| DEPPS14 | Config option `pages.encodePageParamHeaders` defaults to `true` | [#10063](https://github.com/parse-community/parse-server/issues/10063) | 9.4.0 (2026) | 10.0.0 (2027) | deprecated | - | +| DEPPS15 | Config option `readOnlyMasterKeyIps` defaults to `['127.0.0.1', '::1']` | [#10115](https://github.com/parse-community/parse-server/pull/10115) | 9.5.0 (2026) | 10.0.0 (2027) | deprecated | - | +| DEPPS16 | Remove config option `mountPlayground` | [#10110](https://github.com/parse-community/parse-server/issues/10110) | 9.5.0 (2026) | 10.0.0 (2027) | deprecated | - | +| DEPPS17 | Remove config option `playgroundPath` | [#10110](https://github.com/parse-community/parse-server/issues/10110) | 9.5.0 (2026) | 10.0.0 (2027) | deprecated | - | +| DEPPS18 | Config option `requestComplexity` limits enabled by default | [#10207](https://github.com/parse-community/parse-server/pull/10207) | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - | +| DEPPS19 | Remove config option `enableProductPurchaseLegacyApi` | [#10228](https://github.com/parse-community/parse-server/pull/10228) | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - | +| DEPPS20 | Remove config option `allowExpiredAuthDataToken` | | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - | +| DEPPS21 | Config option `protectedFieldsOwnerExempt` defaults to `false` | | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - | +| DEPPS22 | Config option `protectedFieldsTriggerExempt` defaults to `true` | | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - | +| DEPPS23 | Config option `protectedFieldsSaveResponseExempt` defaults to `false` | | 9.7.0 (2026) | 10.0.0 (2027) | deprecated | - | +| DEPPS24 | Config option `installation.duplicateDeviceTokenActionEnforceAuth` defaults to `true` | [#10451](https://github.com/parse-community/parse-server/pull/10451) | 9.9.0 (2026) | 10.0.0 (2027) | deprecated | - | + +[i_deprecation]: ## "The version and date of the deprecation." +[i_change]: ## "The version and date of the planned change." +[i_status]: ## "The current status of the deprecation: deprecated (the feature is deprecated but still available), changed (the deprecated feature has been changed), retracted (the deprecation has been retracted and the feature will not be changed." diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..adb559ad44 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +############################################################ +# Build stage +############################################################ +FROM node:20.19.0-alpine3.20 AS build + +RUN apk --no-cache add \ + build-base \ + git \ + python3 + +WORKDIR /tmp + +# Copy package.json first to benefit from layer caching +COPY package*.json ./ + +# Copy src to have config files for install +COPY . . + +# Increase npm network timeout and retries for slow platforms (e.g. arm64 via QEMU) +ENV npm_config_fetch_retries=5 +ENV npm_config_fetch_retry_mintimeout=60000 +ENV npm_config_fetch_retry_maxtimeout=300000 + +# Install without scripts +RUN npm ci --omit=dev --ignore-scripts \ + # Copy production node_modules aside for later + && cp -R node_modules prod_node_modules \ + # Install all dependencies + && npm ci \ + # Run build steps + && npm run build + +############################################################ +# Release stage +############################################################ +FROM node:20.19.0-alpine3.20 AS release + +VOLUME /parse-server/cloud /parse-server/config + +WORKDIR /parse-server + +# Copy build stage folders +COPY --from=build /tmp/prod_node_modules /parse-server/node_modules +COPY --from=build /tmp/lib lib + +COPY package*.json ./ +COPY bin bin +COPY public public +COPY views views +RUN mkdir -p logs && chown -R node: logs + +ENV PORT=1337 +USER node +EXPOSE $PORT + +ENTRYPOINT ["node", "./bin/parse-server"] diff --git a/LICENSE b/LICENSE index f2207ea9af..4877592850 100644 --- a/LICENSE +++ b/LICENSE @@ -1,30 +1,176 @@ -BSD License + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -For Parse Server software +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -Copyright (c) 2015-present, Parse, LLC. All rights reserved. +1. Definitions. -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. - * Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. - * Neither the name Parse nor the names of its contributors may be used to - endorse or promote products derived from this software without specific - prior written permission. + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000000..ef5ddb2201 --- /dev/null +++ b/NOTICE @@ -0,0 +1,10 @@ +Parse Server + +Copyright 2015-present Parse Platform + +This product includes software developed at Parse Platform. +www.parseplatform.org + +--- + +As of April 5, 2017, Parse, LLC has transferred this code to the Parse Platform organization, and will no longer be contributing to or distributing this code. diff --git a/PATENTS b/PATENTS deleted file mode 100644 index 66bfadd92b..0000000000 --- a/PATENTS +++ /dev/null @@ -1,33 +0,0 @@ -Additional Grant of Patent Rights Version 2 - -"Software" means the Parse Server software distributed by Parse, LLC. - -Parse, LLC. ("Parse") hereby grants to each recipient of the Software -("you") a perpetual, worldwide, royalty-free, non-exclusive, irrevocable -(subject to the termination provision below) license under any Necessary -Claims, to make, have made, use, sell, offer to sell, import, and otherwise -transfer the Software. For avoidance of doubt, no license is granted under -Parse’s rights in any patent claims that are infringed by (i) modifications -to the Software made by you or any third party or (ii) the Software in -combination with any software or other technology. - -The license granted hereunder will terminate, automatically and without notice, -if you (or any of your subsidiaries, corporate affiliates or agents) initiate -directly or indirectly, or take a direct financial interest in, any Patent -Assertion: (i) against Parse or any of its subsidiaries or corporate -affiliates, (ii) against any party if such Patent Assertion arises in whole or -in part from any software, technology, product or service of Parse or any of -its subsidiaries or corporate affiliates, or (iii) against any party relating -to the Software. Notwithstanding the foregoing, if Parse or any of its -subsidiaries or corporate affiliates files a lawsuit alleging patent -infringement against you in the first instance, and you respond by filing a -patent infringement counterclaim in that lawsuit against that party that is -unrelated to the Software, the license granted hereunder will not terminate -under section (i) of this paragraph due to such counterclaim. - -A "Necessary Claim" is a claim of a patent owned by Parse that is -necessarily infringed by the Software standing alone. - -A "Patent Assertion" is any lawsuit or other action alleging direct, indirect, -or contributory infringement or inducement to infringe any patent, including a -cross-claim or counterclaim. diff --git a/README.md b/README.md index 5814ba9826..6e9862f898 100644 --- a/README.md +++ b/README.md @@ -1,275 +1,546 @@ -![Parse Server logo](.github/parse-server-logo.png?raw=true) +![parse-repository-header-server](https://user-images.githubusercontent.com/5673677/138278489-7d0cebc5-1e31-4d3c-8ffb-53efcda6f29d.png) + +--- + +[![Build Status](https://github.com/parse-community/parse-server/actions/workflows/ci.yml/badge.svg?branch=alpha)](https://github.com/parse-community/parse-server/actions/workflows/ci.yml?query=workflow%3Aci+branch%3Aalpha) +[![Build Status](https://github.com/parse-community/parse-server/actions/workflows/ci.yml/badge.svg?branch=release)](https://github.com/parse-community/parse-server/actions/workflows/ci.yml?query=workflow%3Aci+branch%3Arelease) +[![Snyk Badge](https://snyk.io/test/github/parse-community/parse-server/badge.svg)](https://snyk.io/test/github/parse-community/parse-server) +[![Coverage](https://codecov.io/github/parse-community/parse-server/branch/alpha/graph/badge.svg)](https://app.codecov.io/github/parse-community/parse-server/tree/alpha) +[![auto-release](https://img.shields.io/badge/%F0%9F%9A%80-auto--release-9e34eb.svg)](https://github.com/parse-community/parse-dashboard/releases) + +[![Node Version](https://img.shields.io/badge/nodejs-20,_22,_24-green.svg?logo=node.js&style=flat)](https://nodejs.org) +[![MongoDB Version](https://img.shields.io/badge/mongodb-7,_8-green.svg?logo=mongodb&style=flat)](https://www.mongodb.com) +[![Postgres Version](https://img.shields.io/badge/postgresql-16,_17,_18-green.svg?logo=postgresql&style=flat)](https://www.postgresql.org) + +[![npm latest version](https://img.shields.io/npm/v/parse-server/latest.svg)](https://www.npmjs.com/package/parse-server) +[![npm alpha version](https://img.shields.io/npm/v/parse-server/alpha.svg)](https://www.npmjs.com/package/parse-server) + +[![Backers on Open Collective](https://opencollective.com/parse-server/backers/badge.svg)][open-collective-link] +[![Sponsors on Open Collective](https://opencollective.com/parse-server/sponsors/badge.svg)][open-collective-link] +[![Forum](https://img.shields.io/discourse/https/community.parseplatform.org/topics.svg)](https://community.parseplatform.org/c/parse-server) +[![Twitter](https://img.shields.io/twitter/follow/ParsePlatform.svg?label=Follow&style=social)](https://twitter.com/intent/follow?screen_name=ParsePlatform) +[![Chat](https://img.shields.io/badge/Chat-Join!-%23fff?style=social&logo=slack)](https://chat.parseplatform.org) + +--- + +Parse Server is an open source backend that can be deployed to any infrastructure that can run Node.js. Parse Server works with the Express web application framework. It can be added to existing web applications, or run by itself. + +The full documentation for Parse Server is available in the [wiki](https://github.com/parse-community/parse-server/wiki). The [Parse Server guide](http://docs.parseplatform.org/parse-server/guide/) is a good place to get started. An [API reference](http://parseplatform.org/parse-server/api/) and [Cloud Code guide](https://docs.parseplatform.org/cloudcode/guide/) are also available. If you're interested in developing for Parse Server, the [Development guide](http://docs.parseplatform.org/parse-server/guide/#development-guide) will help you get set up. + +--- + +A big _thank you_ 🙏 to our [sponsors](#sponsors) and [backers](#backers) who support the development of Parse Platform! + +#### Bronze Sponsors + +[![Bronze Sponsors](https://opencollective.com/parse-server/tiers/bronze-sponsor.svg?avatarHeight=36&button=false)](https://opencollective.com/parse-server/contribute/bronze-sponsor-10559) + +--- + +- [Flavors \& Branches](#flavors--branches) + - [Long Term Support](#long-term-support) +- [Getting Started](#getting-started) + - [Running Parse Server](#running-parse-server) + - [Compatibility](#compatibility) + - [Node.js](#nodejs) + - [MongoDB](#mongodb) + - [PostgreSQL](#postgresql) + - [Locally](#locally) + - [Docker Container](#docker-container) + - [Saving and Querying Objects](#saving-and-querying-objects) + - [Connect an SDK](#connect-an-sdk) + - [Running Parse Server elsewhere](#running-parse-server-elsewhere) + - [Sample Application](#sample-application) + - [Parse Server + Express](#parse-server--express) + - [Parse Server Health](#parse-server-health) + - [Status Values](#status-values) +- [Configuration](#configuration) + - [Basic Options](#basic-options) + - [Client Key Options](#client-key-options) + - [Access Scopes](#access-scopes) + - [Route Allow List](#route-allow-list) + - [Covered Routes](#covered-routes) + - [Email Verification and Password Reset](#email-verification-and-password-reset) + - [Password and Account Policy](#password-and-account-policy) + - [Custom Routes](#custom-routes) + - [Example](#example) + - [Reserved Paths](#reserved-paths) + - [Parameters](#parameters) + - [Custom Pages](#custom-pages) + - [Using Environment Variables](#using-environment-variables) + - [Available Adapters](#available-adapters) + - [Configuring File Adapters](#configuring-file-adapters) + - [Restricting File URL Domains](#restricting-file-url-domains) + - [Idempotency Enforcement](#idempotency-enforcement) + - [Installations](#installations) + - [Localization](#localization) + - [Pages](#pages) + - [Localization with Directory Structure](#localization-with-directory-structure) + - [Localization with JSON Resource](#localization-with-json-resource) + - [Dynamic placeholders](#dynamic-placeholders) + - [Reserved Keys](#reserved-keys) + - [Parameters](#parameters-1) + - [Multi-Tenancy](#multi-tenancy) + - [Logging](#logging) +- [Deprecations](#deprecations) +- [Live Query](#live-query) +- [GraphQL](#graphql) + - [Running](#running) + - [Using the CLI](#using-the-cli) + - [Using Docker](#using-docker) + - [Using Express.js](#using-expressjs) + - [Checking the API health](#checking-the-api-health) + - [Creating your first class](#creating-your-first-class) + - [Using automatically generated operations](#using-automatically-generated-operations) + - [Customizing your GraphQL Schema](#customizing-your-graphql-schema) + - [Learning more](#learning-more) +- [Contributing](#contributing) +- [Contributors](#contributors) +- [Sponsors](#sponsors) +- [Backers](#backers) + +# Flavors & Branches + +Parse Server is available in different flavors on different branches: + +- The main branches are [release][log_release] and [alpha][log_alpha]. See the [changelog overview](CHANGELOG.md) for details. +- The long-term-support (LTS) branches are named `release-.x.x`, for example `release-5.x.x`. LTS branches do not have pre-release branches. + +## Long Term Support + +Long-Term-Support (LTS) is provided for the previous Parse Server major version. For example, Parse Server 5.x will receive security updates until Parse Server 6.x is superseded by Parse Server 7.x and becomes the new LTS version. While the current major version is published on branch `release`, a LTS version is published on branch `release-#.x.x`, for example `release-5.x.x` for the Parse Server 5.x LTS branch. + +âš ī¸ LTS versions are provided to help you transition as soon as possible to the current major version. While we aim to fix security vulnerabilities in the LTS version, our main focus is on developing the current major version and preparing the next major release. Therefore we may leave certain vulnerabilities up to the community to fix. Search for [pull requests with the specific LTS base branch](https://github.com/parse-community/parse-server/pulls?q=is%3Aopen+is%3Apr+base%3Arelease-5.x.x) to see the current open vulnerabilities for that LTS branch. -[![Build Status](https://img.shields.io/travis/ParsePlatform/parse-server/master.svg?style=flat)](https://travis-ci.org/ParsePlatform/parse-server) -[![Coverage Status](https://img.shields.io/codecov/c/github/ParsePlatform/parse-server/master.svg)](https://codecov.io/github/ParsePlatform/parse-server?branch=master) -[![npm version](https://img.shields.io/npm/v/parse-server.svg?style=flat)](https://www.npmjs.com/package/parse-server) +# Getting Started -[![Join Chat](https://img.shields.io/badge/gitter-join%20chat%20%E2%86%92-brightgreen.svg)](https://gitter.im/ParsePlatform/Chat) +The fastest and easiest way to get started is to run MongoDB and Parse Server locally. -Parse Server is an [open source version of the Parse backend](http://blog.parse.com/announcements/introducing-parse-server-and-the-database-migration-tool/) that can be deployed to any infrastructure that can run Node.js. +## Running Parse Server -Parse Server works with the Express web application framework. It can be added to existing web applications, or run by itself. +Before you start make sure you have installed: -# Getting Started +- [NodeJS](https://www.npmjs.com/) that includes `npm` +- [MongoDB](https://www.mongodb.com/) or [PostgreSQL](https://www.postgresql.org/)(with [PostGIS](https://postgis.net) 2.2.0 or higher) +- Optionally [Docker](https://www.docker.com/) -April 2016 - We created a series of video screencasts, please check them out here: [http://blog.parse.com/learn/parse-server-video-series-april-2016/](http://blog.parse.com/learn/parse-server-video-series-april-2016/) +### Compatibility -The fastest and easiest way to get started is to run MongoDB and Parse Server locally. +#### Node.js -## Running Parse Server locally +Parse Server is continuously tested with the most recent releases of Node.js to ensure compatibility. We follow the [Node.js Long Term Support plan](https://github.com/nodejs/Release) and only test against versions that are officially supported and have not reached their end-of-life date. -``` -$ npm install -g parse-server mongodb-runner -$ mongodb-runner start -$ parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --databaseURI mongodb://localhost/test -``` +| Version | Minimum Version | End-of-Life | Parse Server Support | +| ---------- | --------------- | ----------- | -------------------- | +| Node.js 18 | 18.20.4 | April 2025 | <= 8.x (2025) | +| Node.js 20 | 20.19.0 | April 2026 | <= 9.x (2026) | +| Node.js 22 | 22.12.0 | April 2027 | <= 10.x (2027) | +| Node.js 24 | 24.11.0 | April 2028 | <= 11.x (2028) | -You can use any arbitrary string as your application id and master key. These will be used by your clients to authenticate with the Parse Server. +#### MongoDB -That's it! You are now running a standalone version of Parse Server on your machine. +Parse Server is continuously tested with the most recent releases of MongoDB to ensure compatibility. We follow the [MongoDB support schedule](https://www.mongodb.com/support-policy) and [MongoDB lifecycle schedule](https://www.mongodb.com/support-policy/lifecycles) and only test against versions that are officially supported and have not reached their end-of-life date. MongoDB "rapid releases" are ignored as these are considered pre-releases of the next major version. + +| Version | Minimum Version | End-of-Life | Parse Server Support | +| --------- | --------------- | ----------- | -------------------- | +| MongoDB 6 | 6.0.19 | July 2025 | <= 8.x (2025) | +| MongoDB 7 | 7.0.16 | August 2026 | <= 9.x (2026) | +| MongoDB 8 | 8.0.4 | TDB | <= 10.x (2027) | + +#### PostgreSQL -**Using a remote MongoDB?** Pass the `--databaseURI DATABASE_URI` parameter when starting `parse-server`. Learn more about configuring Parse Server [here](#configuration). For a full list of available options, run `parse-server --help`. +Parse Server is continuously tested with the most recent releases of PostgreSQL and PostGIS to ensure compatibility, using [PostGIS docker images](https://registry.hub.docker.com/r/postgis/postgis/tags?page=1&ordering=last_updated). We follow the [PostgreSQL support schedule](https://www.postgresql.org/support/versioning) and [PostGIS support schedule](https://www.postgis.net/eol_policy/) and only test against versions that are officially supported and have not reached their end-of-life date. Due to the extensive PostgreSQL support duration of 5 years, Parse Server drops support about 2 years before the official end-of-life date. -### Saving your first object +| Version | PostGIS Version | End-of-Life | Parse Server Support | +| ----------- | ----------------------- | ------------- | -------------------- | +| Postgres 13 | 3.1, 3.2, 3.3, 3.4, 3.5 | November 2025 | <= 6.x (2023) | +| Postgres 14 | 3.5 | November 2026 | <= 7.x (2024) | +| Postgres 15 | 3.3, 3.4, 3.5 | November 2027 | <= 8.x (2025) | +| Postgres 16 | 3.5 | November 2028 | <= 9.x (2026) | +| Postgres 17 | 3.5 | November 2029 | <= 10.x (2027) | +| Postgres 18 | 3.6 | November 2030 | <= 11.x (2028) | -Now that you're running Parse Server, it is time to save your first object. We'll use the [REST API](https://parse.com/docs/rest/guide), but you can easily do the same using any of the [Parse SDKs](https://parseplatform.github.io/#sdks). Run the following: +### Locally ```bash -curl -X POST \ --H "X-Parse-Application-Id: APPLICATION_ID" \ --H "Content-Type: application/json" \ --d '{"score":1337,"playerName":"Sean Plott","cheatMode":false}' \ -http://localhost:1337/parse/classes/GameScore +$ npm install -g parse-server mongodb-runner +$ mongodb-runner start +$ parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --databaseURI mongodb://localhost/test ``` -You should get a response similar to this: +**_Note:_** _If installation with_ `-g` _fails due to permission problems_ (`npm ERR! code 'EACCES'`), _please refer to [this link](https://docs.npmjs.com/getting-started/fixing-npm-permissions)._ -```js -{ - "objectId": "2ntvSpRGIK", - "createdAt": "2016-03-11T23:51:48.050Z" -} +### Docker Container + +```bash +$ git clone https://github.com/parse-community/parse-server +$ cd parse-server +$ docker build --tag parse-server . +$ docker run --name my-mongo -d mongo ``` -You can now retrieve this object directly (make sure to replace `2ntvSpRGIK` with the actual `objectId` you received when the object was created): +#### Running the Parse Server Image ```bash -$ curl -X GET \ - -H "X-Parse-Application-Id: APPLICATION_ID" \ - http://localhost:1337/parse/classes/GameScore/2ntvSpRGIK -``` -```json -// Response -{ - "objectId": "2ntvSpRGIK", - "score": 1337, - "playerName": "Sean Plott", - "cheatMode": false, - "updatedAt": "2016-03-11T23:51:48.050Z", - "createdAt": "2016-03-11T23:51:48.050Z" -} +$ docker run --name my-parse-server -v config-vol:/parse-server/config -p 1337:1337 --link my-mongo:mongo -d parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --databaseURI mongodb://mongo/test ``` -Keeping tracks of individual object ids is not ideal, however. In most cases you will want to run a query over the collection, like so: +**_Note:_** _If you want to use [Cloud Code](https://docs.parseplatform.org/cloudcode/guide/), add `-v cloud-code-vol:/parse-server/cloud --cloud /parse-server/cloud/main.js` to the command above. Make sure `main.js` is in the `cloud-code-vol` directory before starting Parse Server._ -``` -$ curl -X GET \ - -H "X-Parse-Application-Id: APPLICATION_ID" \ - http://localhost:1337/parse/classes/GameScore -``` -```json -// The response will provide all the matching objects within the `results` array: -{ - "results": [ - { - "objectId": "2ntvSpRGIK", - "score": 1337, - "playerName": "Sean Plott", - "cheatMode": false, - "updatedAt": "2016-03-11T23:51:48.050Z", - "createdAt": "2016-03-11T23:51:48.050Z" - } - ] -} +You can use any arbitrary string as your application id and master key. These will be used by your clients to authenticate with the Parse Server. -``` +That's it! You are now running a standalone version of Parse Server on your machine. -To learn more about using saving and querying objects on Parse Server, check out the [Parse documentation](https://parse.com/docs). +**Using a remote MongoDB?** Pass the `--databaseURI DATABASE_URI` parameter when starting `parse-server`. Learn more about configuring Parse Server [here](#configuration). For a full list of available options, run `parse-server --help`. -### Connect your app to Parse Server +### Saving and Querying Objects -Parse provides SDKs for all the major platforms. Refer to the Parse Server guide to [learn how to connect your app to Parse Server](https://github.com/ParsePlatform/parse-server/wiki/Parse-Server-Guide#using-parse-sdks-with-parse-server). +Now that you're running Parse Server, it is time to save your first object. The easiest way is to use the [REST API](http://docs.parseplatform.org/rest/guide), but you can easily do the same using any of the [Parse SDKs](http://parseplatform.org/#sdks). To learn more check out the [documentation](http://docs.parseplatform.org). + +### Connect an SDK + +Parse provides SDKs for all the major platforms. Refer to the Parse Server guide to [learn how to connect your app to Parse Server](https://docs.parseplatform.org/parse-server/guide/#using-parse-sdks-with-parse-server). ## Running Parse Server elsewhere -Once you have a better understanding of how the project works, please refer to the [Parse Server wiki](https://github.com/ParsePlatform/parse-server/wiki) for in-depth guides to deploy Parse Server to major infrastructure providers. Read on to learn more about additional ways of running Parse Server. +Once you have a better understanding of how the project works, please refer to the [Parse Server wiki](https://github.com/parse-community/parse-server/wiki) for in-depth guides to deploy Parse Server to major infrastructure providers. Read on to learn more about additional ways of running Parse Server. -### Parse Server Sample Application +### Sample Application -We have provided a basic [Node.js application](https://github.com/ParsePlatform/parse-server-example) that uses the Parse Server module on Express and can be easily deployed to various infrastructure providers: +We have provided a basic [Node.js application](https://github.com/parse-community/parse-server-example) that uses the Parse Server module on Express and can be easily deployed to various infrastructure providers: -* [Heroku and mLab](https://devcenter.heroku.com/articles/deploying-a-parse-server-to-heroku) -* [AWS and Elastic Beanstalk](http://mobile.awsblog.com/post/TxCD57GZLM2JR/How-to-set-up-Parse-Server-on-AWS-using-AWS-Elastic-Beanstalk) -* [Digital Ocean](https://www.digitalocean.com/community/tutorials/how-to-run-parse-server-on-ubuntu-14-04) -* [NodeChef](https://nodechef.com/blog/post/6/migrate-from-parse-to-nodechef%E2%80%99s-managed-parse-server) -* [Google App Engine](https://medium.com/@justinbeckwith/deploying-parse-server-to-google-app-engine-6bc0b7451d50) -* [Microsoft Azure](https://azure.microsoft.com/en-us/blog/azure-welcomes-parse-developers/) -* [Pivotal Web Services](https://github.com/cf-platform-eng/pws-parse-server) -* [Back4app](http://blog.back4app.com/2016/03/01/quick-wizard-migration/) +- [Heroku and mLab](https://devcenter.heroku.com/articles/deploying-a-parse-server-to-heroku) +- [AWS and Elastic Beanstalk](http://mobile.awsblog.com/post/TxCD57GZLM2JR/How-to-set-up-Parse-Server-on-AWS-using-AWS-Elastic-Beanstalk) +- [Google App Engine](https://medium.com/@justinbeckwith/deploying-parse-server-to-google-app-engine-6bc0b7451d50) +- [Microsoft Azure](https://azure.microsoft.com/en-us/blog/azure-welcomes-parse-developers/) +- [SashiDo](https://blog.sashido.io/tag/migration/) +- [Digital Ocean](https://www.digitalocean.com/community/tutorials/how-to-run-parse-server-on-ubuntu-14-04) +- [Pivotal Web Services](https://github.com/cf-platform-eng/pws-parse-server) +- [Back4app](https://www.back4app.com/docs/get-started/welcome) +- [Glitch](https://glitch.com/edit/#!/parse-server) +- [Flynn](https://flynn.io/blog/parse-apps-on-flynn) +- [Elestio](https://elest.io/open-source/parse) ### Parse Server + Express You can also create an instance of Parse Server, and mount it on a new or existing Express website: ```js -var express = require('express'); -var ParseServer = require('parse-server').ParseServer; -var app = express(); +const express = require('express'); +const ParseServer = require('parse-server').ParseServer; +const app = express(); -var api = new ParseServer({ +const server = new ParseServer({ databaseURI: 'mongodb://localhost:27017/dev', // Connection string for your MongoDB database - cloud: '/home/myApp/cloud/main.js', // Absolute path to your Cloud Code + cloud: './cloud/main.js', // Path to your Cloud Code appId: 'myAppId', masterKey: 'myMasterKey', // Keep this key secret! fileKey: 'optionalFileKey', - serverURL: 'http://localhost:1337/parse' // Don't forget to change to https if needed + serverURL: 'http://localhost:1337/parse', // Don't forget to change to https if needed }); +// Start server +await server.start(); + // Serve the Parse API on the /parse URL prefix -app.use('/parse', api); +app.use('/parse', server.app); -app.listen(1337, function() { +app.listen(1337, function () { console.log('parse-server-example running on port 1337.'); }); ``` -For a full list of available options, run `parse-server --help`. - -## Logging - -Parse Server will, by default, will log: -* to the console -* daily rotating files as new line delimited JSON +For a full list of available options, run `parse-server --help` or take a look at [Parse Server Configurations][server-options]. -Logs are also be viewable in Parse Dashboard. +## Parse Server Health -**Want to log each request and response?** Set the `VERBOSE` environment variable when starting `parse-server`. Usage :- `VERBOSE='1' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY` +Check the Parse Server health by sending a request to the `/parse/health` endpoint. -**Want logs to be in placed in other folder?** Pass the `PARSE_SERVER_LOGS_FOLDER` environment variable when starting `parse-server`. Usage :- `PARSE_SERVER_LOGS_FOLDER='' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY` +The response looks like this: -**Want new line delimited JSON error logs (for consumption by CloudWatch, Google Cloud Logging, etc.)?** Pass the `JSON_LOGS` environment variable when starting `parse-server`. Usage :- `JSON_LOGS='1' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY` - -# Documentation - -The full documentation for Parse Server is available in the [wiki](https://github.com/ParsePlatform/parse-server/wiki). The [Parse Server guide](https://github.com/ParsePlatform/parse-server/wiki/Parse-Server-Guide) is a good place to get started. If you're interested in developing for Parse Server, the [Development guide](https://github.com/ParsePlatform/parse-server/wiki/Development-Guide) will help you get set up. +```json +{ + "status": "ok" +} +``` -## Migrating an Existing Parse App +### Status Values -The hosted version of Parse will be fully retired on January 28th, 2017. If you are planning to migrate an app, you need to begin work as soon as possible. There are a few areas where Parse Server does not provide compatibility with the hosted version of Parse. Learn more in the [Migration guide](https://parse.com/migration). +| Value | Description | +| ------------- | --------------------------------------------------------------------------- | +| `initialized` | The server has been created but the `start` method has not been called yet. | +| `starting` | The server is starting up. | +| `ok` | The server started and is running. | +| `error` | There was a startup error, see the logs for details. | -## Configuration +# Configuration Parse Server can be configured using the following options. You may pass these as parameters when running a standalone `parse-server`, or by loading a configuration file in JSON format using `parse-server path/to/configuration.json`. If you're using Parse Server on Express, you may also pass these to the `ParseServer` object as options. -For the full list of available options, run `parse-server --help`. +For the full list of available options, run `parse-server --help` or take a look at [Parse Server Configurations][server-options]. -#### Basic options +## Basic Options -* `appId` **(required)** - The application id to host with this server instance. You can use any arbitrary string. For migrated apps, this should match your hosted Parse app. -* `masterKey` **(required)** - The master key to use for overriding ACL security. You can use any arbitrary string. Keep it secret! For migrated apps, this should match your hosted Parse app. -* `databaseURI` **(required)** - The connection string for your database, i.e. `mongodb://user:pass@host.com/dbname`. Be sure to [URL encode your password](https://app.zencoder.com/docs/guides/getting-started/special-characters-in-usernames-and-passwords) if your password has special characters. -* `port` - The default port is 1337, specify this parameter to use a different port. -* `serverURL` - URL to your Parse Server (don't forget to specify http:// or https://). This URL will be used when making requests to Parse Server from Cloud Code. -* `cloud` - The absolute path to your cloud code `main.js` file. -* `push` - Configuration options for APNS and GCM push. See the [Push Notifications wiki entry](https://github.com/ParsePlatform/parse-server/wiki/Push). +- `appId` **(required)** - The application id to host with this server instance. You can use any arbitrary string. For migrated apps, this should match your hosted Parse app. +- `masterKey` **(required)** - The master key to use for overriding ACL security. You can use any arbitrary string. Keep it secret! For migrated apps, this should match your hosted Parse app. +- `databaseURI` **(required)** - The connection string for your database, i.e. `mongodb://user:pass@host.com/dbname`. Be sure to [URL encode your password](https://app.zencoder.com/docs/guides/getting-started/special-characters-in-usernames-and-passwords) if your password has special characters. +- `port` - The default port is 1337, specify this parameter to use a different port. +- `serverURL` - URL to your Parse Server (don't forget to specify http:// or https://). This URL will be used when making requests to Parse Server from Cloud Code. +- `cloud` - The absolute path to your cloud code `main.js` file. +- `push` - Configuration options for APNS and FCM push. See the [Push Notifications quick start](https://docs.parseplatform.org/parse-server/guide/#push-notifications-quick-start). -#### Client key options +## Client Key Options The client keys used with Parse are no longer necessary with Parse Server. If you wish to still require them, perhaps to be able to refuse access to older clients, you can set the keys at initialization time. Setting any of these keys will require all requests to provide one of the configured keys. -* `clientKey` -* `javascriptKey` -* `restAPIKey` -* `dotNetKey` +- `clientKey` +- `javascriptKey` +- `restAPIKey` +- `dotNetKey` -#### Advanced options +## Access Scopes -* `fileKey` - For migrated apps, this is necessary to provide access to files already hosted on Parse. -* `allowClientClassCreation` - Set to false to disable client class creation. Defaults to true. -* `enableAnonymousUsers` - Set to false to disable anonymous users. Defaults to true. -* `oauth` - Used to configure support for [3rd party authentication](https://github.com/ParsePlatform/parse-server/wiki/OAuth). -* `facebookAppIds` - An array of valid Facebook application IDs that users may authenticate with. -* `mountPath` - Mount path for the server. Defaults to `/parse`. -* `filesAdapter` - The default behavior (GridStore) can be changed by creating an adapter class (see [`FilesAdapter.js`](https://github.com/ParsePlatform/parse-server/blob/master/src/Adapters/Files/FilesAdapter.js)). -* `maxUploadSize` - Max file size for uploads. Defaults to 20 MB. -* `loggerAdapter` - The default behavior/transport (File) can be changed by creating an adapter class (see [`LoggerAdapter.js`](https://github.com/ParsePlatform/parse-server/blob/master/src/Adapters/Logger/LoggerAdapter.js)). -* `sessionLength` - The length of time in seconds that a session should be valid for. Defaults to 31536000 seconds (1 year). -* `revokeSessionOnPasswordReset` - 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. +| Scope | Internal data | Read-only data (1) | Custom data | Restricted by CLP, ACL | Key | +| -------------- | ------------- | ----------------------------- | ----------- | ---------------------- | ------------------- | +| Internal | r/w | r/w | r/w | no | `maintenanceKey` | +| Master | -/- | r/- | r/w | no | `masterKey` | +| ReadOnlyMaster | -/- | r/- | r/- | no | `readOnlyMasterKey` | +| Session | -/- | r/- | r/w | yes | `sessionToken` | -##### Logging +(1) `Parse.Object.createdAt`, `Parse.Object.updatedAt`. -Use the `PARSE_SERVER_LOGS_FOLDER` environment variable when starting `parse-server` to save your server logfiles to the specified folder. +> [!NOTE] +> In Cloud Code, both `masterKey` and `readOnlyMasterKey` set `request.master` to `true`. To distinguish between them, check `request.isReadOnly`. For example, use `request.master && !request.isReadOnly` to ensure full master key access. -Usage: +## Route Allow List -``` -PARSE_SERVER_LOGS_FOLDER='' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY -``` +The `routeAllowList` option restricts which API routes are accessible to external clients. When set, all external requests are denied by default unless the route matches one of the configured regex patterns. This is useful for apps where all logic runs in Cloud Code and clients should not access the API directly. -##### Email verification and password reset +Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected. Master key and maintenance key requests bypass the restriction. + +```js +const server = ParseServer({ + ...otherOptions, + routeAllowList: [ + 'classes/ChatMessage', + 'classes/Public.*', + 'users', + 'login', + 'functions/getMenu', + 'health', + ], +}); +``` -Verifying user email addresses and enabling password reset via email requires an email adapter. As part of the `parse-server` package we provide an adapter for sending email through Mailgun. To use it, sign up for Mailgun, and add this to your initialization code: +Each entry is a regex pattern matched against the normalized route identifier. Patterns are auto-anchored with `^` and `$` for full-match semantics. For example, `classes/Chat` matches only `classes/Chat`, not `classes/ChatRoom`. Use `classes/Chat.*` to match both. + +Setting an empty array `[]` blocks all external non-master-key requests (full lockdown). Not setting the option preserves current behavior (all routes accessible). + +### Covered Routes + +The following table lists all route groups covered by `routeAllowList` with examples of how to allow them. + +| Route group | Example route identifiers | Allow pattern | +| --- | --- | --- | +| **Data** | | | +| Classes | `classes/[className]`, `classes/[className]/[objectId]` | `classes/[className].*` | +| Aggregate | `aggregate/[className]` | `aggregate/.*` | +| Batch | `batch` | `batch` | +| Purge | `purge/[className]` | `purge/.*` | +| | | | +| **System Classes** | | | +| Users | `users`, `users/me`, `users/[objectId]` | `users.*` | +| Sessions | `sessions`, `sessions/me`, `sessions/[objectId]` | `sessions.*` | +| Installations | `installations`, `installations/[objectId]` | `installations.*` | +| Roles | `roles`, `roles/[objectId]` | `roles.*` | +| | | | +| **Auth** | | | +| Login | `login`, `loginAs` | `login.*` | +| Logout | `logout` | `logout` | +| Upgrade session | `upgradeToRevocableSession` | `upgradeToRevocableSession` | +| Auth challenge | `challenge` | `challenge` | +| Email verification | `verificationEmailRequest` | `verificationEmailRequest` | +| Password verification | `verifyPassword` | `verifyPassword` | +| Password reset | `requestPasswordReset` | `requestPasswordReset` | +| | | | +| **Cloud Code** | | | +| Cloud Functions | `functions/[functionName]` | `functions/.*` | +| Cloud Jobs (trigger) | `jobs`, `jobs/[jobName]` | `jobs.*` | +| Cloud Jobs (schedule) | `cloud_code/jobs`, `cloud_code/jobs/data`, `cloud_code/jobs/[objectId]` | `cloud_code/.*` | +| Hooks | `hooks/functions`, `hooks/triggers`, `hooks/functions/[functionName]`, `hooks/triggers/[className]/[triggerName]` | `hooks/.*` | +| | | | +| **Push** | | | +| Push | `push` | `push` | +| Push audiences | `push_audiences`, `push_audiences/[objectId]` | `push_audiences.*` | +| | | | +| **Schema** | | | +| Schemas | `schemas`, `schemas/[className]` | `schemas.*` | +| | | | +| **Config** | | | +| Config | `config` | `config` | +| GraphQL config | `graphql-config` | `graphql-config` | +| | | | +| **Analytics** | | | +| Analytics | `events/AppOpened`, `events/[eventName]` | `events/.*` | +| | | | +| **Server** | | | +| Health | `health` | `health` | +| Server info | `serverInfo` | `serverInfo` | +| Security | `security` | `security` | +| Logs | `scriptlog` | `scriptlog` | +| | | | +| **Legacy** | | | +| Purchase validation | `validate_purchase` | `validate_purchase` | + +> [!NOTE] +> File routes are not covered by `routeAllowList`. File upload access is controlled via the `fileUpload` option. File download and metadata access is controlled via the `fileDownload` option. + +## Email Verification and Password Reset + +Verifying user email addresses and enabling password reset via email requires an email adapter. There are many email adapters provided and maintained by the community. The following is an example configuration with an example email adapter. See the [Parse Server Options][server-options] for more details and a full list of available options. ```js -var server = ParseServer({ +const server = ParseServer({ ...otherOptions, + // Enable email verification verifyUserEmails: true, - // if `verifyUserEmails` is `true` and - // if `emailVerifyTokenValidityDuration` is `undefined` then - // email verify token never expires - // else - // email verify token expires after `emailVerifyTokenValidityDuration` - // - // `emailVerifyTokenValidityDuration` defaults to `undefined` - // - // email verify token below expires in 2 hours (= 2 * 60 * 60 == 7200 seconds) - emailVerifyTokenValidityDuration: 2 * 60 * 60, // in seconds (2 hours = 7200 seconds) - - // set preventLoginWithUnverifiedEmail to false to allow user to login without verifying their email - // set preventLoginWithUnverifiedEmail to true to prevent user from login if their email is not verified - preventLoginWithUnverifiedEmail: false, // defaults to false - - // The public URL of your app. - // This will appear in the link that is used to verify email addresses and reset passwords. - // Set the mount path as it is in serverURL - publicServerURL: 'https://example.com/parse', - // Your apps name. This will appear in the subject and body of the emails that are sent. - appName: 'Parse App', - // The email adapter + // Set email verification token validity to 2 hours + emailVerifyTokenValidityDuration: 2 * 60 * 60, + + // Set email adapter emailAdapter: { - module: 'parse-server-simple-mailgun-adapter', + module: 'example-mail-adapter', options: { - // The address that your emails come from - fromAddress: 'parse@example.com', - // Your domain from mailgun.com - domain: 'example.com', - // Your API key from mailgun.com - apiKey: 'key-mykey', - } - } + // Additional adapter options + ...mailAdapterOptions, + }, + }, }); ``` -You can also use other email adapters contributed by the community such as: +Offical email adapters maintained by Parse Platform: + +- [parse-server-api-mail-adapter](https://github.com/parse-community/parse-server-api-mail-adapter) (localization, templates, universally supports any email provider) + +Email adapters contributed by the community: + +- [parse-smtp-template](https://www.npmjs.com/package/parse-smtp-template) (localization, templates) - [parse-server-postmark-adapter](https://www.npmjs.com/package/parse-server-postmark-adapter) - [parse-server-sendgrid-adapter](https://www.npmjs.com/package/parse-server-sendgrid-adapter) - [parse-server-mandrill-adapter](https://www.npmjs.com/package/parse-server-mandrill-adapter) - [parse-server-simple-ses-adapter](https://www.npmjs.com/package/parse-server-simple-ses-adapter) - [parse-server-mailgun-adapter-template](https://www.npmjs.com/package/parse-server-mailgun-adapter-template) +- [parse-server-sendinblue-adapter](https://www.npmjs.com/package/parse-server-sendinblue-adapter) +- [parse-server-mailjet-adapter](https://www.npmjs.com/package/parse-server-mailjet-adapter) +- [simple-parse-smtp-adapter](https://www.npmjs.com/package/simple-parse-smtp-adapter) +- [parse-server-generic-email-adapter](https://www.npmjs.com/package/parse-server-generic-email-adapter) + +## Password and Account Policy + +Set a password and account policy that meets your security requirements. The following is an example configuration. See the [Parse Server Options][server-options] for more details and a full list of available options. + +```js +const server = ParseServer({ + ...otherOptions, -### Using environment variables to configure Parse Server + // The account lock policy + accountLockout: { + // Lock the account for 5 minutes. + duration: 5, + // Lock an account after 3 failed log-in attempts + threshold: 3, + // Unlock the account after a successful password reset + unlockOnPasswordReset: true, + }, + + // The password policy + passwordPolicy: { + // Enforce a password of at least 8 characters which contain at least 1 lower case, 1 upper case and 1 digit + validatorPattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.{8,})/, + // Do not allow the username as part of the password + doNotAllowUsername: true, + // Do not allow to re-use the last 5 passwords when setting a new password + maxPasswordHistory: 5, + }, +}); +``` + +## Custom Routes + +Custom routes allow to build user flows with webpages, similar to the existing password reset and email verification features. Custom routes are defined with the `pages` option in the Parse Server configuration: + +### Example + +```js +const api = new ParseServer({ + ...otherOptions, + + pages: { + customRoutes: [{ + method: 'GET', + path: 'custom_route', + handler: async request => { + // custom logic + // ... + // then, depending on the outcome, return a HTML file as response + return { file: 'custom_page.html' }; + } + }] + } +} +``` + +The above route can be invoked by sending a `GET` request to: +`https://[parseServerPublicUrl]/[parseMount]/[pagesEndpoint]/[appId]/[customRoute]` + +The `handler` receives the `request` and returns a `custom_page.html` webpage from the `pages.pagesPath` directory as response. The advantage of building a custom route this way is that it automatically makes use of Parse Server's built-in capabilities, such as [page localization](#pages) and [dynamic placeholders](#dynamic-placeholders). + +### Reserved Paths + +The following paths are already used by Parse Server's built-in features and are therefore not available for custom routes. Custom routes with an identical combination of `path` and `method` are ignored. + +| Path | HTTP Method | Feature | +| --------------------------- | ----------- | ------------------ | +| `verify_email` | `GET` | email verification | +| `resend_verification_email` | `POST` | email verification | +| `choose_password` | `GET` | password reset | +| `request_password_reset` | `GET` | password reset | +| `request_password_reset` | `POST` | password reset | + +### Parameters + +| Parameter | Optional | Type | Default value | Example values | Environment variable | Description | +| ---------------------------- | -------- | --------------- | ------------- | --------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `pages` | yes | `Object` | `undefined` | - | `PARSE_SERVER_PAGES` | The options for pages such as password reset and email verification. | +| `pages.customRoutes` | yes | `Array` | `[]` | - | `PARSE_SERVER_PAGES_CUSTOM_ROUTES` | The custom routes. The routes are added in the order they are defined here, which has to be considered since requests traverse routes in an ordered manner. Custom routes are traversed after build-in routes such as password reset and email verification. | +| `pages.customRoutes.method` | | `String` | - | `GET`, `POST` | - | The HTTP method of the custom route. | +| `pages.customRoutes.path` | | `String` | - | `custom_page` | - | The path of the custom route. Note that the same path can used if the `method` is different, for example a path `custom_page` can have two routes, a `GET` and `POST` route, which will be invoked depending on the HTTP request method. | +| `pages.customRoutes.handler` | | `AsyncFunction` | - | `async () => { ... }` | - | The route handler that is invoked when the route matches the HTTP request. If the handler does not return a page, the request is answered with a 404 `Not found.` response. | + +## Custom Pages + +It’s possible to change the default pages of the app and redirect the user to another path or domain. + +```js +const server = ParseServer({ + ...otherOptions, + + customPages: { + passwordResetSuccess: 'http://yourapp.com/passwordResetSuccess', + verifyEmailSuccess: 'http://yourapp.com/verifyEmailSuccess', + parseFrameURL: 'http://yourapp.com/parseFrameURL', + linkSendSuccess: 'http://yourapp.com/linkSendSuccess', + linkSendFail: 'http://yourapp.com/linkSendFail', + invalidLink: 'http://yourapp.com/invalidLink', + invalidVerificationLink: 'http://yourapp.com/invalidVerificationLink', + choosePassword: 'http://yourapp.com/choosePassword', + }, +}); +``` + +## Using Environment Variables You may configure the Parse Server using environment variables: @@ -279,7 +550,7 @@ PARSE_SERVER_APPLICATION_ID PARSE_SERVER_MASTER_KEY PARSE_SERVER_DATABASE_URI PARSE_SERVER_URL -PARSE_SERVER_CLOUD_CODE_MAIN +PARSE_SERVER_CLOUD ``` The default port is 1337, to use a different port set the PORT environment variable: @@ -288,33 +559,753 @@ The default port is 1337, to use a different port set the PORT environment varia $ PORT=8080 parse-server --appId APPLICATION_ID --masterKey MASTER_KEY ``` -For the full list of configurable environment variables, run `parse-server --help`. +For the full list of configurable environment variables, run `parse-server --help` or take a look at [Parse Server Configuration](https://github.com/parse-community/parse-server/blob/master/src/Options/Definitions.js). + +## Available Adapters + +All official adapters are distributed as scoped packages on [npm (@parse)](https://www.npmjs.com/search?q=scope%3Aparse). -### Available Adapters -[Parse Server Modules (Adapters)](https://github.com/parse-server-modules) +Some well maintained adapters are also available on the [Parse Server Modules](https://github.com/parse-server-modules) organization. -### Configuring File Adapters +You can also find more adapters maintained by the community by searching on [npm](https://www.npmjs.com/search?q=parse-server%20adapter&page=1&ranking=optimal). + +## Configuring File Adapters Parse Server allows developers to choose from several options when hosting files: -* `GridStoreAdapter`, which is backed by MongoDB; -* `S3Adapter`, which is backed by [Amazon S3](https://aws.amazon.com/s3/); or -* `GCSAdapter`, which is backed by [Google Cloud Storage](https://cloud.google.com/storage/) +- `GridFSBucketAdapter` - which is backed by MongoDB +- `S3Adapter` - which is backed by [Amazon S3](https://aws.amazon.com/s3/) +- `GCSAdapter` - which is backed by [Google Cloud Storage](https://cloud.google.com/storage/) +- `FSAdapter` - local file storage + +`GridFSBucketAdapter` is used by default and requires no setup, but if you're interested in using Amazon S3, Google Cloud Storage, or local file storage, additional configuration information is available in the [Parse Server guide](http://docs.parseplatform.org/parse-server/guide/#configuring-file-adapters). + +### Restricting File URL Domains + +Parse objects can reference files by URL. To prevent [SSRF attacks](https://owasp.org/www-community/attacks/Server_Side_Request_Forgery) via crafted file URLs, you can restrict the allowed URL domains using the `fileUpload.allowedFileUrlDomains` option. + +This protects against scenarios where an attacker provides a `Parse.File` with an arbitrary URL, for example as a Cloud Function parameter or in a field of type `Object` or `Array`. If Cloud Code or a client calls `getData()` on such a file, the Parse SDK makes an HTTP request to that URL, potentially leaking the server or client IP address and accessing internal services. + +> [!NOTE] +> Fields of type `Parse.File` in the Parse schema are not affected by this attack, because Parse Server discards the URL on write and dynamically generates it on read based on the file adapter configuration. + +```javascript +const parseServer = new ParseServer({ + ...otherOptions, + fileUpload: { + allowedFileUrlDomains: ['cdn.example.com', '*.example.com'], + }, +}); +``` + +| Parameter | Optional | Type | Default | Environment Variable | +|---|---|---|---|---| +| `fileUpload.allowedFileUrlDomains` | yes | `String[]` | `['*']` | `PARSE_SERVER_FILE_UPLOAD_ALLOWED_FILE_URL_DOMAINS` | + +- `['*']` (default) allows file URLs with any domain. +- `['cdn.example.com']` allows only exact hostname matches. +- `['*.example.com']` allows any subdomain of `example.com`. +- `[]` blocks all file URLs; only files referenced by name are allowed. + +## Idempotency 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 multiple 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 requests without this header when this feature is enabled. + +> 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` specify 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`. | + +### Postgres + +To use this feature in Postgres, you will need to create a cron job using [pgAdmin](https://www.pgadmin.org/docs/pgadmin4/development/pgagent_jobs.html) or similar to call the Postgres function `idempotency_delete_expired_records()` that deletes expired idempotency records. You can find an example script below. Make sure the script has the same privileges to log into Postgres as Parse Server. + +```bash +#!/bin/bash + +set -e +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + SELECT idempotency_delete_expired_records(); +EOSQL + +exec "$@" +``` + +Assuming the script above is named, `parse_idempotency_delete_expired_records.sh`, a cron job that runs the script every 2 minutes may look like: + +```bash +2 * * * * /root/parse_idempotency_delete_expired_records.sh >/dev/null 2>&1 +``` + +## Installations + +Parse Server deduplicates `_Installation` records when a new install collides with an existing row's `deviceToken`. The `installation` option block configures the dedup behavior. + +### Options + +| Parameter | Optional | Type | Default | Environment Variable | +|---|---|---|---|---| +| `installation.duplicateDeviceTokenActionEnforceAuth` | yes | `Boolean` | `false` | `PARSE_SERVER_INSTALLATION_DUPLICATE_DEVICE_TOKEN_ACTION_ENFORCE_AUTH` | +| `installation.duplicateDeviceTokenAction` | yes | `String` | `'delete'` | `PARSE_SERVER_INSTALLATION_DUPLICATE_DEVICE_TOKEN_ACTION` | +| `installation.duplicateDeviceTokenMergePriority` | yes | `String` | `'deviceToken'` | `PARSE_SERVER_INSTALLATION_DUPLICATE_DEVICE_TOKEN_MERGE_PRIORITY` | + +#### `duplicateDeviceTokenActionEnforceAuth` + +When `true`, the dedup operation runs with the caller's auth context so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. + +#### `duplicateDeviceTokenAction` + +What Parse Server does to the conflicting `_Installation` row(s) when a new install's `deviceToken` collides with an existing row. + +- `'delete'`: destroys the conflicting row. +- `'update'`: clears the now-conflicting ID field on the conflicting row, preserving custom fields, channels, and history. + +#### `duplicateDeviceTokenMergePriority` + +When an existing row holds the new `deviceToken` but has no `installationId` of its own, Parse Server merges the two rows. This option controls which side wins. + +- `'deviceToken'`: the deviceToken-only row survives; the request's installationId-matched row is the loser. +- `'installationId'`: the request's installationId-matched row survives; the deviceToken-only orphan is the loser. + +### Configuration example + +```javascript +const parseServer = new ParseServer({ + ...otherOptions, + installation: { + duplicateDeviceTokenActionEnforceAuth: true, + duplicateDeviceTokenAction: 'update', + duplicateDeviceTokenMergePriority: 'installationId', + }, +}); +``` + +## Localization + +### Pages + +Custom pages as well as feature pages (e.g. password reset, email verification) can be localized with the `pages` option in the Parse Server configuration: + +```js +const api = new ParseServer({ + ...otherOptions, + + pages: { + enableLocalization: true, + } +} +``` + +Localization is achieved by matching a request-supplied `locale` parameter with localized page content. The locale can be supplied in either the request query, body or header with the following keys: + +- query: `locale` +- body: `locale` +- header: `x-parse-page-param-locale` + +For example, a password reset link with the locale parameter in the query could look like this: + +``` +http://example.com/parse/apps/[appId]/request_password_reset?token=[token]&username=[username]&locale=de-AT +``` + +- Localization is only available for pages in the pages directory as set with `pages.pagesPath`. +- Localization for feature pages (e.g. password reset, email verification) is disabled if `pages.customUrls` are set, even if the custom URLs point to the pages within the pages path. +- Only `.html` files are considered for localization when localizing custom pages. + +Pages can be localized in two ways: + +#### Localization with Directory Structure + +Pages are localized by using the corresponding file in the directory structure where the files are placed in subdirectories named after the locale or language. The file in the base directory is the default file. + +**Example Directory Structure:** + +```js +root/ +├── public/ // pages base path +│ ├── example.html // default file +│ └── de/ // de language folder +│ │ └── example.html // de localized file +│ └── de-AT/ // de-AT locale folder +│ │ └── example.html // de-AT localized file +``` + +Files are matched with the locale in the following order: + +1. Locale match, e.g. locale `de-AT` matches file in folder `de-AT`. +1. Language match, e.g. locale `de-CH` matches file in folder `de`. +1. Default; file in base folder is returned. + +**Configuration Example:** + +```js +const api = new ParseServer({ + ...otherOptions, + + pages: { + enableLocalization: true, + customUrls: { + passwordReset: 'https://example.com/page.html' + } + } +} +``` + +Pros: + +- All files are complete in their content and can be easily opened and previewed by viewing the file in a browser. + +Cons: + +- In most cases, a localized page differs only slightly from the default page, which could cause a lot of duplicate code that is difficult to maintain. + +#### Localization with JSON Resource + +Pages are localized by adding placeholders in the HTML files and providing a JSON resource that contains the translations to fill into the placeholders. + +**Example Directory Structure:** + +```js +root/ +├── public/ // pages base path +│ ├── example.html // the page containing placeholders +├── private/ // folder outside of public scope +│ └── translations.json // JSON resource file +``` + +The JSON resource file loosely follows the [i18next](https://github.com/i18next/i18next) syntax, which is a syntax that is often supported by translation platforms, making it easy to manage translations, exporting them for use in Parse Server, and even to automate this workflow. + +**Example JSON Content:** + +```json +{ + "en": { // resource for language `en` (English) + "translation": { + "greeting": "Hello!" + } + }, + "de": { // resource for language `de` (German) + "translation": { + "greeting": "Hallo!" + } + } + "de-AT": { // resource for locale `de-AT` (Austrian German) + "translation": { + "greeting": "Servus!" + } + } +} +``` + +**Configuration Example:** + +```js +const api = new ParseServer({ + ...otherOptions, + + pages: { + enableLocalization: true, + localizationJsonPath: './private/localization.json', + localizationFallbackLocale: 'en' + } +} +``` + +Pros: + +- There is only one HTML file to maintain that contains the placeholders that are filled with the translations according to the locale. + +Cons: + +- Files cannot be easily previewed by viewing the file in a browser because the content contains only placeholders and even HTML or CSS changes may be dynamically applied, e.g. when a localization requires a Right-To-Left layout direction. +- Style and other fundamental layout changes may be more difficult to apply. + +#### Dynamic placeholders + +In addition to feature related default parameters such as `appId` and the translations provided via JSON resource, it is possible to define custom dynamic placeholders as part of the router configuration. This works independently of localization and, also if `enableLocalization` is disabled. + +**Configuration Example:** + +```js +const api = new ParseServer({ + ...otherOptions, + + pages: { + placeholders: { + exampleKey: 'exampleValue' + } + } +} +``` + +The placeholders can also be provided as function or as async function, with the `locale` and other feature related parameters passed through, to allow for dynamic placeholder values: + +```js +const api = new ParseServer({ + ...otherOptions, + + pages: { + placeholders: async (params) => { + const value = await doSomething(params.locale); + return { + exampleKey: value + }; + } + } +} +``` + +#### Reserved Keys + +The following parameter and placeholder keys are reserved because they are used related to features such as password reset or email verification. They should not be used as translation keys in the JSON resource or as manually defined placeholder keys in the configuration: `appId`, `appName`, `email`, `error`, `locale`, `publicServerUrl`, `token`, `username`. + +#### Parameters + +| Parameter | Optional | Type | Default value | Example values | Environment variable | Description | +| ----------------------------------------------- | -------- | ------------------------------------- | -------------------------------------- | ---------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pages` | yes | `Object` | `undefined` | - | `PARSE_SERVER_PAGES` | The options for pages such as password reset and email verification. | +| `pages.enableLocalization` | yes | `Boolean` | `false` | - | `PARSE_SERVER_PAGES_ENABLE_LOCALIZATION` | Is true if pages should be localized; this has no effect on custom page redirects. | +| `pages.localizationJsonPath` | yes | `String` | `undefined` | `./private/translations.json` | `PARSE_SERVER_PAGES_LOCALIZATION_JSON_PATH` | The path to the JSON file for localization; the translations will be used to fill template placeholders according to the locale. | +| `pages.localizationFallbackLocale` | yes | `String` | `en` | `en`, `en-GB`, `default` | `PARSE_SERVER_PAGES_LOCALIZATION_FALLBACK_LOCALE` | The fallback locale for localization if no matching translation is provided for the given locale. This is only relevant when providing translation resources via JSON file. | +| `pages.placeholders` | yes | `Object`, `Function`, `AsyncFunction` | `undefined` | `{ exampleKey: 'exampleValue' }` | `PARSE_SERVER_PAGES_PLACEHOLDERS` | The placeholder keys and values which will be filled in pages; this can be a simple object or a callback function. | +| `pages.forceRedirect` | yes | `Boolean` | `false` | - | `PARSE_SERVER_PAGES_FORCE_REDIRECT` | Is `true` if responses should always be redirects and never content, `false` if the response type should depend on the request type (`GET` request -> content response; `POST` request -> redirect response). | +| `pages.pagesPath` | yes | `String` | `./public` | `./files/pages`, `../../pages` | `PARSE_SERVER_PAGES_PAGES_PATH` | The path to the pages directory; this also defines where the static endpoint `/apps` points to. | +| `pages.pagesEndpoint` | yes | `String` | `apps` | - | `PARSE_SERVER_PAGES_PAGES_ENDPOINT` | The API endpoint for the pages. | +| `pages.customUrls` | yes | `Object` | `{}` | `{ passwordReset: 'https://example.com/page.html' }` | `PARSE_SERVER_PAGES_CUSTOM_URLS` | The URLs to the custom pages | +| `pages.customUrls.passwordReset` | yes | `String` | `password_reset.html` | - | `PARSE_SERVER_PAGES_CUSTOM_URL_PASSWORD_RESET` | The URL to the custom page for password reset. | +| `pages.customUrls.passwordResetSuccess` | yes | `String` | `password_reset_success.html` | - | `PARSE_SERVER_PAGES_CUSTOM_URL_PASSWORD_RESET_SUCCESS` | The URL to the custom page for password reset -> success. | +| `pages.customUrls.passwordResetLinkInvalid` | yes | `String` | `password_reset_link_invalid.html` | - | `PARSE_SERVER_PAGES_CUSTOM_URL_PASSWORD_RESET_LINK_INVALID` | The URL to the custom page for password reset -> link invalid. | +| `pages.customUrls.emailVerificationSuccess` | yes | `String` | `email_verification_success.html` | - | `PARSE_SERVER_PAGES_CUSTOM_URL_EMAIL_VERIFICATION_SUCCESS` | The URL to the custom page for email verification -> success. | +| `pages.customUrls.emailVerificationSendFail` | yes | `String` | `email_verification_send_fail.html` | - | `PARSE_SERVER_PAGES_CUSTOM_URL_EMAIL_VERIFICATION_SEND_FAIL` | The URL to the custom page for email verification -> link send fail. | +| `pages.customUrls.emailVerificationSendSuccess` | yes | `String` | `email_verification_send_success.html` | - | `PARSE_SERVER_PAGES_CUSTOM_URL_EMAIL_VERIFICATION_SEND_SUCCESS` | The URL to the custom page for email verification -> resend link -> success. | +| `pages.customUrls.emailVerificationLinkInvalid` | yes | `String` | `email_verification_link_invalid.html` | - | `PARSE_SERVER_PAGES_CUSTOM_URL_EMAIL_VERIFICATION_LINK_INVALID` | The URL to the custom page for email verification -> link invalid. | +| `pages.customUrls.emailVerificationLinkExpired` | yes | `String` | `email_verification_link_expired.html` | - | `PARSE_SERVER_PAGES_CUSTOM_URL_EMAIL_VERIFICATION_LINK_EXPIRED` | The URL to the custom page for email verification -> link expired. | + +### Notes + +- In combination with the [Parse Server API Mail Adapter](https://www.npmjs.com/package/parse-server-api-mail-adapter) Parse Server provides a fully localized flow (emails -> pages) for the user. The email adapter sends a localized email and adds a locale parameter to the password reset or email verification link, which is then used to respond with localized pages. + +## Multi-Tenancy + +Parse Server does not support multi-tenancy. Only one Parse Server instance may be mounted per Express app. Among other considerations, there is no isolation between apps in the same process. For example, Cloud Code runs in the same Node.js process as Parse Server and has full access to the server environment, such as server configuration, modules, and environment variables. + +## Logging + +Parse Server will, by default, log: + +- to the console +- daily rotating files as new line delimited JSON + +Logs are also viewable in Parse Dashboard. + +**Want to log each request and response?** Set the `VERBOSE` environment variable when starting `parse-server`. Usage :- `VERBOSE='1' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY` + +**Want logs to be placed in a different folder?** Pass the `PARSE_SERVER_LOGS_FOLDER` environment variable when starting `parse-server`. Usage :- `PARSE_SERVER_LOGS_FOLDER='' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY` + +**Want to log specific levels?** Pass the `logLevel` parameter when starting `parse-server`. Usage :- `parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --logLevel LOG_LEVEL` + +**Want new line delimited JSON error logs (for consumption by CloudWatch, Google Cloud Logging, etc)?** Pass the `JSON_LOGS` environment variable when starting `parse-server`. Usage :- `JSON_LOGS='1' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY` + +# Deprecations + +See the [Deprecation Plan](https://github.com/parse-community/parse-server/blob/master/DEPRECATIONS.md) for an overview of deprecations and planned breaking changes. + +# Live Query + +Live queries are meant to be used in real-time reactive applications, where just using the traditional query paradigm could cause several problems, like increased response time and high network and server usage. Live queries should be used in cases where you need to continuously update a page with fresh data coming from the database, which often happens in (but is not limited to) online games, messaging clients and shared to-do lists. + +Take a look at [Live Query Guide](https://docs.parseplatform.org/parse-server/guide/#live-queries), [Live Query Server Setup Guide](https://docs.parseplatform.org/parse-server/guide/#scalability) and [Live Query Protocol Specification](https://github.com/parse-community/parse-server/wiki/Parse-LiveQuery-Protocol-Specification). You can setup a standalone server or multiple instances for scalability (recommended). + +# GraphQL + +[GraphQL](https://graphql.org/), developed by Facebook, is an open-source data query and manipulation language for APIs. In addition to the traditional REST API, Parse Server automatically generates a GraphQL API based on your current application schema. Parse Server also allows you to define your custom GraphQL queries and mutations, whose resolvers can be bound to your cloud code functions. + +## Running + +### Using the CLI + +The easiest way to run the Parse GraphQL API is through the CLI: + +```bash +$ npm install -g parse-server mongodb-runner +$ mongodb-runner start +$ parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --databaseURI mongodb://localhost/test --publicServerURL http://localhost:1337/parse --mountGraphQL --mountPlayground +``` + +After starting the server, you can visit http://localhost:1337/playground in your browser to start playing with your GraphQL API. + +**_Note:_** Do **_NOT_** use --mountPlayground option in production. The GraphQL Playground exposes the master key in the browser page. [Parse Dashboard](https://github.com/parse-community/parse-dashboard) has a built-in GraphQL Playground and is the recommended option for production apps. + +### Using Docker + +You can also run the Parse GraphQL API inside a Docker container: + +```bash +$ git clone https://github.com/parse-community/parse-server +$ cd parse-server +$ docker build --tag parse-server . +$ docker run --name my-mongo -d mongo +``` + +#### Running the Parse Server Image + +```bash +$ docker run --name my-parse-server --link my-mongo:mongo -v config-vol:/parse-server/config -p 1337:1337 -d parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --databaseURI mongodb://mongo/test --publicServerURL http://localhost:1337/parse --mountGraphQL --mountPlayground +``` + +**_Note:_** _If you want to use [Cloud Code](https://docs.parseplatform.org/cloudcode/guide/), add `-v cloud-code-vol:/parse-server/cloud --cloud /parse-server/cloud/main.js` to the command above. Make sure `main.js` is in the `cloud-code-vol` directory before starting Parse Server._ + +After starting the server, you can visit http://localhost:1337/playground in your browser to start playing with your GraphQL API. + +**_Note:_** Do **_NOT_** use --mountPlayground option in production. The GraphQL Playground exposes the master key in the browser page. [Parse Dashboard](https://github.com/parse-community/parse-dashboard) has a built-in GraphQL Playground and is the recommended option for production apps. + +### Using Express.js + +You can also mount the GraphQL API in an Express.js application together with the REST API or solo. You first need to create a new project and install the required dependencies: + +```bash +$ mkdir my-app +$ cd my-app +$ npm install parse-server express --save +``` + +Then, create an `index.js` file with the following content: + +```js +const express = require('express'); +const { ParseServer, ParseGraphQLServer } = require('parse-server'); + +const app = express(); + +const parseServer = new ParseServer({ + databaseURI: 'mongodb://localhost:27017/test', + appId: 'APPLICATION_ID', + masterKey: 'MASTER_KEY', + serverURL: 'http://localhost:1337/parse', + publicServerURL: 'http://localhost:1337/parse', +}); + +const parseGraphQLServer = new ParseGraphQLServer(parseServer, { + graphQLPath: '/graphql', + playgroundPath: '/playground', +}); + +app.use('/parse', parseServer.app); // (Optional) Mounts the REST API +parseGraphQLServer.applyGraphQL(app); // Mounts the GraphQL API +parseGraphQLServer.applyPlayground(app); // (Optional) Mounts the GraphQL Playground - do NOT use in Production + +await parseServer.start(); +app.listen(1337, function () { + console.log('REST API running on http://localhost:1337/parse'); + console.log('GraphQL API running on http://localhost:1337/graphql'); + console.log('GraphQL Playground running on http://localhost:1337/playground'); +}); +``` + +And finally start your app: + +```bash +$ npx mongodb-runner start +$ node index.js +``` + +After starting the app, you can visit http://localhost:1337/playground in your browser to start playing with your GraphQL API. + +**_Note:_** Do **_NOT_** mount the GraphQL Playground in production. The GraphQL Playground exposes the master key in the browser page. [Parse Dashboard](https://github.com/parse-community/parse-dashboard) has a built-in GraphQL Playground and is the recommended option for production apps. + +## Checking the API health + +Run the following: + +```graphql +query Health { + health +} +``` + +You should receive the following response: + +```json +{ + "data": { + "health": true + } +} +``` + +## Creating your first class + +Since your application does not have any schema yet, you can use the `createClass` mutation to create your first class. Run the following: + +```graphql +mutation CreateClass { + createClass( + name: "GameScore" + schemaFields: { + addStrings: [{ name: "playerName" }] + addNumbers: [{ name: "score" }] + addBooleans: [{ name: "cheatMode" }] + } + ) { + name + schemaFields { + name + __typename + } + } +} +``` + +You should receive the following response: + +```json +{ + "data": { + "createClass": { + "name": "GameScore", + "schemaFields": [ + { + "name": "objectId", + "__typename": "SchemaStringField" + }, + { + "name": "updatedAt", + "__typename": "SchemaDateField" + }, + { + "name": "createdAt", + "__typename": "SchemaDateField" + }, + { + "name": "playerName", + "__typename": "SchemaStringField" + }, + { + "name": "score", + "__typename": "SchemaNumberField" + }, + { + "name": "cheatMode", + "__typename": "SchemaBooleanField" + }, + { + "name": "ACL", + "__typename": "SchemaACLField" + } + ] + } + } +} +``` + +## Using automatically generated operations + +Parse Server learned from the first class that you created and now you have the `GameScore` class in your schema. You can now start using the automatically generated operations! + +Run the following to create your first object: + +```graphql +mutation CreateGameScore { + createGameScore(fields: { playerName: "Sean Plott", score: 1337, cheatMode: false }) { + id + updatedAt + createdAt + playerName + score + cheatMode + ACL + } +} +``` + +You should receive a response similar to this: + +```json +{ + "data": { + "createGameScore": { + "id": "XN75D94OBD", + "updatedAt": "2019-09-17T06:50:26.357Z", + "createdAt": "2019-09-17T06:50:26.357Z", + "playerName": "Sean Plott", + "score": 1337, + "cheatMode": false, + "ACL": null + } + } +} +``` -`GridStoreAdapter` 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 wiki](https://github.com/ParsePlatform/parse-server/wiki/Configuring-File-Adapters). +You can also run a query to this new class: + +```graphql +query GameScores { + gameScores { + results { + id + updatedAt + createdAt + playerName + score + cheatMode + ACL + } + } +} +``` + +You should receive a response similar to this: + +```json +{ + "data": { + "gameScores": { + "results": [ + { + "id": "XN75D94OBD", + "updatedAt": "2019-09-17T06:50:26.357Z", + "createdAt": "2019-09-17T06:50:26.357Z", + "playerName": "Sean Plott", + "score": 1337, + "cheatMode": false, + "ACL": null + } + ] + } + } +} +``` -# Support +## Customizing your GraphQL Schema -For implementation related questions or technical support, please refer to the [Stack Overflow](http://stackoverflow.com/questions/tagged/parse.com) and [Server Fault](https://serverfault.com/tags/parse) communities. +Parse GraphQL Server allows you to create a custom GraphQL schema with own queries and mutations to be merged with the auto-generated ones. You can resolve these operations using your regular cloud code functions. + +To start creating your custom schema, you need to code a `schema.graphql` file and initialize Parse Server with `--graphQLSchema` and `--cloud` options: + +```bash +$ parse-server --appId APPLICATION_ID --masterKey MASTER_KEY --databaseURI mongodb://localhost/test --publicServerURL http://localhost:1337/parse --cloud ./cloud/main.js --graphQLSchema ./cloud/schema.graphql --mountGraphQL --mountPlayground +``` + +### Creating your first custom query + +Use the code below for your `schema.graphql` and `main.js` files. Then restart your Parse Server. + +```graphql +# schema.graphql +extend type Query { + hello: String! @resolve +} +``` + +```js +// main.js +Parse.Cloud.define('hello', async () => { + return 'Hello world!'; +}); +``` + +You can now run your custom query using GraphQL Playground: + +```graphql +query { + hello +} +``` + +You should receive the response below: + +```json +{ + "data": { + "hello": "Hello world!" + } +} +``` -If you believe you've found an issue with Parse Server, make sure these boxes are checked before [reporting an issue](https://github.com/ParsePlatform/parse-server/issues): +## Learning more -- [ ] You've met the [prerequisites](https://github.com/ParsePlatform/parse-server/wiki/Parse-Server-Guide#prerequisites). +The [Parse GraphQL Guide](http://docs.parseplatform.org/graphql/guide/) is a very good source for learning how to use the Parse GraphQL API. -- [ ] You're running the [latest version](https://github.com/ParsePlatform/parse-server/releases) of Parse Server. +You also have a very powerful tool inside your GraphQL Playground. Please look at the right side of your GraphQL Playground. You will see the `DOCS` and `SCHEMA` menus. They are automatically generated by analyzing your application schema. Please refer to them and learn more about everything that you can do with your Parse GraphQL API. -- [ ] You've searched through [existing issues](https://github.com/ParsePlatform/parse-server/issues?utf8=%E2%9C%93&q=). Chances are that your issue has been reported or resolved before. +Additionally, the [GraphQL Learn Section](https://graphql.org/learn/) is a very good source to learn more about the power of the GraphQL language. # Contributing -We really want Parse to be yours, to see it grow and thrive in the open source community. Please see the [Contributing to Parse Server guide](CONTRIBUTING.md). +Please see the [Contributing Guide](CONTRIBUTING.md). + +# Contributors + +This project exists thanks to all the people who contribute... we'd love to see your face on this list! + + + +# Sponsors + +Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [Become a sponsor!](https://opencollective.com/parse-server#sponsor) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +# Backers + +Support us with a monthly donation and help us continue our activities. [Become a backer!](https://opencollective.com/parse-server#backer) + + + +[open-collective-link]: https://opencollective.com/parse-server +[log_release]: https://github.com/parse-community/parse-server/blob/release/changelogs/CHANGELOG_release.md +[log_beta]: https://github.com/parse-community/parse-server/blob/beta/changelogs/CHANGELOG_beta.md +[log_alpha]: https://github.com/parse-community/parse-server/blob/alpha/changelogs/CHANGELOG_alpha.md + +[server-options] http://parseplatform.org/parse-server/api/release/ParseServerOptions.html diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..2330549882 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,19 @@ +# Parse Community Vulnerability Disclosure Program +If you believe you have found a security vulnerability on one of parse-community maintained packages, +we encourage you to let us know right away. +We will investigate all legitimate reports and do our best to quickly fix the problem. +Before making a report, please review this page to understand our disclosure policy and how to communicate with us. + +# Responsible Disclosure Policy +If you comply with the policies below when reporting a security issue to parse community, +we will not initiate a lawsuit or law enforcement investigation against you in response to your report. +We ask that: + +- You give us reasonable time to investigate and mitigate an issue you report before making public any information about the report or sharing such information with others. This means we request _at least_ **7 days** to get back to you with an initial response and _at least_ **30 days** from initial contact (made by you) to apply a patch. +- You do not interact with an individual account (which includes modifying or accessing data from the account) if the account owner has not consented to such actions. +- You make a good faith effort to avoid privacy violations and disruptions to others, including (but not limited to) destruction of data and interruption or degradation of our services. +- You do not exploit a security issue you discover for any reason. (This includes demonstrating additional risk, such as attempted compromise of sensitive company data or probing for additional issues). You do not violate any other applicable laws or regulations. + +# Communicating with us + +All vulnerabilities should be privately reported to us by going to [https://report.parseplatform.org](https://report.parseplatform.org). Alternatively, you can send an email to [security@parseplatform.org](mailto:security@parseplatform.org). diff --git a/benchmark/MongoLatencyWrapper.js b/benchmark/MongoLatencyWrapper.js new file mode 100644 index 0000000000..2b0480c1bc --- /dev/null +++ b/benchmark/MongoLatencyWrapper.js @@ -0,0 +1,137 @@ +/** + * MongoDB Latency Wrapper + * + * Utility to inject artificial latency into MongoDB operations for performance testing. + * This wrapper temporarily wraps MongoDB Collection methods to add delays before + * database operations execute. + * + * Usage: + * const { wrapMongoDBWithLatency } = require('./MongoLatencyWrapper'); + * + * // Before initializing Parse Server + * const unwrap = wrapMongoDBWithLatency(10); // 10ms delay + * + * // ... run benchmarks ... + * + * // Cleanup when done + * unwrap(); + */ + +const { Collection } = require('mongodb'); + +// Store original methods for restoration +const originalMethods = new Map(); + +/** + * Wrap a Collection method to add artificial latency + * @param {string} methodName - Name of the method to wrap + * @param {number} latencyMs - Delay in milliseconds + */ +function wrapMethod(methodName, latencyMs) { + if (!originalMethods.has(methodName)) { + originalMethods.set(methodName, Collection.prototype[methodName]); + } + + const originalMethod = originalMethods.get(methodName); + + Collection.prototype[methodName] = function (...args) { + // For methods that return cursors (like find, aggregate), we need to delay the execution + // but still return a cursor-like object + const result = originalMethod.apply(this, args); + + // Check if result has cursor methods (toArray, forEach, etc.) + if (result && typeof result.toArray === 'function') { + // Wrap cursor methods that actually execute the query + const originalToArray = result.toArray.bind(result); + result.toArray = function() { + // Wait for the original promise to settle, then delay the result + return originalToArray().then( + value => new Promise(resolve => setTimeout(() => resolve(value), latencyMs)), + error => new Promise((_, reject) => setTimeout(() => reject(error), latencyMs)) + ); + }; + return result; + } + + // For promise-returning methods, wrap the promise with delay + if (result && typeof result.then === 'function') { + // Wait for the original promise to settle, then delay the result + return result.then( + value => new Promise(resolve => setTimeout(() => resolve(value), latencyMs)), + error => new Promise((_, reject) => setTimeout(() => reject(error), latencyMs)) + ); + } + + // For synchronous methods, just add delay + return new Promise((resolve) => { + setTimeout(() => { + resolve(result); + }, latencyMs); + }); + }; +} + +/** + * Wrap MongoDB Collection methods with artificial latency + * @param {number} latencyMs - Delay in milliseconds to inject before each operation + * @returns {Function} unwrap - Function to restore original methods + */ +function wrapMongoDBWithLatency(latencyMs) { + if (typeof latencyMs !== 'number' || latencyMs < 0) { + throw new Error('latencyMs must be a non-negative number'); + } + + if (latencyMs === 0) { + // eslint-disable-next-line no-console + console.log('Latency is 0ms, skipping MongoDB wrapping'); + return () => {}; // No-op unwrap function + } + + // eslint-disable-next-line no-console + console.log(`Wrapping MongoDB operations with ${latencyMs}ms artificial latency`); + + // List of MongoDB Collection methods to wrap + const methodsToWrap = [ + 'find', + 'findOne', + 'countDocuments', + 'estimatedDocumentCount', + 'distinct', + 'aggregate', + 'insertOne', + 'insertMany', + 'updateOne', + 'updateMany', + 'replaceOne', + 'deleteOne', + 'deleteMany', + 'findOneAndUpdate', + 'findOneAndReplace', + 'findOneAndDelete', + 'createIndex', + 'createIndexes', + 'dropIndex', + 'dropIndexes', + 'drop', + ]; + + methodsToWrap.forEach(methodName => { + wrapMethod(methodName, latencyMs); + }); + + // Return unwrap function to restore original methods + return function unwrap() { + // eslint-disable-next-line no-console + console.log('Removing MongoDB latency wrapper, restoring original methods'); + + originalMethods.forEach((originalMethod, methodName) => { + Collection.prototype[methodName] = originalMethod; + }); + + originalMethods.clear(); + }; +} + +module.exports = { + wrapMongoDBWithLatency, +}; diff --git a/benchmark/performance.js b/benchmark/performance.js new file mode 100644 index 0000000000..4d6bc74dc6 --- /dev/null +++ b/benchmark/performance.js @@ -0,0 +1,925 @@ +/** + * Performance Benchmark Suite for Parse Server + * + * This suite measures the performance of critical Parse Server operations + * using the Node.js Performance API. Results are output in a format + * compatible with github-action-benchmark. + * + * Run with: npm run benchmark + */ + +const Parse = require('parse/node'); +const { performance } = require('node:perf_hooks'); +const { MongoClient } = require('mongodb'); +const { wrapMongoDBWithLatency } = require('./MongoLatencyWrapper'); + +// Configuration +const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/parse_benchmark_test'; +const SERVER_URL = 'http://localhost:1337/parse'; +const APP_ID = 'benchmark-app-id'; +const MASTER_KEY = 'benchmark-master-key'; +const ITERATIONS = process.env.BENCHMARK_ITERATIONS ? parseInt(process.env.BENCHMARK_ITERATIONS, 10) : undefined; +const LOG_ITERATIONS = false; + +// Parse Server instance +let parseServer; +let httpServer; +let mongoClient; +let core; + +// Logging helpers +const logInfo = message => core.info(message); +const logError = message => core.error(message); +const logGroup = title => core.startGroup(title); +const logGroupEnd = () => core.endGroup(); + +/** + * Initialize Parse Server for benchmarking + */ +async function initializeParseServer() { + const express = require('express'); + const { default: ParseServer } = require('../lib/index.js'); + + const app = express(); + + parseServer = new ParseServer({ + databaseURI: MONGODB_URI, + appId: APP_ID, + masterKey: MASTER_KEY, + serverURL: SERVER_URL, + silent: true, + allowClientClassCreation: true, + logLevel: 'error', // Minimal logging for performance + verbose: false, + liveQuery: { classNames: ['BenchmarkLiveQuery'] }, + }); + + app.use('/parse', parseServer.app); + + return new Promise((resolve, reject) => { + const server = app.listen(1337, (err) => { + if (err) { + reject(new Error(`Failed to start server: ${err.message}`)); + return; + } + Parse.initialize(APP_ID); + Parse.masterKey = MASTER_KEY; + Parse.serverURL = SERVER_URL; + resolve(server); + }); + + server.on('error', (err) => { + reject(new Error(`Server error: ${err.message}`)); + }); + }); +} + +/** + * Clean up database between benchmarks + */ +async function cleanupDatabase() { + try { + if (!mongoClient) { + mongoClient = await MongoClient.connect(MONGODB_URI); + } + const db = mongoClient.db(); + const collections = await db.listCollections().toArray(); + + for (const collection of collections) { + if (!collection.name.startsWith('system.')) { + await db.collection(collection.name).deleteMany({}); + } + } + } catch (error) { + throw new Error(`Failed to cleanup database: ${error.message}`); + } +} + +/** + * Reset Parse SDK to use the default server + */ +function resetParseServer() { + Parse.serverURL = SERVER_URL; +} + +/** + * Measure average time for an async operation over multiple iterations. + * @param {Object} options Measurement options. + * @param {string} options.name Name of the operation being measured. + * @param {Function} options.operation Async function to measure. + * @param {number} options.iterations Number of iterations to run; choose a value that is high + * enough to create reliable benchmark metrics with low variance but low enough to keep test + * duration reasonable around <=10 seconds. + * @param {boolean} [options.skipWarmup=false] Skip warmup phase. + * @param {number} [options.dbLatency] Artificial DB latency in milliseconds to apply during + * this benchmark. + */ +async function measureOperation({ name, operation, iterations, skipWarmup = false, dbLatency }) { + // Override iterations if global ITERATIONS is set + iterations = ITERATIONS || iterations; + + // Determine warmup count (20% of iterations) + const warmupCount = skipWarmup ? 0 : Math.floor(iterations * 0.2); + const times = []; + + // Apply artificial latency if specified + let unwrapLatency = null; + if (dbLatency !== undefined && dbLatency > 0) { + logInfo(`Applying ${dbLatency}ms artificial DB latency for this benchmark`); + unwrapLatency = wrapMongoDBWithLatency(dbLatency); + } + + try { + if (warmupCount > 0) { + logInfo(`Starting warmup phase of ${warmupCount} iterations...`); + const warmupStart = performance.now(); + for (let i = 0; i < warmupCount; i++) { + await operation(); + } + logInfo(`Warmup took: ${(performance.now() - warmupStart).toFixed(2)}ms`); + } + + // Measurement phase + logInfo(`Starting measurement phase of ${iterations} iterations...`); + const progressInterval = Math.ceil(iterations / 10); // Log every 10% + const measurementStart = performance.now(); + + for (let i = 0; i < iterations; i++) { + const start = performance.now(); + await operation(); + const end = performance.now(); + const duration = end - start; + times.push(duration); + + // Log progress every 10% or individual iterations if LOG_ITERATIONS is enabled + if (LOG_ITERATIONS) { + logInfo(`Iteration ${i + 1}: ${duration.toFixed(2)}ms`); + } else if ((i + 1) % progressInterval === 0 || i + 1 === iterations) { + const progress = Math.round(((i + 1) / iterations) * 100); + logInfo(`Progress: ${progress}%`); + } + } + + logInfo(`Measurement took: ${(performance.now() - measurementStart).toFixed(2)}ms`); + + // Sort times for percentile calculations + times.sort((a, b) => a - b); + + // Filter outliers using Interquartile Range (IQR) method + const q1Index = Math.floor(times.length * 0.25); + const q3Index = Math.floor(times.length * 0.75); + const q1 = times[q1Index]; + const q3 = times[q3Index]; + const iqr = q3 - q1; + const lowerBound = q1 - 1.5 * iqr; + const upperBound = q3 + 1.5 * iqr; + + const filtered = times.filter(t => t >= lowerBound && t <= upperBound); + + // Calculate statistics on filtered data + const median = filtered[Math.floor(filtered.length * 0.5)]; + const p95 = filtered[Math.floor(filtered.length * 0.95)]; + const p99 = filtered[Math.floor(filtered.length * 0.99)]; + const min = filtered[0]; + const max = filtered[filtered.length - 1]; + + return { + name, + value: median, // Use median (p50) as primary metric for stability in CI + unit: 'ms', + range: `${min.toFixed(2)} - ${max.toFixed(2)}`, + extra: `p95: ${p95.toFixed(2)}ms, p99: ${p99.toFixed(2)}ms, n=${filtered.length}/${times.length}`, + }; + } finally { + // Remove latency wrapper if it was applied + if (unwrapLatency) { + unwrapLatency(); + logInfo('Removed artificial DB latency'); + } + } +} + +/** + * Measure GC pressure for an async operation over multiple iterations. + * Tracks total garbage collection time per operation using PerformanceObserver. + * Using total GC time (sum of all pauses) rather than max single pause provides + * much more stable metrics — it eliminates the variance from V8 choosing to do + * one long pause vs. many short pauses for the same amount of GC work. + * @param {Object} options Measurement options. + * @param {string} options.name Name of the operation being measured. + * @param {Function} options.operation Async function to measure. + * @param {number} options.iterations Number of iterations to run. + * @param {boolean} [options.skipWarmup=false] Skip warmup phase. + */ +async function measureMemoryOperation({ name, operation, iterations, skipWarmup = false }) { + const { PerformanceObserver } = require('node:perf_hooks'); + + // Override iterations if global ITERATIONS is set + iterations = ITERATIONS || iterations; + + // Determine warmup count (20% of iterations) + const warmupCount = skipWarmup ? 0 : Math.floor(iterations * 0.2); + const gcDurations = []; + + if (warmupCount > 0) { + logInfo(`Starting warmup phase of ${warmupCount} iterations...`); + for (let i = 0; i < warmupCount; i++) { + await operation(); + } + logInfo('Warmup complete.'); + } + + // Measurement phase + logInfo(`Starting measurement phase of ${iterations} iterations...`); + const progressInterval = Math.ceil(iterations / 10); + + for (let i = 0; i < iterations; i++) { + // Force GC before each iteration to start from a clean state + if (typeof global.gc === 'function') { + global.gc(); + } + + // Track GC events during this iteration; sum all GC pause durations to + // measure total GC work, which is stable regardless of whether V8 chooses + // one long pause or many short pauses + let totalGcTime = 0; + const obs = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + totalGcTime += entry.duration; + } + }); + obs.observe({ type: 'gc', buffered: false }); + + await operation(); + + // Force GC after the operation to flush pending GC work into this + // iteration's measurement, preventing cross-iteration contamination + if (typeof global.gc === 'function') { + global.gc(); + } + + // Flush any buffered entries before disconnecting to avoid data loss + for (const entry of obs.takeRecords()) { + totalGcTime += entry.duration; + } + obs.disconnect(); + gcDurations.push(totalGcTime); + + if (LOG_ITERATIONS) { + logInfo(`Iteration ${i + 1}: ${totalGcTime.toFixed(2)} ms GC`); + } else if ((i + 1) % progressInterval === 0 || i + 1 === iterations) { + const progress = Math.round(((i + 1) / iterations) * 100); + logInfo(`Progress: ${progress}%`); + } + } + + // Sort for percentile calculations + gcDurations.sort((a, b) => a - b); + + // Filter outliers using IQR method + const q1Index = Math.floor(gcDurations.length * 0.25); + const q3Index = Math.floor(gcDurations.length * 0.75); + const q1 = gcDurations[q1Index]; + const q3 = gcDurations[q3Index]; + const iqr = q3 - q1; + const lowerBound = q1 - 1.5 * iqr; + const upperBound = q3 + 1.5 * iqr; + + const filtered = gcDurations.filter(d => d >= lowerBound && d <= upperBound); + + const median = filtered[Math.floor(filtered.length * 0.5)]; + const p95 = filtered[Math.floor(filtered.length * 0.95)]; + const p99 = filtered[Math.floor(filtered.length * 0.99)]; + const min = filtered[0]; + const max = filtered[filtered.length - 1]; + + return { + name, + value: median, + unit: 'ms', + range: `${min.toFixed(2)} - ${max.toFixed(2)}`, + extra: `p95: ${p95.toFixed(2)}ms, p99: ${p99.toFixed(2)}ms, n=${filtered.length}/${gcDurations.length}`, + }; +} + +/** + * Benchmark: Object Create + */ +async function benchmarkObjectCreate(name) { + let counter = 0; + + return measureOperation({ + name, + iterations: 1_000, + operation: async () => { + const TestObject = Parse.Object.extend('BenchmarkTest'); + const obj = new TestObject(); + obj.set('testField', `test-value-${counter++}`); + obj.set('number', counter); + obj.set('boolean', true); + await obj.save(); + }, + }); +} + +/** + * Benchmark: Object Read (by ID) + */ +async function benchmarkObjectRead(name) { + // Setup: Create test objects + const TestObject = Parse.Object.extend('BenchmarkTest'); + const objects = []; + + for (let i = 0; i < 1_000; i++) { + const obj = new TestObject(); + obj.set('testField', `read-test-${i}`); + objects.push(obj); + } + + await Parse.Object.saveAll(objects); + + let counter = 0; + + return measureOperation({ + name, + iterations: 1_000, + operation: async () => { + const query = new Parse.Query('BenchmarkTest'); + await query.get(objects[counter++ % objects.length].id); + }, + }); +} + +/** + * Benchmark: Object Update + */ +async function benchmarkObjectUpdate(name) { + // Setup: Create test objects + const TestObject = Parse.Object.extend('BenchmarkTest'); + const objects = []; + + for (let i = 0; i < 1_000; i++) { + const obj = new TestObject(); + obj.set('testField', `update-test-${i}`); + obj.set('counter', 0); + objects.push(obj); + } + + await Parse.Object.saveAll(objects); + + let counter = 0; + + return measureOperation({ + name, + iterations: 1_000, + operation: async () => { + const obj = objects[counter++ % objects.length]; + obj.increment('counter'); + obj.set('lastUpdated', new Date()); + await obj.save(); + }, + }); +} + +/** + * Benchmark: Simple Query + */ +async function benchmarkSimpleQuery(name) { + // Setup: Create test data + const TestObject = Parse.Object.extend('BenchmarkTest'); + const objects = []; + + for (let i = 0; i < 100; i++) { + const obj = new TestObject(); + obj.set('category', i % 10); + obj.set('value', i); + objects.push(obj); + } + + await Parse.Object.saveAll(objects); + + let counter = 0; + + return measureOperation({ + name, + iterations: 1_000, + operation: async () => { + const query = new Parse.Query('BenchmarkTest'); + query.equalTo('category', counter++ % 10); + await query.find(); + }, + }); +} + +/** + * Benchmark: Batch Save (saveAll) + */ +async function benchmarkBatchSave(name) { + const BATCH_SIZE = 10; + + return measureOperation({ + name, + iterations: 1_000, + operation: async () => { + const TestObject = Parse.Object.extend('BenchmarkTest'); + const objects = []; + + for (let i = 0; i < BATCH_SIZE; i++) { + const obj = new TestObject(); + obj.set('batchField', `batch-${i}`); + obj.set('timestamp', new Date()); + objects.push(obj); + } + + await Parse.Object.saveAll(objects); + }, + }); +} + +/** + * Benchmark: User Signup + */ +async function benchmarkUserSignup(name) { + let counter = 0; + + return measureOperation({ + name, + iterations: 500, + operation: async () => { + counter++; + const user = new Parse.User(); + user.set('username', `benchmark_user_${Date.now()}_${counter}`); + user.set('password', 'benchmark_password'); + user.set('email', `benchmark${counter}@example.com`); + await user.signUp(); + }, + }); +} + +/** + * Benchmark: User Login + */ +async function benchmarkUserLogin(name) { + // Setup: Create test users + const users = []; + + for (let i = 0; i < 10; i++) { + const user = new Parse.User(); + user.set('username', `benchmark_login_user_${i}`); + user.set('password', 'benchmark_password'); + user.set('email', `login${i}@example.com`); + await user.signUp(); + users.push({ username: user.get('username'), password: 'benchmark_password' }); + await Parse.User.logOut(); + } + + let counter = 0; + + return measureOperation({ + name, + iterations: 500, + operation: async () => { + const userCreds = users[counter++ % users.length]; + await Parse.User.logIn(userCreds.username, userCreds.password); + await Parse.User.logOut(); + }, + }); +} + +/** + * Benchmark: Query with Include (Parallel Pointers) + * Tests the performance improvement when fetching multiple pointers at the same level. + */ +async function benchmarkQueryWithIncludeParallel(name) { + const PointerAClass = Parse.Object.extend('PointerA'); + const PointerBClass = Parse.Object.extend('PointerB'); + const PointerCClass = Parse.Object.extend('PointerC'); + const RootClass = Parse.Object.extend('Root'); + + // Create pointer objects + const pointerAObjects = []; + for (let i = 0; i < 10; i++) { + const obj = new PointerAClass(); + obj.set('name', `pointerA-${i}`); + pointerAObjects.push(obj); + } + await Parse.Object.saveAll(pointerAObjects); + + const pointerBObjects = []; + for (let i = 0; i < 10; i++) { + const obj = new PointerBClass(); + obj.set('name', `pointerB-${i}`); + pointerBObjects.push(obj); + } + await Parse.Object.saveAll(pointerBObjects); + + const pointerCObjects = []; + for (let i = 0; i < 10; i++) { + const obj = new PointerCClass(); + obj.set('name', `pointerC-${i}`); + pointerCObjects.push(obj); + } + await Parse.Object.saveAll(pointerCObjects); + + // Create Root objects with multiple pointers at the same level + const rootObjects = []; + for (let i = 0; i < 10; i++) { + const obj = new RootClass(); + obj.set('name', `root-${i}`); + obj.set('pointerA', pointerAObjects[i % pointerAObjects.length]); + obj.set('pointerB', pointerBObjects[i % pointerBObjects.length]); + obj.set('pointerC', pointerCObjects[i % pointerCObjects.length]); + rootObjects.push(obj); + } + await Parse.Object.saveAll(rootObjects); + + return measureOperation({ + name, + skipWarmup: true, + dbLatency: 100, + iterations: 100, + operation: async () => { + const query = new Parse.Query('Root'); + // Include multiple pointers at the same level - should fetch in parallel + query.include(['pointerA', 'pointerB', 'pointerC']); + await query.find(); + }, + }); +} + +/** + * Benchmark: Query with Include (Nested Pointers with Parallel Leaf Nodes) + * Tests the PR's optimization for parallel fetching at each nested level. + * Pattern: p1.p2.p3, p1.p2.p4, p1.p2.p5 + * After fetching p2, we know the objectIds and can fetch p3, p4, p5 in parallel. + */ +async function benchmarkQueryWithIncludeNested(name) { + const Level3AClass = Parse.Object.extend('Level3A'); + const Level3BClass = Parse.Object.extend('Level3B'); + const Level3CClass = Parse.Object.extend('Level3C'); + const Level2Class = Parse.Object.extend('Level2'); + const Level1Class = Parse.Object.extend('Level1'); + const RootClass = Parse.Object.extend('Root'); + + // Create Level3 objects (leaf nodes) + const level3AObjects = []; + for (let i = 0; i < 10; i++) { + const obj = new Level3AClass(); + obj.set('name', `level3A-${i}`); + level3AObjects.push(obj); + } + await Parse.Object.saveAll(level3AObjects); + + const level3BObjects = []; + for (let i = 0; i < 10; i++) { + const obj = new Level3BClass(); + obj.set('name', `level3B-${i}`); + level3BObjects.push(obj); + } + await Parse.Object.saveAll(level3BObjects); + + const level3CObjects = []; + for (let i = 0; i < 10; i++) { + const obj = new Level3CClass(); + obj.set('name', `level3C-${i}`); + level3CObjects.push(obj); + } + await Parse.Object.saveAll(level3CObjects); + + // Create Level2 objects pointing to multiple Level3 objects + const level2Objects = []; + for (let i = 0; i < 10; i++) { + const obj = new Level2Class(); + obj.set('name', `level2-${i}`); + obj.set('level3A', level3AObjects[i % level3AObjects.length]); + obj.set('level3B', level3BObjects[i % level3BObjects.length]); + obj.set('level3C', level3CObjects[i % level3CObjects.length]); + level2Objects.push(obj); + } + await Parse.Object.saveAll(level2Objects); + + // Create Level1 objects pointing to Level2 + const level1Objects = []; + for (let i = 0; i < 10; i++) { + const obj = new Level1Class(); + obj.set('name', `level1-${i}`); + obj.set('level2', level2Objects[i % level2Objects.length]); + level1Objects.push(obj); + } + await Parse.Object.saveAll(level1Objects); + + // Create Root objects pointing to Level1 + const rootObjects = []; + for (let i = 0; i < 10; i++) { + const obj = new RootClass(); + obj.set('name', `root-${i}`); + obj.set('level1', level1Objects[i % level1Objects.length]); + rootObjects.push(obj); + } + await Parse.Object.saveAll(rootObjects); + + return measureOperation({ + name, + skipWarmup: true, + dbLatency: 100, + iterations: 100, + operation: async () => { + const query = new Parse.Query('Root'); + // After fetching level1.level2, the PR should fetch level3A, level3B, level3C in parallel + query.include(['level1.level2.level3A', 'level1.level2.level3B', 'level1.level2.level3C']); + await query.find(); + }, + }); +} + +/** + * Benchmark: Large Result Set GC Pressure + * Measures max GC pause when querying many large documents, which is affected + * by MongoDB cursor batch size configuration. Without a batch size limit, + * the driver processes larger data chunks between yield points, creating more + * garbage that triggers longer GC pauses. + */ +async function benchmarkLargeResultMemory(name) { + const TestObject = Parse.Object.extend('BenchmarkLargeResult'); + const TOTAL_OBJECTS = 3_000; + const SAVE_BATCH_SIZE = 200; + + // Seed data in batches; ~8 KB per document so 3,000 docs ≈ 24 MB total, + // exceeding MongoDB's 16 MiB default batch limit to test cursor batching + for (let i = 0; i < TOTAL_OBJECTS; i += SAVE_BATCH_SIZE) { + const batch = []; + for (let j = 0; j < SAVE_BATCH_SIZE && i + j < TOTAL_OBJECTS; j++) { + const obj = new TestObject(); + obj.set('category', (i + j) % 10); + obj.set('value', i + j); + obj.set('data', `padding-${i + j}-${'x'.repeat(8000)}`); + batch.push(obj); + } + await Parse.Object.saveAll(batch); + } + + return measureMemoryOperation({ + name, + iterations: 100, + operation: async () => { + const query = new Parse.Query('BenchmarkLargeResult'); + query.limit(TOTAL_OBJECTS); + await query.find({ useMasterKey: true }); + }, + }); +} + +/** + * Benchmark: Concurrent Query GC Pressure + * Measures max GC pause under concurrent load with large result sets. + * Simulates production conditions where multiple clients query simultaneously, + * compounding GC pressure from cursor batch sizes. + */ +async function benchmarkConcurrentQueryMemory(name) { + const TestObject = Parse.Object.extend('BenchmarkConcurrentResult'); + const TOTAL_OBJECTS = 3_000; + const SAVE_BATCH_SIZE = 200; + const CONCURRENT_QUERIES = 10; + + // Seed data in batches; ~8 KB per document so 3,000 docs ≈ 24 MB total, + // exceeding MongoDB's 16 MiB default batch limit to test cursor batching + for (let i = 0; i < TOTAL_OBJECTS; i += SAVE_BATCH_SIZE) { + const batch = []; + for (let j = 0; j < SAVE_BATCH_SIZE && i + j < TOTAL_OBJECTS; j++) { + const obj = new TestObject(); + obj.set('category', (i + j) % 10); + obj.set('value', i + j); + obj.set('data', `padding-${i + j}-${'x'.repeat(8000)}`); + batch.push(obj); + } + await Parse.Object.saveAll(batch); + } + + return measureMemoryOperation({ + name, + iterations: 50, + operation: async () => { + const queries = []; + for (let i = 0; i < CONCURRENT_QUERIES; i++) { + const query = new Parse.Query('BenchmarkConcurrentResult'); + query.limit(TOTAL_OBJECTS); + queries.push(query.find({ useMasterKey: true })); + } + await Promise.all(queries); + }, + }); +} + +/** + * Benchmark: Query $regex + * + * Measures a standard Parse.Query.find() with a $regex constraint. + * Each iteration uses a different regex to avoid database query cache hits. + */ +async function benchmarkQueryRegex(name) { + // Seed objects that will match the various regex patterns + const objects = []; + for (let i = 0; i < 1_000; i++) { + const obj = new Parse.Object('BenchmarkRegex'); + obj.set('field', `BenchRegex_${i} data`); + objects.push(obj); + } + await Parse.Object.saveAll(objects); + + let counter = 0; + + const bases = ['^BenchRegex_', 'BenchRegex_', '[a-z]+_']; + + return measureOperation({ + name, + iterations: 1_000, + operation: async () => { + const idx = counter++; + const regex = bases[idx % bases.length] + idx; + const query = new Parse.Query('BenchmarkRegex'); + query._addCondition('field', '$regex', regex); + await query.find(); + }, + }); +} + +/** + * Benchmark: LiveQuery $regex end-to-end + * + * Measures the full round-trip of a LiveQuery subscription with a $regex constraint: + * subscribe with a unique regex pattern, save an object that matches, and measure + * the time until the LiveQuery event fires. Each iteration uses a different regex + * to avoid cache hits on the RE2JS compile step. + */ +async function benchmarkLiveQueryRegex(name) { + // Enable LiveQuery on the running server + const { default: ParseServer } = require('../lib/index.js'); + const liveQueryServer = await ParseServer.createLiveQueryServer(httpServer, { + appId: APP_ID, + masterKey: MASTER_KEY, + serverURL: SERVER_URL, + }); + Parse.liveQueryServerURL = 'ws://localhost:1337'; + + let counter = 0; + + // Cycle through different regex patterns to avoid RE2JS cache hits + const patterns = [ + { base: '^BenchLQ_', fieldValue: i => `BenchLQ_${i} data` }, + { base: 'benchfield_', fieldValue: i => `some benchfield_${i} here` }, + { base: '[a-z]+_benchclass_', fieldValue: i => `abc_benchclass_${i}` }, + ]; + + try { + return await measureOperation({ + name, + iterations: 500, + operation: async () => { + const idx = counter++; + const pattern = patterns[idx % patterns.length]; + const regex = pattern.base + idx; + const query = new Parse.Query('BenchmarkLiveQuery'); + query._addCondition('field', '$regex', regex); + const subscription = await query.subscribe(); + const eventPromise = new Promise(resolve => { + subscription.on('create', () => resolve()); + }); + const obj = new Parse.Object('BenchmarkLiveQuery'); + obj.set('field', pattern.fieldValue(idx)); + await obj.save(); + await eventPromise; + subscription.unsubscribe(); + }, + }); + } finally { + await liveQueryServer.shutdown(); + Parse.liveQueryServerURL = undefined; + } +} + +/** + * Benchmark: Object.save with nested data (denylist scanning) + * + * Measures create latency for objects with deeply nested structures containing + * multiple sibling objects at each level. This exercises the requestKeywordDenylist + * scanner (objectContainsKeyValue) which must traverse all keys and nested values. + */ +async function benchmarkObjectCreateNestedDenylist(name) { + let counter = 0; + + return measureOperation({ + name, + iterations: 1_000, + operation: async () => { + const TestObject = Parse.Object.extend('BenchmarkDenylist'); + const obj = new TestObject(); + const idx = counter++; + obj.set('nested', { + meta1: { info: { detail: `value-${idx}` } }, + meta2: { info: { detail: `value-${idx}` } }, + meta3: { info: { detail: `value-${idx}` } }, + tags: ['a', 'b', 'c'], + config: { + setting1: { enabled: true, params: { x: 1 } }, + setting2: { enabled: false, params: { y: 2 } }, + }, + }); + await obj.save(); + }, + }); +} + +/** + * Run all benchmarks + */ +async function runBenchmarks() { + core = await import('@actions/core'); + logInfo('Starting Parse Server Performance Benchmarks...'); + + let server; + + try { + // Initialize Parse Server + logInfo('Initializing Parse Server...'); + server = await initializeParseServer(); + httpServer = server; + + // Wait for server to be ready + await new Promise(resolve => setTimeout(resolve, 2000)); + + const results = []; + + // Define all benchmarks to run + const benchmarks = [ + { name: 'Object.save (create)', fn: benchmarkObjectCreate }, + { name: 'Object.save (update)', fn: benchmarkObjectUpdate }, + { name: 'Object.saveAll (batch save)', fn: benchmarkBatchSave }, + { name: 'Query.get (by objectId)', fn: benchmarkObjectRead }, + { name: 'Query.find (simple query)', fn: benchmarkSimpleQuery }, + { name: 'User.signUp', fn: benchmarkUserSignup }, + { name: 'User.login', fn: benchmarkUserLogin }, + { name: 'Query.include (parallel pointers)', fn: benchmarkQueryWithIncludeParallel }, + { name: 'Query.include (nested pointers)', fn: benchmarkQueryWithIncludeNested }, + { name: 'Query.find (large result, GC pressure)', fn: benchmarkLargeResultMemory }, + { name: 'Query.find (concurrent, GC pressure)', fn: benchmarkConcurrentQueryMemory }, + { name: 'Object.save (nested data, denylist scan)', fn: benchmarkObjectCreateNestedDenylist }, + { name: 'Query $regex', fn: benchmarkQueryRegex }, + { name: 'LiveQuery $regex', fn: benchmarkLiveQueryRegex }, + ]; + + // Run each benchmark with database cleanup + const suiteStart = performance.now(); + for (let idx = 0; idx < benchmarks.length; idx++) { + const benchmark = benchmarks[idx]; + const label = `[${idx + 1}/${benchmarks.length}] ${benchmark.name}`; + logGroup(label); + try { + logInfo('Resetting database...'); + resetParseServer(); + await cleanupDatabase(); + logInfo('Running benchmark...'); + const benchStart = performance.now(); + const result = await benchmark.fn(benchmark.name); + const benchDuration = ((performance.now() - benchStart) / 1000).toFixed(1); + results.push(result); + logInfo(`Result: ${result.value.toFixed(2)} ${result.unit} (${result.extra})`); + logInfo(`Duration: ${benchDuration}s`); + } finally { + logGroupEnd(); + } + } + const suiteDuration = ((performance.now() - suiteStart) / 1000).toFixed(1); + + // Output results in github-action-benchmark format (stdout) + logInfo(JSON.stringify(results, null, 2)); + + // Output summary + logGroup('Summary'); + results.forEach(result => { + logInfo(`${result.name}: ${result.value.toFixed(2)} ${result.unit} (${result.extra})`); + }); + logInfo(`Total duration: ${suiteDuration}s`); + logGroupEnd(); + + } catch (error) { + logError('Error running benchmarks:', error); + process.exit(1); + } finally { + // Cleanup + if (mongoClient) { + await mongoClient.close(); + } + if (server) { + server.close(); + } + // Give some time for cleanup + setTimeout(() => process.exit(0), 1000); + } +} + +// Run benchmarks if executed directly +if (require.main === module) { + runBenchmarks(); +} + +module.exports = { runBenchmarks }; diff --git a/bin/dev b/bin/dev deleted file mode 100755 index 5549b75d29..0000000000 --- a/bin/dev +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env node - -var nodemon = require('nodemon'); -var babel = require("babel-core"); -var gaze = require('gaze'); -var fs = require('fs'); -var path = require('path'); - -// Watch the src and transpile when changed -gaze('src/**/*', function(err, watcher) { - if (err) throw err; - watcher.on('changed', function(sourceFile) { - console.log(sourceFile + " has changed"); - try { - targetFile = path.relative(__dirname, sourceFile).replace(/\/src\//, '/lib/'); - targetFile = path.resolve(__dirname, targetFile); - fs.writeFile(targetFile, babel.transformFileSync(sourceFile).code); - } catch (e) { - console.error(e.message, e.stack); - } - }); -}); - -try { - // Run and watch dist - nodemon({ - script: 'bin/parse-server', - ext: 'js json', - watch: 'lib' - }); -} catch (e) { - console.error(e.message, e.stack); -} - -process.once('SIGINT', function() { - process.exit(0); -}); \ No newline at end of file diff --git a/bin/parse-live-query-server b/bin/parse-live-query-server new file mode 100755 index 0000000000..8f22879d85 --- /dev/null +++ b/bin/parse-live-query-server @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +require("../lib/cli/parse-live-query-server"); diff --git a/bootstrap.sh b/bootstrap.sh index 4fb2fab3c1..c36f0ad402 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -1,10 +1,10 @@ -#!/bin/sh +#!/bin/bash RED='\033[0;31m' GREEN='\033[0;32m' NC='\033[0m' BOLD='\033[1m' CHECK="${GREEN}\xE2\x9C\x93${NC}" -DEFAULT_MONGODB_URI='mongodb://localhost:127.0.0.1:27017/parse' +DEFAULT_MONGODB_URI='mongodb://127.0.0.1:27017/parse' confirm() { DEFAULT=$1; @@ -53,15 +53,60 @@ check_npm() { echo '' -echo 'This will setup parse-server in the current directory' +echo ' + `.-://////:-..` + `:/oooooooooooooooo+:.` + `:+oooooooooooooooooooooo+/` + :+ooooooooooooooooooooooooooo/. + .+oooooooooooooo/:.....-:+ooooooo- + .+ooooooooooooo/` .:///:-` -+oooooo: + `+ooooooooooooo: `/ooooooo+- `ooooooo- + :oooooooooooooo :ooooooooo+` /oooooo+ + +ooooooooooooo/ +ooooooooo+ /ooooooo. + oooooooooooooo+ ooooooooo` .oooooooo. + +ooooooooooo+/: `ooooooo` .:ooooooooo. + :ooooooo+.````````````` /+oooooooooo+ + `+oooooo- `ooo+ /oooooooooooooooooooo- + .+ooooo/ :/:` -ooooooooooooooooooo: + .+ooooo+:-..-/ooooooooooooooooooo- + :+ooooooooooooooooooooooooooo/. + `:+oooooooooooooooooooooo+/` + `:/oooooooooooooooo+:.` + `.-://////:-..` + + parse-server + +' + + +INSTALL_DIR="" +printf "Enter an installation directory\n" +printf "(%s): " "${PWD}" +read -r INSTALL_DIR + +if [ "$INSTALL_DIR" = "" ]; then + INSTALL_DIR="${PWD}" +fi + +echo '' +printf "This will setup parse-server in %s\n" "${INSTALL_DIR}" confirm 'Y' 'Do you want to continue? (Y/n): ' check_node check_npm -echo "Setting up parse-server in $PWD" +printf "Setting up parse-server in %s\n" "${INSTALL_DIR}" + +if [ -d "${INSTALL_DIR}" ]; then + echo "${CHECK} ${INSTALL_DIR} exists" +else + mkdir -p "${INSTALL_DIR}" + echo "${CHECK} Created ${INSTALL_DIR}" +fi + +cd "${INSTALL_DIR}" -if [ -f './package.json' ]; then +if [ -f "package.json" ]; then echo "\n${RED}package.json exists${NC}" confirm 'N' "Do you want to continue? \n${RED}this will erase your configuration${NC} (y/N): " fi @@ -77,33 +122,33 @@ i=0 while [ "$APP_NAME" = "" ] do [[ $i != 0 ]] && printf "${RED}An application name is required!${NC}\n" - printf 'Enter your Application Name: ' + printf "Enter your ${BOLD}Application Name${NC}: " read -r APP_NAME i=$(($i+1)) done -printf 'Enter your appId (leave empty to generate): ' +printf "Enter your ${BOLD}Application Id${NC} (leave empty to generate): " read -r APP_ID [[ $APP_ID = '' ]] && APP_ID=$(genstring) && printf "\n$APP_ID\n\n" -printf 'Enter your masterKey (leave empty to generate): ' +printf "Enter your ${BOLD}Master Key${NC} (leave empty to generate): " read -r MASTER_KEY [[ $MASTER_KEY = '' ]] && MASTER_KEY=$(genstring) && printf "\n$MASTER_KEY\n\n" -printf "Enter your mongodbURI (%s): " $DEFAULT_MONGODB_URI +printf "Enter your ${BOLD}mongodbURI${NC} (%s): " $DEFAULT_MONGODB_URI read -r MONGODB_URI [[ $MONGODB_URI = '' ]] && MONGODB_URI="$DEFAULT_MONGODB_URI" cat > ./config.json << EOF { - "appId": "$APP_ID", - "masterKey": "$MASTER_KEY", - "appName": "$APP_NAME", + "appId": "${APP_ID}", + "masterKey": "${MASTER_KEY}", + "appName": "${APP_NAME}", "cloud": "./cloud/main", - "databaseURI": "$MONGODB_URI" + "databaseURI": "${MONGODB_URI}" } EOF echo "${CHECK} Created config.json" @@ -113,11 +158,12 @@ NPM_APP_NAME=$(echo "$APP_NAME" | tr '[:upper:]' '[:lower:]' | tr ' ' '-') cat > ./package.json << EOF { "name": "$NPM_APP_NAME", + "description": "parse-server for $APP_NAME", "scripts": { - "start": "parse-server ./config.json" + "start": "parse-server config.json" }, "dependencies": { - "parse-server": "^2.0.0" + "parse-server": "^3.9.0" } } EOF @@ -149,7 +195,7 @@ fi echo "\n${CHECK} running npm install\n" -npm install +npm install -s CURL_CMD=$(cat << EOF curl -X POST -H 'X-Parse-Application-Id: ${APP_ID}' \\ diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md new file mode 100644 index 0000000000..98c0cf907f --- /dev/null +++ b/changelogs/CHANGELOG_alpha.md @@ -0,0 +1,3459 @@ +# [9.9.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.9.0-alpha.2...9.9.0-alpha.3) (2026-04-30) + + +### Features + +* Add installation deviceToken deduplication options ([#10451](https://github.com/parse-community/parse-server/issues/10451)) ([9fee1a0](https://github.com/parse-community/parse-server/commit/9fee1a07080ab8bda2a3d4798881bcc288e5b37a)) + +# [9.9.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.9.0-alpha.1...9.9.0-alpha.2) (2026-04-26) + + +### Bug Fixes + +* MFA SMS one-time password accepted twice under concurrent login ([GHSA-jpq4-7fmq-q5fj](https://github.com/parse-community/parse-server/security/advisories/GHSA-jpq4-7fmq-q5fj)) ([#10448](https://github.com/parse-community/parse-server/issues/10448)) ([725be0d](https://github.com/parse-community/parse-server/commit/725be0d602baa619492606e7b3f6829082d93a4c)) + +# [9.9.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.8.1-alpha.1...9.9.0-alpha.1) (2026-04-17) + + +### Features + +* Add `rawValues` and `rawFieldNames` options for aggregation queries ([#10438](https://github.com/parse-community/parse-server/issues/10438)) ([f26700e](https://github.com/parse-community/parse-server/commit/f26700e39d1980940467bee0d26ca3deb88e3924)) + +## [9.8.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.8.0...9.8.1-alpha.1) (2026-04-12) + + +### Bug Fixes + +* Context mutations leak across requests in `ParseServerRESTController` ([#10291](https://github.com/parse-community/parse-server/issues/10291)) ([60a58ec](https://github.com/parse-community/parse-server/commit/60a58ec11a8bb67aaf217b1e7362b89d742b66da)) + +# [9.8.0-alpha.13](https://github.com/parse-community/parse-server/compare/9.8.0-alpha.12...9.8.0-alpha.13) (2026-04-12) + + +### Bug Fixes + +* Facebook Standard Login missing app ID validation ([#10429](https://github.com/parse-community/parse-server/issues/10429)) ([fd31159](https://github.com/parse-community/parse-server/commit/fd31159859ed90f57eb3713f82c9f5b04b20a28c)) + +# [9.8.0-alpha.12](https://github.com/parse-community/parse-server/compare/9.8.0-alpha.11...9.8.0-alpha.12) (2026-04-10) + + +### Features + +* Add `requestComplexity.subqueryLimit` option to limit subquery results ([#10420](https://github.com/parse-community/parse-server/issues/10420)) ([bf40004](https://github.com/parse-community/parse-server/commit/bf40004d258f114c06a3085052ca094384b52b43)) + +# [9.8.0-alpha.11](https://github.com/parse-community/parse-server/compare/9.8.0-alpha.10...9.8.0-alpha.11) (2026-04-09) + + +### Features + +* Add `requestComplexity.allowRegex` option to disable `$regex` query operator ([#10418](https://github.com/parse-community/parse-server/issues/10418)) ([18482e3](https://github.com/parse-community/parse-server/commit/18482e386c1e723da2df3137f61fa5e2bc8983a6)) + +# [9.8.0-alpha.10](https://github.com/parse-community/parse-server/compare/9.8.0-alpha.9...9.8.0-alpha.10) (2026-04-07) + + +### Bug Fixes + +* Master key does not bypass `protectedFields` on various endpoints ([#10412](https://github.com/parse-community/parse-server/issues/10412)) ([c0889c8](https://github.com/parse-community/parse-server/commit/c0889c8575ee6c6ee01c79cd1ae457124e2a08b3)) + +# [9.8.0-alpha.9](https://github.com/parse-community/parse-server/compare/9.8.0-alpha.8...9.8.0-alpha.9) (2026-04-07) + + +### Bug Fixes + +* Endpoints `/login` and `/verifyPassword` ignore `_User` `protectedFields` ([#10409](https://github.com/parse-community/parse-server/issues/10409)) ([8a3db3b](https://github.com/parse-community/parse-server/commit/8a3db3b9666ea998a8843c629e1af55b105e22e0)) + +# [9.8.0-alpha.8](https://github.com/parse-community/parse-server/compare/9.8.0-alpha.7...9.8.0-alpha.8) (2026-04-07) + + +### Bug Fixes + +* Endpoint `/upgradeToRevocableSession` ignores `_Session` `protectedFields` ([#10408](https://github.com/parse-community/parse-server/issues/10408)) ([c136e2b](https://github.com/parse-community/parse-server/commit/c136e2b7ab74609a5127fb68fc5ba40fef440f48)) + +# [9.8.0-alpha.7](https://github.com/parse-community/parse-server/compare/9.8.0-alpha.6...9.8.0-alpha.7) (2026-04-06) + + +### Bug Fixes + +* Endpoint `/sessions/me` bypasses `_Session` `protectedFields` ([GHSA-g4v2-qx3q-4p64](https://github.com/parse-community/parse-server/security/advisories/GHSA-g4v2-qx3q-4p64)) ([#10406](https://github.com/parse-community/parse-server/issues/10406)) ([d507575](https://github.com/parse-community/parse-server/commit/d5075758f6c3ae9d806671de196fd8b419bc517e)) + +# [9.8.0-alpha.6](https://github.com/parse-community/parse-server/compare/9.8.0-alpha.5...9.8.0-alpha.6) (2026-04-05) + + +### Bug Fixes + +* Login timing side-channel reveals user existence ([GHSA-mmpq-5hcv-hf2v](https://github.com/parse-community/parse-server/security/advisories/GHSA-mmpq-5hcv-hf2v)) ([#10398](https://github.com/parse-community/parse-server/issues/10398)) ([531b9ab](https://github.com/parse-community/parse-server/commit/531b9ab6dda4268ede365367fcdc6d98e737ccc3)) + +# [9.8.0-alpha.5](https://github.com/parse-community/parse-server/compare/9.8.0-alpha.4...9.8.0-alpha.5) (2026-04-04) + + +### Features + +* Add support for invoking Cloud Function with `multipart/form-data` protocol ([#10395](https://github.com/parse-community/parse-server/issues/10395)) ([a3f36a2](https://github.com/parse-community/parse-server/commit/a3f36a2ddb981d9868ddf26b128e24b2d58214bd)) + +# [9.8.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.8.0-alpha.3...9.8.0-alpha.4) (2026-04-03) + + +### Features + +* Add server option `fileDownload` to restrict file download ([#10394](https://github.com/parse-community/parse-server/issues/10394)) ([fc117ef](https://github.com/parse-community/parse-server/commit/fc117efa4dc233ad6dfee6f46d80991b10927ba8)) + +# [9.8.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.8.0-alpha.2...9.8.0-alpha.3) (2026-04-03) + + +### Bug Fixes + +* Bump lodash from 4.17.23 to 4.18.1 ([#10393](https://github.com/parse-community/parse-server/issues/10393)) ([19716ad](https://github.com/parse-community/parse-server/commit/19716ad9afe9400ad2440c0ed3c5fbfe376a8585)) + +# [9.8.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.8.0-alpha.1...9.8.0-alpha.2) (2026-04-03) + + +### Bug Fixes + +* Maintenance key IP mismatch silently downgrades to regular auth instead of rejecting ([#10391](https://github.com/parse-community/parse-server/issues/10391)) ([7d8b367](https://github.com/parse-community/parse-server/commit/7d8b367e0b3ef9e9dd6735408068895ead873a0c)) + +# [9.8.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.7.1-alpha.4...9.8.0-alpha.1) (2026-04-03) + + +### Features + +* Add route block with new server option `routeAllowList` ([#10389](https://github.com/parse-community/parse-server/issues/10389)) ([f2d06e7](https://github.com/parse-community/parse-server/commit/f2d06e7b95242268607bfa5205b4e86ba7c7698e)) + +## [9.7.1-alpha.4](https://github.com/parse-community/parse-server/compare/9.7.1-alpha.3...9.7.1-alpha.4) (2026-04-02) + + +### Bug Fixes + +* File upload Content-Type override via extension mismatch ([GHSA-vr5f-2r24-w5hc](https://github.com/parse-community/parse-server/security/advisories/GHSA-vr5f-2r24-w5hc)) ([#10383](https://github.com/parse-community/parse-server/issues/10383)) ([dd7cc41](https://github.com/parse-community/parse-server/commit/dd7cc41a952b9ec6fa655a5655f106cca27d65c7)) + +## [9.7.1-alpha.3](https://github.com/parse-community/parse-server/compare/9.7.1-alpha.2...9.7.1-alpha.3) (2026-04-01) + + +### Bug Fixes + +* Session field guard bypass via falsy values for ACL and user fields ([#10382](https://github.com/parse-community/parse-server/issues/10382)) ([ead12bd](https://github.com/parse-community/parse-server/commit/ead12bd1df7f11013d9266e41014dcb143351341)) + +## [9.7.1-alpha.2](https://github.com/parse-community/parse-server/compare/9.7.1-alpha.1...9.7.1-alpha.2) (2026-04-01) + + +### Bug Fixes + +* Nested batch sub-requests cause unclear error ([#10371](https://github.com/parse-community/parse-server/issues/10371)) ([6635096](https://github.com/parse-community/parse-server/commit/66350964c8a200eb9e4540f6fcdc0fe0099c5ff6)) + +## [9.7.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.7.0...9.7.1-alpha.1) (2026-03-30) + + +### Bug Fixes + +* Streaming file download bypasses afterFind file trigger authorization ([GHSA-hpm8-9qx6-jvwv](https://github.com/parse-community/parse-server/security/advisories/GHSA-hpm8-9qx6-jvwv)) ([#10361](https://github.com/parse-community/parse-server/issues/10361)) ([a0b0c69](https://github.com/parse-community/parse-server/commit/a0b0c69fc44f87f80d793d257344e7dcbf676e22)) + +# [9.7.0-alpha.18](https://github.com/parse-community/parse-server/compare/9.7.0-alpha.17...9.7.0-alpha.18) (2026-03-30) + + +### Features + +* Extend storage adapter interface to optionally return `matchedCount` and `modifiedCount` from `DatabaseController.update` with `many: true` ([#10353](https://github.com/parse-community/parse-server/issues/10353)) ([aea7596](https://github.com/parse-community/parse-server/commit/aea7596cd2336c1c179ae130efd550f1596f5f3a)) + +# [9.7.0-alpha.17](https://github.com/parse-community/parse-server/compare/9.7.0-alpha.16...9.7.0-alpha.17) (2026-03-29) + + +### Bug Fixes + +* Cloud Code trigger context vulnerable to prototype pollution ([#10352](https://github.com/parse-community/parse-server/issues/10352)) ([d5f5128](https://github.com/parse-community/parse-server/commit/d5f5128ade49749856d8ad5f9750ffd26d44836a)) + +# [9.7.0-alpha.16](https://github.com/parse-community/parse-server/compare/9.7.0-alpha.15...9.7.0-alpha.16) (2026-03-29) + + +### Bug Fixes + +* LiveQuery protected-field guard bypass via array-like logical operator value ([GHSA-mmg8-87c5-jrc2](https://github.com/parse-community/parse-server/security/advisories/GHSA-mmg8-87c5-jrc2)) ([#10350](https://github.com/parse-community/parse-server/issues/10350)) ([f63fd1a](https://github.com/parse-community/parse-server/commit/f63fd1a3fe0a7c1c5fe809f01b0e04759e8c9b98)) + +# [9.7.0-alpha.15](https://github.com/parse-community/parse-server/compare/9.7.0-alpha.14...9.7.0-alpha.15) (2026-03-29) + + +### Bug Fixes + +* Batch login sub-request rate limit uses IP-based keying ([#10349](https://github.com/parse-community/parse-server/issues/10349)) ([63c37c4](https://github.com/parse-community/parse-server/commit/63c37c49c7a72dc617635da8859004503021b8fd)) + +# [9.7.0-alpha.14](https://github.com/parse-community/parse-server/compare/9.7.0-alpha.13...9.7.0-alpha.14) (2026-03-29) + + +### Bug Fixes + +* Session field immutability bypass via falsy-value guard ([GHSA-f6j3-w9v3-cq22](https://github.com/parse-community/parse-server/security/advisories/GHSA-f6j3-w9v3-cq22)) ([#10347](https://github.com/parse-community/parse-server/issues/10347)) ([9080296](https://github.com/parse-community/parse-server/commit/90802969fc713b7bc9733d7255c7519a6ed75d21)) + +# [9.7.0-alpha.13](https://github.com/parse-community/parse-server/compare/9.7.0-alpha.12...9.7.0-alpha.13) (2026-03-29) + + +### Features + +* Add support for `partialFilterExpression` in MongoDB storage adapter ([#10346](https://github.com/parse-community/parse-server/issues/10346)) ([8dd7bf2](https://github.com/parse-community/parse-server/commit/8dd7bf2f61c07b0467d6dbc7aad5142db6694339)) + +# [9.7.0-alpha.12](https://github.com/parse-community/parse-server/compare/9.7.0-alpha.11...9.7.0-alpha.12) (2026-03-29) + + +### Bug Fixes + +* GraphQL complexity validator exponential fragment traversal DoS ([GHSA-mfj6-6p54-m98c](https://github.com/parse-community/parse-server/security/advisories/GHSA-mfj6-6p54-m98c)) ([#10344](https://github.com/parse-community/parse-server/issues/10344)) ([f759bda](https://github.com/parse-community/parse-server/commit/f759bda075298ec44e2b4fb57659a0c56620483b)) + +# [9.7.0-alpha.11](https://github.com/parse-community/parse-server/compare/9.7.0-alpha.10...9.7.0-alpha.11) (2026-03-28) + + +### Bug Fixes + +* Cloud function validator bypass via prototype chain traversal ([GHSA-vpj2-qq7w-5qq6](https://github.com/parse-community/parse-server/security/advisories/GHSA-vpj2-qq7w-5qq6)) ([#10342](https://github.com/parse-community/parse-server/issues/10342)) ([dc59e27](https://github.com/parse-community/parse-server/commit/dc59e272665644083c5b7f6862d88ce1ef0b2674)) + +# [9.7.0-alpha.10](https://github.com/parse-community/parse-server/compare/9.7.0-alpha.9...9.7.0-alpha.10) (2026-03-27) + + +### Bug Fixes + +* GraphQL API endpoint ignores CORS origin restriction ([GHSA-q3p6-g7c4-829c](https://github.com/parse-community/parse-server/security/advisories/GHSA-q3p6-g7c4-829c)) ([#10334](https://github.com/parse-community/parse-server/issues/10334)) ([4dd0d3d](https://github.com/parse-community/parse-server/commit/4dd0d3d8be1c39664c74ad10bb0abaa76bc41203)) + +# [9.7.0-alpha.9](https://github.com/parse-community/parse-server/compare/9.7.0-alpha.8...9.7.0-alpha.9) (2026-03-27) + + +### Bug Fixes + +* LiveQuery protected field leak via shared mutable state across concurrent subscribers ([GHSA-m983-v2ff-wq65](https://github.com/parse-community/parse-server/security/advisories/GHSA-m983-v2ff-wq65)) ([#10330](https://github.com/parse-community/parse-server/issues/10330)) ([776c71c](https://github.com/parse-community/parse-server/commit/776c71c3078e77d38c94937f463741793609d055)) + +# [9.7.0-alpha.8](https://github.com/parse-community/parse-server/compare/9.7.0-alpha.7...9.7.0-alpha.8) (2026-03-26) + + +### Bug Fixes + +* MFA single-use token bypass via concurrent authData login requests ([GHSA-w73w-g5xw-rwhf](https://github.com/parse-community/parse-server/security/advisories/GHSA-w73w-g5xw-rwhf)) ([#10326](https://github.com/parse-community/parse-server/issues/10326)) ([e7efbeb](https://github.com/parse-community/parse-server/commit/e7efbebba398ce6abe5b6b6fb9829c6ebe310fbf)) + +# [9.7.0-alpha.7](https://github.com/parse-community/parse-server/compare/9.7.0-alpha.6...9.7.0-alpha.7) (2026-03-26) + + +### Bug Fixes + +* Auth data exposed via verify password endpoint ([GHSA-wp76-gg32-8258](https://github.com/parse-community/parse-server/security/advisories/GHSA-wp76-gg32-8258)) ([#10323](https://github.com/parse-community/parse-server/issues/10323)) ([770be86](https://github.com/parse-community/parse-server/commit/770be8647424d92f5425c41fa81065ffbbb171ed)) + +# [9.7.0-alpha.6](https://github.com/parse-community/parse-server/compare/9.7.0-alpha.5...9.7.0-alpha.6) (2026-03-26) + + +### Bug Fixes + +* Duplicate session destruction can cause unhandled promise rejection ([#10319](https://github.com/parse-community/parse-server/issues/10319)) ([92791c1](https://github.com/parse-community/parse-server/commit/92791c1d1d4b042a0e615ba45dcef491b904eccf)) + +# [9.7.0-alpha.5](https://github.com/parse-community/parse-server/compare/9.7.0-alpha.4...9.7.0-alpha.5) (2026-03-25) + + +### Bug Fixes + +* Postgres query on non-existent column throws internal server error ([#10308](https://github.com/parse-community/parse-server/issues/10308)) ([c5c4325](https://github.com/parse-community/parse-server/commit/c5c43259d1f98af5bbbbc44d9daf7c0f1f8168d3)) + +# [9.7.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.7.0-alpha.3...9.7.0-alpha.4) (2026-03-24) + + +### Bug Fixes + +* Missing error messages in Parse errors ([#10304](https://github.com/parse-community/parse-server/issues/10304)) ([f128048](https://github.com/parse-community/parse-server/commit/f12804800bc9232de02b4314e886bab6b169f041)) + +# [9.7.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.7.0-alpha.2...9.7.0-alpha.3) (2026-03-23) + + +### Bug Fixes + +* Maintenance key blocked from querying protected fields ([#10290](https://github.com/parse-community/parse-server/issues/10290)) ([7c8b213](https://github.com/parse-community/parse-server/commit/7c8b213d96f1fd79f27d3a2bc01bef8bcaf588cd)) + +# [9.7.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.7.0-alpha.1...9.7.0-alpha.2) (2026-03-23) + + +### Features + +* Add `protectedFieldsSaveResponseExempt` option to strip protected fields from save responses ([#10289](https://github.com/parse-community/parse-server/issues/10289)) ([4f7cb53](https://github.com/parse-community/parse-server/commit/4f7cb53bd114554cf9e6d7855b5e8911cb87544b)) + +# [9.7.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.6.1...9.7.0-alpha.1) (2026-03-23) + + +### Features + +* Add `protectedFieldsTriggerExempt` option to exempt Cloud Code triggers from `protectedFields` ([#10288](https://github.com/parse-community/parse-server/issues/10288)) ([1610f98](https://github.com/parse-community/parse-server/commit/1610f98316f7cb1120a7e20be7a1570b0e116df7)) + +## [9.6.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.6.0...9.6.1-alpha.1) (2026-03-22) + + +### Bug Fixes + +* User cannot retrieve own email with `protectedFieldsOwnerExempt: false` despite `email` not in `protectedFields` ([#10284](https://github.com/parse-community/parse-server/issues/10284)) ([4a65d77](https://github.com/parse-community/parse-server/commit/4a65d77ea3fd2ccb121d4bd28e92435295203bf7)) + +# [9.6.0-alpha.56](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.55...9.6.0-alpha.56) (2026-03-22) + + +### Features + +* Add `protectedFieldsOwnerExempt` option to control `_User` class owner exemption for `protectedFields` ([#10280](https://github.com/parse-community/parse-server/issues/10280)) ([d5213f8](https://github.com/parse-community/parse-server/commit/d5213f88054fbe066692b7a4661c1b2242aaeddb)) + +# [9.6.0-alpha.55](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.54...9.6.0-alpha.55) (2026-03-22) + + +### Bug Fixes + +* Auth data exposed via /users/me endpoint ([GHSA-37mj-c2wf-cx96](https://github.com/parse-community/parse-server/security/advisories/GHSA-37mj-c2wf-cx96)) ([#10278](https://github.com/parse-community/parse-server/issues/10278)) ([875cf10](https://github.com/parse-community/parse-server/commit/875cf10ac979bd60f70e7a0c534e2bc194d6982f)) + +# [9.6.0-alpha.54](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.53...9.6.0-alpha.54) (2026-03-22) + + +### Bug Fixes + +* MFA recovery code single-use bypass via concurrent requests ([GHSA-2299-ghjr-6vjp](https://github.com/parse-community/parse-server/security/advisories/GHSA-2299-ghjr-6vjp)) ([#10275](https://github.com/parse-community/parse-server/issues/10275)) ([5e70094](https://github.com/parse-community/parse-server/commit/5e70094250a36bfcc14ecd49592be2b94fba66ff)) + +# [9.6.0-alpha.53](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.52...9.6.0-alpha.53) (2026-03-21) + + +### Bug Fixes + +* SQL injection via aggregate and distinct field names in PostgreSQL adapter ([GHSA-p2w6-rmh7-w8q3](https://github.com/parse-community/parse-server/security/advisories/GHSA-p2w6-rmh7-w8q3)) ([#10272](https://github.com/parse-community/parse-server/issues/10272)) ([bdddab5](https://github.com/parse-community/parse-server/commit/bdddab5f8b61a40cb8fc62dd895887bdd2f3838e)) + +# [9.6.0-alpha.52](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.51...9.6.0-alpha.52) (2026-03-21) + + +### Bug Fixes + +* Denial of service via unindexed database query for unconfigured auth providers ([GHSA-g4cf-xj29-wqqr](https://github.com/parse-community/parse-server/security/advisories/GHSA-g4cf-xj29-wqqr)) ([#10270](https://github.com/parse-community/parse-server/issues/10270)) ([fbac847](https://github.com/parse-community/parse-server/commit/fbac847499e57f243315c5fc7135be1d58bb8e54)) + +# [9.6.0-alpha.51](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.50...9.6.0-alpha.51) (2026-03-21) + + +### Bug Fixes + +* Create CLP not enforced before user field validation on signup ([#10268](https://github.com/parse-community/parse-server/issues/10268)) ([a0530c2](https://github.com/parse-community/parse-server/commit/a0530c251a9e15198c60c1c15c6cc0802a1dd18c)) + +# [9.6.0-alpha.50](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.49...9.6.0-alpha.50) (2026-03-21) + + +### Bug Fixes + +* Account lockout race condition allows bypassing threshold via concurrent requests ([#10266](https://github.com/parse-community/parse-server/issues/10266)) ([ff70fee](https://github.com/parse-community/parse-server/commit/ff70fee7e18d7e627b590f7f5717a58ee91cfecb)) + +# [9.6.0-alpha.49](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.48...9.6.0-alpha.49) (2026-03-21) + + +### Bug Fixes + +* Add configurable batch request sub-request limit via option `requestComplexity.batchRequestLimit` ([#10265](https://github.com/parse-community/parse-server/issues/10265)) ([164ed0d](https://github.com/parse-community/parse-server/commit/164ed0dd1206e96ce42e46058016a7d7eaf84d85)) + +# [9.6.0-alpha.48](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.47...9.6.0-alpha.48) (2026-03-21) + + +### Bug Fixes + +* Session update endpoint allows overwriting server-generated session fields ([GHSA-jc39-686j-wp6q](https://github.com/parse-community/parse-server/security/advisories/GHSA-jc39-686j-wp6q)) ([#10263](https://github.com/parse-community/parse-server/issues/10263)) ([ea68fc0](https://github.com/parse-community/parse-server/commit/ea68fc0b22a6056c9675149469ff57817f7cf984)) + +# [9.6.0-alpha.47](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.46...9.6.0-alpha.47) (2026-03-20) + + +### Bug Fixes + +* Normalize HTTP method case in `allowMethodOverride` middleware ([#10262](https://github.com/parse-community/parse-server/issues/10262)) ([a248e8c](https://github.com/parse-community/parse-server/commit/a248e8cc99d857466aa5a5d3a472795a238acbc2)) + +# [9.6.0-alpha.46](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.45...9.6.0-alpha.46) (2026-03-20) + + +### Bug Fixes + +* Incomplete JSON key escaping in PostgreSQL Increment on nested Object fields ([#10261](https://github.com/parse-community/parse-server/issues/10261)) ([a692873](https://github.com/parse-community/parse-server/commit/a6928737dd40a3310f6e419f223cf93fdd442f2b)) + +# [9.6.0-alpha.45](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.44...9.6.0-alpha.45) (2026-03-20) + + +### Bug Fixes + +* LiveQuery subscription query depth bypass ([GHSA-6qh5-m6g3-xhq6](https://github.com/parse-community/parse-server/security/advisories/GHSA-6qh5-m6g3-xhq6)) ([#10259](https://github.com/parse-community/parse-server/issues/10259)) ([2126fe4](https://github.com/parse-community/parse-server/commit/2126fe4e12f9b399dc6b4b6a3fa70cb1825f159b)) + +# [9.6.0-alpha.44](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.43...9.6.0-alpha.44) (2026-03-20) + + +### Bug Fixes + +* Query condition depth bypass via pre-validation transform pipeline ([GHSA-9fjp-q3c4-6w3j](https://github.com/parse-community/parse-server/security/advisories/GHSA-9fjp-q3c4-6w3j)) ([#10257](https://github.com/parse-community/parse-server/issues/10257)) ([85994ef](https://github.com/parse-community/parse-server/commit/85994eff9e7b34cac7e1a2f5791985022a1461d1)) + +# [9.6.0-alpha.43](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.42...9.6.0-alpha.43) (2026-03-20) + + +### Bug Fixes + +* Protected field change detection oracle via LiveQuery watch parameter ([GHSA-qpc3-fg4j-8hgm](https://github.com/parse-community/parse-server/security/advisories/GHSA-qpc3-fg4j-8hgm)) ([#10253](https://github.com/parse-community/parse-server/issues/10253)) ([0c0a0a5](https://github.com/parse-community/parse-server/commit/0c0a0a5a37ca821d2553119f2cb3be35322eda4b)) + +# [9.6.0-alpha.42](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.41...9.6.0-alpha.42) (2026-03-20) + + +### Bug Fixes + +* LiveQuery bypasses CLP pointer permission enforcement ([GHSA-fph2-r4qg-9576](https://github.com/parse-community/parse-server/security/advisories/GHSA-fph2-r4qg-9576)) ([#10250](https://github.com/parse-community/parse-server/issues/10250)) ([6c3317a](https://github.com/parse-community/parse-server/commit/6c3317aca6eb618ac48f999021ae3ef7766ad1ea)) + +# [9.6.0-alpha.41](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.40...9.6.0-alpha.41) (2026-03-19) + + +### Bug Fixes + +* Auth provider validation bypass on login via partial authData ([GHSA-pfj7-wv7c-22pr](https://github.com/parse-community/parse-server/security/advisories/GHSA-pfj7-wv7c-22pr)) ([#10246](https://github.com/parse-community/parse-server/issues/10246)) ([98f4ba5](https://github.com/parse-community/parse-server/commit/98f4ba5bcf2c199bfe6225f672e8edcd08ba732d)) + +# [9.6.0-alpha.40](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.39...9.6.0-alpha.40) (2026-03-19) + + +### Bug Fixes + +* Email verification resend page leaks user existence (GHSA-h29g-q5c2-9h4f) ([#10238](https://github.com/parse-community/parse-server/issues/10238)) ([fbda4cb](https://github.com/parse-community/parse-server/commit/fbda4cb0c5cbc8fad08a216823b6b64d4ae289c3)) + +# [9.6.0-alpha.39](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.38...9.6.0-alpha.39) (2026-03-18) + + +### Bug Fixes + +* Locale parameter path traversal in pages router ([#10242](https://github.com/parse-community/parse-server/issues/10242)) ([01fb6a9](https://github.com/parse-community/parse-server/commit/01fb6a972cf2437ba965dff590afec50184cf6e1)) + +# [9.6.0-alpha.38](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.37...9.6.0-alpha.38) (2026-03-18) + + +### Bug Fixes + +* Sanitize control characters in page parameter response headers ([#10237](https://github.com/parse-community/parse-server/issues/10237)) ([337ffd6](https://github.com/parse-community/parse-server/commit/337ffd65ccf94495a54cd883c5e8fa7a3892606c)) + +# [9.6.0-alpha.37](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.36...9.6.0-alpha.37) (2026-03-18) + + +### Bug Fixes + +* Security fix fast-xml-parser from 5.5.5 to 5.5.6 ([#10235](https://github.com/parse-community/parse-server/issues/10235)) ([f521576](https://github.com/parse-community/parse-server/commit/f521576143336334aad2cbac82c3f368afe8f706)) + +# [9.6.0-alpha.36](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.35...9.6.0-alpha.36) (2026-03-18) + + +### Bug Fixes + +* Rate limit bypass via HTTP method override and batch method spoofing ([#10234](https://github.com/parse-community/parse-server/issues/10234)) ([7d72d26](https://github.com/parse-community/parse-server/commit/7d72d264c03b63b463664d545c8c57f4851e4287)) + +# [9.6.0-alpha.35](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.34...9.6.0-alpha.35) (2026-03-17) + + +### Bug Fixes + +* Protected fields leak via LiveQuery afterEvent trigger ([GHSA-5hmj-jcgp-6hff](https://github.com/parse-community/parse-server/security/advisories/GHSA-5hmj-jcgp-6hff)) ([#10232](https://github.com/parse-community/parse-server/issues/10232)) ([6648500](https://github.com/parse-community/parse-server/commit/6648500428f33fb8ba336757702644d94ca0796a)) + +# [9.6.0-alpha.34](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.33...9.6.0-alpha.34) (2026-03-17) + + +### Bug Fixes + +* Input type validation for query operators and batch path ([#10230](https://github.com/parse-community/parse-server/issues/10230)) ([a628911](https://github.com/parse-community/parse-server/commit/a6289118d268d5dd5c453a22e99a48d36dcc81da)) + +# [9.6.0-alpha.33](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.32...9.6.0-alpha.33) (2026-03-17) + + +### Features + +* Add `enableProductPurchaseLegacyApi` option to disable legacy IAP validation ([#10228](https://github.com/parse-community/parse-server/issues/10228)) ([622ee85](https://github.com/parse-community/parse-server/commit/622ee85dc27a4ef721c1d4f61d3ed881a064da0b)) + +# [9.6.0-alpha.32](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.31...9.6.0-alpha.32) (2026-03-16) + + +### Bug Fixes + +* Instance comparison with `instanceof` is not realm-safe ([#10225](https://github.com/parse-community/parse-server/issues/10225)) ([51efb1e](https://github.com/parse-community/parse-server/commit/51efb1efb9fa3f2d578de63f61b20c6a4fbcbd9a)) + +# [9.6.0-alpha.31](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.30...9.6.0-alpha.31) (2026-03-16) + + +### Bug Fixes + +* Validate authData provider values in challenge endpoint ([#10224](https://github.com/parse-community/parse-server/issues/10224)) ([e5e1f5b](https://github.com/parse-community/parse-server/commit/e5e1f5bbc008c869614a13ab540f72af57adda8f)) + +# [9.6.0-alpha.30](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.29...9.6.0-alpha.30) (2026-03-16) + + +### Bug Fixes + +* Block dot-notation updates to authData sub-fields and harden login provider checks ([#10223](https://github.com/parse-community/parse-server/issues/10223)) ([12c24c6](https://github.com/parse-community/parse-server/commit/12c24c6c6c578219703aaea186625f8f36c0d020)) + +# [9.6.0-alpha.29](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.28...9.6.0-alpha.29) (2026-03-16) + + +### Bug Fixes + +* Empty authData bypasses credential requirement on signup ([GHSA-wjqw-r9x4-j59v](https://github.com/parse-community/parse-server/security/advisories/GHSA-wjqw-r9x4-j59v)) ([#10219](https://github.com/parse-community/parse-server/issues/10219)) ([5dcbf41](https://github.com/parse-community/parse-server/commit/5dcbf41249f1b67c72296934bc4f8538f3b1d821)) + +# [9.6.0-alpha.28](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.27...9.6.0-alpha.28) (2026-03-16) + + +### Bug Fixes + +* Password reset token single-use bypass via concurrent requests ([GHSA-r3xq-68wh-gwvh](https://github.com/parse-community/parse-server/security/advisories/GHSA-r3xq-68wh-gwvh)) ([#10216](https://github.com/parse-community/parse-server/issues/10216)) ([84db0a0](https://github.com/parse-community/parse-server/commit/84db0a083bf7cc5ab8e0b56515d9305c4af55d5b)) + +# [9.6.0-alpha.27](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.26...9.6.0-alpha.27) (2026-03-15) + + +### Bug Fixes + +* Rate limit user zone key fallback and batch request bypass ([#10214](https://github.com/parse-community/parse-server/issues/10214)) ([434ecbe](https://github.com/parse-community/parse-server/commit/434ecbec702e74fe8d151fbfc5ec0779f77a25f2)) + +# [9.6.0-alpha.26](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.25...9.6.0-alpha.26) (2026-03-15) + + +### Bug Fixes + +* Validate session in middleware for non-GET requests to `/sessions/me` ([#10213](https://github.com/parse-community/parse-server/issues/10213)) ([2a9fdab](https://github.com/parse-community/parse-server/commit/2a9fdab3672e702ce296fc83c99902da37e53e29)) + +# [9.6.0-alpha.25](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.24...9.6.0-alpha.25) (2026-03-15) + + +### Bug Fixes + +* Validate token type in PagesRouter to prevent type confusion errors ([#10212](https://github.com/parse-community/parse-server/issues/10212)) ([386a989](https://github.com/parse-community/parse-server/commit/386a989bd2d5b9a48e4830a87ecb01f8ef22d903)) + +# [9.6.0-alpha.24](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.23...9.6.0-alpha.24) (2026-03-15) + + +### Bug Fixes + +* Cloud function dispatch crashes server via prototype chain traversal ([GHSA-4263-jgmp-7pf4](https://github.com/parse-community/parse-server/security/advisories/GHSA-4263-jgmp-7pf4)) ([#10210](https://github.com/parse-community/parse-server/issues/10210)) ([286373d](https://github.com/parse-community/parse-server/commit/286373dddfef5ef90505be5d954297daed32458c)) + +# [9.6.0-alpha.23](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.22...9.6.0-alpha.23) (2026-03-15) + + +### Bug Fixes + +* Validate body field types in request middleware ([#10209](https://github.com/parse-community/parse-server/issues/10209)) ([df69046](https://github.com/parse-community/parse-server/commit/df690463f8066dcde17a2e90e53dfbd7e86ff0bd)) + +# [9.6.0-alpha.22](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.21...9.6.0-alpha.22) (2026-03-15) + + +### Bug Fixes + +* Revert accidental breaking default values for query complexity limits ([#10205](https://github.com/parse-community/parse-server/issues/10205)) ([ab8dd54](https://github.com/parse-community/parse-server/commit/ab8dd54d8bcfea996aa60f0b9fac67dedb79d0e6)) + +# [9.6.0-alpha.21](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.20...9.6.0-alpha.21) (2026-03-15) + + +### Bug Fixes + +* Server crash via deeply nested query condition operators ([GHSA-9xp9-j92r-p88v](https://github.com/parse-community/parse-server/security/advisories/GHSA-9xp9-j92r-p88v)) ([#10202](https://github.com/parse-community/parse-server/issues/10202)) ([f44e306](https://github.com/parse-community/parse-server/commit/f44e3061471c9d527b7c0894bbd86f1823de52c4)) + +# [9.6.0-alpha.20](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.19...9.6.0-alpha.20) (2026-03-14) + + +### Bug Fixes + +* Schema poisoning via prototype pollution in deep copy ([GHSA-9ccr-fpp6-78qf](https://github.com/parse-community/parse-server/security/advisories/GHSA-9ccr-fpp6-78qf)) ([#10200](https://github.com/parse-community/parse-server/issues/10200)) ([b321423](https://github.com/parse-community/parse-server/commit/b321423867f5e779b4750f97c4e42d408499fc3b)) + +# [9.6.0-alpha.19](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.18...9.6.0-alpha.19) (2026-03-14) + + +### Bug Fixes + +* LiveQuery subscription with invalid regular expression crashes server ([GHSA-827p-g5x5-h86c](https://github.com/parse-community/parse-server/security/advisories/GHSA-827p-g5x5-h86c)) ([#10197](https://github.com/parse-community/parse-server/issues/10197)) ([0ae0eee](https://github.com/parse-community/parse-server/commit/0ae0eeee524204325e09efcb315c50096aaf20f8)) + +# [9.6.0-alpha.18](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.17...9.6.0-alpha.18) (2026-03-14) + + +### Bug Fixes + +* Security upgrade fast-xml-parser from 5.3.7 to 5.4.2 ([#10086](https://github.com/parse-community/parse-server/issues/10086)) ([b04ca5e](https://github.com/parse-community/parse-server/commit/b04ca5eec41065caccc7f7dbed8a0595f0364914)) + +# [9.6.0-alpha.17](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.16...9.6.0-alpha.17) (2026-03-13) + + +### Bug Fixes + +* Session creation endpoint allows overwriting server-generated session fields ([GHSA-5v7g-9h8f-8pgg](https://github.com/parse-community/parse-server/security/advisories/GHSA-5v7g-9h8f-8pgg)) ([#10195](https://github.com/parse-community/parse-server/issues/10195)) ([7ccfb97](https://github.com/parse-community/parse-server/commit/7ccfb972d4a6679726f3a0b3cc8d6a8f1838273c)) + +# [9.6.0-alpha.16](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.15...9.6.0-alpha.16) (2026-03-13) + + +### Bug Fixes + +* Session token expiration unchecked on cache hit ([#10194](https://github.com/parse-community/parse-server/issues/10194)) ([a944203](https://github.com/parse-community/parse-server/commit/a944203b268cf467ab4c720928f744d0c889b1e5)) + +# [9.6.0-alpha.15](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.14...9.6.0-alpha.15) (2026-03-13) + + +### Bug Fixes + +* Stored XSS filter bypass via Content-Type MIME parameter and missing XML extension blocklist entries ([GHSA-42ph-pf9q-cr72](https://github.com/parse-community/parse-server/security/advisories/GHSA-42ph-pf9q-cr72)) ([#10191](https://github.com/parse-community/parse-server/issues/10191)) ([4f53ab3](https://github.com/parse-community/parse-server/commit/4f53ab3cad5502a51a509d53f999e00ff7217b8d)) + +# [9.6.0-alpha.14](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.13...9.6.0-alpha.14) (2026-03-12) + + +### Bug Fixes + +* GraphQL WebSocket endpoint bypasses security middleware ([GHSA-p2x3-8689-cwpg](https://github.com/parse-community/parse-server/security/advisories/GHSA-p2x3-8689-cwpg)) ([#10189](https://github.com/parse-community/parse-server/issues/10189)) ([3ffba75](https://github.com/parse-community/parse-server/commit/3ffba757bfc836bd034e1369f4f64304e110e375)) + +# [9.6.0-alpha.13](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.12...9.6.0-alpha.13) (2026-03-11) + + +### Bug Fixes + +* OAuth2 adapter app ID validation sends wrong token to introspection endpoint ([GHSA-69xg-f649-w5g2](https://github.com/parse-community/parse-server/security/advisories/GHSA-69xg-f649-w5g2)) ([#10187](https://github.com/parse-community/parse-server/issues/10187)) ([7f9f854](https://github.com/parse-community/parse-server/commit/7f9f854be7a5c1bc2263ed516b651b16b438cd5d)) + +# [9.6.0-alpha.12](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.11...9.6.0-alpha.12) (2026-03-11) + + +### Bug Fixes + +* Account takeover via operator injection in authentication data identifier ([GHSA-5fw2-8jcv-xh87](https://github.com/parse-community/parse-server/security/advisories/GHSA-5fw2-8jcv-xh87)) ([#10185](https://github.com/parse-community/parse-server/issues/10185)) ([0d0a554](https://github.com/parse-community/parse-server/commit/0d0a5543b35c35c12f69d5182693e50182b6faad)) + +# [9.6.0-alpha.11](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.10...9.6.0-alpha.11) (2026-03-11) + + +### Bug Fixes + +* OAuth2 adapter shares mutable state across providers via singleton instance ([GHSA-2cjm-2gwv-m892](https://github.com/parse-community/parse-server/security/advisories/GHSA-2cjm-2gwv-m892)) ([#10183](https://github.com/parse-community/parse-server/issues/10183)) ([6009bc1](https://github.com/parse-community/parse-server/commit/6009bc15c8c19db436dba8078fd59244c955d7ad)) + +# [9.6.0-alpha.10](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.9...9.6.0-alpha.10) (2026-03-11) + + +### Bug Fixes + +* SQL injection via query field name when using PostgreSQL ([GHSA-c442-97qw-j6c6](https://github.com/parse-community/parse-server/security/advisories/GHSA-c442-97qw-j6c6)) ([#10181](https://github.com/parse-community/parse-server/issues/10181)) ([be281b1](https://github.com/parse-community/parse-server/commit/be281b1ed9c6b7abf992e5583fc2db7875031172)) + +# [9.6.0-alpha.9](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.8...9.6.0-alpha.9) (2026-03-10) + + +### Bug Fixes + +* Protected fields bypass via LiveQuery subscription WHERE clause ([GHSA-j7mm-f4rv-6q6q](https://github.com/parse-community/parse-server/security/advisories/GHSA-j7mm-f4rv-6q6q)) ([#10175](https://github.com/parse-community/parse-server/issues/10175)) ([4d48847](https://github.com/parse-community/parse-server/commit/4d48847e9909c70761be381d3c3cddcfa9f0fca3)) + +# [9.6.0-alpha.8](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.7...9.6.0-alpha.8) (2026-03-10) + + +### Bug Fixes + +* User enumeration via email verification endpoint ([GHSA-w54v-hf9p-8856](https://github.com/parse-community/parse-server/security/advisories/GHSA-w54v-hf9p-8856)) ([#10172](https://github.com/parse-community/parse-server/issues/10172)) ([936abd4](https://github.com/parse-community/parse-server/commit/936abd4905e501838e8d46503da66ce9fe6a4f9d)) + +# [9.6.0-alpha.7](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.6...9.6.0-alpha.7) (2026-03-10) + + +### Bug Fixes + +* MFA recovery codes not consumed after use ([GHSA-4hf6-3x24-c9m8](https://github.com/parse-community/parse-server/security/advisories/GHSA-4hf6-3x24-c9m8)) ([#10170](https://github.com/parse-community/parse-server/issues/10170)) ([18abdd9](https://github.com/parse-community/parse-server/commit/18abdd960baf97cf5dce5cd46ca6b0b874218d94)) + +# [9.6.0-alpha.6](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.5...9.6.0-alpha.6) (2026-03-10) + + +### Bug Fixes + +* Protected fields bypass via dot-notation in query and sort ([GHSA-r2m8-pxm9-9c4g](https://github.com/parse-community/parse-server/security/advisories/GHSA-r2m8-pxm9-9c4g)) ([#10167](https://github.com/parse-community/parse-server/issues/10167)) ([8f54c54](https://github.com/parse-community/parse-server/commit/8f54c5437b4f3e184956cfbb8dd46840a4357344)) + +# [9.6.0-alpha.5](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.4...9.6.0-alpha.5) (2026-03-10) + + +### Bug Fixes + +* SQL Injection via dot-notation sub-key name in `Increment` operation on PostgreSQL ([GHSA-gqpp-xgvh-9h7h](https://github.com/parse-community/parse-server/security/advisories/GHSA-gqpp-xgvh-9h7h)) ([#10165](https://github.com/parse-community/parse-server/issues/10165)) ([169d692](https://github.com/parse-community/parse-server/commit/169d69257dda670daf0b20a967d0598a90510c82)) + +# [9.6.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.3...9.6.0-alpha.4) (2026-03-09) + + +### Bug Fixes + +* Stored XSS via file upload of HTML-renderable file types ([GHSA-v5hf-f4c3-m5rv](https://github.com/parse-community/parse-server/security/advisories/GHSA-v5hf-f4c3-m5rv)) ([#10162](https://github.com/parse-community/parse-server/issues/10162)) ([03287cf](https://github.com/parse-community/parse-server/commit/03287cf83bc05ee08bb29885d38a86e722cc3bf9)) + +# [9.6.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.2...9.6.0-alpha.3) (2026-03-09) + + +### Bug Fixes + +* SQL injection via `Increment` operation on nested object field in PostgreSQL ([GHSA-q3vj-96h2-gwvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-q3vj-96h2-gwvg)) ([#10161](https://github.com/parse-community/parse-server/issues/10161)) ([8f82282](https://github.com/parse-community/parse-server/commit/8f822826a48169528a66626118bbaead3064b055)) + +# [9.6.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.1...9.6.0-alpha.2) (2026-03-09) + + +### Bug Fixes + +* SQL injection via dot-notation field name in PostgreSQL ([GHSA-qpr4-jrj4-6f27](https://github.com/parse-community/parse-server/security/advisories/GHSA-qpr4-jrj4-6f27)) ([#10159](https://github.com/parse-community/parse-server/issues/10159)) ([ea538a4](https://github.com/parse-community/parse-server/commit/ea538a4ba320f5ead4e784de5de815edf765a9f5)) + +# [9.6.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.14...9.6.0-alpha.1) (2026-03-09) + + +### Features + +* Add `X-Content-Type-Options: nosniff` header and customizable response headers for files via `Parse.Cloud.afterFind(Parse.File)` ([#10158](https://github.com/parse-community/parse-server/issues/10158)) ([28d11a3](https://github.com/parse-community/parse-server/commit/28d11a33bcdb0f89604e2289018a6f4729d4ba67)) + +## [9.5.2-alpha.14](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.13...9.5.2-alpha.14) (2026-03-09) + + +### Bug Fixes + +* LiveQuery `regexTimeout` default value not applied ([#10156](https://github.com/parse-community/parse-server/issues/10156)) ([416cfbc](https://github.com/parse-community/parse-server/commit/416cfbcd73f0da398e577a188c7976716a3c27ab)) + +## [9.5.2-alpha.13](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.12...9.5.2-alpha.13) (2026-03-09) + + +### Bug Fixes + +* LDAP injection via unsanitized user input in DN and group filter construction ([GHSA-7m6r-fhh7-r47c](https://github.com/parse-community/parse-server/security/advisories/GHSA-7m6r-fhh7-r47c)) ([#10154](https://github.com/parse-community/parse-server/issues/10154)) ([5bbca7b](https://github.com/parse-community/parse-server/commit/5bbca7b862840909bb130920c33794abebbc15d4)) + +## [9.5.2-alpha.12](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.11...9.5.2-alpha.12) (2026-03-09) + + +### Bug Fixes + +* Classes `_GraphQLConfig` and `_Audience` master key bypass via generic class routes ([GHSA-7xg7-rqf6-pw6c](https://github.com/parse-community/parse-server/security/advisories/GHSA-7xg7-rqf6-pw6c)) ([#10151](https://github.com/parse-community/parse-server/issues/10151)) ([1de4e43](https://github.com/parse-community/parse-server/commit/1de4e43ca2c894f1c0c1ca5611f5b491e8d24d40)) + +## [9.5.2-alpha.11](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.10...9.5.2-alpha.11) (2026-03-09) + + +### Bug Fixes + +* Concurrent signup with same authentication creates duplicate users ([#10149](https://github.com/parse-community/parse-server/issues/10149)) ([853bfe1](https://github.com/parse-community/parse-server/commit/853bfe1bd3b104aefbcf87cf0cac391c9772ab9d)) + +## [9.5.2-alpha.10](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.9...9.5.2-alpha.10) (2026-03-08) + + +### Bug Fixes + +* Rate limit bypass via batch request endpoint ([GHSA-775h-3xrc-c228](https://github.com/parse-community/parse-server/security/advisories/GHSA-775h-3xrc-c228)) ([#10147](https://github.com/parse-community/parse-server/issues/10147)) ([2766f4f](https://github.com/parse-community/parse-server/commit/2766f4f7a2ce3afde4e1628907cdc556b6d6355c)) + +## [9.5.2-alpha.9](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.8...9.5.2-alpha.9) (2026-03-08) + + +### Bug Fixes + +* Parse Server OAuth2 authentication adapter account takeover via identity spoofing ([GHSA-fr88-w35c-r596](https://github.com/parse-community/parse-server/security/advisories/GHSA-fr88-w35c-r596)) ([#10145](https://github.com/parse-community/parse-server/issues/10145)) ([9cfd06e](https://github.com/parse-community/parse-server/commit/9cfd06e0d055ba96f965a0684995807adfe32b75)) + +## [9.5.2-alpha.8](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.7...9.5.2-alpha.8) (2026-03-08) + + +### Bug Fixes + +* Parse Server session token exfiltration via `redirectClassNameForKey` query parameter ([GHSA-6r2j-cxgf-495f](https://github.com/parse-community/parse-server/security/advisories/GHSA-6r2j-cxgf-495f)) ([#10143](https://github.com/parse-community/parse-server/issues/10143)) ([70b7b07](https://github.com/parse-community/parse-server/commit/70b7b070e1135949dd80ecf382f34db0bfdbb71e)) + +## [9.5.2-alpha.7](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.6...9.5.2-alpha.7) (2026-03-08) + + +### Bug Fixes + +* Parse Server role escalation and CLP bypass via direct `_Join table write ([GHSA-5f92-jrq3-28rc](https://github.com/parse-community/parse-server/security/advisories/GHSA-5f92-jrq3-28rc)) ([#10141](https://github.com/parse-community/parse-server/issues/10141)) ([22faa08](https://github.com/parse-community/parse-server/commit/22faa08a7b89b15c3c96da2af9387bd44cbec088)) + +## [9.5.2-alpha.6](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.5...9.5.2-alpha.6) (2026-03-08) + + +### Bug Fixes + +* Protected fields bypass via logical query operators ([GHSA-72hp-qff8-4pvv](https://github.com/parse-community/parse-server/security/advisories/GHSA-72hp-qff8-4pvv)) ([#10140](https://github.com/parse-community/parse-server/issues/10140)) ([be1d65d](https://github.com/parse-community/parse-server/commit/be1d65dac5d2718491e38727f96f205e43463e4c)) + +## [9.5.2-alpha.5](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.4...9.5.2-alpha.5) (2026-03-08) + + +### Bug Fixes + +* Missing audience validation in Keycloak authentication adapter ([GHSA-48mh-j4p5-7j9v](https://github.com/parse-community/parse-server/security/advisories/GHSA-48mh-j4p5-7j9v)) ([#10137](https://github.com/parse-community/parse-server/issues/10137)) ([78ef1a1](https://github.com/parse-community/parse-server/commit/78ef1a175d3b8da83d33fd5c69830b12d366212f)) + +## [9.5.2-alpha.4](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.3...9.5.2-alpha.4) (2026-03-08) + + +### Bug Fixes + +* Stored cross-site scripting (XSS) via SVG file upload ([GHSA-hcj7-6gxh-24ww](https://github.com/parse-community/parse-server/security/advisories/GHSA-hcj7-6gxh-24ww)) ([#10136](https://github.com/parse-community/parse-server/issues/10136)) ([93b784d](https://github.com/parse-community/parse-server/commit/93b784d21a8be13c6db1e8f0baeb0feda1fe12be)) + +## [9.5.2-alpha.3](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.2...9.5.2-alpha.3) (2026-03-08) + + +### Bug Fixes + +* Bypass of class-level permissions in LiveQuery ([GHSA-7ch5-98q2-7289](https://github.com/parse-community/parse-server/security/advisories/GHSA-7ch5-98q2-7289)) ([#10133](https://github.com/parse-community/parse-server/issues/10133)) ([98188d9](https://github.com/parse-community/parse-server/commit/98188d92c0b05ef498fa066588da1740de047bde)) + +## [9.5.2-alpha.2](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.1...9.5.2-alpha.2) (2026-03-07) + + +### Bug Fixes + +* Denial-of-service via unbounded query complexity in REST and GraphQL API ([GHSA-cmj3-wx7h-ffvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-cmj3-wx7h-ffvg)) ([#10130](https://github.com/parse-community/parse-server/issues/10130)) ([0ae9c25](https://github.com/parse-community/parse-server/commit/0ae9c25bc13847d547871511749b58b575b96333)) + +## [9.5.2-alpha.1](https://github.com/parse-community/parse-server/compare/9.5.1...9.5.2-alpha.1) (2026-03-07) + + +### Bug Fixes + +* NoSQL injection via token type in password reset and email verification endpoints ([GHSA-vgjh-hmwf-c588](https://github.com/parse-community/parse-server/security/advisories/GHSA-vgjh-hmwf-c588)) ([#10128](https://github.com/parse-community/parse-server/issues/10128)) ([b2f2317](https://github.com/parse-community/parse-server/commit/b2f23172e4983e4597226ef80ccc75d3054d31ad)) + +## [9.5.1-alpha.2](https://github.com/parse-community/parse-server/compare/9.5.1-alpha.1...9.5.1-alpha.2) (2026-03-07) + + +### Bug Fixes + +* Denial of Service (DoS) and Cloud Function Dispatch Bypass via Prototype Chain Resolution ([GHSA-5j86-7r7m-p8h6](https://github.com/parse-community/parse-server/security/advisories/GHSA-5j86-7r7m-p8h6)) ([#10125](https://github.com/parse-community/parse-server/issues/10125)) ([560e6e7](https://github.com/parse-community/parse-server/commit/560e6e77c7625da0655b2d01dc2d10632a80f591)) + +## [9.5.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.5.0...9.5.1-alpha.1) (2026-03-07) + + +### Bug Fixes + +* Denylist `requestKeywordDenylist` keyword scan bypass through nested object placement ([GHSA-q342-9w2p-57fp](https://github.com/parse-community/parse-server/security/advisories/GHSA-q342-9w2p-57fp)) ([#10123](https://github.com/parse-community/parse-server/issues/10123)) ([4a44247](https://github.com/parse-community/parse-server/commit/4a44247a649a40ef3f1db8261a0e780080f494ba)) + +# [9.5.0-alpha.14](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.13...9.5.0-alpha.14) (2026-03-07) + + +### Bug Fixes + +* Regular Expression Denial of Service (ReDoS) via `$regex` query in LiveQuery ([GHSA-mf3j-86qx-cq5j](https://github.com/parse-community/parse-server/security/advisories/GHSA-mf3j-86qx-cq5j)) ([#10118](https://github.com/parse-community/parse-server/issues/10118)) ([5e113c2](https://github.com/parse-community/parse-server/commit/5e113c2128239b26551f77e127d0120502dc152a)) + +# [9.5.0-alpha.13](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.12...9.5.0-alpha.13) (2026-03-06) + + +### Features + +* Deprecate GraphQL Playground that exposes master key in HTTP response ([#10112](https://github.com/parse-community/parse-server/issues/10112)) ([d54d800](https://github.com/parse-community/parse-server/commit/d54d800f596f1937701f5bd57c81104f102bc3ae)) + +# [9.5.0-alpha.12](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.11...9.5.0-alpha.12) (2026-03-06) + + +### Features + +* Add server option `readOnlyMasterKeyIps` to restrict `readOnlyMasterKey` by IP ([#10115](https://github.com/parse-community/parse-server/issues/10115)) ([cbff6b4](https://github.com/parse-community/parse-server/commit/cbff6b42a0b4f02552457f04a8757ac2376d3e04)) + +# [9.5.0-alpha.11](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.10...9.5.0-alpha.11) (2026-03-06) + + +### Bug Fixes + +* JWT audience validation bypass in Google, Apple, and Facebook authentication adapters ([GHSA-x6fw-778m-wr9v](https://github.com/parse-community/parse-server/security/advisories/GHSA-x6fw-778m-wr9v)) ([#10113](https://github.com/parse-community/parse-server/issues/10113)) ([9f8d3f3](https://github.com/parse-community/parse-server/commit/9f8d3f3d5591c17f9857bad035950fdff75d0ce6)) + +# [9.5.0-alpha.10](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.9...9.5.0-alpha.10) (2026-03-06) + + +### Bug Fixes + +* GraphQL `__type` introspection bypass via inline fragments when public introspection is disabled ([GHSA-q5q9-2rhp-33qw](https://github.com/parse-community/parse-server/security/advisories/GHSA-q5q9-2rhp-33qw)) ([#10111](https://github.com/parse-community/parse-server/issues/10111)) ([61261a5](https://github.com/parse-community/parse-server/commit/61261a5aa15c95a22a87a5a9c53077059ad49d15)) + +# [9.5.0-alpha.9](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.8...9.5.0-alpha.9) (2026-03-06) + + +### Bug Fixes + +* File metadata endpoint bypasses `beforeFind` / `afterFind` trigger authorization ([GHSA-hwx8-q9cg-mqmc](https://github.com/parse-community/parse-server/security/advisories/GHSA-hwx8-q9cg-mqmc)) ([#10106](https://github.com/parse-community/parse-server/issues/10106)) ([72e7707](https://github.com/parse-community/parse-server/commit/72e7707ac17b9df888cc20732583411544adcd36)) + +# [9.5.0-alpha.8](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.7...9.5.0-alpha.8) (2026-03-05) + + +### Bug Fixes + +* `PagesRouter` path traversal allows reading files outside configured pages directory ([GHSA-hm3f-q6rw-m6wh](https://github.com/parse-community/parse-server/security/advisories/GHSA-hm3f-q6rw-m6wh)) ([#10104](https://github.com/parse-community/parse-server/issues/10104)) ([e772543](https://github.com/parse-community/parse-server/commit/e772543ad8d01bce83664566551893dffc5b8117)) + +# [9.5.0-alpha.7](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.6...9.5.0-alpha.7) (2026-03-05) + + +### Features + +* Add security check for server option `mountPlayground` for GraphQL development ([#10103](https://github.com/parse-community/parse-server/issues/10103)) ([2ae5db1](https://github.com/parse-community/parse-server/commit/2ae5db142574b0e62f4263e2fa9a9831c966b478)) + +# [9.5.0-alpha.6](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.5...9.5.0-alpha.6) (2026-03-05) + + +### Bug Fixes + +* Malformed `$regex` query leaks database error details in API response ([GHSA-9cp7-3q5w-j92g](https://github.com/parse-community/parse-server/security/advisories/GHSA-9cp7-3q5w-j92g)) ([#10101](https://github.com/parse-community/parse-server/issues/10101)) ([9792d24](https://github.com/parse-community/parse-server/commit/9792d24b963f3b45e5ade2bbceb6f5c0b5d0251c)) + +# [9.5.0-alpha.5](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.4...9.5.0-alpha.5) (2026-03-05) + + +### Features + +* Allow to identify `readOnlyMasterKey` invocation of Cloud Function via `request.isReadOnly` ([#10100](https://github.com/parse-community/parse-server/issues/10100)) ([2c48751](https://github.com/parse-community/parse-server/commit/2c48751c6de36ec090ac6ab08e289876561ed324)) + +# [9.5.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.3...9.5.0-alpha.4) (2026-03-05) + + +### Bug Fixes + +* Endpoint `/loginAs` allows `readOnlyMasterKey` to gain full read and write access as any user ([GHSA-79wj-8rqv-jvp5](https://github.com/parse-community/parse-server/security/advisories/GHSA-79wj-8rqv-jvp5)) ([#10098](https://github.com/parse-community/parse-server/issues/10098)) ([bc20945](https://github.com/parse-community/parse-server/commit/bc20945fc7cdb2e56d7c46d537d8f4baf7231303)) + +# [9.5.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.2...9.5.0-alpha.3) (2026-03-05) + + +### Bug Fixes + +* File creation and deletion bypasses `readOnlyMasterKey` write restriction ([GHSA-xfh7-phr7-gr2x](https://github.com/parse-community/parse-server/security/advisories/GHSA-xfh7-phr7-gr2x)) ([#10095](https://github.com/parse-community/parse-server/issues/10095)) ([036365a](https://github.com/parse-community/parse-server/commit/036365af6dedd10746327f46bf69408b5c56439e)) + +# [9.5.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.1...9.5.0-alpha.2) (2026-03-04) + + +### Features + +* Add `Parse.File` option `maxUploadSize` to override the Parse Server option `maxUploadSize` per file upload ([#10093](https://github.com/parse-community/parse-server/issues/10093)) ([3d8807b](https://github.com/parse-community/parse-server/commit/3d8807b4eceafab92ac9c23516d564f5fce6cb8e)) + +# [9.5.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.4.1...9.5.0-alpha.1) (2026-03-04) + + +### Features + +* Add support for `Parse.File.setDirectory`, `setMetadata`, `setTags` with stream-based file upload ([#10092](https://github.com/parse-community/parse-server/issues/10092)) ([ca666b0](https://github.com/parse-community/parse-server/commit/ca666b02fcc2229180621a42694c0838f700c06d)) + +## [9.4.1-alpha.3](https://github.com/parse-community/parse-server/compare/9.4.1-alpha.2...9.4.1-alpha.3) (2026-03-04) + + +### Bug Fixes + +* Cloud Hooks and Cloud Jobs bypass `readOnlyMasterKey` write restriction ([GHSA-vc89-5g3r-cmhh](https://github.com/parse-community/parse-server/security/advisories/GHSA-vc89-5g3r-cmhh)) ([#10088](https://github.com/parse-community/parse-server/issues/10088)) ([9a3dd4d](https://github.com/parse-community/parse-server/commit/9a3dd4d2d55ad506348062b43a7fe42e22a57fe9)) + +## [9.4.1-alpha.2](https://github.com/parse-community/parse-server/compare/9.4.1-alpha.1...9.4.1-alpha.2) (2026-03-03) + + +### Performance Improvements + +* Upgrade to mongodb 7.1.0 ([#10087](https://github.com/parse-community/parse-server/issues/10087)) ([bebf2fd](https://github.com/parse-community/parse-server/commit/bebf2fd62b51cfc35c271ad4c76b8f552f886ce8)) + +## [9.4.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.4.0...9.4.1-alpha.1) (2026-03-03) + + +### Bug Fixes + +* MongoDB default batch size changed from 1000 to 100 without announcement ([#10085](https://github.com/parse-community/parse-server/issues/10085)) ([8f17397](https://github.com/parse-community/parse-server/commit/8f1739788d434c91109f049a438c32bdd4fc26a5)) + +# [9.4.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.4.0-alpha.1...9.4.0-alpha.2) (2026-02-27) + + +### Bug Fixes + +* `PagesRouter` header parameters are not URL-encoded to support non-ASCII characters in app name ([#10078](https://github.com/parse-community/parse-server/issues/10078)) ([c92660b](https://github.com/parse-community/parse-server/commit/c92660bd9a776eec81e4ef18217916b931c267a1)) + +# [9.4.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.3.1...9.4.0-alpha.1) (2026-02-26) + + +### Features + +* Add support for `Parse.File.setDirectory()` with master key to save file in directory ([#10076](https://github.com/parse-community/parse-server/issues/10076)) ([17d987c](https://github.com/parse-community/parse-server/commit/17d987c95accdb2d75f63aed25abd919b0999589)) + +## [9.3.1-alpha.4](https://github.com/parse-community/parse-server/compare/9.3.1-alpha.3...9.3.1-alpha.4) (2026-02-23) + + +### Bug Fixes + +* JWT Algorithm Confusion in Google Auth Adapter ([GHSA-4q3h-vp4r-prv2](https://github.com/parse-community/parse-server/security/advisories/GHSA-4q3h-vp4r-prv2)) ([#10072](https://github.com/parse-community/parse-server/issues/10072)) ([9d5942d](https://github.com/parse-community/parse-server/commit/9d5942d50e55c822924c27b05aa98f1393e7a330)) + +## [9.3.1-alpha.3](https://github.com/parse-community/parse-server/compare/9.3.1-alpha.2...9.3.1-alpha.3) (2026-02-23) + + +### Bug Fixes + +* GraphQL introspection disabled in `NODE_ENV=production` even with master key ([#10071](https://github.com/parse-community/parse-server/issues/10071)) ([a5269f0](https://github.com/parse-community/parse-server/commit/a5269f077666537fad1d2eeefee82a36a148255c)) + +## [9.3.1-alpha.2](https://github.com/parse-community/parse-server/compare/9.3.1-alpha.1...9.3.1-alpha.2) (2026-02-21) + + +### Bug Fixes + +* Remove obsolete Parse Server option `pages.enableRouter` ([#10070](https://github.com/parse-community/parse-server/issues/10070)) ([00b3b72](https://github.com/parse-community/parse-server/commit/00b3b7297d806b4b40d7c08dd987b748e018e4b6)) + +## [9.3.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.3.0...9.3.1-alpha.1) (2026-02-21) + + +### Bug Fixes + +* Type error in docs creation ([#10069](https://github.com/parse-community/parse-server/issues/10069)) ([02a277f](https://github.com/parse-community/parse-server/commit/02a277f1e937fd3e6bd85bdb49870bf3f47678a0)) + +# [9.3.0-alpha.9](https://github.com/parse-community/parse-server/compare/9.3.0-alpha.8...9.3.0-alpha.9) (2026-02-21) + + +### Features + +* Add support for streaming file upload via `Buffer`, `Readable`, `ReadableStream` ([#10065](https://github.com/parse-community/parse-server/issues/10065)) ([f0feb48](https://github.com/parse-community/parse-server/commit/f0feb48d0fb697a161693721eadd09d740336283)) + +# [9.3.0-alpha.8](https://github.com/parse-community/parse-server/compare/9.3.0-alpha.7...9.3.0-alpha.8) (2026-02-21) + + +### Bug Fixes + +* Incorrect dependency chain of `Parse` uses browser build instead of Node build ([#10067](https://github.com/parse-community/parse-server/issues/10067)) ([1a2521d](https://github.com/parse-community/parse-server/commit/1a2521d930b855845aa13fde700b2e8170ff65a1)) + +# [9.3.0-alpha.7](https://github.com/parse-community/parse-server/compare/9.3.0-alpha.6...9.3.0-alpha.7) (2026-02-20) + + +### Features + +* Upgrade to parse 8.2.0, @parse/push-adapter 8.3.0 ([#10066](https://github.com/parse-community/parse-server/issues/10066)) ([8b5a14e](https://github.com/parse-community/parse-server/commit/8b5a14ecaf0b58b899651fb97d43e0e5d9be506d)) + +# [9.3.0-alpha.6](https://github.com/parse-community/parse-server/compare/9.3.0-alpha.5...9.3.0-alpha.6) (2026-02-14) + + +### Bug Fixes + +* Default ACL overwrites custom ACL on `Parse.Object` update ([#10061](https://github.com/parse-community/parse-server/issues/10061)) ([4ef89d9](https://github.com/parse-community/parse-server/commit/4ef89d912c08bb24500a4d4142a3220f024a2d34)) + +# [9.3.0-alpha.5](https://github.com/parse-community/parse-server/compare/9.3.0-alpha.4...9.3.0-alpha.5) (2026-02-12) + + +### Bug Fixes + +* `Parse.Query.select('authData')` for `_User` class doesn't return auth data ([#10055](https://github.com/parse-community/parse-server/issues/10055)) ([44a5bb1](https://github.com/parse-community/parse-server/commit/44a5bb105e11e6918e899e0f1427b0adb38d6d67)) + +# [9.3.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.3.0-alpha.3...9.3.0-alpha.4) (2026-02-12) + + +### Bug Fixes + +* Unlinking auth provider triggers auth data validation ([#10045](https://github.com/parse-community/parse-server/issues/10045)) ([b6b6327](https://github.com/parse-community/parse-server/commit/b6b632755263417c2a3c3a31381eedc516723740)) + +# [9.3.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.3.0-alpha.2...9.3.0-alpha.3) (2026-02-07) + + +### Features + +* Add `Parse.File.url` validation with config `fileUpload.allowedFileUrlDomains` against SSRF attacks ([#10044](https://github.com/parse-community/parse-server/issues/10044)) ([4c9c948](https://github.com/parse-community/parse-server/commit/4c9c9489f062bec6d751b23f4a68aea2a63936bd)) + +# [9.3.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.3.0-alpha.1...9.3.0-alpha.2) (2026-02-06) + + +### Bug Fixes + +* Default HTML pages for password reset, email verification not found ([#10041](https://github.com/parse-community/parse-server/issues/10041)) ([a4265bb](https://github.com/parse-community/parse-server/commit/a4265bb1241551b7147e8aee08c36e1f8ab09ba4)) + +# [9.3.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.2.1-alpha.2...9.3.0-alpha.1) (2026-02-06) + + +### Features + +* Add event information to `verifyUserEmails`, `preventLoginWithUnverifiedEmail` to identify invoking signup / login action and auth provider ([#9963](https://github.com/parse-community/parse-server/issues/9963)) ([ed98c15](https://github.com/parse-community/parse-server/commit/ed98c15f90f2fa6a66780941fd3705b805d6eb14)) + +## [9.2.1-alpha.2](https://github.com/parse-community/parse-server/compare/9.2.1-alpha.1...9.2.1-alpha.2) (2026-02-06) + + +### Bug Fixes + +* AuthData validation incorrectly triggered on unchanged providers ([#10025](https://github.com/parse-community/parse-server/issues/10025)) ([d3d6e9e](https://github.com/parse-community/parse-server/commit/d3d6e9e22a212885690853cbbb84bb8c53da5646)) + +## [9.2.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.2.0...9.2.1-alpha.1) (2026-02-06) + + +### Bug Fixes + +* Default HTML pages for password reset, email verification not found ([#10034](https://github.com/parse-community/parse-server/issues/10034)) ([e299107](https://github.com/parse-community/parse-server/commit/e29910764daef3c03ed1b09eee19cedc3b12a86a)) + +# [9.2.0-alpha.5](https://github.com/parse-community/parse-server/compare/9.2.0-alpha.4...9.2.0-alpha.5) (2026-02-05) + + +### Bug Fixes + +* Security upgrade @apollo/server from 5.0.0 to 5.4.0 ([#10035](https://github.com/parse-community/parse-server/issues/10035)) ([9f368ff](https://github.com/parse-community/parse-server/commit/9f368ff9ca322c61cdcfab735e5b5240d1c8f917)) + +# [9.2.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.2.0-alpha.3...9.2.0-alpha.4) (2026-01-29) + + +### Features + +* Upgrade mongodb from 6.20.0 to 7.0.0 ([#10027](https://github.com/parse-community/parse-server/issues/10027)) ([14b3fce](https://github.com/parse-community/parse-server/commit/14b3fce203be0abaf29c27c123cba47f35d09c68)) + +# [9.2.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.2.0-alpha.2...9.2.0-alpha.3) (2026-01-27) + + +### Features + +* Upgrade to parse 8.0.3 and @parse/push-adapter 8.2.0 ([#10021](https://github.com/parse-community/parse-server/issues/10021)) ([9833fdb](https://github.com/parse-community/parse-server/commit/9833fdb111c373dc75fc74ea5f9209408186a475)) + +# [9.2.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.2.0-alpha.1...9.2.0-alpha.2) (2026-01-24) + + +### Bug Fixes + +* MongoDB timeout errors unhandled and potentially revealing internal data ([#10020](https://github.com/parse-community/parse-server/issues/10020)) ([1d3336d](https://github.com/parse-community/parse-server/commit/1d3336d128671c974b419b9b34db35ada7d1a44d)) + +# [9.2.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.1.1...9.2.0-alpha.1) (2026-01-24) + + +### Features + +* Add option `databaseOptions.clientMetadata` to send custom metadata to database server for logging and debugging ([#10017](https://github.com/parse-community/parse-server/issues/10017)) ([756c204](https://github.com/parse-community/parse-server/commit/756c204220a2c7be3770b7d4a49f11e8903323db)) + +## [9.1.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.1.0...9.1.1-alpha.1) (2025-12-16) + + +### Bug Fixes + +* Server-Side Request Forgery (SSRF) in Instagram auth adapter [GHSA-3f5f-xgrj-97pf](https://github.com/parse-community/parse-server/security/advisories/GHSA-3f5f-xgrj-97pf) ([#9988](https://github.com/parse-community/parse-server/issues/9988)) ([fbcc938](https://github.com/parse-community/parse-server/commit/fbcc938b5ade5ff4c30598ac51272ef7ecef0616)) + +# [9.1.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.1.0-alpha.3...9.1.0-alpha.4) (2025-12-14) + + +### Features + +* Log more debug info when failing to set duplicate value for field with unique values ([#9919](https://github.com/parse-community/parse-server/issues/9919)) ([a23b192](https://github.com/parse-community/parse-server/commit/a23b1924668920f3c92fec0566b57091d0e8aae8)) + +# [9.1.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.1.0-alpha.2...9.1.0-alpha.3) (2025-12-14) + + +### Bug Fixes + +* Cross-Site Scripting (XSS) via HTML pages for password reset and email verification [GHSA-jhgf-2h8h-ggxv](https://github.com/parse-community/parse-server/security/advisories/GHSA-jhgf-2h8h-ggxv) ([#9985](https://github.com/parse-community/parse-server/issues/9985)) ([3074eb7](https://github.com/parse-community/parse-server/commit/3074eb70f5b58bf72b528ae7b7804ed2d90455ce)) + +# [9.1.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.1.0-alpha.1...9.1.0-alpha.2) (2025-12-14) + + +### Features + +* Add support for custom HTTP status code and headers to Cloud Function response with Express-style syntax ([#9980](https://github.com/parse-community/parse-server/issues/9980)) ([8eeab8d](https://github.com/parse-community/parse-server/commit/8eeab8dc57edef3751aa188d8247f296a270b083)) + +# [9.1.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.0.0...9.1.0-alpha.1) (2025-12-14) + + +### Features + +* Add option `logLevels.signupUsernameTaken` to change log level of username already exists sign-up rejection ([#9962](https://github.com/parse-community/parse-server/issues/9962)) ([f18f307](https://github.com/parse-community/parse-server/commit/f18f3073d70a292bc70b5d572ef58e4845de89ca)) + +# [9.0.0-alpha.11](https://github.com/parse-community/parse-server/compare/9.0.0-alpha.10...9.0.0-alpha.11) (2025-12-14) + + +### Features + +* Deprecation DEPPS113: Config option `enableInsecureAuthAdapters` defaults to `false` ([#9982](https://github.com/parse-community/parse-server/issues/9982)) ([22d4622](https://github.com/parse-community/parse-server/commit/22d4622230b74839ed408a02bfcabb7b37b85aba)) + + +### BREAKING CHANGES + +* This release changes the config option `enableInsecureAuthAdapters` default to `false` (Deprecation DEPPS13). ([22d4622](22d4622)) + +# [9.0.0-alpha.10](https://github.com/parse-community/parse-server/compare/9.0.0-alpha.9...9.0.0-alpha.10) (2025-12-12) + + +### Features + +* Upgrade to @parse/push-adapter 8.1.0 ([#9938](https://github.com/parse-community/parse-server/issues/9938)) ([d5e76b0](https://github.com/parse-community/parse-server/commit/d5e76b01db2b4eeb22a0bb5a04347a89209aa822)) + +# [9.0.0-alpha.9](https://github.com/parse-community/parse-server/compare/9.0.0-alpha.8...9.0.0-alpha.9) (2025-12-12) + + +### Features + +* Deprecation DEPPS12: Database option `allowPublicExplain` defaults to `false` ([#9975](https://github.com/parse-community/parse-server/issues/9975)) ([c1c7e69](https://github.com/parse-community/parse-server/commit/c1c7e6976d868ccbc7dff325edce78ddfa999bb9)) + + +### BREAKING CHANGES + +* This release changes the MongoDB database option `allowPublicExplain` default to `false` (Deprecation DEPPS12). ([c1c7e69](c1c7e69)) + +# [9.0.0-alpha.8](https://github.com/parse-community/parse-server/compare/9.0.0-alpha.7...9.0.0-alpha.8) (2025-12-12) + + +### Features + +* Deprecation DEPPS11: Replace `PublicAPIRouter` with `PagesRouter` ([#9974](https://github.com/parse-community/parse-server/issues/9974)) ([8f877d4](https://github.com/parse-community/parse-server/commit/8f877d42c02a6492b97c61e75ab77a896878f866)) + + +### BREAKING CHANGES + +* This release replaces `PublicAPIRouter` with `PagesRouter` (Deprecation DEPPS11). ([8f877d4](8f877d4)) + +# [9.0.0-alpha.7](https://github.com/parse-community/parse-server/compare/9.0.0-alpha.6...9.0.0-alpha.7) (2025-12-12) + + +### Features + +* Deprecation DEPPS10: Encode `Parse.Object` in Cloud Function and remove option `encodeParseObjectInCloudFunction` ([#9973](https://github.com/parse-community/parse-server/issues/9973)) ([a2d3dbe](https://github.com/parse-community/parse-server/commit/a2d3dbe972e2e02ac599bfffe1ae6cd9768b02ca)) + + +### BREAKING CHANGES + +* This release encodes `Parse.Object` in Cloud Function and removes option `encodeParseObjectInCloudFunction` (Deprecation DEPPS10). ([a2d3dbe](a2d3dbe)) + +# [9.0.0-alpha.6](https://github.com/parse-community/parse-server/compare/9.0.0-alpha.5...9.0.0-alpha.6) (2025-12-12) + + +### Features + +* Increase required minimum version to Postgres `16`, PostGIS `3.5` ([#9972](https://github.com/parse-community/parse-server/issues/9972)) ([7483add](https://github.com/parse-community/parse-server/commit/7483add73934e7d16098ccfb672cc45b3f7c7fbe)) + + +### BREAKING CHANGES + +* This releases increases the required minimum version to Postgres `16`, PostGIS `3.5`. ([7483add](7483add)) + +# [9.0.0-alpha.5](https://github.com/parse-community/parse-server/compare/9.0.0-alpha.4...9.0.0-alpha.5) (2025-12-12) + + +### Features + +* Update route patterns to use path-to-regexp v8 syntax ([#9942](https://github.com/parse-community/parse-server/issues/9942)) ([fa8723b](https://github.com/parse-community/parse-server/commit/fa8723b3d1e895602d1187540818bbdb446259ba)) + + +### BREAKING CHANGES + +* Route pattern syntax across cloud routes and rate-limiting now use the new path-to-regexp v8 syntax; see the [migration guide](https://github.com/parse-community/parse-server/blob/alpha/9.0.0.md) for more details. ([fa8723b](fa8723b)) + +# [9.0.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.0.0-alpha.3...9.0.0-alpha.4) (2025-12-12) + + +### Features + +* Increase required minimum MongoDB version to `7.0.16` ([#9971](https://github.com/parse-community/parse-server/issues/9971)) ([7bb548b](https://github.com/parse-community/parse-server/commit/7bb548bf81b3cebc9ec92ef9e5e6faf8f9edbd3b)) + + +### BREAKING CHANGES + +* This releases increases the required minimum MongoDB version to `7.0.16`. ([7bb548b](7bb548b)) + +# [9.0.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.0.0-alpha.2...9.0.0-alpha.3) (2025-12-12) + + +### Bug Fixes + +* Upgrade to GraphQL Apollo Server 5 and restrict GraphQL introspection ([#9888](https://github.com/parse-community/parse-server/issues/9888)) ([87c7f07](https://github.com/parse-community/parse-server/commit/87c7f076eb84c9540f79f06c27fe13e102dc6295)) + + +### BREAKING CHANGES + +* Upgrade to Apollo Server 5 and GraphQL express 5 integration; GraphQL introspection now requires using `masterKey` or setting `graphQLPublicIntrospection: true`. ([87c7f07](87c7f07)) + +# [9.0.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.0.0-alpha.1...9.0.0-alpha.2) (2025-12-12) + + +### Features + +* Upgrade to parse 8.0.0 ([#9976](https://github.com/parse-community/parse-server/issues/9976)) ([f9970d4](https://github.com/parse-community/parse-server/commit/f9970d4bb253494392fb4cc366f222119927f082)) + +# [9.0.0-alpha.1](https://github.com/parse-community/parse-server/compare/8.6.0...9.0.0-alpha.1) (2025-12-12) + + +### Features + +* Increase required minimum Node version to `20.19.0` ([#9970](https://github.com/parse-community/parse-server/issues/9970)) ([633964d](https://github.com/parse-community/parse-server/commit/633964d32e249d8cc16c58de7ddd9b7637c69fb1)) + + +### BREAKING CHANGES + +* This releases increases the required minimum Node version to `20.19.0`. ([633964d](633964d)) + +# [8.6.0-alpha.2](https://github.com/parse-community/parse-server/compare/8.6.0-alpha.1...8.6.0-alpha.2) (2025-12-10) + + +### Bug Fixes + +* Remove elevated permissions in GitHub CI performance benchmark ([#9966](https://github.com/parse-community/parse-server/issues/9966)) ([6b9f896](https://github.com/parse-community/parse-server/commit/6b9f8963cc3debf59cd9c5dfc5422aff9404ce9d)) + +# [8.6.0-alpha.1](https://github.com/parse-community/parse-server/compare/8.5.0...8.6.0-alpha.1) (2025-12-03) + + +### Features + +* Add GraphQL query `cloudConfig` to retrieve and mutation `updateCloudConfig` to update Cloud Config ([#9947](https://github.com/parse-community/parse-server/issues/9947)) ([3ca85cd](https://github.com/parse-community/parse-server/commit/3ca85cd4a632f234c9d3d731331c0524dfe54075)) + +# [8.5.0-alpha.18](https://github.com/parse-community/parse-server/compare/8.5.0-alpha.17...8.5.0-alpha.18) (2025-12-01) + + +### Features + +* Upgrade to parse 7.1.2 ([#9955](https://github.com/parse-community/parse-server/issues/9955)) ([5c644a5](https://github.com/parse-community/parse-server/commit/5c644a55ac25986f214b68ba4bcbe7a62ad6d6d1)) + +# [8.5.0-alpha.17](https://github.com/parse-community/parse-server/compare/8.5.0-alpha.16...8.5.0-alpha.17) (2025-12-01) + + +### Features + +* Upgrade to parse 7.1.1 ([#9954](https://github.com/parse-community/parse-server/issues/9954)) ([fa57d69](https://github.com/parse-community/parse-server/commit/fa57d69cbec525189da98d7136c1c0e9eaf74338)) + +# [8.5.0-alpha.16](https://github.com/parse-community/parse-server/compare/8.5.0-alpha.15...8.5.0-alpha.16) (2025-11-28) + + +### Features + +* Add Parse Server option `enableSanitizedErrorResponse` to remove detailed error messages from responses sent to clients ([#9944](https://github.com/parse-community/parse-server/issues/9944)) ([4752197](https://github.com/parse-community/parse-server/commit/47521974aeafcf41102be62f19612a4ab0a4837f)) + +# [8.5.0-alpha.15](https://github.com/parse-community/parse-server/compare/8.5.0-alpha.14...8.5.0-alpha.15) (2025-11-23) + + +### Performance Improvements + +* Remove unused dependencies ([#9943](https://github.com/parse-community/parse-server/issues/9943)) ([d4c6de0](https://github.com/parse-community/parse-server/commit/d4c6de0096b3ac95289c6bddfe25eb397d790e41)) + +# [8.5.0-alpha.14](https://github.com/parse-community/parse-server/compare/8.5.0-alpha.13...8.5.0-alpha.14) (2025-11-23) + + +### Bug Fixes + +* Parse Server option `rateLimit.zone` does not use default value `ip` ([#9941](https://github.com/parse-community/parse-server/issues/9941)) ([12beb8f](https://github.com/parse-community/parse-server/commit/12beb8f6ee5d3002fec017bb4525eb3f1375f806)) + +# [8.5.0-alpha.13](https://github.com/parse-community/parse-server/compare/8.5.0-alpha.12...8.5.0-alpha.13) (2025-11-23) + + +### Bug Fixes + +* Server internal error details leaking in error messages returned to clients ([#9937](https://github.com/parse-community/parse-server/issues/9937)) ([50edb5a](https://github.com/parse-community/parse-server/commit/50edb5ab4bb4a6ce474bfb7cf159d918933753b8)) + +# [8.5.0-alpha.12](https://github.com/parse-community/parse-server/compare/8.5.0-alpha.11...8.5.0-alpha.12) (2025-11-19) + + +### Features + +* Add `beforePasswordResetRequest` hook ([#9906](https://github.com/parse-community/parse-server/issues/9906)) ([94cee5b](https://github.com/parse-community/parse-server/commit/94cee5bfafca10c914c73cf17fcdb627a9f0837b)) + +# [8.5.0-alpha.11](https://github.com/parse-community/parse-server/compare/8.5.0-alpha.10...8.5.0-alpha.11) (2025-11-17) + + +### Bug Fixes + +* Deprecation warning logged at server launch for nested Parse Server option even if option is explicitly set ([#9934](https://github.com/parse-community/parse-server/issues/9934)) ([c22cb0a](https://github.com/parse-community/parse-server/commit/c22cb0ae58e64cd0e4597ab9610d57a1155c44a2)) + +# [8.5.0-alpha.10](https://github.com/parse-community/parse-server/compare/8.5.0-alpha.9...8.5.0-alpha.10) (2025-11-17) + + +### Bug Fixes + +* Queries with object field `authData.provider.id` are incorrectly transformed to `_auth_data_provider.id` for custom classes ([#9932](https://github.com/parse-community/parse-server/issues/9932)) ([7b9fa18](https://github.com/parse-community/parse-server/commit/7b9fa18f968ec084ea0b35dad2b5ba0451d59787)) + +# [8.5.0-alpha.9](https://github.com/parse-community/parse-server/compare/8.5.0-alpha.8...8.5.0-alpha.9) (2025-11-17) + + +### Bug Fixes + +* Race condition can cause multiple Apollo server initializations under load ([#9929](https://github.com/parse-community/parse-server/issues/9929)) ([7d5e9fc](https://github.com/parse-community/parse-server/commit/7d5e9fcf3ceb0abad8ab49c75bc26f521a0f1bde)) + +# [8.5.0-alpha.8](https://github.com/parse-community/parse-server/compare/8.5.0-alpha.7...8.5.0-alpha.8) (2025-11-17) + + +### Performance Improvements + +* `Parse.Query.include` now fetches pointers at same level in parallel ([#9861](https://github.com/parse-community/parse-server/issues/9861)) ([dafea21](https://github.com/parse-community/parse-server/commit/dafea21eb39b0fdc2b52bb8a14f7b61e3f2b8d13)) + +# [8.5.0-alpha.7](https://github.com/parse-community/parse-server/compare/8.5.0-alpha.6...8.5.0-alpha.7) (2025-11-08) + + +### Performance Improvements + +* Upgrade MongoDB driver to 6.20.0 ([#9887](https://github.com/parse-community/parse-server/issues/9887)) ([3c9af48](https://github.com/parse-community/parse-server/commit/3c9af48edd999158443b797e388e29495953799e)) + +# [8.5.0-alpha.6](https://github.com/parse-community/parse-server/compare/8.5.0-alpha.5...8.5.0-alpha.6) (2025-11-08) + + +### Bug Fixes + +* `GridFSBucketAdapter` throws when using some Parse Server specific options in MongoDB database options ([#9915](https://github.com/parse-community/parse-server/issues/9915)) ([d3d4003](https://github.com/parse-community/parse-server/commit/d3d4003570b9872f2b0f5a25fc06ce4c4132860d)) + +# [8.5.0-alpha.5](https://github.com/parse-community/parse-server/compare/8.5.0-alpha.4...8.5.0-alpha.5) (2025-11-08) + + +### Features + +* Add Parse Server option `allowPublicExplain` to allow `Parse.Query.explain` without master key ([#9890](https://github.com/parse-community/parse-server/issues/9890)) ([4456b02](https://github.com/parse-community/parse-server/commit/4456b02280c2d8dd58b7250e9e67f1a8647b3452)) + +# [8.5.0-alpha.4](https://github.com/parse-community/parse-server/compare/8.5.0-alpha.3...8.5.0-alpha.4) (2025-11-08) + + +### Features + +* Add MongoDB client event logging via database option `logClientEvents` ([#9914](https://github.com/parse-community/parse-server/issues/9914)) ([b760733](https://github.com/parse-community/parse-server/commit/b760733b98bcfc9c09ac9780066602e1fda108fe)) + +# [8.5.0-alpha.3](https://github.com/parse-community/parse-server/compare/8.5.0-alpha.2...8.5.0-alpha.3) (2025-11-07) + + +### Features + +* Add support for more MongoDB driver options ([#9911](https://github.com/parse-community/parse-server/issues/9911)) ([cff451e](https://github.com/parse-community/parse-server/commit/cff451eabdc380affa600ed711de66f7bd1d00aa)) + +# [8.5.0-alpha.2](https://github.com/parse-community/parse-server/compare/8.5.0-alpha.1...8.5.0-alpha.2) (2025-11-07) + + +### Features + +* Add support for MongoDB driver options `serverSelectionTimeoutMS`, `maxIdleTimeMS`, `heartbeatFrequencyMS` ([#9910](https://github.com/parse-community/parse-server/issues/9910)) ([1b661e9](https://github.com/parse-community/parse-server/commit/1b661e98c86a1db79e076a7297cd9199a72ae1ac)) + +# [8.5.0-alpha.1](https://github.com/parse-community/parse-server/compare/8.4.0...8.5.0-alpha.1) (2025-11-07) + + +### Features + +* Allow option `publicServerURL` to be set dynamically as asynchronous function ([#9803](https://github.com/parse-community/parse-server/issues/9803)) ([460a65c](https://github.com/parse-community/parse-server/commit/460a65cf612f4c86af8038cafcc7e7ffe9eb8440)) + +# [8.4.0-alpha.2](https://github.com/parse-community/parse-server/compare/8.4.0-alpha.1...8.4.0-alpha.2) (2025-11-05) + + +### Bug Fixes + +* Uploading a file by providing an origin URL allows for Server-Side Request Forgery (SSRF); fixes vulnerability [GHSA-x4qj-2f4q-r4rx](https://github.com/parse-community/parse-server/security/advisories/GHSA-x4qj-2f4q-r4rx) ([#9903](https://github.com/parse-community/parse-server/issues/9903)) ([9776386](https://github.com/parse-community/parse-server/commit/97763863b72689a29ad7a311dfb590c3e3c50585)) + +# [8.4.0-alpha.1](https://github.com/parse-community/parse-server/compare/8.3.1-alpha.1...8.4.0-alpha.1) (2025-11-05) + + +### Features + +* Add support for Node 24 ([#9901](https://github.com/parse-community/parse-server/issues/9901)) ([25dfe19](https://github.com/parse-community/parse-server/commit/25dfe19fef02fd44224e4a6d198584a694a1aa52)) + +## [8.3.1-alpha.1](https://github.com/parse-community/parse-server/compare/8.3.0...8.3.1-alpha.1) (2025-11-05) + + +### Bug Fixes + +* Add problematic MIME types to default value of Parse Server option `fileUpload.fileExtensions` ([#9902](https://github.com/parse-community/parse-server/issues/9902)) ([fa245cb](https://github.com/parse-community/parse-server/commit/fa245cbb5f5b7a0dad962b2ce0524fa4dafcb5f7)) + +# [8.3.0-alpha.14](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.13...8.3.0-alpha.14) (2025-11-01) + + +### Features + +* Add options to skip automatic creation of internal database indexes on server start ([#9897](https://github.com/parse-community/parse-server/issues/9897)) ([ea91aca](https://github.com/parse-community/parse-server/commit/ea91aca1420c33e038516a321b2640709589f886)) + +# [8.3.0-alpha.13](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.12...8.3.0-alpha.13) (2025-11-01) + + +### Bug Fixes + +* Indexes `_email_verify_token` for email verification and `_perishable_token` password reset are not created automatically ([#9893](https://github.com/parse-community/parse-server/issues/9893)) ([62dd3c5](https://github.com/parse-community/parse-server/commit/62dd3c565ab70765cb1c547996b616b72e9bb800)) + +# [8.3.0-alpha.12](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.11...8.3.0-alpha.12) (2025-10-25) + + +### Features + +* Add Parse Server option `verifyServerUrl` to disable server URL verification on server launch ([#9881](https://github.com/parse-community/parse-server/issues/9881)) ([b298ccc](https://github.com/parse-community/parse-server/commit/b298cccd9fb4f664b9d83894faad6d1ea7a3c964)) + +# [8.3.0-alpha.11](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.10...8.3.0-alpha.11) (2025-10-24) + + +### Bug Fixes + +* Stale data read in validation query on `Parse.Object` update causes inconsistency between validation read and subsequent update write operation ([#9859](https://github.com/parse-community/parse-server/issues/9859)) ([f49efaf](https://github.com/parse-community/parse-server/commit/f49efaf5bb1d6b19f6d6712f7cdf073855c95c6e)) + +# [8.3.0-alpha.10](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.9...8.3.0-alpha.10) (2025-10-22) + + +### Bug Fixes + +* Error in `afterSave` trigger for `Parse.Role` due to `name` field ([#9883](https://github.com/parse-community/parse-server/issues/9883)) ([eb052d8](https://github.com/parse-community/parse-server/commit/eb052d8e6abe1ae32505fd068d5445eaf950a770)) + +# [8.3.0-alpha.9](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.8...8.3.0-alpha.9) (2025-10-19) + + +### Bug Fixes + +* Server URL verification before server is ready ([#9882](https://github.com/parse-community/parse-server/issues/9882)) ([178bd5c](https://github.com/parse-community/parse-server/commit/178bd5c5e258d9501c9ac4d35a3a105ab64be67e)) + +# [8.3.0-alpha.8](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.7...8.3.0-alpha.8) (2025-10-16) + + +### Bug Fixes + +* Warning logged when setting option `databaseOptions.disableIndexFieldValidation` ([#9880](https://github.com/parse-community/parse-server/issues/9880)) ([1815b01](https://github.com/parse-community/parse-server/commit/1815b019b52565d2bc87be2596a49aea7600aeba)) + +# [8.3.0-alpha.7](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.6...8.3.0-alpha.7) (2025-10-15) + + +### Bug Fixes + +* Security upgrade to parse 7.0.1 ([#9877](https://github.com/parse-community/parse-server/issues/9877)) ([abfa94c](https://github.com/parse-community/parse-server/commit/abfa94cd6de2c4e76337931c8ea8311c4ccf2a1a)) + +# [8.3.0-alpha.6](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.5...8.3.0-alpha.6) (2025-10-14) + + +### Features + +* Add request context middleware for config and dependency injection in hooks ([#8480](https://github.com/parse-community/parse-server/issues/8480)) ([64f104e](https://github.com/parse-community/parse-server/commit/64f104e5c5f8863098e801eee632c14fcbd9b6f9)) + +# [8.3.0-alpha.5](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.4...8.3.0-alpha.5) (2025-10-14) + + +### Features + +* Allow returning objects in `Parse.Cloud.beforeFind` without invoking database query ([#9770](https://github.com/parse-community/parse-server/issues/9770)) ([0b47407](https://github.com/parse-community/parse-server/commit/0b4740714c29ba99672bc535619ee3516abd356f)) + +# [8.3.0-alpha.4](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.3...8.3.0-alpha.4) (2025-10-09) + + +### Features + +* Disable index-field validation to create index for fields that don't yet exist ([#8137](https://github.com/parse-community/parse-server/issues/8137)) ([1b23475](https://github.com/parse-community/parse-server/commit/1b2347524ca84ade0f6badf175a815fc8a7bef49)) + +# [8.3.0-alpha.3](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.2...8.3.0-alpha.3) (2025-10-07) + + +### Features + +* Add support for Postgres 18 ([#9870](https://github.com/parse-community/parse-server/issues/9870)) ([d275c18](https://github.com/parse-community/parse-server/commit/d275c1806e0a5a037cc06cde7eefff3e12c91d7d)) + +# [8.3.0-alpha.2](https://github.com/parse-community/parse-server/compare/8.3.0-alpha.1...8.3.0-alpha.2) (2025-10-03) + + +### Features + +* Add regex option `u` for unicode support in `Parse.Query.matches` for MongoDB ([#9867](https://github.com/parse-community/parse-server/issues/9867)) ([7cb962a](https://github.com/parse-community/parse-server/commit/7cb962a02845f3dded61baffd84515f94b66ee50)) + +# [8.3.0-alpha.1](https://github.com/parse-community/parse-server/compare/8.2.5...8.3.0-alpha.1) (2025-10-03) + + +### Features + +* Add option `keepUnknownIndexes` to retain indexes which are not specified in schema ([#9857](https://github.com/parse-community/parse-server/issues/9857)) ([89fad46](https://github.com/parse-community/parse-server/commit/89fad468c3a43772879c06c4d939a83b72517a8e)) + +## [8.2.5-alpha.1](https://github.com/parse-community/parse-server/compare/8.2.4...8.2.5-alpha.1) (2025-09-21) + + +### Bug Fixes + +* GraphQL playground shows blank page ([#9858](https://github.com/parse-community/parse-server/issues/9858)) ([7b5395c](https://github.com/parse-community/parse-server/commit/7b5395c5d481235c022d96603280672366a50715)) + +## [8.2.4-alpha.1](https://github.com/parse-community/parse-server/compare/8.2.3...8.2.4-alpha.1) (2025-08-02) + + +### Bug Fixes + +* Security upgrade form-data from 4.0.3 to 4.0.4 ([#9829](https://github.com/parse-community/parse-server/issues/9829)) ([c2c593f](https://github.com/parse-community/parse-server/commit/c2c593f437c33f37101b4f3bb1287eef31dbbc3b)) + +## [8.2.3-alpha.1](https://github.com/parse-community/parse-server/compare/8.2.2...8.2.3-alpha.1) (2025-07-13) + + +### Bug Fixes + +* MongoDB aggregation pipeline with `$dateSubtract` from `$$NOW` returns no results ([#9822](https://github.com/parse-community/parse-server/issues/9822)) ([847a274](https://github.com/parse-community/parse-server/commit/847a274cdb8c22f8e0fc249162e5e2c9e29a594a)) + +## [8.2.2-alpha.1](https://github.com/parse-community/parse-server/compare/8.2.1...8.2.2-alpha.1) (2025-07-10) + + +### Bug Fixes + +* Data schema exposed via GraphQL API public introspection (GHSA-48q3-prgv-gm4w) ([#9819](https://github.com/parse-community/parse-server/issues/9819)) ([c58b2eb](https://github.com/parse-community/parse-server/commit/c58b2eb6eb48af9d3c2e69b4829810a021347b40)) + +## [8.2.1-alpha.2](https://github.com/parse-community/parse-server/compare/8.2.1-alpha.1...8.2.1-alpha.2) (2025-05-14) + + +### Performance Improvements + +* Remove saving Parse Cloud Job request parameters in internal collection `_JobStatus` ([#8343](https://github.com/parse-community/parse-server/issues/8343)) ([e98733c](https://github.com/parse-community/parse-server/commit/e98733cbac9451521a3acc388d2f9d29eb4610e0)) + +## [8.2.1-alpha.1](https://github.com/parse-community/parse-server/compare/8.2.0...8.2.1-alpha.1) (2025-05-03) + + +### Bug Fixes + +* `Parse.Query.containedIn` and `matchesQuery` do not work with nested objects ([#9738](https://github.com/parse-community/parse-server/issues/9738)) ([0db3a6f](https://github.com/parse-community/parse-server/commit/0db3a6ff27a129427770e314a792cc586e4255b5)) + +# [8.2.0-alpha.1](https://github.com/parse-community/parse-server/compare/8.1.1-alpha.1...8.2.0-alpha.1) (2025-04-15) + + +### Features + +* Add TypeScript definitions ([#9693](https://github.com/parse-community/parse-server/issues/9693)) ([e86718f](https://github.com/parse-community/parse-server/commit/e86718fc59c7c8e6f3c6abd0feb7d1a68ca76c23)) + +## [8.1.1-alpha.1](https://github.com/parse-community/parse-server/compare/8.1.0...8.1.1-alpha.1) (2025-04-07) + + +### Performance Improvements + +* Add details to error message in `Parse.Query.aggregate` ([#9689](https://github.com/parse-community/parse-server/issues/9689)) ([9de6999](https://github.com/parse-community/parse-server/commit/9de6999e257d839b68bbca282447777edfdb1ddf)) + +# [8.1.0-alpha.4](https://github.com/parse-community/parse-server/compare/8.1.0-alpha.3...8.1.0-alpha.4) (2025-04-01) + + +### Features + +* Upgrade Parse JS SDK from 6.0.0 to 6.1.0 ([#9686](https://github.com/parse-community/parse-server/issues/9686)) ([f49c371](https://github.com/parse-community/parse-server/commit/f49c371c1373d41e68b091e65f33a71ff6fc6dd0)) + +# [8.1.0-alpha.3](https://github.com/parse-community/parse-server/compare/8.1.0-alpha.2...8.1.0-alpha.3) (2025-03-27) + + +### Bug Fixes + +* Parse Server doesn't shutdown gracefully ([#9634](https://github.com/parse-community/parse-server/issues/9634)) ([aed918d](https://github.com/parse-community/parse-server/commit/aed918d3109e739f7231d481b5f48c68fc01cf04)) + +# [8.1.0-alpha.2](https://github.com/parse-community/parse-server/compare/8.1.0-alpha.1...8.1.0-alpha.2) (2025-03-27) + + +### Features + +* Add Cloud Code triggers `Parse.Cloud.beforeFind(Parse.File)`and `Parse.Cloud.afterFind(Parse.File)` ([#8700](https://github.com/parse-community/parse-server/issues/8700)) ([b2beaa8](https://github.com/parse-community/parse-server/commit/b2beaa86ff543a7aa4ad274c7a23bc4aa302c3fa)) + +# [8.1.0-alpha.1](https://github.com/parse-community/parse-server/compare/8.0.2...8.1.0-alpha.1) (2025-03-24) + + +### Features + +* Add default ACL ([#8701](https://github.com/parse-community/parse-server/issues/8701)) ([12b5d78](https://github.com/parse-community/parse-server/commit/12b5d781dc3f8c43c0c566dffa9308d02a7d8043)) + +## [8.0.2-alpha.1](https://github.com/parse-community/parse-server/compare/8.0.1...8.0.2-alpha.1) (2025-03-21) + + +### Bug Fixes + +* Authentication provider credentials are usable across Parse Server apps; fixes security vulnerability [GHSA-837q-jhwx-cmpv](https://github.com/parse-community/parse-server/security/advisories/GHSA-837q-jhwx-cmpv) ([#9667](https://github.com/parse-community/parse-server/issues/9667)) ([5ef0440](https://github.com/parse-community/parse-server/commit/5ef0440c8e763854e62341acaeb6dc4ade3ba82f)) + +## [8.0.1-alpha.2](https://github.com/parse-community/parse-server/compare/8.0.1-alpha.1...8.0.1-alpha.2) (2025-03-16) + + +### Bug Fixes + +* Security upgrade node from 20.18.2-alpine3.20 to 20.19.0-alpine3.20 ([#9652](https://github.com/parse-community/parse-server/issues/9652)) ([2be1a19](https://github.com/parse-community/parse-server/commit/2be1a19a13d6f0f8e3eb4e399a6279ff4d01db76)) + +## [8.0.1-alpha.1](https://github.com/parse-community/parse-server/compare/8.0.0...8.0.1-alpha.1) (2025-03-06) + + +### Bug Fixes + +* Using Parse Server option `extendSessionOnUse` does not correctly clear memory and functions as a debounce instead of a throttle ([#8683](https://github.com/parse-community/parse-server/issues/8683)) ([6258a6a](https://github.com/parse-community/parse-server/commit/6258a6a11235dc642c71074d24e19c055294d26d)) + +# [8.0.0-alpha.15](https://github.com/parse-community/parse-server/compare/8.0.0-alpha.14...8.0.0-alpha.15) (2025-03-03) + + +### Features + +* Upgrade to express 5.0.1 ([#9530](https://github.com/parse-community/parse-server/issues/9530)) ([e0480df](https://github.com/parse-community/parse-server/commit/e0480dfa8d97946e57eac6b74d937978f8454b3a)) + + +### BREAKING CHANGES + +* This upgrades the internally used Express framework from version 4 to 5, which may be a breaking change. If Parse Server is set up to be mounted on an Express application, we recommend to also use version 5 of the Express framework to avoid any compatibility issues. Note that even if there are no issues after upgrading, future releases of Parse Server may introduce issues if Parse Server internally relies on Express 5-specific features which are unsupported by the Express version on which it is mounted. See the Express [migration guide](https://expressjs.com/en/guide/migrating-5.html) and [release announcement](https://expressjs.com/2024/10/15/v5-release.html#breaking-changes) for more info. ([e0480df](e0480df)) + +# [8.0.0-alpha.14](https://github.com/parse-community/parse-server/compare/8.0.0-alpha.13...8.0.0-alpha.14) (2025-03-02) + + +### Features + +* Upgrade to Parse JS SDK 6.0.0 ([#9624](https://github.com/parse-community/parse-server/issues/9624)) ([bf9db75](https://github.com/parse-community/parse-server/commit/bf9db75e8685def1407034944725e758bc926c26)) + + +### BREAKING CHANGES + +* This upgrades to the Parse JS SDK 6.0.0. See the [change log](https://github.com/parse-community/Parse-SDK-JS/releases/tag/6.0.0) of the Parse JS SDK for breaking changes and more details. ([bf9db75](bf9db75)) + +# [8.0.0-alpha.13](https://github.com/parse-community/parse-server/compare/8.0.0-alpha.12...8.0.0-alpha.13) (2025-03-02) + + +### Bug Fixes + +* Remove username from email verification and password reset process ([#8488](https://github.com/parse-community/parse-server/issues/8488)) ([d21dd97](https://github.com/parse-community/parse-server/commit/d21dd973363f9c5eca86a1007cb67e445b0d2e02)) + + +### BREAKING CHANGES + +* This removes the username from the email verification and password reset process to prevent storing personally identifiable information (PII) in server and infrastructure logs. Customized HTML pages or emails related to email verification and password reset may need to be adapted accordingly. See the new templates that come bundled with Parse Server and the [migration guide](https://github.com/parse-community/parse-server/blob/alpha/8.0.0.md) for more details. ([d21dd97](d21dd97)) + +# [8.0.0-alpha.12](https://github.com/parse-community/parse-server/compare/8.0.0-alpha.11...8.0.0-alpha.12) (2025-02-24) + + +### Bug Fixes + +* LiveQueryServer crashes using cacheAdapter on disconnect from Redis 4 server ([#9616](https://github.com/parse-community/parse-server/issues/9616)) ([bbc6bd4](https://github.com/parse-community/parse-server/commit/bbc6bd4b3f493170c13ad3314924cbf1f379eca4)) + +# [8.0.0-alpha.11](https://github.com/parse-community/parse-server/compare/8.0.0-alpha.10...8.0.0-alpha.11) (2025-02-12) + + +### Features + +* Add dynamic master key by setting Parse Server option `masterKey` to a function ([#9582](https://github.com/parse-community/parse-server/issues/9582)) ([6f1d161](https://github.com/parse-community/parse-server/commit/6f1d161a2f263a166981f9544cf2aadce65afe23)) + +# [8.0.0-alpha.10](https://github.com/parse-community/parse-server/compare/8.0.0-alpha.9...8.0.0-alpha.10) (2025-02-01) + + +### Bug Fixes + +* Security upgrade node from 20.17.0-alpine3.20 to 20.18.2-alpine3.20 ([#9583](https://github.com/parse-community/parse-server/issues/9583)) ([8f85ae2](https://github.com/parse-community/parse-server/commit/8f85ae205474f65414c0536754de12c87dbbf82a)) + +# [8.0.0-alpha.9](https://github.com/parse-community/parse-server/compare/8.0.0-alpha.8...8.0.0-alpha.9) (2025-01-30) + + +### Features + +* Add TypeScript support ([#9550](https://github.com/parse-community/parse-server/issues/9550)) ([59e46d0](https://github.com/parse-community/parse-server/commit/59e46d0aea3e6529994d98160d993144b8075291)) + +# [8.0.0-alpha.8](https://github.com/parse-community/parse-server/compare/8.0.0-alpha.7...8.0.0-alpha.8) (2025-01-30) + + +### Features + +* Add support for MongoDB `databaseOptions` keys `autoSelectFamily`, `autoSelectFamilyAttemptTimeout` ([#9579](https://github.com/parse-community/parse-server/issues/9579)) ([5966068](https://github.com/parse-community/parse-server/commit/5966068e963e7a79eac8fba8720ee7d83578f207)) + +# [8.0.0-alpha.7](https://github.com/parse-community/parse-server/compare/8.0.0-alpha.6...8.0.0-alpha.7) (2025-01-28) + + +### Features + +* Add support for MongoDB `databaseOptions` keys `minPoolSize`, `connectTimeoutMS`, `socketTimeoutMS` ([#9522](https://github.com/parse-community/parse-server/issues/9522)) ([91618fe](https://github.com/parse-community/parse-server/commit/91618fe738217b937cbfcec35969679e0adb7676)) + +# [8.0.0-alpha.6](https://github.com/parse-community/parse-server/compare/8.0.0-alpha.5...8.0.0-alpha.6) (2025-01-12) + + +### Features + +* Increase required minimum versions to Postgres `15`, PostGIS `3.3` ([#9538](https://github.com/parse-community/parse-server/issues/9538)) ([89c9b54](https://github.com/parse-community/parse-server/commit/89c9b5485a07a411fb35de4f8cf0467e7eb01f85)) + + +### BREAKING CHANGES + +* This releases increases the required minimum versions to Postgres `15`, PostGIS `3.3` and removes support for Postgres `13`, `14`, PostGIS `3.1`, `3.2`. ([89c9b54](89c9b54)) + +# [8.0.0-alpha.5](https://github.com/parse-community/parse-server/compare/8.0.0-alpha.4...8.0.0-alpha.5) (2025-01-12) + + +### Features + +* Change default value of Parse Server option `encodeParseObjectInCloudFunction` to `true` ([#9527](https://github.com/parse-community/parse-server/issues/9527)) ([5c5ad69](https://github.com/parse-community/parse-server/commit/5c5ad69b4a917b7ed7c328a8255144e105c40b08)) + + +### BREAKING CHANGES + +* The default value of Parse Server option `encodeParseObjectInCloudFunction` changes to `true`; the option has been deprecated and will be removed in a future version. ([5c5ad69](5c5ad69)) + +# [8.0.0-alpha.4](https://github.com/parse-community/parse-server/compare/8.0.0-alpha.3...8.0.0-alpha.4) (2025-01-12) + + +### Features + +* Deprecate `PublicAPIRouter` in favor of `PagesRouter` ([#9526](https://github.com/parse-community/parse-server/issues/9526)) ([7f66629](https://github.com/parse-community/parse-server/commit/7f666292e8b9692966672486b7108edefc356309)) + +# [8.0.0-alpha.3](https://github.com/parse-community/parse-server/compare/8.0.0-alpha.2...8.0.0-alpha.3) (2025-01-12) + + +### Features + +* Increase required minimum MongoDB versions to `6.0.19`, `7.0.16`, `8.0.4` ([#9531](https://github.com/parse-community/parse-server/issues/9531)) ([871e508](https://github.com/parse-community/parse-server/commit/871e5082a9fd768cee3012e26d3c8ddff5c2952c)) + + +### BREAKING CHANGES + +* This releases increases the required minimum MongoDB versions to `6.0.19`, `7.0.16`, `8.0.4` and removes support for MongoDB `4`, `5`. ([871e508](871e508)) + +# [8.0.0-alpha.2](https://github.com/parse-community/parse-server/compare/8.0.0-alpha.1...8.0.0-alpha.2) (2025-01-11) + + +### Bug Fixes + +* Push adapter not loading on some versions of Node 22 ([#9524](https://github.com/parse-community/parse-server/issues/9524)) ([ff7f671](https://github.com/parse-community/parse-server/commit/ff7f671c79f5dcdc44e4319a10f3654e12662c23)) + +# [8.0.0-alpha.1](https://github.com/parse-community/parse-server/compare/7.4.0-alpha.7...8.0.0-alpha.1) (2025-01-11) + + +### Features + +* Increase required minimum Node versions to `18.20.4`, `20.18.0`, `22.12.0` ([#9521](https://github.com/parse-community/parse-server/issues/9521)) ([4e151cd](https://github.com/parse-community/parse-server/commit/4e151cd0a52191809452f197b2f29c3a12525b67)) + + +### BREAKING CHANGES + +* This releases increases the required minimum Node versions to 18.20.4, 20.18.0, 22.12.0 and removes unofficial support for Node 19. ([4e151cd](4e151cd)) + +# [7.4.0-alpha.7](https://github.com/parse-community/parse-server/compare/7.4.0-alpha.6...7.4.0-alpha.7) (2024-12-16) + + +### Features + +* Upgrade @parse/push-adapter from 6.7.1 to 6.8.0 ([#9489](https://github.com/parse-community/parse-server/issues/9489)) ([286aa66](https://github.com/parse-community/parse-server/commit/286aa664ac8830d36c3e70d2316917d15f0b6df5)) + +# [7.4.0-alpha.6](https://github.com/parse-community/parse-server/compare/7.4.0-alpha.5...7.4.0-alpha.6) (2024-11-19) + + +### Bug Fixes + +* Security upgrade cross-spawn from 7.0.3 to 7.0.6 ([#9444](https://github.com/parse-community/parse-server/issues/9444)) ([3d034e0](https://github.com/parse-community/parse-server/commit/3d034e0a993e3e5bd9bb96a7e382bb3464f1eb68)) + +# [7.4.0-alpha.5](https://github.com/parse-community/parse-server/compare/7.4.0-alpha.4...7.4.0-alpha.5) (2024-10-22) + + +### Bug Fixes + +* Security upgrade node from 20.14.0-alpine3.20 to 20.17.0-alpine3.20 ([#9300](https://github.com/parse-community/parse-server/issues/9300)) ([15bb17d](https://github.com/parse-community/parse-server/commit/15bb17d87153bf0d38f08fe4c720da29a204b36b)) + +# [7.4.0-alpha.4](https://github.com/parse-community/parse-server/compare/7.4.0-alpha.3...7.4.0-alpha.4) (2024-10-22) + + +### Bug Fixes + +* `Parse.Query.distinct` fails due to invalid aggregate stage 'hint' ([#9295](https://github.com/parse-community/parse-server/issues/9295)) ([5f66c6a](https://github.com/parse-community/parse-server/commit/5f66c6a075cbe1cdaf9d1b108ee65af8ae596b89)) + +# [7.4.0-alpha.3](https://github.com/parse-community/parse-server/compare/7.4.0-alpha.2...7.4.0-alpha.3) (2024-10-22) + + +### Features + +* Add support for PostGIS 3.5 ([#9354](https://github.com/parse-community/parse-server/issues/9354)) ([8ea3538](https://github.com/parse-community/parse-server/commit/8ea35382db3436d54ab59bd30706705564b0985c)) + +# [7.4.0-alpha.2](https://github.com/parse-community/parse-server/compare/7.4.0-alpha.1...7.4.0-alpha.2) (2024-10-07) + + +### Features + +* Add support for Postgres 17 ([#9324](https://github.com/parse-community/parse-server/issues/9324)) ([fa2ee31](https://github.com/parse-community/parse-server/commit/fa2ee3196e4319a142b3838bb947c98dcba5d5cb)) + +# [7.4.0-alpha.1](https://github.com/parse-community/parse-server/compare/7.3.1-alpha.1...7.4.0-alpha.1) (2024-10-06) + + +### Features + +* Add support for MongoDB 8 ([#9269](https://github.com/parse-community/parse-server/issues/9269)) ([4756c66](https://github.com/parse-community/parse-server/commit/4756c66cd9f55afa1621d1a3f6fa850ed605cb53)) + +## [7.3.1-alpha.1](https://github.com/parse-community/parse-server/compare/7.3.0...7.3.1-alpha.1) (2024-10-05) + + +### Bug Fixes + +* Security upgrade fast-xml-parser from 4.4.0 to 4.4.1 ([#9262](https://github.com/parse-community/parse-server/issues/9262)) ([992d39d](https://github.com/parse-community/parse-server/commit/992d39d508f230c774dcb764d1d907ec8887e6c5)) + +# [7.3.0-alpha.9](https://github.com/parse-community/parse-server/compare/7.3.0-alpha.8...7.3.0-alpha.9) (2024-10-03) + + +### Bug Fixes + +* Custom object ID allows to acquire role privileges ([GHSA-8xq9-g7ch-35hg](https://github.com/parse-community/parse-server/security/advisories/GHSA-8xq9-g7ch-35hg)) ([#9317](https://github.com/parse-community/parse-server/issues/9317)) ([13ee52f](https://github.com/parse-community/parse-server/commit/13ee52f0d19ef3a3524b3d79aea100e587eb3cfc)) + +# [7.3.0-alpha.8](https://github.com/parse-community/parse-server/compare/7.3.0-alpha.7...7.3.0-alpha.8) (2024-09-25) + + +### Bug Fixes + +* Security upgrade path-to-regexp from 6.2.1 to 6.3.0 ([#9314](https://github.com/parse-community/parse-server/issues/9314)) ([8b7fe69](https://github.com/parse-community/parse-server/commit/8b7fe699c1c376ecd8cc1c97cce8e704ee41f28a)) + +# [7.3.0-alpha.7](https://github.com/parse-community/parse-server/compare/7.3.0-alpha.6...7.3.0-alpha.7) (2024-08-27) + + +### Features + +* Add support for asynchronous invocation of `FilesAdapter.getFileLocation` ([#9271](https://github.com/parse-community/parse-server/issues/9271)) ([1a2da40](https://github.com/parse-community/parse-server/commit/1a2da4055abe831b3017172fb75e16d7a8093873)) + +# [7.3.0-alpha.6](https://github.com/parse-community/parse-server/compare/7.3.0-alpha.5...7.3.0-alpha.6) (2024-07-20) + + +### Features + +* Add Cloud Code triggers `Parse.Cloud.beforeSave` and `Parse.Cloud.afterSave` for Parse Config ([#9232](https://github.com/parse-community/parse-server/issues/9232)) ([90a1e4a](https://github.com/parse-community/parse-server/commit/90a1e4a200423d644efb3f0ba2fba4b99f5cf954)) + +# [7.3.0-alpha.5](https://github.com/parse-community/parse-server/compare/7.3.0-alpha.4...7.3.0-alpha.5) (2024-07-18) + + +### Bug Fixes + +* Parse Server option `maxLogFiles` doesn't recognize day duration literals such as `1d` to mean 1 day ([#9215](https://github.com/parse-community/parse-server/issues/9215)) ([0319cee](https://github.com/parse-community/parse-server/commit/0319cee2dbf65e90bad377af1ed14ea25c595bf5)) + +# [7.3.0-alpha.4](https://github.com/parse-community/parse-server/compare/7.3.0-alpha.3...7.3.0-alpha.4) (2024-07-18) + + +### Features + +* Add atomic operations for Cloud Config parameters ([#9219](https://github.com/parse-community/parse-server/issues/9219)) ([35cadf9](https://github.com/parse-community/parse-server/commit/35cadf9b8324879fb7309ba5d7ea46f2c722d614)) + +# [7.3.0-alpha.3](https://github.com/parse-community/parse-server/compare/7.3.0-alpha.2...7.3.0-alpha.3) (2024-07-17) + + +### Bug Fixes + +* Parse Server installation fails due to post install script incorrectly parsing required min. Node version ([#9216](https://github.com/parse-community/parse-server/issues/9216)) ([0fa82a5](https://github.com/parse-community/parse-server/commit/0fa82a54fe38ec14e8054339285d3db71a8624c8)) + +# [7.3.0-alpha.2](https://github.com/parse-community/parse-server/compare/7.3.0-alpha.1...7.3.0-alpha.2) (2024-07-17) + + +### Bug Fixes + +* Parse Server `databaseOptions` nested keys incorrectly identified as invalid ([#9213](https://github.com/parse-community/parse-server/issues/9213)) ([77206d8](https://github.com/parse-community/parse-server/commit/77206d804443cfc1618c24f8961bd677de9920c0)) + +# [7.3.0-alpha.1](https://github.com/parse-community/parse-server/compare/7.2.0...7.3.0-alpha.1) (2024-07-09) + + +### Features + +* Add Node 22 support ([#9187](https://github.com/parse-community/parse-server/issues/9187)) ([7778471](https://github.com/parse-community/parse-server/commit/7778471999c7e42236ce404229660d80ecc2acd6)) + +# [7.1.0-alpha.16](https://github.com/parse-community/parse-server/compare/7.1.0-alpha.15...7.1.0-alpha.16) (2024-07-08) + + +### Features + +* Add support for dot notation on array fields of Parse Object ([#9115](https://github.com/parse-community/parse-server/issues/9115)) ([cf4c880](https://github.com/parse-community/parse-server/commit/cf4c8807b9da87a0a5f9c94e5bdfcf17cda80cf4)) + +# [7.1.0-alpha.15](https://github.com/parse-community/parse-server/compare/7.1.0-alpha.14...7.1.0-alpha.15) (2024-07-08) + + +### Features + +* Upgrade to @parse/push-adapter 6.4.0 ([#9182](https://github.com/parse-community/parse-server/issues/9182)) ([ef1634b](https://github.com/parse-community/parse-server/commit/ef1634bf1f360429108d29b08032fc7961ff96a1)) + +# [7.1.0-alpha.14](https://github.com/parse-community/parse-server/compare/7.1.0-alpha.13...7.1.0-alpha.14) (2024-07-07) + + +### Features + +* Upgrade to Parse JS SDK 5.3.0 ([#9180](https://github.com/parse-community/parse-server/issues/9180)) ([dca187f](https://github.com/parse-community/parse-server/commit/dca187f91b93cbb362b22a3fb9ee38451799ff13)) + +# [7.1.0-alpha.13](https://github.com/parse-community/parse-server/compare/7.1.0-alpha.12...7.1.0-alpha.13) (2024-07-01) + + +### Bug Fixes + +* Invalid push notification tokens are not cleaned up from database for FCM API v2 ([#9173](https://github.com/parse-community/parse-server/issues/9173)) ([284da09](https://github.com/parse-community/parse-server/commit/284da09f4546356b37511a589fb5f64a3efffe79)) + +# [7.1.0-alpha.12](https://github.com/parse-community/parse-server/compare/7.1.0-alpha.11...7.1.0-alpha.12) (2024-06-30) + + +### Bug Fixes + +* SQL injection when using Parse Server with PostgreSQL; fixes security vulnerability [GHSA-c2hr-cqg6-8j6r](https://github.com/parse-community/parse-server/security/advisories/GHSA-c2hr-cqg6-8j6r) ([#9167](https://github.com/parse-community/parse-server/issues/9167)) ([2edf1e4](https://github.com/parse-community/parse-server/commit/2edf1e4c0363af01e97a7fbc97694f851b7d1ff3)) + +# [7.1.0-alpha.11](https://github.com/parse-community/parse-server/compare/7.1.0-alpha.10...7.1.0-alpha.11) (2024-06-29) + + +### Features + +* Upgrade to Parse JS SDK 5.2.0 ([#9128](https://github.com/parse-community/parse-server/issues/9128)) ([665b8d5](https://github.com/parse-community/parse-server/commit/665b8d52d6cf5275179a5e1fb132c934edb53ecc)) + +# [7.1.0-alpha.10](https://github.com/parse-community/parse-server/compare/7.1.0-alpha.9...7.1.0-alpha.10) (2024-06-11) + + +### Bug Fixes + +* Live query throws error when constraint `notEqualTo` is set to `null` ([#8835](https://github.com/parse-community/parse-server/issues/8835)) ([11d3e48](https://github.com/parse-community/parse-server/commit/11d3e484df862224c15d20f6171514948981ea90)) + +# [7.1.0-alpha.9](https://github.com/parse-community/parse-server/compare/7.1.0-alpha.8...7.1.0-alpha.9) (2024-05-27) + + +### Bug Fixes + +* Parse Server option `extendSessionOnUse` not working for session lengths < 24 hours ([#9113](https://github.com/parse-community/parse-server/issues/9113)) ([0a054e6](https://github.com/parse-community/parse-server/commit/0a054e6b541fd5ab470bf025665f5f7d2acedaa0)) + +# [7.1.0-alpha.8](https://github.com/parse-community/parse-server/compare/7.1.0-alpha.7...7.1.0-alpha.8) (2024-05-16) + + +### Features + +* Upgrade to @parse/push-adapter 6.2.0 ([#9127](https://github.com/parse-community/parse-server/issues/9127)) ([ca20496](https://github.com/parse-community/parse-server/commit/ca20496f28e5ec1294a7a23c8559df82b79b2a04)) + +# [7.1.0-alpha.7](https://github.com/parse-community/parse-server/compare/7.1.0-alpha.6...7.1.0-alpha.7) (2024-05-16) + + +### Bug Fixes + +* Facebook Limited Login not working due to incorrect domain in JWT validation ([#9122](https://github.com/parse-community/parse-server/issues/9122)) ([9d0bd2b](https://github.com/parse-community/parse-server/commit/9d0bd2badd6e5f7429d1af00b118225752e5d86a)) + +# [7.1.0-alpha.6](https://github.com/parse-community/parse-server/compare/7.1.0-alpha.5...7.1.0-alpha.6) (2024-04-14) + + +### Bug Fixes + +* `Parse.Cloud.startJob` and `Parse.Push.send` not returning status ID when setting Parse Server option `directAccess: true` ([#8766](https://github.com/parse-community/parse-server/issues/8766)) ([5b0efb2](https://github.com/parse-community/parse-server/commit/5b0efb22efe94c47f243cf8b1e6407ed5c5a67d3)) + +# [7.1.0-alpha.5](https://github.com/parse-community/parse-server/compare/7.1.0-alpha.4...7.1.0-alpha.5) (2024-04-07) + + +### Features + +* Prevent Parse Server start in case of unknown option in server configuration ([#8987](https://github.com/parse-community/parse-server/issues/8987)) ([8758e6a](https://github.com/parse-community/parse-server/commit/8758e6abb9dbb68757bddcbd332ad25100c24a0e)) + +# [7.1.0-alpha.4](https://github.com/parse-community/parse-server/compare/7.1.0-alpha.3...7.1.0-alpha.4) (2024-03-31) + + +### Features + +* Upgrade to @parse/push-adapter 6.0.0 ([#9066](https://github.com/parse-community/parse-server/issues/9066)) ([18bdbf8](https://github.com/parse-community/parse-server/commit/18bdbf89c53a57648891ef582614ba7c2941e587)) + +# [7.1.0-alpha.3](https://github.com/parse-community/parse-server/compare/7.1.0-alpha.2...7.1.0-alpha.3) (2024-03-24) + + +### Bug Fixes + +* Rate limiting can fail when using Parse Server option `rateLimit.redisUrl` with clusters ([#8632](https://github.com/parse-community/parse-server/issues/8632)) ([c277739](https://github.com/parse-community/parse-server/commit/c27773962399f8e27691e3b8087e7e1d59516efd)) + +# [7.1.0-alpha.2](https://github.com/parse-community/parse-server/compare/7.1.0-alpha.1...7.1.0-alpha.2) (2024-03-24) + + +### Features + +* Add server security check status `security.enableCheck` to Features Router ([#8679](https://github.com/parse-community/parse-server/issues/8679)) ([b07ec15](https://github.com/parse-community/parse-server/commit/b07ec153825882e97cc48dc84072c7f549f3238b)) + +# [7.1.0-alpha.1](https://github.com/parse-community/parse-server/compare/7.0.0...7.1.0-alpha.1) (2024-03-23) + + +### Bug Fixes + +* `Required` option not handled correctly for special fields (File, GeoPoint, Polygon) on GraphQL API mutations ([#8915](https://github.com/parse-community/parse-server/issues/8915)) ([907ad42](https://github.com/parse-community/parse-server/commit/907ad4267c228d26cfcefe7848b30ce85ba7ff8f)) + +### Features + +* Add `silent` log level for Cloud Code ([#8803](https://github.com/parse-community/parse-server/issues/8803)) ([5f81efb](https://github.com/parse-community/parse-server/commit/5f81efb42964c4c2fa8bcafee9446a0122e3ce21)) + +# [7.0.0-alpha.31](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.30...7.0.0-alpha.31) (2024-03-21) + + +### Features + +* Add `silent` log level for Cloud Code ([#8803](https://github.com/parse-community/parse-server/issues/8803)) ([5f81efb](https://github.com/parse-community/parse-server/commit/5f81efb42964c4c2fa8bcafee9446a0122e3ce21)) + +# [7.0.0-alpha.30](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.29...7.0.0-alpha.30) (2024-03-20) + + +### Bug Fixes + +* `Required` option not handled correctly for special fields (File, GeoPoint, Polygon) on GraphQL API mutations ([#8915](https://github.com/parse-community/parse-server/issues/8915)) ([907ad42](https://github.com/parse-community/parse-server/commit/907ad4267c228d26cfcefe7848b30ce85ba7ff8f)) + +# [7.0.0-alpha.29](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.28...7.0.0-alpha.29) (2024-03-19) + + +### Bug Fixes + +* Server crashes on invalid Cloud Function or Cloud Job name; fixes security vulnerability [GHSA-6hh7-46r2-vf29](https://github.com/parse-community/parse-server/security/advisories/GHSA-6hh7-46r2-vf29) ([#9024](https://github.com/parse-community/parse-server/issues/9024)) ([9f6e342](https://github.com/parse-community/parse-server/commit/9f6e3429d3b326cf4e2994733c618d08032fac6e)) + +# [7.0.0-alpha.28](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.27...7.0.0-alpha.28) (2024-03-17) + + +### Features + +* Upgrade to Parse JS SDK 5 ([#9022](https://github.com/parse-community/parse-server/issues/9022)) ([ad4aa83](https://github.com/parse-community/parse-server/commit/ad4aa83983205a0e27639f6ee6a4a5963b67e4b8)) + +# [7.0.0-alpha.27](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.26...7.0.0-alpha.27) (2024-03-15) + + +### Bug Fixes + +* CacheAdapter does not connect when using a CacheAdapter with a JSON config ([#8633](https://github.com/parse-community/parse-server/issues/8633)) ([720d24e](https://github.com/parse-community/parse-server/commit/720d24e18540da35d50957f17be878316ec30318)) + +# [7.0.0-alpha.26](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.25...7.0.0-alpha.26) (2024-03-10) + + +### Bug Fixes + +* Parse Server option `fileExtensions` default value rejects file extensions that are less than 3 or more than 4 characters long ([#8699](https://github.com/parse-community/parse-server/issues/8699)) ([2760381](https://github.com/parse-community/parse-server/commit/276038118377c2b22381bcd8d30337203822121b)) + +# [7.0.0-alpha.25](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.24...7.0.0-alpha.25) (2024-03-05) + + +### Features + +* Deprecation DEPPS5: Config option `allowClientClassCreation` defaults to `false` ([#8849](https://github.com/parse-community/parse-server/issues/8849)) ([29624e0](https://github.com/parse-community/parse-server/commit/29624e0fae17161cd412ae58d35a195cfa286cad)) + + +### BREAKING CHANGES + +* The Parse Server option `allowClientClassCreation` defaults to `false`. ([29624e0](29624e0)) + +# [7.0.0-alpha.24](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.23...7.0.0-alpha.24) (2024-03-05) + + +### Bug Fixes + +* Docker version releases by removing arm/v6 and arm/v7 support ([#8976](https://github.com/parse-community/parse-server/issues/8976)) ([1f62dd0](https://github.com/parse-community/parse-server/commit/1f62dd0f4e107b22a387692558a042ee26ce8703)) + +# [7.0.0-alpha.23](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.22...7.0.0-alpha.23) (2024-03-03) + + +### Features + +* Add support for MongoDB query comment ([#8928](https://github.com/parse-community/parse-server/issues/8928)) ([2170962](https://github.com/parse-community/parse-server/commit/2170962a50fa353ed85eda3f11dce7ee3647b087)) + +# [7.0.0-alpha.22](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.21...7.0.0-alpha.22) (2024-03-02) + + +### Features + +* Switch GraphQL server from Yoga v2 to Apollo v4 ([#8959](https://github.com/parse-community/parse-server/issues/8959)) ([105ae7c](https://github.com/parse-community/parse-server/commit/105ae7c8a57d5a650b243174a80c26bf6db16e28)) + +# [7.0.0-alpha.21](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.20...7.0.0-alpha.21) (2024-03-01) + + +### Bug Fixes + +* Deny request if master key is not set in Parse Server option `masterKeyIps` regardless of ACL and CLP ([#8957](https://github.com/parse-community/parse-server/issues/8957)) ([a7b5b38](https://github.com/parse-community/parse-server/commit/a7b5b38418cbed9be3f4a7665f25b97f592663e1)) + + +### BREAKING CHANGES + +* A request using the master key will now be rejected as unauthorized if the IP from which the request originates is not set in the Parse Server option `masterKeyIps`, even if the request does not require the master key permission, for example for a public object in a public class class. ([a7b5b38](a7b5b38)) + +# [7.0.0-alpha.20](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.19...7.0.0-alpha.20) (2024-03-01) + + +### Bug Fixes + +* Improve PostgreSQL injection detection; fixes security vulnerability [GHSA-6927-3vr9-fxf2](https://github.com/parse-community/parse-server/security/advisories/GHSA-6927-3vr9-fxf2) which affects Parse Server deployments using a Postgres database ([#8961](https://github.com/parse-community/parse-server/issues/8961)) ([cbefe77](https://github.com/parse-community/parse-server/commit/cbefe770a7260b54748a058b8a7389937dc35833)) + +# [7.0.0-alpha.19](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.18...7.0.0-alpha.19) (2024-02-15) + + +### Features + +* Node process exits with error code 1 on uncaught exception to allow custom uncaught exception handling ([#8894](https://github.com/parse-community/parse-server/issues/8894)) ([70c280c](https://github.com/parse-community/parse-server/commit/70c280ca578ff28b5acf92f37fbe06d42a5b34ca)) + + +### BREAKING CHANGES + +* Node process now exits with code 1 on uncaught exceptions, enabling custom handlers that were blocked by Parse Server's default behavior of re-throwing errors. This change may lead to automatic process restarts by the environment, unlike before. ([70c280c](70c280c)) + +# [7.0.0-alpha.18](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.17...7.0.0-alpha.18) (2024-02-15) + + +### Features + +* Deprecation DEPPS6: Authentication adapters disabled by default ([#8858](https://github.com/parse-community/parse-server/issues/8858)) ([0cf58eb](https://github.com/parse-community/parse-server/commit/0cf58eb8d60c8e5f485764e154f3214c49eee430)) + + +### BREAKING CHANGES + +* Authentication adapters are disabled by default; to use an authentication adapter it needs to be explicitly enabled in the Parse Server authentication adapter option `auth..enabled: true` ([0cf58eb](0cf58eb)) + +# [7.0.0-alpha.17](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.16...7.0.0-alpha.17) (2024-02-15) + + +### Features + +* Deprecation DEPPS8: Parse Server option `allowExpiredAuthDataToken` defaults to `false` ([#8860](https://github.com/parse-community/parse-server/issues/8860)) ([e29845f](https://github.com/parse-community/parse-server/commit/e29845f8dacac09ce3093d75c0d92330c24389e8)) + + +### BREAKING CHANGES + +* Parse Server option `allowExpiredAuthDataToken` defaults to `false`; a 3rd party authentication token will be validated every time the user tries to log in and the login will fail if the token has expired; the effect of this change may differ for different authentication adapters, depending on the token lifetime and the token refresh logic of the adapter ([e29845f](e29845f)) + +# [7.0.0-alpha.16](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.15...7.0.0-alpha.16) (2024-02-14) + + +### Features + +* Deprecation DEPPS9: LiveQuery `fields` option is renamed to `keys` ([#8852](https://github.com/parse-community/parse-server/issues/8852)) ([38983e8](https://github.com/parse-community/parse-server/commit/38983e8e9b5cdbd006f311a2338103624137d013)) + + +### BREAKING CHANGES + +* LiveQuery `fields` option is renamed to `keys` ([38983e8](38983e8)) + +# [7.0.0-alpha.15](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.14...7.0.0-alpha.15) (2024-02-14) + + +### Features + +* Deprecation DEPPS7: Remove deprecated Cloud Code file trigger syntax ([#8855](https://github.com/parse-community/parse-server/issues/8855)) ([4e6a375](https://github.com/parse-community/parse-server/commit/4e6a375b5184ae0f7aa256a921eca4021c609435)) + + +### BREAKING CHANGES + +* Cloud Code file trigger syntax has been aligned with object trigger syntax, for example `Parse.Cloud.beforeDeleteFile'` has been changed to `Parse.Cloud.beforeDelete(Parse.File, (request) => {})'` ([4e6a375](4e6a375)) + +# [7.0.0-alpha.14](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.13...7.0.0-alpha.14) (2024-02-14) + + +### Bug Fixes + +* GraphQL file upload fails in case of use of pointer or relation ([#8721](https://github.com/parse-community/parse-server/issues/8721)) ([1aba638](https://github.com/parse-community/parse-server/commit/1aba6382c873fb489d4a898d301e6da9fb6aa61b)) + +# [7.0.0-alpha.13](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.12...7.0.0-alpha.13) (2024-02-14) + + +### Bug Fixes + +* Docker image not published to Docker Hub on new release ([#8905](https://github.com/parse-community/parse-server/issues/8905)) ([a2ac8d1](https://github.com/parse-community/parse-server/commit/a2ac8d133c71cd7b61e5ef59c4be915cfea85db6)) + +# [7.0.0-alpha.12](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.11...7.0.0-alpha.12) (2024-02-14) + + +### Features + +* Add support for Node 20, drop support for Node 14, 16 ([#8907](https://github.com/parse-community/parse-server/issues/8907)) ([ced4872](https://github.com/parse-community/parse-server/commit/ced487246ea0ef72a8aa014991f003209b34841e)) + + +### BREAKING CHANGES + +* Removes support for Node 14 and 16 ([ced4872](ced4872)) + +# [7.0.0-alpha.11](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.10...7.0.0-alpha.11) (2024-01-22) + + +### Features + +* Add support for Postgres 16 ([#8898](https://github.com/parse-community/parse-server/issues/8898)) ([99489b2](https://github.com/parse-community/parse-server/commit/99489b22e4f0982e6cb39992974b51aa8d3a31e4)) + + +### BREAKING CHANGES + +* Removes support for Postgres 11 and 12 ([99489b2](99489b2)) + +# [7.0.0-alpha.10](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.9...7.0.0-alpha.10) (2024-01-17) + + +### Features + +* Add password validation via POST request for user with unverified email using master key and option `ignoreEmailVerification` ([#8895](https://github.com/parse-community/parse-server/issues/8895)) ([633a9d2](https://github.com/parse-community/parse-server/commit/633a9d25e4253e2125bc93c02ee8a37e0f5f7b83)) + +# [7.0.0-alpha.9](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.8...7.0.0-alpha.9) (2024-01-15) + + +### Bug Fixes + +* Server crashes when receiving an array of `Parse.Pointer` in the request body ([#8784](https://github.com/parse-community/parse-server/issues/8784)) ([66e3603](https://github.com/parse-community/parse-server/commit/66e36039d8af654cfa0284666c0ddd94975dcb52)) + +# [7.0.0-alpha.8](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.7...7.0.0-alpha.8) (2024-01-15) + + +### Bug Fixes + +* Incomplete user object in `verifyEmail` function if both username and email are changed ([#8889](https://github.com/parse-community/parse-server/issues/8889)) ([1eb95ae](https://github.com/parse-community/parse-server/commit/1eb95aeb41a96250e582d79a703f6adcb403c08b)) + +# [7.0.0-alpha.7](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.6...7.0.0-alpha.7) (2024-01-14) + + +### Bug Fixes + +* Username is `undefined` in email verification link on email change ([#8887](https://github.com/parse-community/parse-server/issues/8887)) ([e315c13](https://github.com/parse-community/parse-server/commit/e315c137bf41bedfa8f0df537f2c3f6ab45b7e60)) + +# [7.0.0-alpha.6](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.5...7.0.0-alpha.6) (2024-01-14) + + +### Bug Fixes + +* Parse Server option `emailVerifyTokenReuseIfValid: true` generates new token on every email verification request ([#8885](https://github.com/parse-community/parse-server/issues/8885)) ([0023ce4](https://github.com/parse-community/parse-server/commit/0023ce448a5e9423337d0e1a25648bde1156bc95)) + +# [7.0.0-alpha.5](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.4...7.0.0-alpha.5) (2024-01-06) + + +### Features + +* Add `installationId`, `ip`, `resendRequest` to arguments passed to `verifyUserEmails` on verification email request ([#8873](https://github.com/parse-community/parse-server/issues/8873)) ([8adcbee](https://github.com/parse-community/parse-server/commit/8adcbee11283d3e95179ca2047e2615f52c18806)) + + +### BREAKING CHANGES + +* The `Parse.User` passed as argument if `verifyUserEmails` is set to a function is renamed from `user` to `object` for consistency with invocations of `verifyUserEmails` on signup or login; the user object is not a plain JavaScript object anymore but an instance of `Parse.User` ([8adcbee](8adcbee)) + +# [7.0.0-alpha.4](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.3...7.0.0-alpha.4) (2023-12-27) + + +### Features + +* Add `Parse.User` as function parameter to Parse Server options `verifyUserEmails`, `preventLoginWithUnverifiedEmail` on login ([#8850](https://github.com/parse-community/parse-server/issues/8850)) ([972f630](https://github.com/parse-community/parse-server/commit/972f6300163b3cd7d95eeb95986e8322c95f821c)) + +# [7.0.0-alpha.3](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.2...7.0.0-alpha.3) (2023-12-26) + + +### Bug Fixes + +* Conditional email verification not working in some cases if `verifyUserEmails`, `preventLoginWithUnverifiedEmail` set to functions ([#8838](https://github.com/parse-community/parse-server/issues/8838)) ([8e7a6b1](https://github.com/parse-community/parse-server/commit/8e7a6b1480c0117e6c73e7adc5a6619115a04e85)) + +### Features + +* Allow `Parse.Session.current` on expired session token instead of throwing error ([#8722](https://github.com/parse-community/parse-server/issues/8722)) ([f9dde4a](https://github.com/parse-community/parse-server/commit/f9dde4a9f8a90c63f71172c9bc515b0f6c6d2e4a)) + + +### BREAKING CHANGES + +* `Parse.Session.current()` no longer throws an error if the session token is expired, but instead returns the session token with its expiration date to allow checking its validity ([f9dde4a](f9dde4a)) + +# [7.0.0-alpha.2](https://github.com/parse-community/parse-server/compare/7.0.0-alpha.1...7.0.0-alpha.2) (2023-12-17) + + +### Features + +* Add `installationId` to arguments for `verifyUserEmails`, `preventLoginWithUnverifiedEmail` ([#8836](https://github.com/parse-community/parse-server/issues/8836)) ([a22dbe1](https://github.com/parse-community/parse-server/commit/a22dbe16d5ac0090608f6caaf0ebd134925b7fd4)) + +# [7.0.0-alpha.1](https://github.com/parse-community/parse-server/compare/6.5.0-alpha.2...7.0.0-alpha.1) (2023-12-10) + + +### Features + +* Add support for MongoDB 7 ([#8761](https://github.com/parse-community/parse-server/issues/8761)) ([3de8494](https://github.com/parse-community/parse-server/commit/3de8494a221991dfd10a74e0a2dc89576265c9b7)) + + +### BREAKING CHANGES + +* `Parse.Query` no longer supports the BSON type `code`; although this feature was never officially documented, its removal is announced as a breaking change to protect deployments where it might be in use. ([3de8494](3de8494)) + +# [6.5.0-alpha.2](https://github.com/parse-community/parse-server/compare/6.5.0-alpha.1...6.5.0-alpha.2) (2023-11-19) + + +### Performance Improvements + +* Improved IP validation performance for `masterKeyIPs`, `maintenanceKeyIPs` ([#8510](https://github.com/parse-community/parse-server/issues/8510)) ([b87daba](https://github.com/parse-community/parse-server/commit/b87daba0671a1b0b7b8d63bc671d665c91a04522)) + +# [6.5.0-alpha.1](https://github.com/parse-community/parse-server/compare/6.4.0...6.5.0-alpha.1) (2023-11-18) + + +### Bug Fixes + +* Context not passed to Cloud Code Trigger `beforeFind` when using `Parse.Query.include` ([#8765](https://github.com/parse-community/parse-server/issues/8765)) ([7d32d89](https://github.com/parse-community/parse-server/commit/7d32d8934f3ae7af7a7d8b9cc6a829c7d73973d3)) +* Parse Server option `fileUpload.fileExtensions` fails to determine file extension if filename contains multiple dots ([#8754](https://github.com/parse-community/parse-server/issues/8754)) ([3d6d50e](https://github.com/parse-community/parse-server/commit/3d6d50e0afff18b95fb906914e2cebd3839b517a)) +* Security bump @babel/traverse from 7.20.5 to 7.23.2 ([#8777](https://github.com/parse-community/parse-server/issues/8777)) ([2d6b3d1](https://github.com/parse-community/parse-server/commit/2d6b3d18499179e99be116f25c0850d3f449509c)) +* Security upgrade graphql from 16.6.0 to 16.8.1 ([#8758](https://github.com/parse-community/parse-server/issues/8758)) ([71dfd8a](https://github.com/parse-community/parse-server/commit/71dfd8a7ece8c0dd1a66d03bb9420cfd39f4f9b1)) + +### Features + +* Add `$setOnInsert` operator to `Parse.Server.database.update` ([#8791](https://github.com/parse-community/parse-server/issues/8791)) ([f630a45](https://github.com/parse-community/parse-server/commit/f630a45aa5e87bc73a81fded061400c199b71a29)) +* Add compatibility for MongoDB Atlas Serverless and AWS Amazon DocumentDB with collation options `enableCollationCaseComparison`, `transformEmailToLowercase`, `transformUsernameToLowercase` ([#8805](https://github.com/parse-community/parse-server/issues/8805)) ([09fbeeb](https://github.com/parse-community/parse-server/commit/09fbeebba8870e7cf371fb84371a254c7b368620)) +* Add context to Cloud Code Triggers `beforeLogin` and `afterLogin` ([#8724](https://github.com/parse-community/parse-server/issues/8724)) ([a9c34ef](https://github.com/parse-community/parse-server/commit/a9c34ef1e2c78a42fb8b5fa8d569b7677c74919d)) +* Allow setting `createdAt` and `updatedAt` during `Parse.Object` creation with maintenance key ([#8696](https://github.com/parse-community/parse-server/issues/8696)) ([77bbfb3](https://github.com/parse-community/parse-server/commit/77bbfb3f186f5651c33ba152f04cff95128eaf2d)) +* Upgrade Parse Server Push Adapter to 5.0.2 ([#8813](https://github.com/parse-community/parse-server/issues/8813)) ([6ef1986](https://github.com/parse-community/parse-server/commit/6ef1986c03a1d84b7e11c05851e5bf9688d88740)) + +# [6.4.0-alpha.8](https://github.com/parse-community/parse-server/compare/6.4.0-alpha.7...6.4.0-alpha.8) (2023-11-13) + + +### Features + +* Add compatibility for MongoDB Atlas Serverless and AWS Amazon DocumentDB with collation options `enableCollationCaseComparison`, `transformEmailToLowercase`, `transformUsernameToLowercase` ([#8805](https://github.com/parse-community/parse-server/issues/8805)) ([09fbeeb](https://github.com/parse-community/parse-server/commit/09fbeebba8870e7cf371fb84371a254c7b368620)) + +# [6.4.0-alpha.7](https://github.com/parse-community/parse-server/compare/6.4.0-alpha.6...6.4.0-alpha.7) (2023-10-25) + + +### Features + +* Add `$setOnInsert` operator to `Parse.Server.database.update` ([#8791](https://github.com/parse-community/parse-server/issues/8791)) ([f630a45](https://github.com/parse-community/parse-server/commit/f630a45aa5e87bc73a81fded061400c199b71a29)) + +# [6.4.0-alpha.6](https://github.com/parse-community/parse-server/compare/6.4.0-alpha.5...6.4.0-alpha.6) (2023-10-18) + + +### Bug Fixes + +* Security bump @babel/traverse from 7.20.5 to 7.23.2 ([#8777](https://github.com/parse-community/parse-server/issues/8777)) ([2d6b3d1](https://github.com/parse-community/parse-server/commit/2d6b3d18499179e99be116f25c0850d3f449509c)) + +# [6.4.0-alpha.5](https://github.com/parse-community/parse-server/compare/6.4.0-alpha.4...6.4.0-alpha.5) (2023-10-14) + + +### Bug Fixes + +* Context not passed to Cloud Code Trigger `beforeFind` when using `Parse.Query.include` ([#8765](https://github.com/parse-community/parse-server/issues/8765)) ([7d32d89](https://github.com/parse-community/parse-server/commit/7d32d8934f3ae7af7a7d8b9cc6a829c7d73973d3)) + +# [6.4.0-alpha.4](https://github.com/parse-community/parse-server/compare/6.4.0-alpha.3...6.4.0-alpha.4) (2023-09-29) + + +### Features + +* Allow setting `createdAt` and `updatedAt` during `Parse.Object` creation with maintenance key ([#8696](https://github.com/parse-community/parse-server/issues/8696)) ([77bbfb3](https://github.com/parse-community/parse-server/commit/77bbfb3f186f5651c33ba152f04cff95128eaf2d)) + +# [6.4.0-alpha.3](https://github.com/parse-community/parse-server/compare/6.4.0-alpha.2...6.4.0-alpha.3) (2023-09-23) + + +### Bug Fixes + +* Parse Server option `fileUpload.fileExtensions` fails to determine file extension if filename contains multiple dots ([#8754](https://github.com/parse-community/parse-server/issues/8754)) ([3d6d50e](https://github.com/parse-community/parse-server/commit/3d6d50e0afff18b95fb906914e2cebd3839b517a)) + +# [6.4.0-alpha.2](https://github.com/parse-community/parse-server/compare/6.4.0-alpha.1...6.4.0-alpha.2) (2023-09-22) + + +### Bug Fixes + +* Security upgrade graphql from 16.6.0 to 16.8.1 ([#8758](https://github.com/parse-community/parse-server/issues/8758)) ([71dfd8a](https://github.com/parse-community/parse-server/commit/71dfd8a7ece8c0dd1a66d03bb9420cfd39f4f9b1)) + +# [6.4.0-alpha.1](https://github.com/parse-community/parse-server/compare/6.3.0...6.4.0-alpha.1) (2023-09-20) + +### Features + +* Add context to Cloud Code Triggers `beforeLogin` and `afterLogin` ([#8724](https://github.com/parse-community/parse-server/issues/8724)) ([a9c34ef](https://github.com/parse-community/parse-server/commit/a9c34ef1e2c78a42fb8b5fa8d569b7677c74919d)) + +# [6.3.0-alpha.9](https://github.com/parse-community/parse-server/compare/6.3.0-alpha.8...6.3.0-alpha.9) (2023-09-13) + + +### Performance Improvements + +* Improve performance of recursive pointer iterations ([#8741](https://github.com/parse-community/parse-server/issues/8741)) ([45a3ed0](https://github.com/parse-community/parse-server/commit/45a3ed0fcf2c0170607505a1550fb15896e705fd)) + +# [6.3.0-alpha.8](https://github.com/parse-community/parse-server/compare/6.3.0-alpha.7...6.3.0-alpha.8) (2023-08-30) + + +### Bug Fixes + +* Redis 4 does not reconnect after unhandled error ([#8706](https://github.com/parse-community/parse-server/issues/8706)) ([2b3d4e5](https://github.com/parse-community/parse-server/commit/2b3d4e5d3c85cd142f85af68dec51a8523548d49)) + +# [6.3.0-alpha.7](https://github.com/parse-community/parse-server/compare/6.3.0-alpha.6...6.3.0-alpha.7) (2023-08-18) + + +### Bug Fixes + +* Remove config logging when launching Parse Server via CLI ([#8710](https://github.com/parse-community/parse-server/issues/8710)) ([ae68f0c](https://github.com/parse-community/parse-server/commit/ae68f0c31b741eeb83379c905c7ddfaa124436ec)) + +# [6.3.0-alpha.6](https://github.com/parse-community/parse-server/compare/6.3.0-alpha.5...6.3.0-alpha.6) (2023-07-17) + + +### Bug Fixes + +* Parse Server option `fileUpload.fileExtensions` does not work with an array of extensions ([#8688](https://github.com/parse-community/parse-server/issues/8688)) ([6a4a00c](https://github.com/parse-community/parse-server/commit/6a4a00ca7af1163ea74b047b85cd6817366b824b)) + +# [6.3.0-alpha.5](https://github.com/parse-community/parse-server/compare/6.3.0-alpha.4...6.3.0-alpha.5) (2023-07-05) + + +### Features + +* Add property `Parse.Server.version` to determine current version of Parse Server in Cloud Code ([#8670](https://github.com/parse-community/parse-server/issues/8670)) ([a9d376b](https://github.com/parse-community/parse-server/commit/a9d376b61f5b07806eafbda91c4e36c322f09298)) + +# [6.3.0-alpha.4](https://github.com/parse-community/parse-server/compare/6.3.0-alpha.3...6.3.0-alpha.4) (2023-07-04) + + +### Bug Fixes + +* Server does not start via CLI when `auth` option is set ([#8666](https://github.com/parse-community/parse-server/issues/8666)) ([4e2000b](https://github.com/parse-community/parse-server/commit/4e2000bc563324389584ace3c090a5c1a7796a64)) + +# [6.3.0-alpha.3](https://github.com/parse-community/parse-server/compare/6.3.0-alpha.2...6.3.0-alpha.3) (2023-06-23) + + +### Features + +* Add TOTP authentication adapter ([#8457](https://github.com/parse-community/parse-server/issues/8457)) ([cc079a4](https://github.com/parse-community/parse-server/commit/cc079a40f6849a0e9bc6fdc811e8649ecb67b589)) + +# [6.3.0-alpha.2](https://github.com/parse-community/parse-server/compare/6.3.0-alpha.1...6.3.0-alpha.2) (2023-06-20) + + +### Features + +* Add conditional email verification via dynamic Parse Server options `verifyUserEmails`, `sendUserEmailVerification` that now accept functions ([#8425](https://github.com/parse-community/parse-server/issues/8425)) ([44acd6d](https://github.com/parse-community/parse-server/commit/44acd6d9ed157ad4842200c9d01f9c77a05fec3a)) + +# [6.3.0-alpha.1](https://github.com/parse-community/parse-server/compare/6.2.0...6.3.0-alpha.1) (2023-06-18) + + +### Bug Fixes + +* Cloud Code Trigger `afterSave` executes even if not set ([#8520](https://github.com/parse-community/parse-server/issues/8520)) ([afd0515](https://github.com/parse-community/parse-server/commit/afd0515e207bd947840579d3f245980dffa6f804)) +* GridFS file storage doesn't work with certain `enableSchemaHooks` settings ([#8467](https://github.com/parse-community/parse-server/issues/8467)) ([d4cda4b](https://github.com/parse-community/parse-server/commit/d4cda4b26c9bde8c812549b8780bea1cfabdb394)) +* Inaccurate table total row count for PostgreSQL ([#8511](https://github.com/parse-community/parse-server/issues/8511)) ([0823a02](https://github.com/parse-community/parse-server/commit/0823a02fbf80bc88dc403bc47e9f5c6597ea78b4)) +* LiveQuery server is not shut down properly when `handleShutdown` is called ([#8491](https://github.com/parse-community/parse-server/issues/8491)) ([967700b](https://github.com/parse-community/parse-server/commit/967700bdbc94c74f75ba84d2b3f4b9f3fd2dca0b)) +* Rate limit feature is incompatible with Node 14 ([#8578](https://github.com/parse-community/parse-server/issues/8578)) ([f911f2c](https://github.com/parse-community/parse-server/commit/f911f2cd3a8c45cd326272dcd681532764a3761e)) +* Unnecessary log entries by `extendSessionOnUse` ([#8562](https://github.com/parse-community/parse-server/issues/8562)) ([fd6a007](https://github.com/parse-community/parse-server/commit/fd6a0077f2e5cf83d65e52172ae5a950ab0f1eae)) + +### Features + +* `extendSessionOnUse` to automatically renew Parse Sessions ([#8505](https://github.com/parse-community/parse-server/issues/8505)) ([6f885d3](https://github.com/parse-community/parse-server/commit/6f885d36b94902fdfea873fc554dee83589e6029)) +* Add new Parse Server option `preventSignupWithUnverifiedEmail` to prevent returning a user without session token on sign-up with unverified email address ([#8451](https://github.com/parse-community/parse-server/issues/8451)) ([82da308](https://github.com/parse-community/parse-server/commit/82da30842a55980aa90cb7680fbf6db37ee16dab)) +* Add option to change the log level of logs emitted by Cloud Functions ([#8530](https://github.com/parse-community/parse-server/issues/8530)) ([2caea31](https://github.com/parse-community/parse-server/commit/2caea310be412d82b04a85716bc769ccc410316d)) +* Add support for `$eq` query constraint in LiveQuery ([#8614](https://github.com/parse-community/parse-server/issues/8614)) ([656d673](https://github.com/parse-community/parse-server/commit/656d673cf5dea354e4f2b3d4dc2b29a41d311b3e)) +* Add zones for rate limiting by `ip`, `user`, `session`, `global` ([#8508](https://github.com/parse-community/parse-server/issues/8508)) ([03fba97](https://github.com/parse-community/parse-server/commit/03fba97e0549bfcaeee9f2fa4c9905dbcc91840e)) +* Allow `Parse.Object` pointers in Cloud Code arguments ([#8490](https://github.com/parse-community/parse-server/issues/8490)) ([28aeda3](https://github.com/parse-community/parse-server/commit/28aeda3f160efcbbcf85a85484a8d26567fa9761)) + +### Reverts + +* fix: Inaccurate table total row count for PostgreSQL ([6722110](https://github.com/parse-community/parse-server/commit/6722110f203bc5fdcaa68cdf091cf9e7b48d1cff)) + +# [6.1.0-alpha.20](https://github.com/parse-community/parse-server/compare/6.1.0-alpha.19...6.1.0-alpha.20) (2023-06-09) + + +### Features + +* Add zones for rate limiting by `ip`, `user`, `session`, `global` ([#8508](https://github.com/parse-community/parse-server/issues/8508)) ([03fba97](https://github.com/parse-community/parse-server/commit/03fba97e0549bfcaeee9f2fa4c9905dbcc91840e)) + +# [6.1.0-alpha.19](https://github.com/parse-community/parse-server/compare/6.1.0-alpha.18...6.1.0-alpha.19) (2023-06-08) + + +### Bug Fixes + +* LiveQuery server is not shut down properly when `handleShutdown` is called ([#8491](https://github.com/parse-community/parse-server/issues/8491)) ([967700b](https://github.com/parse-community/parse-server/commit/967700bdbc94c74f75ba84d2b3f4b9f3fd2dca0b)) + +# [6.1.0-alpha.18](https://github.com/parse-community/parse-server/compare/6.1.0-alpha.17...6.1.0-alpha.18) (2023-06-08) + + +### Features + +* Add support for `$eq` query constraint in LiveQuery ([#8614](https://github.com/parse-community/parse-server/issues/8614)) ([656d673](https://github.com/parse-community/parse-server/commit/656d673cf5dea354e4f2b3d4dc2b29a41d311b3e)) + +# [6.1.0-alpha.17](https://github.com/parse-community/parse-server/compare/6.1.0-alpha.16...6.1.0-alpha.17) (2023-06-07) + + +### Features + +* Add new Parse Server option `preventSignupWithUnverifiedEmail` to prevent returning a user without session token on sign-up with unverified email address ([#8451](https://github.com/parse-community/parse-server/issues/8451)) ([82da308](https://github.com/parse-community/parse-server/commit/82da30842a55980aa90cb7680fbf6db37ee16dab)) + +# [6.1.0-alpha.16](https://github.com/parse-community/parse-server/compare/6.1.0-alpha.15...6.1.0-alpha.16) (2023-05-28) + + +### Reverts + +* fix: Inaccurate table total row count for PostgreSQL ([6722110](https://github.com/parse-community/parse-server/commit/6722110f203bc5fdcaa68cdf091cf9e7b48d1cff)) + +# [6.1.0-alpha.15](https://github.com/parse-community/parse-server/compare/6.1.0-alpha.14...6.1.0-alpha.15) (2023-05-28) + + +### Bug Fixes + +* Inaccurate table total row count for PostgreSQL ([#8511](https://github.com/parse-community/parse-server/issues/8511)) ([0823a02](https://github.com/parse-community/parse-server/commit/0823a02fbf80bc88dc403bc47e9f5c6597ea78b4)) + +# [6.1.0-alpha.14](https://github.com/parse-community/parse-server/compare/6.1.0-alpha.13...6.1.0-alpha.14) (2023-05-27) + + +### Bug Fixes + +* Unnecessary log entries by `extendSessionOnUse` ([#8562](https://github.com/parse-community/parse-server/issues/8562)) ([fd6a007](https://github.com/parse-community/parse-server/commit/fd6a0077f2e5cf83d65e52172ae5a950ab0f1eae)) + +### Features + +* Allow `Parse.Object` pointers in Cloud Code arguments ([#8490](https://github.com/parse-community/parse-server/issues/8490)) ([28aeda3](https://github.com/parse-community/parse-server/commit/28aeda3f160efcbbcf85a85484a8d26567fa9761)) + +# [6.1.0-alpha.13](https://github.com/parse-community/parse-server/compare/6.1.0-alpha.12...6.1.0-alpha.13) (2023-05-25) + + +### Bug Fixes + +* Rate limit feature is incompatible with Node 14 ([#8578](https://github.com/parse-community/parse-server/issues/8578)) ([f911f2c](https://github.com/parse-community/parse-server/commit/f911f2cd3a8c45cd326272dcd681532764a3761e)) + +# [6.1.0-alpha.12](https://github.com/parse-community/parse-server/compare/6.1.0-alpha.11...6.1.0-alpha.12) (2023-05-19) + + +### Bug Fixes + +* GridFS file storage doesn't work with certain `enableSchemaHooks` settings ([#8467](https://github.com/parse-community/parse-server/issues/8467)) ([d4cda4b](https://github.com/parse-community/parse-server/commit/d4cda4b26c9bde8c812549b8780bea1cfabdb394)) + +# [6.1.0-alpha.11](https://github.com/parse-community/parse-server/compare/6.1.0-alpha.10...6.1.0-alpha.11) (2023-05-17) + + +### Features + +* `extendSessionOnUse` to automatically renew Parse Sessions ([#8505](https://github.com/parse-community/parse-server/issues/8505)) ([6f885d3](https://github.com/parse-community/parse-server/commit/6f885d36b94902fdfea873fc554dee83589e6029)) + +# [6.1.0-alpha.10](https://github.com/parse-community/parse-server/compare/6.1.0-alpha.9...6.1.0-alpha.10) (2023-05-12) + + +### Bug Fixes + +* Cloud Code Trigger `afterSave` executes even if not set ([#8520](https://github.com/parse-community/parse-server/issues/8520)) ([afd0515](https://github.com/parse-community/parse-server/commit/afd0515e207bd947840579d3f245980dffa6f804)) + +# [6.1.0-alpha.9](https://github.com/parse-community/parse-server/compare/6.1.0-alpha.8...6.1.0-alpha.9) (2023-05-09) + + +### Features + +* Add option to change the log level of logs emitted by Cloud Functions ([#8530](https://github.com/parse-community/parse-server/issues/8530)) ([2caea31](https://github.com/parse-community/parse-server/commit/2caea310be412d82b04a85716bc769ccc410316d)) + +# [6.1.0-alpha.8](https://github.com/parse-community/parse-server/compare/6.1.0-alpha.7...6.1.0-alpha.8) (2023-05-01) + + +### Features + +* Allow multiple origins for header `Access-Control-Allow-Origin` ([#8517](https://github.com/parse-community/parse-server/issues/8517)) ([4f15539](https://github.com/parse-community/parse-server/commit/4f15539ac244aa2d393ac5177f7604b43f69e271)) + +# [6.1.0-alpha.7](https://github.com/parse-community/parse-server/compare/6.1.0-alpha.6...6.1.0-alpha.7) (2023-03-10) + + +### Bug Fixes + +* Rate limiting across multiple servers via Redis not working ([#8469](https://github.com/parse-community/parse-server/issues/8469)) ([d9e347d](https://github.com/parse-community/parse-server/commit/d9e347d7413f30f58ffbb8397fc8b5ae23be6ff0)) + +# [6.1.0-alpha.6](https://github.com/parse-community/parse-server/compare/6.1.0-alpha.5...6.1.0-alpha.6) (2023-03-06) + + +### Features + +* Add rate limiting across multiple servers via Redis ([#8394](https://github.com/parse-community/parse-server/issues/8394)) ([34833e4](https://github.com/parse-community/parse-server/commit/34833e42eec08b812b733be78df0535ab0e096b6)) + +# [6.1.0-alpha.5](https://github.com/parse-community/parse-server/compare/6.1.0-alpha.4...6.1.0-alpha.5) (2023-03-06) + + +### Bug Fixes + +* LiveQuery can return incorrectly formatted date ([#8456](https://github.com/parse-community/parse-server/issues/8456)) ([4ce135a](https://github.com/parse-community/parse-server/commit/4ce135a4fe930776044bc8fd786a4e17a0144e03)) + +# [6.1.0-alpha.4](https://github.com/parse-community/parse-server/compare/6.1.0-alpha.3...6.1.0-alpha.4) (2023-03-06) + + +### Bug Fixes + +* Parameters missing in `afterFind` trigger of authentication adapters ([#8458](https://github.com/parse-community/parse-server/issues/8458)) ([ce34747](https://github.com/parse-community/parse-server/commit/ce34747e8af54cb0b6b975da38f779a5955d2d59)) + +# [6.1.0-alpha.3](https://github.com/parse-community/parse-server/compare/6.1.0-alpha.2...6.1.0-alpha.3) (2023-03-06) + + +### Features + +* Add `afterFind` trigger to authentication adapters ([#8444](https://github.com/parse-community/parse-server/issues/8444)) ([c793bb8](https://github.com/parse-community/parse-server/commit/c793bb88e7485743c7ceb65fe419cde75833ff33)) + +# [6.1.0-alpha.2](https://github.com/parse-community/parse-server/compare/6.1.0-alpha.1...6.1.0-alpha.2) (2023-03-05) + + +### Bug Fixes + +* Nested date is incorrectly decoded as empty object `{}` when fetching a Parse Object ([#8446](https://github.com/parse-community/parse-server/issues/8446)) ([22d2446](https://github.com/parse-community/parse-server/commit/22d2446dfea2bc339affc20535d181097e152acf)) + +# [6.1.0-alpha.1](https://github.com/parse-community/parse-server/compare/6.0.0...6.1.0-alpha.1) (2023-03-03) + + +### Bug Fixes + +* Security upgrade jsonwebtoken to 9.0.0 ([#8420](https://github.com/parse-community/parse-server/issues/8420)) ([f5bfe45](https://github.com/parse-community/parse-server/commit/f5bfe4571e82b2b7440d41f3cff0d49937398164)) + +### Features + +* Add option `schemaCacheTtl` for schema cache pulling as alternative to `enableSchemaHooks` ([#8436](https://github.com/parse-community/parse-server/issues/8436)) ([b3b76de](https://github.com/parse-community/parse-server/commit/b3b76de71b1d4265689d052e7837c38ec1fa4323)) +* Add Parse Server option `resetPasswordSuccessOnInvalidEmail` to choose success or error response on password reset with invalid email ([#7551](https://github.com/parse-community/parse-server/issues/7551)) ([e5d610e](https://github.com/parse-community/parse-server/commit/e5d610e5e487ddab86409409ac3d7362aba8f59b)) +* Deprecate LiveQuery `fields` option in favor of `keys` for semantic consistency ([#8388](https://github.com/parse-community/parse-server/issues/8388)) ([a49e323](https://github.com/parse-community/parse-server/commit/a49e323d5ae640bff1c6603ec37fdaddb9328dd1)) +* Export `AuthAdapter` to make it available for extension with custom authentication adapters ([#8443](https://github.com/parse-community/parse-server/issues/8443)) ([40c1961](https://github.com/parse-community/parse-server/commit/40c196153b8efa12ae384c1c0092b2ed60a260d6)) + +# [6.0.0-alpha.35](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.34...6.0.0-alpha.35) (2023-02-27) + + +### Features + +* Add option `schemaCacheTtl` for schema cache pulling as alternative to `enableSchemaHooks` ([#8436](https://github.com/parse-community/parse-server/issues/8436)) ([b3b76de](https://github.com/parse-community/parse-server/commit/b3b76de71b1d4265689d052e7837c38ec1fa4323)) + +# [6.0.0-alpha.34](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.33...6.0.0-alpha.34) (2023-02-24) + + +### Features + +* Add Parse Server option `resetPasswordSuccessOnInvalidEmail` to choose success or error response on password reset with invalid email ([#7551](https://github.com/parse-community/parse-server/issues/7551)) ([e5d610e](https://github.com/parse-community/parse-server/commit/e5d610e5e487ddab86409409ac3d7362aba8f59b)) + +# [6.0.0-alpha.33](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.32...6.0.0-alpha.33) (2023-02-17) + + +### Features + +* Deprecate LiveQuery `fields` option in favor of `keys` for semantic consistency ([#8388](https://github.com/parse-community/parse-server/issues/8388)) ([a49e323](https://github.com/parse-community/parse-server/commit/a49e323d5ae640bff1c6603ec37fdaddb9328dd1)) + +# [6.0.0-alpha.32](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.31...6.0.0-alpha.32) (2023-02-07) + + +### Bug Fixes + +* Security upgrade jsonwebtoken to 9.0.0 ([#8420](https://github.com/parse-community/parse-server/issues/8420)) ([f5bfe45](https://github.com/parse-community/parse-server/commit/f5bfe4571e82b2b7440d41f3cff0d49937398164)) + +# [6.0.0-alpha.31](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.30...6.0.0-alpha.31) (2023-01-31) + + +### Bug Fixes + +* Parse Server option `requestKeywordDenylist` can be bypassed via Cloud Code Webhooks or Triggers; fixes security vulnerability [GHSA-xprv-wvh7-qqqx](https://github.com/parse-community/parse-server/security/advisories/GHSA-xprv-wvh7-qqqx) ([#8302](https://github.com/parse-community/parse-server/issues/8302)) ([6728da1](https://github.com/parse-community/parse-server/commit/6728da1e3591db1e27031d335d64d8f25546a06f)) +* Prototype pollution via Cloud Code Webhooks; fixes security vulnerability [GHSA-93vw-8fm5-p2jf](https://github.com/parse-community/parse-server/security/advisories/GHSA-93vw-8fm5-p2jf) ([#8305](https://github.com/parse-community/parse-server/issues/8305)) ([60c5a73](https://github.com/parse-community/parse-server/commit/60c5a73d257e0d536056b38bdafef8b7130524d8)) +* Remote code execution via MongoDB BSON parser through prototype pollution; fixes security vulnerability [GHSA-prm5-8g2m-24gg](https://github.com/parse-community/parse-server/security/advisories/GHSA-prm5-8g2m-24gg) ([#8295](https://github.com/parse-community/parse-server/issues/8295)) ([50eed3c](https://github.com/parse-community/parse-server/commit/50eed3cffe80fadfb4bdac52b2783a18da2cfc4f)) + +# [6.0.0-alpha.30](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.29...6.0.0-alpha.30) (2023-01-27) + + +### Bug Fixes + +* Schema without class level permissions may cause error ([#8409](https://github.com/parse-community/parse-server/issues/8409)) ([aa2cd51](https://github.com/parse-community/parse-server/commit/aa2cd51b703388d925e4572e5c2b2d883c68e49c)) + +# [6.0.0-alpha.29](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.28...6.0.0-alpha.29) (2023-01-26) + + +### Features + +* Upgrade to Parse JavaScript SDK 4 ([#8332](https://github.com/parse-community/parse-server/issues/8332)) ([9092874](https://github.com/parse-community/parse-server/commit/9092874a9a482a24dfdce1dce56615702999d6b8)) + +# [6.0.0-alpha.28](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.27...6.0.0-alpha.28) (2023-01-25) + + +### Bug Fixes + +* Rate limiter may reject requests that contain a session token ([#8399](https://github.com/parse-community/parse-server/issues/8399)) ([c114dc8](https://github.com/parse-community/parse-server/commit/c114dc8831055d74187b9dfb4c9eeb558520237c)) + +# [6.0.0-alpha.27](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.26...6.0.0-alpha.27) (2023-01-23) + + +### Bug Fixes + +* `ParseServer.verifyServerUrl` may fail if server response headers are missing; remove unnecessary logging ([#8391](https://github.com/parse-community/parse-server/issues/8391)) ([1c37a7c](https://github.com/parse-community/parse-server/commit/1c37a7cd0715949a70b220a629071c7dab7d5e7b)) + +# [6.0.0-alpha.26](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.25...6.0.0-alpha.26) (2023-01-20) + + +### Bug Fixes + +* ES6 modules do not await the import of Cloud Code files ([#8368](https://github.com/parse-community/parse-server/issues/8368)) ([a7bd180](https://github.com/parse-community/parse-server/commit/a7bd180cddd784c8735622f22e012c342ad535fb)) + +# [6.0.0-alpha.25](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.24...6.0.0-alpha.25) (2023-01-16) + + +### Features + +* Add `ParseQuery.watch` to trigger LiveQuery only on update of specific fields ([#8028](https://github.com/parse-community/parse-server/issues/8028)) ([fc92faa](https://github.com/parse-community/parse-server/commit/fc92faac75107b3392eeddd916c4c5b45e3c5e0c)) + +# [6.0.0-alpha.24](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.23...6.0.0-alpha.24) (2023-01-09) + + +### Features + +* Reduce Docker image size by improving stages ([#8359](https://github.com/parse-community/parse-server/issues/8359)) ([40810b4](https://github.com/parse-community/parse-server/commit/40810b48ebde8b1f21d2448a3a4de0585b1b5e34)) + + +### BREAKING CHANGES + +* The Docker image does not contain the git dependency anymore; if you have been using git as a transitive dependency it now needs to be explicitly installed in your Docker file, for example with `RUN apk --no-cache add git` (#8359) ([40810b4](40810b4)) + +# [6.0.0-alpha.23](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.22...6.0.0-alpha.23) (2023-01-08) + + +### Features + +* Access the internal scope of Parse Server using the new `maintenanceKey`; the internal scope contains unofficial and undocumented fields (prefixed with underscore `_`) which are used internally by Parse Server; you may want to manipulate these fields for out-of-band changes such as data migration or correction tasks; changes within the internal scope of Parse Server may happen at any time without notice or changelog entry, it is therefore recommended to look at the source code of Parse Server to understand the effects of manipulating internal fields before using the key; it is discouraged to use the `maintenanceKey` for routine operations in a production environment; see [access scopes](https://github.com/parse-community/parse-server#access-scopes) ([#8212](https://github.com/parse-community/parse-server/issues/8212)) ([f3bcc93](https://github.com/parse-community/parse-server/commit/f3bcc9365cd6f08b0a32c132e8e5ff6d1b650863)) + + +### BREAKING CHANGES + +* Fields in the internal scope of Parse Server (prefixed with underscore `_`) are only returned using the new `maintenanceKey`; previously the `masterKey` allowed reading of internal fields; see [access scopes](https://github.com/parse-community/parse-server#access-scopes) for a comparison of the keys' access permissions (#8212) ([f3bcc93](f3bcc93)) + +# [6.0.0-alpha.22](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.21...6.0.0-alpha.22) (2023-01-08) + + +### Features + +* Adapt `verifyServerUrl` for new asynchronous Parse Server start-up states ([#8366](https://github.com/parse-community/parse-server/issues/8366)) ([ffa4974](https://github.com/parse-community/parse-server/commit/ffa4974158615fbff4a2692b9db41dcb50d3f77b)) + + +### BREAKING CHANGES + +* The method `ParseServer.verifyServerUrl` now returns a promise instead of a callback. ([ffa4974](ffa4974)) + +# [6.0.0-alpha.21](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.20...6.0.0-alpha.21) (2023-01-06) + + +### Features + +* Add request rate limiter based on IP address ([#8174](https://github.com/parse-community/parse-server/issues/8174)) ([6c79f6a](https://github.com/parse-community/parse-server/commit/6c79f6a69e25e47846e3b0685d6bdfd6b91086b1)) + +# [6.0.0-alpha.20](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.19...6.0.0-alpha.20) (2023-01-06) + + +### Features + +* Add Node 19 support ([#8363](https://github.com/parse-community/parse-server/issues/8363)) ([a4990dc](https://github.com/parse-community/parse-server/commit/a4990dcd29abcb4442f3c424aff482a0a116160f)) + +# [6.0.0-alpha.19](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.18...6.0.0-alpha.19) (2023-01-05) + + +### Features + +* Remove deprecation `DEPPS1`: Native MongoDB syntax in aggregation pipeline ([#8362](https://github.com/parse-community/parse-server/issues/8362)) ([d0d30c4](https://github.com/parse-community/parse-server/commit/d0d30c4f1394f563724644a8fc81734be538a2c0)) + + +### BREAKING CHANGES + +* The MongoDB aggregation pipeline requires native MongoDB syntax instead of the custom Parse Server syntax; for example pipeline stage names require a leading dollar sign like `$match` and the MongoDB document ID is referenced using `_id` instead of `objectId` (#8362) ([d0d30c4](d0d30c4)) + +# [6.0.0-alpha.18](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.17...6.0.0-alpha.18) (2023-01-05) + + +### Bug Fixes + +* The client IP address may be determined incorrectly in some cases; this fixes a security vulnerability in which the Parse Server option `masterKeyIps` may be circumvented, see [GHSA-vm5r-c87r-pf6x](https://github.com/parse-community/parse-server/security/advisories/GHSA-vm5r-c87r-pf6x) ([#8372](https://github.com/parse-community/parse-server/issues/8372)) ([892040d](https://github.com/parse-community/parse-server/commit/892040dc2f82a3e2abe2824e4b553521b6f894de)) + + +### BREAKING CHANGES + +* The mechanism to determine the client IP address has been rewritten; to correctly determine the IP address it is now required to set the Parse Server option `trustProxy` accordingly if Parse Server runs behind a proxy server, see the express framework's [trust proxy](https://expressjs.com/en/guide/behind-proxies.html) setting (#8372) ([892040d](892040d)) + +# [6.0.0-alpha.17](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.16...6.0.0-alpha.17) (2022-12-22) + + +### Features + +* Upgrade Node Package Manager lock file `package-lock.json` to version 2 ([#8285](https://github.com/parse-community/parse-server/issues/8285)) ([ee72467](https://github.com/parse-community/parse-server/commit/ee7246733d63e4bda20401f7b00262ff03299f20)) + + +### BREAKING CHANGES + +* The Node Package Manager lock file `package-lock.json` is upgraded to version 2; while it is backwards with version 1 for the npm installer, consider this if you run any non-npm analysis tools that use the lock file (#8285) ([ee72467](ee72467)) + +# [6.0.0-alpha.16](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.15...6.0.0-alpha.16) (2022-12-21) + + +### Features + +* Asynchronous initialization of Parse Server ([#8232](https://github.com/parse-community/parse-server/issues/8232)) ([99fcf45](https://github.com/parse-community/parse-server/commit/99fcf45e55c368de2345b0c4d780e70e0adf0e15)) + + +### BREAKING CHANGES + +* This release introduces the asynchronous initialization of Parse Server to prevent mounting Parse Server before being ready to receive request; it changes how Parse Server is imported, initialized and started; it also removes the callback `serverStartComplete`; see the [Parse Server 6 migration guide](https://github.com/parse-community/parse-server/blob/alpha/6.0.0.md) for more details (#8232) ([99fcf45](99fcf45)) + +# [6.0.0-alpha.15](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.14...6.0.0-alpha.15) (2022-12-20) + + +### Bug Fixes + +* Nested objects are encoded incorrectly for MongoDB ([#8209](https://github.com/parse-community/parse-server/issues/8209)) ([1412666](https://github.com/parse-community/parse-server/commit/1412666f75829612de6fb9d7ccae35761c9b75cb)) + + +### BREAKING CHANGES + +* Nested objects are now properly stored in the database using JSON serialization; previously, due to a bug only top-level objects were serialized, but nested objects were saved as raw JSON; for example, a nested `Date` object was saved as a JSON object like `{ "__type": "Date", "iso": "2020-01-01T00:00:00.000Z" }` instead of its serialized representation `2020-01-01T00:00:00.000Z` (#8209) ([1412666](1412666)) + +# [6.0.0-alpha.14](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.13...6.0.0-alpha.14) (2022-12-16) + + +### Features + +* Write log entry when request with master key is rejected as outside of `masterKeyIps` ([#8350](https://github.com/parse-community/parse-server/issues/8350)) ([e22b73d](https://github.com/parse-community/parse-server/commit/e22b73d4b700c8ff745aa81726c6680082294b45)) + +# [6.0.0-alpha.13](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.12...6.0.0-alpha.13) (2022-12-07) + + +### Features + +* Add option to change the log level of the logs emitted by triggers ([#8328](https://github.com/parse-community/parse-server/issues/8328)) ([8f3b694](https://github.com/parse-community/parse-server/commit/8f3b694e39d4a966567e50dbea4d62e954fa5c06)) + +# [6.0.0-alpha.12](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.11...6.0.0-alpha.12) (2022-11-26) + + +### Features + +* Upgrade Redis 3 to 4 for LiveQuery ([#8333](https://github.com/parse-community/parse-server/issues/8333)) ([b2761fb](https://github.com/parse-community/parse-server/commit/b2761fb3786b519d9bbcf35be54309d2d35da1a9)) + +# [6.0.0-alpha.11](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.10...6.0.0-alpha.11) (2022-11-25) + + +### Bug Fixes + +* Parse Server option `masterKeyIps` does not include localhost by default for IPv6 ([#8322](https://github.com/parse-community/parse-server/issues/8322)) ([ab82635](https://github.com/parse-community/parse-server/commit/ab82635b0d4cf323a07ddee51fee587b43dce95c)) + +# [6.0.0-alpha.10](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.9...6.0.0-alpha.10) (2022-11-19) + + +### Bug Fixes + +* Cloud Code trigger `beforeSave` does not work with `Parse.Role` ([#8320](https://github.com/parse-community/parse-server/issues/8320)) ([f29d972](https://github.com/parse-community/parse-server/commit/f29d9720e9b37918fd885c97a31e34c42750e724)) + +# [6.0.0-alpha.9](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.8...6.0.0-alpha.9) (2022-11-16) + + +### Features + +* Remove deprecation `DEPPS3`: Config option `enforcePrivateUsers` defaults to `true` ([#8283](https://github.com/parse-community/parse-server/issues/8283)) ([ed499e3](https://github.com/parse-community/parse-server/commit/ed499e32a21bab9a874a9e5367dc71248ce836c4)) + + +### BREAKING CHANGES + +* The Parse Server option `enforcePrivateUsers` is set to `true` by default; in previous releases this option defaults to `false`; this change improves the default security configuration of Parse Server (#8283) ([ed499e3](ed499e3)) + +# [6.0.0-alpha.8](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.7...6.0.0-alpha.8) (2022-11-11) + + +### Features + +* Restrict use of `masterKey` to localhost by default ([#8281](https://github.com/parse-community/parse-server/issues/8281)) ([6c16021](https://github.com/parse-community/parse-server/commit/6c16021a1f03a70a6d9e68cb64df362d07f3b693)) + + +### BREAKING CHANGES + +* This release restricts the use of `masterKey` to localhost by default; if you are using Parse Dashboard on a different server to connect to Parse Server you need to add the IP address of the server that hosts Parse Dashboard to this option (#8281) ([6c16021](6c16021)) + +# [6.0.0-alpha.7](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.6...6.0.0-alpha.7) (2022-11-11) + + +### Features + +* Upgrade Redis 3 to 4 ([#8293](https://github.com/parse-community/parse-server/issues/8293)) ([7d622f0](https://github.com/parse-community/parse-server/commit/7d622f06a4347e0ad2cba9a4ec07d8d4fb0f67bc)) + + +### BREAKING CHANGES + +* This release upgrades to Redis 4; if you are using the Redis cache adapter with Parse Server then this is a breaking change as the Redis client options have changed; see the [Redis migration guide](https://github.com/redis/node-redis/blob/redis%404.0.0/docs/v3-to-v4.md) for more details (#8293) ([7d622f0](7d622f0)) + +# [6.0.0-alpha.6](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.5...6.0.0-alpha.6) (2022-11-10) + + +### Features + +* Remove support for MongoDB 4.0 ([#8292](https://github.com/parse-community/parse-server/issues/8292)) ([37245f6](https://github.com/parse-community/parse-server/commit/37245f62ce83516b6b95a54b850f0274ef680478)) + + +### BREAKING CHANGES + +* This release removes support for MongoDB 4.0; the new minimum supported MongoDB version is 4.2. which also removes support for the deprecated MongoDB MMAPv1 storage engine ([37245f6](37245f6)) + +# [6.0.0-alpha.5](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.4...6.0.0-alpha.5) (2022-11-10) + + +### Bug Fixes + +* Throwing error in Cloud Code Triggers `afterLogin`, `afterLogout` crashes server ([#8280](https://github.com/parse-community/parse-server/issues/8280)) ([130d290](https://github.com/parse-community/parse-server/commit/130d29074e3f763460e5685d0b9059e5a333caff)) + + +### BREAKING CHANGES + +* Throwing an error in Cloud Code Triggers `afterLogin`, `afterLogout` returns a rejected promise; in previous releases it crashed the server if you did not handle the error on the Node.js process level; consider adapting your code if your app currently handles these errors on the Node.js process level with `process.on('unhandledRejection', ...)` ([130d290](130d290)) + +# [6.0.0-alpha.4](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.3...6.0.0-alpha.4) (2022-11-10) + + +### Features + +* Remove deprecation `DEPPS2`: Config option `directAccess` defaults to true ([#8284](https://github.com/parse-community/parse-server/issues/8284)) ([f535ee6](https://github.com/parse-community/parse-server/commit/f535ee6ec2abba63f702127258ca49fa5b4e08c9)) + + +### BREAKING CHANGES + +* Config option `directAccess` defaults to true; set this to `false` in environments where multiple Parse Server instances run behind a load balancer and Parse requests within the current Node.js environment should be routed via the load balancer and distributed as HTTP requests among all instances via the `serverURL`. ([f535ee6](f535ee6)) + +# [6.0.0-alpha.3](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.2...6.0.0-alpha.3) (2022-11-10) + + +### Features + +* Remove deprecation `DEPPS4`: Remove convenience method for http request `Parse.Cloud.httpRequest` ([#8287](https://github.com/parse-community/parse-server/issues/8287)) ([2d79c08](https://github.com/parse-community/parse-server/commit/2d79c0835b6a9acaf20d5c943d9b4619bb96831c)) + + +### BREAKING CHANGES + +* The convenience method for HTTP requests `Parse.Cloud.httpRequest` is removed; use your preferred 3rd party library for making HTTP requests ([2d79c08](2d79c08)) + +# [6.0.0-alpha.2](https://github.com/parse-community/parse-server/compare/6.0.0-alpha.1...6.0.0-alpha.2) (2022-11-10) + + +### Features + +* Improve authentication adapter interface to support multi-factor authentication (MFA), authentication challenges, and provide a more powerful interface for writing custom authentication adapters ([#8156](https://github.com/parse-community/parse-server/issues/8156)) ([5bbf9ca](https://github.com/parse-community/parse-server/commit/5bbf9cade9a527787fd1002072d4013ab5d8db2b)) + +# [6.0.0-alpha.1](https://github.com/parse-community/parse-server/compare/5.4.0-alpha.1...6.0.0-alpha.1) (2022-11-10) + + +### Bug Fixes + +* Remove Node 12 and Node 17 support ([#8279](https://github.com/parse-community/parse-server/issues/8279)) ([2546cc8](https://github.com/parse-community/parse-server/commit/2546cc8572bea6610cb9b3c7401d9afac0e3c1d6)) + + +### BREAKING CHANGES + +* This release removes Node 12 and Node 17 support ([2546cc8](2546cc8)) + +# [5.4.0-alpha.1](https://github.com/parse-community/parse-server/compare/5.3.0...5.4.0-alpha.1) (2022-10-31) + + +### Bug Fixes + +* authentication adapter app ID validation may be circumvented; this fixes a vulnerability that affects configurations which allow users to authenticate using the Parse Server authentication adapter for *Facebook* or *Spotify* and where the server-side authentication adapter configuration `appIds` is set as a string (e.g. `abc`) instead of an array of strings (e.g. `["abc"]`) ([GHSA-r657-33vp-gp22](https://github.com/parse-community/parse-server/security/advisories/GHSA-r657-33vp-gp22)) [skip release] ([#8187](https://github.com/parse-community/parse-server/issues/8187)) ([8c8ec71](https://github.com/parse-community/parse-server/commit/8c8ec715739e0f851338cfed794409ebac66c51b)) +* brute force guessing of user sensitive data via search patterns (GHSA-2m6g-crv8-p3c6) ([#8146](https://github.com/parse-community/parse-server/issues/8146)) [skip release] ([4c0c7c7](https://github.com/parse-community/parse-server/commit/4c0c7c77b76257878b9bcb05ff9de01c9d790262)) +* certificate in Apple Game Center auth adapter not validated [skip release] ([#8058](https://github.com/parse-community/parse-server/issues/8058)) ([75af9a2](https://github.com/parse-community/parse-server/commit/75af9a26cc8e9e88a33d1e452c93a0ee6e509f17)) +* graphQL query ignores condition `equalTo` with value `false` ([#8032](https://github.com/parse-community/parse-server/issues/8032)) ([7f5a15d](https://github.com/parse-community/parse-server/commit/7f5a15d5df0dfa3515e9f73709d6a49663545f9b)) +* internal indices for classes `_Idempotency` and `_Role` are not protected in defined schema ([#8121](https://github.com/parse-community/parse-server/issues/8121)) ([c16f529](https://github.com/parse-community/parse-server/commit/c16f529f74f92154401bf662f634b3c5fa45e18e)) +* invalid file request not properly handled [skip release] ([#8062](https://github.com/parse-community/parse-server/issues/8062)) ([4c9e956](https://github.com/parse-community/parse-server/commit/4c9e95674ad081f13062e8cd30b77b1962d5df57)) +* liveQuery with `containedIn` not working when object field is an array ([#8128](https://github.com/parse-community/parse-server/issues/8128)) ([1d9605b](https://github.com/parse-community/parse-server/commit/1d9605bc93009263d3811df4d4249034ba6eb8c4)) +* protected fields exposed via LiveQuery (GHSA-crrq-vr9j-fxxh) [skip release] ([#8076](https://github.com/parse-community/parse-server/issues/8076)) ([9fd4516](https://github.com/parse-community/parse-server/commit/9fd4516cde5c742f9f29dd05468b4a43a85639a6)) +* push notifications `badge` doesn't update with Installation beforeSave trigger ([#8162](https://github.com/parse-community/parse-server/issues/8162)) ([3c75c2b](https://github.com/parse-community/parse-server/commit/3c75c2ba4851fae96a8c19b11a3efde03816c9a1)) +* query aggregation pipeline cannot handle value of type `Date` when `directAccess: true` ([#8167](https://github.com/parse-community/parse-server/issues/8167)) ([e424137](https://github.com/parse-community/parse-server/commit/e4241374061caef66538de15112fb6bbafb1f5bb)) +* relation constraints in compound queries `Parse.Query.or`, `Parse.Query.and` not working ([#8203](https://github.com/parse-community/parse-server/issues/8203)) ([28f0d26](https://github.com/parse-community/parse-server/commit/28f0d2667787d2ac68726607b811d6f0ef62b9f1)) +* security upgrade undici from 5.6.0 to 5.8.0 ([#8108](https://github.com/parse-community/parse-server/issues/8108)) ([4aa016b](https://github.com/parse-community/parse-server/commit/4aa016b7322467422b9fdf05d8e29b9ecf910da7)) +* server crashes when receiving file download request with invalid byte range; this fixes a security vulnerability that allows an attacker to impact the availability of the server instance; the fix improves parsing of the range parameter to properly handle invalid range requests ([GHSA-h423-w6qv-2wj3](https://github.com/parse-community/parse-server/security/advisories/GHSA-h423-w6qv-2wj3)) [skip release] ([#8238](https://github.com/parse-community/parse-server/issues/8238)) ([c03908f](https://github.com/parse-community/parse-server/commit/c03908f74e5c9eed834874a89df6c89c1a1e849f)) +* session object properties can be updated by foreign user; this fixes a security vulnerability in which a foreign user can write to the session object of another user if the session object ID is known; the fix prevents writing to foreign session objects ([GHSA-6w4q-23cf-j9jp](https://github.com/parse-community/parse-server/security/advisories/GHSA-6w4q-23cf-j9jp)) [skip release] ([#8180](https://github.com/parse-community/parse-server/issues/8180)) ([37fed30](https://github.com/parse-community/parse-server/commit/37fed3062ccc3ef1dfd49a9fc53318e72b3e4aff)) +* sorting by non-existing value throws `INVALID_SERVER_ERROR` on Postgres ([#8157](https://github.com/parse-community/parse-server/issues/8157)) ([3b775a1](https://github.com/parse-community/parse-server/commit/3b775a1fb8a1878714e3451191438963d688f1b0)) +* updating object includes unchanged keys in client response for certain key types ([#8159](https://github.com/parse-community/parse-server/issues/8159)) ([37af1d7](https://github.com/parse-community/parse-server/commit/37af1d78fce5a15039ffe3af7b323c1f1e8582fc)) + +### Features + +* add convenience access to Parse Server configuration in Cloud Code via `Parse.Server` ([#8244](https://github.com/parse-community/parse-server/issues/8244)) ([9f11115](https://github.com/parse-community/parse-server/commit/9f111158edf7fd57a65db0c4f9244b37e58cf293)) +* add option to change the default value of the `Parse.Query.limit()` constraint ([#8152](https://github.com/parse-community/parse-server/issues/8152)) ([0388956](https://github.com/parse-community/parse-server/commit/038895680894984e569dff54bf5c7b31094f3891)) +* add support for MongoDB 6 ([#8242](https://github.com/parse-community/parse-server/issues/8242)) ([aba0081](https://github.com/parse-community/parse-server/commit/aba0081ce1a166a93de57f3928c19a05562b5cc1)) +* add support for Postgres 15 ([#8215](https://github.com/parse-community/parse-server/issues/8215)) ([2feb6c4](https://github.com/parse-community/parse-server/commit/2feb6c46080946c984daa351187fa07cd582355d)) +* liveQuery support for unsorted distance queries ([#8221](https://github.com/parse-community/parse-server/issues/8221)) ([0f763da](https://github.com/parse-community/parse-server/commit/0f763da17d646b2fec2cd980d3857e46072a8a07)) + +# [5.3.0-alpha.32](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.31...5.3.0-alpha.32) (2022-10-29) + + +### Features + +* add convenience access to Parse Server configuration in Cloud Code via `Parse.Server` ([#8244](https://github.com/parse-community/parse-server/issues/8244)) ([9f11115](https://github.com/parse-community/parse-server/commit/9f111158edf7fd57a65db0c4f9244b37e58cf293)) + +# [5.3.0-alpha.31](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.30...5.3.0-alpha.31) (2022-10-24) + + +### Bug Fixes + +* relation constraints in compound queries `Parse.Query.or`, `Parse.Query.and` not working ([#8203](https://github.com/parse-community/parse-server/issues/8203)) ([28f0d26](https://github.com/parse-community/parse-server/commit/28f0d2667787d2ac68726607b811d6f0ef62b9f1)) + +# [5.3.0-alpha.30](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.29...5.3.0-alpha.30) (2022-10-17) + + +### Features + +* add support for MongoDB 6 ([#8242](https://github.com/parse-community/parse-server/issues/8242)) ([aba0081](https://github.com/parse-community/parse-server/commit/aba0081ce1a166a93de57f3928c19a05562b5cc1)) + +# [5.3.0-alpha.29](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.28...5.3.0-alpha.29) (2022-10-15) + + +### Bug Fixes + +* server crashes when receiving file download request with invalid byte range; this fixes a security vulnerability that allows an attacker to impact the availability of the server instance; the fix improves parsing of the range parameter to properly handle invalid range requests ([GHSA-h423-w6qv-2wj3](https://github.com/parse-community/parse-server/security/advisories/GHSA-h423-w6qv-2wj3)) [skip release] ([#8238](https://github.com/parse-community/parse-server/issues/8238)) ([c03908f](https://github.com/parse-community/parse-server/commit/c03908f74e5c9eed834874a89df6c89c1a1e849f)) + +### Features + +* add support for Postgres 15 ([#8215](https://github.com/parse-community/parse-server/issues/8215)) ([2feb6c4](https://github.com/parse-community/parse-server/commit/2feb6c46080946c984daa351187fa07cd582355d)) + +# [5.3.0-alpha.28](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.27...5.3.0-alpha.28) (2022-10-11) + + +### Features + +* liveQuery support for unsorted distance queries ([#8221](https://github.com/parse-community/parse-server/issues/8221)) ([0f763da](https://github.com/parse-community/parse-server/commit/0f763da17d646b2fec2cd980d3857e46072a8a07)) + +# [5.3.0-alpha.27](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.26...5.3.0-alpha.27) (2022-09-29) + + +### Bug Fixes + +* authentication adapter app ID validation may be circumvented; this fixes a vulnerability that affects configurations which allow users to authenticate using the Parse Server authentication adapter for *Facebook* or *Spotify* and where the server-side authentication adapter configuration `appIds` is set as a string (e.g. `abc`) instead of an array of strings (e.g. `["abc"]`) ([GHSA-r657-33vp-gp22](https://github.com/parse-community/parse-server/security/advisories/GHSA-r657-33vp-gp22)) [skip release] ([#8187](https://github.com/parse-community/parse-server/issues/8187)) ([8c8ec71](https://github.com/parse-community/parse-server/commit/8c8ec715739e0f851338cfed794409ebac66c51b)) +* session object properties can be updated by foreign user; this fixes a security vulnerability in which a foreign user can write to the session object of another user if the session object ID is known; the fix prevents writing to foreign session objects ([GHSA-6w4q-23cf-j9jp](https://github.com/parse-community/parse-server/security/advisories/GHSA-6w4q-23cf-j9jp)) [skip release] ([#8180](https://github.com/parse-community/parse-server/issues/8180)) ([37fed30](https://github.com/parse-community/parse-server/commit/37fed3062ccc3ef1dfd49a9fc53318e72b3e4aff)) + +### Features + +* add option to change the default value of the `Parse.Query.limit()` constraint ([#8152](https://github.com/parse-community/parse-server/issues/8152)) ([0388956](https://github.com/parse-community/parse-server/commit/038895680894984e569dff54bf5c7b31094f3891)) + +# [5.3.0-alpha.26](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.25...5.3.0-alpha.26) (2022-09-17) + + +### Bug Fixes + +* sorting by non-existing value throws `INVALID_SERVER_ERROR` on Postgres ([#8157](https://github.com/parse-community/parse-server/issues/8157)) ([3b775a1](https://github.com/parse-community/parse-server/commit/3b775a1fb8a1878714e3451191438963d688f1b0)) + +# [5.3.0-alpha.25](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.24...5.3.0-alpha.25) (2022-09-17) + + +### Bug Fixes + +* updating object includes unchanged keys in client response for certain key types ([#8159](https://github.com/parse-community/parse-server/issues/8159)) ([37af1d7](https://github.com/parse-community/parse-server/commit/37af1d78fce5a15039ffe3af7b323c1f1e8582fc)) + +# [5.3.0-alpha.24](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.23...5.3.0-alpha.24) (2022-09-17) + + +### Bug Fixes + +* query aggregation pipeline cannot handle value of type `Date` when `directAccess: true` ([#8167](https://github.com/parse-community/parse-server/issues/8167)) ([e424137](https://github.com/parse-community/parse-server/commit/e4241374061caef66538de15112fb6bbafb1f5bb)) + +# [5.3.0-alpha.23](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.22...5.3.0-alpha.23) (2022-09-17) + + +### Bug Fixes + +* liveQuery with `containedIn` not working when object field is an array ([#8128](https://github.com/parse-community/parse-server/issues/8128)) ([1d9605b](https://github.com/parse-community/parse-server/commit/1d9605bc93009263d3811df4d4249034ba6eb8c4)) + +# [5.3.0-alpha.22](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.21...5.3.0-alpha.22) (2022-09-16) + + +### Bug Fixes + +* brute force guessing of user sensitive data via search patterns (GHSA-2m6g-crv8-p3c6) ([#8146](https://github.com/parse-community/parse-server/issues/8146)) [skip release] ([4c0c7c7](https://github.com/parse-community/parse-server/commit/4c0c7c77b76257878b9bcb05ff9de01c9d790262)) +* push notifications `badge` doesn't update with Installation beforeSave trigger ([#8162](https://github.com/parse-community/parse-server/issues/8162)) ([3c75c2b](https://github.com/parse-community/parse-server/commit/3c75c2ba4851fae96a8c19b11a3efde03816c9a1)) + +# [5.3.0-alpha.21](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.20...5.3.0-alpha.21) (2022-08-05) + + +### Bug Fixes + +* internal indices for classes `_Idempotency` and `_Role` are not protected in defined schema ([#8121](https://github.com/parse-community/parse-server/issues/8121)) ([c16f529](https://github.com/parse-community/parse-server/commit/c16f529f74f92154401bf662f634b3c5fa45e18e)) + +# [5.3.0-alpha.20](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.19...5.3.0-alpha.20) (2022-07-22) + + +### Bug Fixes + +* security upgrade undici from 5.6.0 to 5.8.0 ([#8108](https://github.com/parse-community/parse-server/issues/8108)) ([4aa016b](https://github.com/parse-community/parse-server/commit/4aa016b7322467422b9fdf05d8e29b9ecf910da7)) + +# [5.3.0-alpha.19](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.18...5.3.0-alpha.19) (2022-07-03) + + +### Bug Fixes + +* certificate in Apple Game Center auth adapter not validated [skip release] ([#8058](https://github.com/parse-community/parse-server/issues/8058)) ([75af9a2](https://github.com/parse-community/parse-server/commit/75af9a26cc8e9e88a33d1e452c93a0ee6e509f17)) +* graphQL query ignores condition `equalTo` with value `false` ([#8032](https://github.com/parse-community/parse-server/issues/8032)) ([7f5a15d](https://github.com/parse-community/parse-server/commit/7f5a15d5df0dfa3515e9f73709d6a49663545f9b)) +* invalid file request not properly handled [skip release] ([#8062](https://github.com/parse-community/parse-server/issues/8062)) ([4c9e956](https://github.com/parse-community/parse-server/commit/4c9e95674ad081f13062e8cd30b77b1962d5df57)) +* protected fields exposed via LiveQuery (GHSA-crrq-vr9j-fxxh) [skip release] ([#8076](https://github.com/parse-community/parse-server/issues/8076)) ([9fd4516](https://github.com/parse-community/parse-server/commit/9fd4516cde5c742f9f29dd05468b4a43a85639a6)) + +# [5.3.0-alpha.18](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.17...5.3.0-alpha.18) (2022-06-17) + + +### Bug Fixes + +* auto-release process may fail if optional back-merging task fails ([#8051](https://github.com/parse-community/parse-server/issues/8051)) ([cf925e7](https://github.com/parse-community/parse-server/commit/cf925e75e87a6989f41e2e2abb2aba4332b1e79f)) + +# [5.3.0-alpha.17](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.16...5.3.0-alpha.17) (2022-06-17) + + +### Bug Fixes + +* errors in GraphQL do not show the original error but a general `Unexpected Error` ([#8045](https://github.com/parse-community/parse-server/issues/8045)) ([0d81887](https://github.com/parse-community/parse-server/commit/0d818879c217f9c56100a5f59868fa37e6d24b71)) +* websocket connection of LiveQuery interrupts frequently ([#8048](https://github.com/parse-community/parse-server/issues/8048)) ([03caae1](https://github.com/parse-community/parse-server/commit/03caae1e611f28079cdddbbe433daaf69e3f595c)) + +# [5.3.0-alpha.16](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.15...5.3.0-alpha.16) (2022-06-11) + + +### Bug Fixes + +* live query role cache does not clear when a user is added to a role ([#8026](https://github.com/parse-community/parse-server/issues/8026)) ([199dfc1](https://github.com/parse-community/parse-server/commit/199dfc17226d85a78ab85f24362cce740f4ada39)) + +# [5.3.0-alpha.15](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.14...5.3.0-alpha.15) (2022-06-05) + + +### Bug Fixes + +* interrupted WebSocket connection not closed by LiveQuery server ([#8012](https://github.com/parse-community/parse-server/issues/8012)) ([2d5221e](https://github.com/parse-community/parse-server/commit/2d5221e48012fb7781c0406d543a922d313075ea)) + +# [5.3.0-alpha.14](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.13...5.3.0-alpha.14) (2022-05-29) + + +### Features + +* align file trigger syntax with class trigger; use the new syntax `Parse.Cloud.beforeSave(Parse.File, (request) => {})`, the old syntax `Parse.Cloud.beforeSaveFile((request) => {})` has been deprecated ([#7966](https://github.com/parse-community/parse-server/issues/7966)) ([c6dcad8](https://github.com/parse-community/parse-server/commit/c6dcad8d167d44912dbd416d328519314c0809bd)) + +# [5.3.0-alpha.13](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.12...5.3.0-alpha.13) (2022-05-28) + + +### Features + +* selectively enable / disable default authentication adapters ([#7953](https://github.com/parse-community/parse-server/issues/7953)) ([c1e808f](https://github.com/parse-community/parse-server/commit/c1e808f9e807fc49508acbde0d8b3f2b901a1638)) + +# [5.3.0-alpha.12](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.11...5.3.0-alpha.12) (2022-05-20) + + +### Bug Fixes + +* afterSave trigger removes pointer in Parse object ([#7913](https://github.com/parse-community/parse-server/issues/7913)) ([47d796e](https://github.com/parse-community/parse-server/commit/47d796ea58f65e71612ce37149be692abc9ea97f)) + +# [5.3.0-alpha.11](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.10...5.3.0-alpha.11) (2022-05-18) + + +### Features + +* replace GraphQL Apollo with GraphQL Yoga ([#7967](https://github.com/parse-community/parse-server/issues/7967)) ([1aa2204](https://github.com/parse-community/parse-server/commit/1aa2204aebfdbe273d54d6d56c6029f7c34aab14)) + +# [5.3.0-alpha.10](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.9...5.3.0-alpha.10) (2022-05-09) + + +### Features + +* upgrade mongodb from 4.4.1 to 4.5.0 ([#7991](https://github.com/parse-community/parse-server/issues/7991)) ([e692b5d](https://github.com/parse-community/parse-server/commit/e692b5dd8214cdb0ce79bedd30d9aa3cf4de76a5)) + +# [5.3.0-alpha.9](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.8...5.3.0-alpha.9) (2022-05-07) + + +### Bug Fixes + +* depreciate allowClientClassCreation defaulting to true ([#7925](https://github.com/parse-community/parse-server/issues/7925)) ([38ed96a](https://github.com/parse-community/parse-server/commit/38ed96ace534d639db007aa7dd5387b2da8f03ae)) + +# [5.3.0-alpha.8](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.7...5.3.0-alpha.8) (2022-05-06) + + +### Features + +* add support for Node 17 and 18 ([#7896](https://github.com/parse-community/parse-server/issues/7896)) ([3e9f292](https://github.com/parse-community/parse-server/commit/3e9f292d840334244934cee9a34545ac86313549)) + +# [5.3.0-alpha.7](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.6...5.3.0-alpha.7) (2022-04-25) + + +### Bug Fixes + +* security upgrade @parse/fs-files-adapter from 1.2.1 to 1.2.2 ([#7948](https://github.com/parse-community/parse-server/issues/7948)) ([20fc4e2](https://github.com/parse-community/parse-server/commit/20fc4e23b53c91aac657f894bd70d049b7525c37)) + +# [5.3.0-alpha.6](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.5...5.3.0-alpha.6) (2022-04-11) + + +### Bug Fixes + +* peer dependency mismatch for GraphQL dependencies ([#7934](https://github.com/parse-community/parse-server/issues/7934)) ([b7a1d76](https://github.com/parse-community/parse-server/commit/b7a1d7617b4bcac677cecedfeb6ac4a27447083b)) + +# [5.3.0-alpha.5](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.4...5.3.0-alpha.5) (2022-04-09) + + +### Bug Fixes + +* security upgrade moment from 2.29.1 to 2.29.2 ([#7931](https://github.com/parse-community/parse-server/issues/7931)) ([6b68593](https://github.com/parse-community/parse-server/commit/6b68593eaec17e8b183899d2b92699c9ede7625b)) + +# [5.3.0-alpha.4](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.3...5.3.0-alpha.4) (2022-04-04) + + +### Bug Fixes + +* custom database options are not passed to MongoDB GridFS ([#7911](https://github.com/parse-community/parse-server/issues/7911)) ([a72b384](https://github.com/parse-community/parse-server/commit/a72b384f76137a3d83ffb69f65cb25aff1bbab4f)) + +# [5.3.0-alpha.3](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.2...5.3.0-alpha.3) (2022-03-27) + + +### Features + +* add MongoDB 5.2 support ([#7894](https://github.com/parse-community/parse-server/issues/7894)) ([6b4b358](https://github.com/parse-community/parse-server/commit/6b4b358f0842ae920e45652f5e8b2afebc6caf3a)) + +# [5.3.0-alpha.2](https://github.com/parse-community/parse-server/compare/5.3.0-alpha.1...5.3.0-alpha.2) (2022-03-27) + + +### Bug Fixes + +* security upgrade parse push adapter from 4.1.0 to 4.1.2 ([#7893](https://github.com/parse-community/parse-server/issues/7893)) ([ef56e98](https://github.com/parse-community/parse-server/commit/ef56e98ef65041b4d3b7b82cce3473269c27f6fd)) + +# [5.3.0-alpha.1](https://github.com/parse-community/parse-server/compare/5.2.1-alpha.2...5.3.0-alpha.1) (2022-03-27) + + +### Features + +* add MongoDB 5.1 compatibility ([#7682](https://github.com/parse-community/parse-server/issues/7682)) ([90155cf](https://github.com/parse-community/parse-server/commit/90155cf1680e5e0499b0000e071c6cb0ce3aef96)) + +## [5.2.1-alpha.2](https://github.com/parse-community/parse-server/compare/5.2.1-alpha.1...5.2.1-alpha.2) (2022-03-26) + + +### Performance Improvements + +* reduce database operations when using the constant parameter in Cloud Function validation ([#7892](https://github.com/parse-community/parse-server/issues/7892)) ([48bd512](https://github.com/parse-community/parse-server/commit/48bd512eeb47666967dff8c5e723ddc5b7801daa)) + +## [5.2.1-alpha.1](https://github.com/parse-community/parse-server/compare/5.2.0...5.2.1-alpha.1) (2022-03-26) + + +### Bug Fixes + +* return correct response when revert is used in beforeSave ([#7839](https://github.com/parse-community/parse-server/issues/7839)) ([f63fb2b](https://github.com/parse-community/parse-server/commit/f63fb2b338c908f0e7a648d338c26b9daa50c8f2)) + +# [5.2.0-alpha.3](https://github.com/parse-community/parse-server/compare/5.2.0-alpha.2...5.2.0-alpha.3) (2022-03-24) + + +### Bug Fixes + +* security bump minimist from 1.2.5 to 1.2.6 ([#7884](https://github.com/parse-community/parse-server/issues/7884)) ([c5cf282](https://github.com/parse-community/parse-server/commit/c5cf282d11ffdc023764f8e7539a2bd6bc246fe1)) + +# [5.2.0-alpha.2](https://github.com/parse-community/parse-server/compare/5.2.0-alpha.1...5.2.0-alpha.2) (2022-03-24) + + +### Bug Fixes + +* sensitive keyword detection may produce false positives ([#7881](https://github.com/parse-community/parse-server/issues/7881)) ([0d6f9e9](https://github.com/parse-community/parse-server/commit/0d6f9e951d9e186e95e96d8869066ce7022bad02)) + +# [5.2.0-alpha.1](https://github.com/parse-community/parse-server/compare/5.1.1...5.2.0-alpha.1) (2022-03-23) + + +### Features + +* improved LiveQuery error logging with additional information ([#7837](https://github.com/parse-community/parse-server/issues/7837)) ([443a509](https://github.com/parse-community/parse-server/commit/443a5099059538d379fe491793a5871fcbb4f377)) + +# [5.0.0-alpha.29](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.28...5.0.0-alpha.29) (2022-03-12) + + +### Features + +* bump required node engine to >=12.22.10 ([#7846](https://github.com/parse-community/parse-server/issues/7846)) ([5ace99d](https://github.com/parse-community/parse-server/commit/5ace99d542a11e422af46d9fd6b1d3d2513b34cf)) + + +### BREAKING CHANGES + +* This requires Node.js version >=12.22.10. ([5ace99d](5ace99d)) + +# [5.0.0-alpha.28](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.27...5.0.0-alpha.28) (2022-03-12) + + +### Bug Fixes + +* security vulnerability that allows remote code execution (GHSA-p6h4-93qp-jhcm) ([#7844](https://github.com/parse-community/parse-server/issues/7844)) ([e569f40](https://github.com/parse-community/parse-server/commit/e569f402b1fd8648fb0d1523b71b2a03273902a5)) + +# [5.0.0-alpha.27](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.26...5.0.0-alpha.27) (2022-03-12) + + +### Reverts + +* update node engine to 2.22.0 ([#7827](https://github.com/parse-community/parse-server/issues/7827)) ([f235412](https://github.com/parse-community/parse-server/commit/f235412c1b6c2b173b7531f285429ea7214b56a2)) + +# [5.0.0-alpha.26](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.25...5.0.0-alpha.26) (2022-02-25) + + +### Bug Fixes + +* package.json & package-lock.json to reduce vulnerabilities ([#7823](https://github.com/parse-community/parse-server/issues/7823)) ([5ca2288](https://github.com/parse-community/parse-server/commit/5ca228882332b65f3ac05407e6e4da1ee3ef3749)) + +# [5.0.0-alpha.25](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.24...5.0.0-alpha.25) (2022-02-23) + + +### Bug Fixes + +* upgrade winston from 3.5.0 to 3.5.1 ([#7820](https://github.com/parse-community/parse-server/issues/7820)) ([4af253d](https://github.com/parse-community/parse-server/commit/4af253d1f8654a6f57b5137ad310cdacadc922cc)) + +# [5.0.0-alpha.24](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.23...5.0.0-alpha.24) (2022-02-10) + + +### Bug Fixes + +* security upgrade follow-redirects from 1.14.7 to 1.14.8 ([#7801](https://github.com/parse-community/parse-server/issues/7801)) ([70088a9](https://github.com/parse-community/parse-server/commit/70088a95a78393da2a4ac68be81e63107747626a)) + +# [5.0.0-alpha.23](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.22...5.0.0-alpha.23) (2022-02-06) + + +### Bug Fixes + +* server crash using GraphQL due to missing @apollo/client peer dependency ([#7787](https://github.com/parse-community/parse-server/issues/7787)) ([08089d6](https://github.com/parse-community/parse-server/commit/08089d6fcbb215412448ce7d92b21b9fe6c929f2)) + +# [5.0.0-alpha.22](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.21...5.0.0-alpha.22) (2022-02-06) + + +### Features + +* upgrade to MongoDB Node.js driver 4.x for MongoDB 5.0 support ([#7794](https://github.com/parse-community/parse-server/issues/7794)) ([f88aa2a](https://github.com/parse-community/parse-server/commit/f88aa2a62a533e5344d1c13dd38c5a0b283a480a)) + + +### BREAKING CHANGES + +* The MongoDB GridStore adapter has been removed. By default, Parse Server already uses GridFS, so if you do not manually use the GridStore adapter, you can ignore this change. ([f88aa2a](f88aa2a)) + +# [5.0.0-alpha.21](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.20...5.0.0-alpha.21) (2022-01-25) + + +### Features + +* add Cloud Code context to `ParseObject.fetch` ([#7779](https://github.com/parse-community/parse-server/issues/7779)) ([315290d](https://github.com/parse-community/parse-server/commit/315290d16110110938f80a6b779cc2d1db58c552)) + +# [5.0.0-alpha.20](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.19...5.0.0-alpha.20) (2022-01-22) + + +### Bug Fixes + +* bump node-fetch from 2.6.1 to 3.1.1 ([#7782](https://github.com/parse-community/parse-server/issues/7782)) ([9082351](https://github.com/parse-community/parse-server/commit/90823514113a1a085ebc818f7109b3fd7591346f)) + +# [5.0.0-alpha.19](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.18...5.0.0-alpha.19) (2022-01-22) + + +### Bug Fixes + +* bump nanoid from 3.1.25 to 3.2.0 ([#7781](https://github.com/parse-community/parse-server/issues/7781)) ([f5f63bf](https://github.com/parse-community/parse-server/commit/f5f63bfc64d3481ed944ceb5e9f50b33dccd1ce9)) + +# [5.0.0-alpha.18](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.17...5.0.0-alpha.18) (2022-01-13) + + +### Bug Fixes + +* security upgrade follow-redirects from 1.14.6 to 1.14.7 ([#7769](https://github.com/parse-community/parse-server/issues/7769)) ([8f5a861](https://github.com/parse-community/parse-server/commit/8f5a8618cfa7ed9a2a239a095abffa8f3fd8d31a)) + +# [5.0.0-alpha.17](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.16...5.0.0-alpha.17) (2022-01-13) + + +### Bug Fixes + +* schema cache not cleared in some cases ([#7678](https://github.com/parse-community/parse-server/issues/7678)) ([5af6e5d](https://github.com/parse-community/parse-server/commit/5af6e5dfaa129b1a350afcba4fb381b21c4cc35d)) + +# [5.0.0-alpha.16](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.15...5.0.0-alpha.16) (2022-01-02) + + +### Features + +* add Idempotency to Postgres ([#7750](https://github.com/parse-community/parse-server/issues/7750)) ([0c3feaa](https://github.com/parse-community/parse-server/commit/0c3feaaa1751964c0db89f25674935c3354b1538)) + +# [5.0.0-alpha.15](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.14...5.0.0-alpha.15) (2022-01-02) + + +### Features + +* support `postgresql` protocol in database URI ([#7757](https://github.com/parse-community/parse-server/issues/7757)) ([caf4a23](https://github.com/parse-community/parse-server/commit/caf4a2341f554b28e3918c53e7e897a3ca47bf8b)) + +# [5.0.0-alpha.14](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.13...5.0.0-alpha.14) (2022-01-02) + + +### Features + +* support relativeTime query constraint on Postgres ([#7747](https://github.com/parse-community/parse-server/issues/7747)) ([16b1b2a](https://github.com/parse-community/parse-server/commit/16b1b2a19714535ca805f2dbb3b561d8f6a519a7)) + +# [5.0.0-alpha.13](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.12...5.0.0-alpha.13) (2021-12-08) + + +### Bug Fixes + +* node engine compatibility did not include node 16 ([#7739](https://github.com/parse-community/parse-server/issues/7739)) ([ea7c014](https://github.com/parse-community/parse-server/commit/ea7c01400f992a1263543706fe49b6174758a2d6)) + +# [5.0.0-alpha.12](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.11...5.0.0-alpha.12) (2021-12-06) + + +### Bug Fixes + +* adding or modifying a nested property requires addField permissions ([#7679](https://github.com/parse-community/parse-server/issues/7679)) ([6a6248b](https://github.com/parse-community/parse-server/commit/6a6248b6cb2e732d17131e18e659943b894ed2f1)) + +# [5.0.0-alpha.11](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.10...5.0.0-alpha.11) (2021-11-29) + + +### Bug Fixes + +* upgrade mime from 2.5.2 to 3.0.0 ([#7725](https://github.com/parse-community/parse-server/issues/7725)) ([f5ef98b](https://github.com/parse-community/parse-server/commit/f5ef98bde32083403c0e30a12162fcc1e52cac37)) + +# [5.0.0-alpha.10](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.9...5.0.0-alpha.10) (2021-11-29) + + +### Bug Fixes + +* upgrade parse from 3.3.1 to 3.4.0 ([#7723](https://github.com/parse-community/parse-server/issues/7723)) ([d4c1f47](https://github.com/parse-community/parse-server/commit/d4c1f473073764cb0570c633fc4a30669c2ce889)) + +# [5.0.0-alpha.9](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.8...5.0.0-alpha.9) (2021-11-27) + + +### Bug Fixes + +* unable to use objectId size higher than 19 on GraphQL API ([#7627](https://github.com/parse-community/parse-server/issues/7627)) ([ed86c80](https://github.com/parse-community/parse-server/commit/ed86c807721cc52a1a5a9dea0b768717eec269ed)) + +# [5.0.0-alpha.8](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.7...5.0.0-alpha.8) (2021-11-18) + + +### Features + +* add support for Node 16 ([#7707](https://github.com/parse-community/parse-server/issues/7707)) ([45cc58c](https://github.com/parse-community/parse-server/commit/45cc58c7e5e640a46c5d508019a3aa81242964b1)) + + +### BREAKING CHANGES + +* Removes official Node 15 support which has reached it end-of-life date. ([45cc58c](45cc58c)) + +# [5.0.0-alpha.7](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.6...5.0.0-alpha.7) (2021-11-12) + + +### Bug Fixes + +* node engine range has no upper limit to exclude incompatible node versions ([#7692](https://github.com/parse-community/parse-server/issues/7692)) ([573558d](https://github.com/parse-community/parse-server/commit/573558d3adcbcc6222c92003829867e1a73eef94)) + +# [5.0.0-alpha.6](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.5...5.0.0-alpha.6) (2021-11-10) + + +### Reverts + +* refactor: allow ES import for cloud string if package type is module ([b64640c](https://github.com/parse-community/parse-server/commit/b64640c5705f733798783e68d216e957044ef23c)) + +# [5.0.0-alpha.5](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.4...5.0.0-alpha.5) (2021-11-01) + + +### Features + +* add user-defined schema and migrations ([#7418](https://github.com/parse-community/parse-server/issues/7418)) ([25d5c30](https://github.com/parse-community/parse-server/commit/25d5c30be2111be332eb779eb0697774a17da7af)) + +# [5.0.0-alpha.4](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.3...5.0.0-alpha.4) (2021-10-31) + + +### Features + +* add support for Postgres 14 ([#7644](https://github.com/parse-community/parse-server/issues/7644)) ([090350a](https://github.com/parse-community/parse-server/commit/090350a7a0fac945394ca1cb24b290316ef06aa7)) + +# [5.0.0-alpha.3](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.2...5.0.0-alpha.3) (2021-10-29) + + +### Bug Fixes + +* combined `and` query with relational query condition returns incorrect results ([#7593](https://github.com/parse-community/parse-server/issues/7593)) ([174886e](https://github.com/parse-community/parse-server/commit/174886e385e091c6bbd4a84891ef95f80b50d05c)) + +# [5.0.0-alpha.2](https://github.com/parse-community/parse-server/compare/5.0.0-alpha.1...5.0.0-alpha.2) (2021-10-27) + + +### Bug Fixes + +* setting a field to null does not delete it via GraphQL API ([#7649](https://github.com/parse-community/parse-server/issues/7649)) ([626fad2](https://github.com/parse-community/parse-server/commit/626fad2e71017dcc62196c487de5f908fa43000b)) + + +### BREAKING CHANGES + +* To delete a field via the GraphQL API, the field value has to be set to `null`. Previously, setting a field value to `null` would save a null value in the database, which was not according to the [GraphQL specs](https://spec.graphql.org/June2018/#sec-Null-Value). To delete a file field use `file: null`, the previous way of using `file: { file: null }` has become obsolete. ([626fad2](626fad2)) + +# [5.0.0-alpha.1](https://github.com/parse-community/parse-server/compare/4.10.4...5.0.0-alpha.1) (2021-10-12) + +## Breaking Changes +- Improved schema caching through database real-time hooks. Reduces DB queries, decreases Parse Query execution time and fixes a potential schema memory leak. If multiple Parse Server instances connect to the same DB (for example behind a load balancer), set the [Parse Server Option](https://parseplatform.org/parse-server/api/master/ParseServerOptions.html) `databaseOptions.enableSchemaHooks: true` to enable this feature and keep the schema in sync across all instances. Failing to do so will cause a schema change to not propagate to other instances and re-syncing will only happen when these instances restart. The options `enableSingleSchemaCache` and `schemaCacheTTL` have been removed. To use this feature with MongoDB, a replica set cluster with [change stream](https://docs.mongodb.com/manual/changeStreams/#availability) support is required. (Diamond Lewis, SebC) [#7214](https://github.com/parse-community/parse-server/issues/7214) +- Added file upload restriction. File upload is now only allowed for authenticated users by default for improved security. To allow file upload also for Anonymous Users or Public, set the `fileUpload` parameter in the [Parse Server Options](https://parseplatform.org/parse-server/api/master/ParseServerOptions.html) (dblythy, Manuel Trezza) [#7071](https://github.com/parse-community/parse-server/pull/7071) +- Removed [parse-server-simple-mailgun-adapter](https://github.com/parse-community/parse-server-simple-mailgun-adapter) dependency; to continue using the adapter it has to be explicitly installed (Manuel Trezza) [#7321](https://github.com/parse-community/parse-server/pull/7321) +- Remove support for MongoDB 3.6 which has reached its End-of-Life date and PostgreSQL 10 (Manuel Trezza) [#7315](https://github.com/parse-community/parse-server/pull/7315) +- Remove support for Node 10 which has reached its End-of-Life date (Manuel Trezza) [#7314](https://github.com/parse-community/parse-server/pull/7314) +- Remove S3 Files Adapter from Parse Server, instead install separately as `@parse/s3-files-adapter` (Manuel Trezza) [#7324](https://github.com/parse-community/parse-server/pull/7324) +- Remove Session field `restricted`; the field was a code artifact from a feature that never existed in Open Source Parse Server; if you have been using this field for custom purposes, consider that for new Parse Server installations the field does not exist anymore in the schema, and for existing installations the field default value `false` will not be set anymore when creating a new session (Manuel Trezza) [#7543](https://github.com/parse-community/parse-server/pull/7543) +- ci: add node engine version check (Manuel Trezza) [#7574](https://github.com/parse-community/parse-server/pull/7574) + +## Notable Changes +- Alphabetical ordered GraphQL API, improved GraphQL Schema cache system and fix GraphQL input reassign issue (Moumouls) [#7344](https://github.com/parse-community/parse-server/issues/7344) +- Added Parse Server Security Check to report weak security settings (Manuel Trezza, dblythy) [#7247](https://github.com/parse-community/parse-server/issues/7247) +- EXPERIMENTAL: Added new page router with placeholder rendering and localization of custom and feature pages such as password reset and email verification (Manuel Trezza) [#7128](https://github.com/parse-community/parse-server/pull/7128) +- EXPERIMENTAL: Added custom routes to easily customize flows for password reset, email verification or build entirely new flows (Manuel Trezza) [#7231](https://github.com/parse-community/parse-server/pull/7231) +- Added Deprecation Policy to govern the introduction of breaking changes in a phased pattern that is more predictable for developers (Manuel Trezza) [#7199](https://github.com/parse-community/parse-server/pull/7199) +- Add REST API endpoint `/loginAs` to create session of any user with master key; allows to impersonate another user. (GormanFletcher) [#7406](https://github.com/parse-community/parse-server/pull/7406) +- Add official support for MongoDB 5.0 (Manuel Trezza) [#7469](https://github.com/parse-community/parse-server/pull/7469) +- Added Parse Server Configuration `enforcePrivateUsers`, which will remove public access by default on new Parse.Users (dblythy) [#7319](https://github.com/parse-community/parse-server/pull/7319) + +## Other Changes +- Support native mongodb syntax in aggregation pipelines (Raschid JF Rafeally) [#7339](https://github.com/parse-community/parse-server/pull/7339) +- Fix error when a not yet inserted job is updated (Antonio Davi Macedo Coelho de Castro) [#7196](https://github.com/parse-community/parse-server/pull/7196) +- request.context for afterFind triggers (dblythy) [#7078](https://github.com/parse-community/parse-server/pull/7078) +- Winston Logger interpolating stdout to console (dplewis) [#7114](https://github.com/parse-community/parse-server/pull/7114) +- Added convenience method `Parse.Cloud.sendEmail(...)` to send email via email adapter in Cloud Code (dblythy) [#7089](https://github.com/parse-community/parse-server/pull/7089) +- LiveQuery support for $and, $nor, $containedBy, $geoWithin, $geoIntersects queries (dplewis) [#7113](https://github.com/parse-community/parse-server/pull/7113) +- Supporting patterns in LiveQuery server's config parameter `classNames` (Nes-si) [#7131](https://github.com/parse-community/parse-server/pull/7131) +- Added `requireAnyUserRoles` and `requireAllUserRoles` for Parse Cloud validator (dblythy) [#7097](https://github.com/parse-community/parse-server/pull/7097) +- Support Facebook Limited Login (miguel-s) [#7219](https://github.com/parse-community/parse-server/pull/7219) +- Removed Stage name check on aggregate pipelines (BRETT71) [#7237](https://github.com/parse-community/parse-server/pull/7237) +- Retry transactions on MongoDB when it fails due to transient error (Antonio Davi Macedo Coelho de Castro) [#7187](https://github.com/parse-community/parse-server/pull/7187) +- Bump tests to use Mongo 4.4.4 (Antonio Davi Macedo Coelho de Castro) [#7184](https://github.com/parse-community/parse-server/pull/7184) +- Added new account lockout policy option `accountLockout.unlockOnPasswordReset` to automatically unlock account on password reset (Manuel Trezza) [#7146](https://github.com/parse-community/parse-server/pull/7146) +- Test Parse Server continuously against all recent MongoDB versions that have not reached their end-of-life support date, added MongoDB compatibility table to Parse Server docs (Manuel Trezza) [#7161](https://github.com/parse-community/parse-server/pull/7161) +- Test Parse Server continuously against all recent Node.js versions that have not reached their end-of-life support date, added Node.js compatibility table to Parse Server docs (Manuel Trezza) [7161](https://github.com/parse-community/parse-server/pull/7177) +- Throw error on invalid Cloud Function validation configuration (dblythy) [#7154](https://github.com/parse-community/parse-server/pull/7154) +- Allow Cloud Validator `options` to be async (dblythy) [#7155](https://github.com/parse-community/parse-server/pull/7155) +- Optimize queries on classes with pointer permissions (Pedro Diaz) [#7061](https://github.com/parse-community/parse-server/pull/7061) +- Test Parse Server continuously against all relevant Postgres versions (minor versions), added Postgres compatibility table to Parse Server docs (Corey Baker) [#7176](https://github.com/parse-community/parse-server/pull/7176) +- Randomize test suite (Diamond Lewis) [#7265](https://github.com/parse-community/parse-server/pull/7265) +- LDAP: Properly unbind client on group search error (Diamond Lewis) [#7265](https://github.com/parse-community/parse-server/pull/7265) +- Improve data consistency in Push and Job Status update (Diamond Lewis) [#7267](https://github.com/parse-community/parse-server/pull/7267) +- Excluding keys that have trailing edges.node when performing GraphQL resolver (Chris Bland) [#7273](https://github.com/parse-community/parse-server/pull/7273) +- Added centralized feature deprecation with standardized warning logs (Manuel Trezza) [#7303](https://github.com/parse-community/parse-server/pull/7303) +- Use Node.js 15.13.0 in CI (Olle Jonsson) [#7312](https://github.com/parse-community/parse-server/pull/7312) +- Fix file upload issue for S3 compatible storage (Linode, DigitalOcean) by avoiding empty tags property when creating a file (Ali Oguzhan Yildiz) [#7300](https://github.com/parse-community/parse-server/pull/7300) +- Add building Docker image as CI check (Manuel Trezza) [#7332](https://github.com/parse-community/parse-server/pull/7332) +- Add NPM package-lock version check to CI (Manuel Trezza) [#7333](https://github.com/parse-community/parse-server/pull/7333) +- Fix incorrect LiveQuery events triggered for multiple subscriptions on the same class with different events [#7341](https://github.com/parse-community/parse-server/pull/7341) +- Fix select and excludeKey queries to properly accept JSON string arrays. Also allow nested fields in exclude (Corey Baker) [#7242](https://github.com/parse-community/parse-server/pull/7242) +- Fix LiveQuery server crash when using $all query operator on a missing object key (Jason Posthuma) [#7421](https://github.com/parse-community/parse-server/pull/7421) +- Added runtime deprecation warnings (Manuel Trezza) [#7451](https://github.com/parse-community/parse-server/pull/7451) +- Add ability to pass context of an object via a header, X-Parse-Cloud-Context, for Cloud Code triggers. The header addition allows client SDK's to add context without injecting _context in the body of JSON objects (Corey Baker) [#7437](https://github.com/parse-community/parse-server/pull/7437) +- Add CI check to add changelog entry (Manuel Trezza) [#7512](https://github.com/parse-community/parse-server/pull/7512) +- Refactor: uniform issue templates across repos (Manuel Trezza) [#7528](https://github.com/parse-community/parse-server/pull/7528) +- ci: bump ci environment (Manuel Trezza) [#7539](https://github.com/parse-community/parse-server/pull/7539) +- CI now pushes docker images to Docker Hub (Corey Baker) [#7548](https://github.com/parse-community/parse-server/pull/7548) +- Allow afterFind and afterLiveQueryEvent to set unsaved pointers and keys (dblythy) [#7310](https://github.com/parse-community/parse-server/pull/7310) +- Allow setting descending sort to full text queries (dblythy) [#7496](https://github.com/parse-community/parse-server/pull/7496) +- Allow cloud string for ES modules (Daniel Blyth) [#7560](https://github.com/parse-community/parse-server/pull/7560) +- docs: Introduce deprecation ID for reference in comments and online search (Manuel Trezza) [#7562](https://github.com/parse-community/parse-server/pull/7562) +- refactor: deprecate `Parse.Cloud.httpRequest`; it is recommended to use a HTTP library instead. (Daniel Blyth) [#7595](https://github.com/parse-community/parse-server/pull/7595) +- refactor: Modernize HTTPRequest tests (brandongregoryscott) [#7604](https://github.com/parse-community/parse-server/pull/7604) +- Allow liveQuery on Session class (Daniel Blyth) [#7554](https://github.com/parse-community/parse-server/pull/7554) diff --git a/changelogs/CHANGELOG_beta.md b/changelogs/CHANGELOG_beta.md new file mode 100644 index 0000000000..cb5e432a86 --- /dev/null +++ b/changelogs/CHANGELOG_beta.md @@ -0,0 +1,535 @@ +# [7.4.0-beta.1](https://github.com/parse-community/parse-server/compare/7.3.0...7.4.0-beta.1) (2024-12-23) + + +### Bug Fixes + +* `Parse.Query.distinct` fails due to invalid aggregate stage 'hint' ([#9295](https://github.com/parse-community/parse-server/issues/9295)) ([5f66c6a](https://github.com/parse-community/parse-server/commit/5f66c6a075cbe1cdaf9d1b108ee65af8ae596b89)) +* Security upgrade cross-spawn from 7.0.3 to 7.0.6 ([#9444](https://github.com/parse-community/parse-server/issues/9444)) ([3d034e0](https://github.com/parse-community/parse-server/commit/3d034e0a993e3e5bd9bb96a7e382bb3464f1eb68)) +* Security upgrade fast-xml-parser from 4.4.0 to 4.4.1 ([#9262](https://github.com/parse-community/parse-server/issues/9262)) ([992d39d](https://github.com/parse-community/parse-server/commit/992d39d508f230c774dcb764d1d907ec8887e6c5)) +* Security upgrade node from 20.14.0-alpine3.20 to 20.17.0-alpine3.20 ([#9300](https://github.com/parse-community/parse-server/issues/9300)) ([15bb17d](https://github.com/parse-community/parse-server/commit/15bb17d87153bf0d38f08fe4c720da29a204b36b)) + +### Features + +* Add support for MongoDB 8 ([#9269](https://github.com/parse-community/parse-server/issues/9269)) ([4756c66](https://github.com/parse-community/parse-server/commit/4756c66cd9f55afa1621d1a3f6fa850ed605cb53)) +* Add support for PostGIS 3.5 ([#9354](https://github.com/parse-community/parse-server/issues/9354)) ([8ea3538](https://github.com/parse-community/parse-server/commit/8ea35382db3436d54ab59bd30706705564b0985c)) +* Add support for Postgres 17 ([#9324](https://github.com/parse-community/parse-server/issues/9324)) ([fa2ee31](https://github.com/parse-community/parse-server/commit/fa2ee3196e4319a142b3838bb947c98dcba5d5cb)) +* Upgrade @parse/push-adapter from 6.7.1 to 6.8.0 ([#9489](https://github.com/parse-community/parse-server/issues/9489)) ([286aa66](https://github.com/parse-community/parse-server/commit/286aa664ac8830d36c3e70d2316917d15f0b6df5)) + +# [7.3.0-beta.1](https://github.com/parse-community/parse-server/compare/7.2.0...7.3.0-beta.1) (2024-10-03) + + +### Bug Fixes + +* Custom object ID allows to acquire role privileges ([GHSA-8xq9-g7ch-35hg](https://github.com/parse-community/parse-server/security/advisories/GHSA-8xq9-g7ch-35hg)) ([#9317](https://github.com/parse-community/parse-server/issues/9317)) ([13ee52f](https://github.com/parse-community/parse-server/commit/13ee52f0d19ef3a3524b3d79aea100e587eb3cfc)) +* Parse Server `databaseOptions` nested keys incorrectly identified as invalid ([#9213](https://github.com/parse-community/parse-server/issues/9213)) ([77206d8](https://github.com/parse-community/parse-server/commit/77206d804443cfc1618c24f8961bd677de9920c0)) +* Parse Server installation fails due to post install script incorrectly parsing required min. Node version ([#9216](https://github.com/parse-community/parse-server/issues/9216)) ([0fa82a5](https://github.com/parse-community/parse-server/commit/0fa82a54fe38ec14e8054339285d3db71a8624c8)) +* Parse Server option `maxLogFiles` doesn't recognize day duration literals such as `1d` to mean 1 day ([#9215](https://github.com/parse-community/parse-server/issues/9215)) ([0319cee](https://github.com/parse-community/parse-server/commit/0319cee2dbf65e90bad377af1ed14ea25c595bf5)) +* Security upgrade path-to-regexp from 6.2.1 to 6.3.0 ([#9314](https://github.com/parse-community/parse-server/issues/9314)) ([8b7fe69](https://github.com/parse-community/parse-server/commit/8b7fe699c1c376ecd8cc1c97cce8e704ee41f28a)) + +### Features + +* Add atomic operations for Cloud Config parameters ([#9219](https://github.com/parse-community/parse-server/issues/9219)) ([35cadf9](https://github.com/parse-community/parse-server/commit/35cadf9b8324879fb7309ba5d7ea46f2c722d614)) +* Add Cloud Code triggers `Parse.Cloud.beforeSave` and `Parse.Cloud.afterSave` for Parse Config ([#9232](https://github.com/parse-community/parse-server/issues/9232)) ([90a1e4a](https://github.com/parse-community/parse-server/commit/90a1e4a200423d644efb3f0ba2fba4b99f5cf954)) +* Add Node 22 support ([#9187](https://github.com/parse-community/parse-server/issues/9187)) ([7778471](https://github.com/parse-community/parse-server/commit/7778471999c7e42236ce404229660d80ecc2acd6)) +* Add support for asynchronous invocation of `FilesAdapter.getFileLocation` ([#9271](https://github.com/parse-community/parse-server/issues/9271)) ([1a2da40](https://github.com/parse-community/parse-server/commit/1a2da4055abe831b3017172fb75e16d7a8093873)) + +# [7.2.0-beta.1](https://github.com/parse-community/parse-server/compare/7.1.0...7.2.0-beta.1) (2024-07-09) + + +### Bug Fixes + +* Invalid push notification tokens are not cleaned up from database for FCM API v2 ([#9173](https://github.com/parse-community/parse-server/issues/9173)) ([284da09](https://github.com/parse-community/parse-server/commit/284da09f4546356b37511a589fb5f64a3efffe79)) + +### Features + +* Add support for dot notation on array fields of Parse Object ([#9115](https://github.com/parse-community/parse-server/issues/9115)) ([cf4c880](https://github.com/parse-community/parse-server/commit/cf4c8807b9da87a0a5f9c94e5bdfcf17cda80cf4)) +* Upgrade to @parse/push-adapter 6.4.0 ([#9182](https://github.com/parse-community/parse-server/issues/9182)) ([ef1634b](https://github.com/parse-community/parse-server/commit/ef1634bf1f360429108d29b08032fc7961ff96a1)) +* Upgrade to Parse JS SDK 5.3.0 ([#9180](https://github.com/parse-community/parse-server/issues/9180)) ([dca187f](https://github.com/parse-community/parse-server/commit/dca187f91b93cbb362b22a3fb9ee38451799ff13)) + +# [7.1.0-beta.1](https://github.com/parse-community/parse-server/compare/7.0.0...7.1.0-beta.1) (2024-06-30) + + +### Bug Fixes + +* `Parse.Cloud.startJob` and `Parse.Push.send` not returning status ID when setting Parse Server option `directAccess: true` ([#8766](https://github.com/parse-community/parse-server/issues/8766)) ([5b0efb2](https://github.com/parse-community/parse-server/commit/5b0efb22efe94c47f243cf8b1e6407ed5c5a67d3)) +* `Required` option not handled correctly for special fields (File, GeoPoint, Polygon) on GraphQL API mutations ([#8915](https://github.com/parse-community/parse-server/issues/8915)) ([907ad42](https://github.com/parse-community/parse-server/commit/907ad4267c228d26cfcefe7848b30ce85ba7ff8f)) +* Facebook Limited Login not working due to incorrect domain in JWT validation ([#9122](https://github.com/parse-community/parse-server/issues/9122)) ([9d0bd2b](https://github.com/parse-community/parse-server/commit/9d0bd2badd6e5f7429d1af00b118225752e5d86a)) +* Live query throws error when constraint `notEqualTo` is set to `null` ([#8835](https://github.com/parse-community/parse-server/issues/8835)) ([11d3e48](https://github.com/parse-community/parse-server/commit/11d3e484df862224c15d20f6171514948981ea90)) +* Parse Server option `extendSessionOnUse` not working for session lengths < 24 hours ([#9113](https://github.com/parse-community/parse-server/issues/9113)) ([0a054e6](https://github.com/parse-community/parse-server/commit/0a054e6b541fd5ab470bf025665f5f7d2acedaa0)) +* Rate limiting can fail when using Parse Server option `rateLimit.redisUrl` with clusters ([#8632](https://github.com/parse-community/parse-server/issues/8632)) ([c277739](https://github.com/parse-community/parse-server/commit/c27773962399f8e27691e3b8087e7e1d59516efd)) +* SQL injection when using Parse Server with PostgreSQL; fixes security vulnerability [GHSA-c2hr-cqg6-8j6r](https://github.com/parse-community/parse-server/security/advisories/GHSA-c2hr-cqg6-8j6r) ([#9167](https://github.com/parse-community/parse-server/issues/9167)) ([2edf1e4](https://github.com/parse-community/parse-server/commit/2edf1e4c0363af01e97a7fbc97694f851b7d1ff3)) + +### Features + +* Add `silent` log level for Cloud Code ([#8803](https://github.com/parse-community/parse-server/issues/8803)) ([5f81efb](https://github.com/parse-community/parse-server/commit/5f81efb42964c4c2fa8bcafee9446a0122e3ce21)) +* Add server security check status `security.enableCheck` to Features Router ([#8679](https://github.com/parse-community/parse-server/issues/8679)) ([b07ec15](https://github.com/parse-community/parse-server/commit/b07ec153825882e97cc48dc84072c7f549f3238b)) +* Prevent Parse Server start in case of unknown option in server configuration ([#8987](https://github.com/parse-community/parse-server/issues/8987)) ([8758e6a](https://github.com/parse-community/parse-server/commit/8758e6abb9dbb68757bddcbd332ad25100c24a0e)) +* Upgrade to @parse/push-adapter 6.0.0 ([#9066](https://github.com/parse-community/parse-server/issues/9066)) ([18bdbf8](https://github.com/parse-community/parse-server/commit/18bdbf89c53a57648891ef582614ba7c2941e587)) +* Upgrade to @parse/push-adapter 6.2.0 ([#9127](https://github.com/parse-community/parse-server/issues/9127)) ([ca20496](https://github.com/parse-community/parse-server/commit/ca20496f28e5ec1294a7a23c8559df82b79b2a04)) +* Upgrade to Parse JS SDK 5.2.0 ([#9128](https://github.com/parse-community/parse-server/issues/9128)) ([665b8d5](https://github.com/parse-community/parse-server/commit/665b8d52d6cf5275179a5e1fb132c934edb53ecc)) + +# [7.0.0-beta.1](https://github.com/parse-community/parse-server/compare/6.5.0-beta.1...7.0.0-beta.1) (2024-03-19) + + +### Bug Fixes + +* CacheAdapter does not connect when using a CacheAdapter with a JSON config ([#8633](https://github.com/parse-community/parse-server/issues/8633)) ([720d24e](https://github.com/parse-community/parse-server/commit/720d24e18540da35d50957f17be878316ec30318)) +* Conditional email verification not working in some cases if `verifyUserEmails`, `preventLoginWithUnverifiedEmail` set to functions ([#8838](https://github.com/parse-community/parse-server/issues/8838)) ([8e7a6b1](https://github.com/parse-community/parse-server/commit/8e7a6b1480c0117e6c73e7adc5a6619115a04e85)) +* Deny request if master key is not set in Parse Server option `masterKeyIps` regardless of ACL and CLP ([#8957](https://github.com/parse-community/parse-server/issues/8957)) ([a7b5b38](https://github.com/parse-community/parse-server/commit/a7b5b38418cbed9be3f4a7665f25b97f592663e1)) +* Docker image not published to Docker Hub on new release ([#8905](https://github.com/parse-community/parse-server/issues/8905)) ([a2ac8d1](https://github.com/parse-community/parse-server/commit/a2ac8d133c71cd7b61e5ef59c4be915cfea85db6)) +* Docker version releases by removing arm/v6 and arm/v7 support ([#8976](https://github.com/parse-community/parse-server/issues/8976)) ([1f62dd0](https://github.com/parse-community/parse-server/commit/1f62dd0f4e107b22a387692558a042ee26ce8703)) +* GraphQL file upload fails in case of use of pointer or relation ([#8721](https://github.com/parse-community/parse-server/issues/8721)) ([1aba638](https://github.com/parse-community/parse-server/commit/1aba6382c873fb489d4a898d301e6da9fb6aa61b)) +* Improve PostgreSQL injection detection; fixes security vulnerability [GHSA-6927-3vr9-fxf2](https://github.com/parse-community/parse-server/security/advisories/GHSA-6927-3vr9-fxf2) which affects Parse Server deployments using a Postgres database ([#8961](https://github.com/parse-community/parse-server/issues/8961)) ([cbefe77](https://github.com/parse-community/parse-server/commit/cbefe770a7260b54748a058b8a7389937dc35833)) +* Incomplete user object in `verifyEmail` function if both username and email are changed ([#8889](https://github.com/parse-community/parse-server/issues/8889)) ([1eb95ae](https://github.com/parse-community/parse-server/commit/1eb95aeb41a96250e582d79a703f6adcb403c08b)) +* Parse Server option `emailVerifyTokenReuseIfValid: true` generates new token on every email verification request ([#8885](https://github.com/parse-community/parse-server/issues/8885)) ([0023ce4](https://github.com/parse-community/parse-server/commit/0023ce448a5e9423337d0e1a25648bde1156bc95)) +* Parse Server option `fileExtensions` default value rejects file extensions that are less than 3 or more than 4 characters long ([#8699](https://github.com/parse-community/parse-server/issues/8699)) ([2760381](https://github.com/parse-community/parse-server/commit/276038118377c2b22381bcd8d30337203822121b)) +* Server crashes on invalid Cloud Function or Cloud Job name; fixes security vulnerability [GHSA-6hh7-46r2-vf29](https://github.com/parse-community/parse-server/security/advisories/GHSA-6hh7-46r2-vf29) ([#9024](https://github.com/parse-community/parse-server/issues/9024)) ([9f6e342](https://github.com/parse-community/parse-server/commit/9f6e3429d3b326cf4e2994733c618d08032fac6e)) +* Server crashes when receiving an array of `Parse.Pointer` in the request body ([#8784](https://github.com/parse-community/parse-server/issues/8784)) ([66e3603](https://github.com/parse-community/parse-server/commit/66e36039d8af654cfa0284666c0ddd94975dcb52)) +* Username is `undefined` in email verification link on email change ([#8887](https://github.com/parse-community/parse-server/issues/8887)) ([e315c13](https://github.com/parse-community/parse-server/commit/e315c137bf41bedfa8f0df537f2c3f6ab45b7e60)) + +### Features + +* Add `installationId` to arguments for `verifyUserEmails`, `preventLoginWithUnverifiedEmail` ([#8836](https://github.com/parse-community/parse-server/issues/8836)) ([a22dbe1](https://github.com/parse-community/parse-server/commit/a22dbe16d5ac0090608f6caaf0ebd134925b7fd4)) +* Add `installationId`, `ip`, `resendRequest` to arguments passed to `verifyUserEmails` on verification email request ([#8873](https://github.com/parse-community/parse-server/issues/8873)) ([8adcbee](https://github.com/parse-community/parse-server/commit/8adcbee11283d3e95179ca2047e2615f52c18806)) +* Add `Parse.User` as function parameter to Parse Server options `verifyUserEmails`, `preventLoginWithUnverifiedEmail` on login ([#8850](https://github.com/parse-community/parse-server/issues/8850)) ([972f630](https://github.com/parse-community/parse-server/commit/972f6300163b3cd7d95eeb95986e8322c95f821c)) +* Add password validation via POST request for user with unverified email using master key and option `ignoreEmailVerification` ([#8895](https://github.com/parse-community/parse-server/issues/8895)) ([633a9d2](https://github.com/parse-community/parse-server/commit/633a9d25e4253e2125bc93c02ee8a37e0f5f7b83)) +* Add support for MongoDB 7 ([#8761](https://github.com/parse-community/parse-server/issues/8761)) ([3de8494](https://github.com/parse-community/parse-server/commit/3de8494a221991dfd10a74e0a2dc89576265c9b7)) +* Add support for MongoDB query comment ([#8928](https://github.com/parse-community/parse-server/issues/8928)) ([2170962](https://github.com/parse-community/parse-server/commit/2170962a50fa353ed85eda3f11dce7ee3647b087)) +* Add support for Node 20, drop support for Node 14, 16 ([#8907](https://github.com/parse-community/parse-server/issues/8907)) ([ced4872](https://github.com/parse-community/parse-server/commit/ced487246ea0ef72a8aa014991f003209b34841e)) +* Add support for Postgres 16 ([#8898](https://github.com/parse-community/parse-server/issues/8898)) ([99489b2](https://github.com/parse-community/parse-server/commit/99489b22e4f0982e6cb39992974b51aa8d3a31e4)) +* Allow `Parse.Session.current` on expired session token instead of throwing error ([#8722](https://github.com/parse-community/parse-server/issues/8722)) ([f9dde4a](https://github.com/parse-community/parse-server/commit/f9dde4a9f8a90c63f71172c9bc515b0f6c6d2e4a)) +* Deprecation DEPPS5: Config option `allowClientClassCreation` defaults to `false` ([#8849](https://github.com/parse-community/parse-server/issues/8849)) ([29624e0](https://github.com/parse-community/parse-server/commit/29624e0fae17161cd412ae58d35a195cfa286cad)) +* Deprecation DEPPS6: Authentication adapters disabled by default ([#8858](https://github.com/parse-community/parse-server/issues/8858)) ([0cf58eb](https://github.com/parse-community/parse-server/commit/0cf58eb8d60c8e5f485764e154f3214c49eee430)) +* Deprecation DEPPS7: Remove deprecated Cloud Code file trigger syntax ([#8855](https://github.com/parse-community/parse-server/issues/8855)) ([4e6a375](https://github.com/parse-community/parse-server/commit/4e6a375b5184ae0f7aa256a921eca4021c609435)) +* Deprecation DEPPS8: Parse Server option `allowExpiredAuthDataToken` defaults to `false` ([#8860](https://github.com/parse-community/parse-server/issues/8860)) ([e29845f](https://github.com/parse-community/parse-server/commit/e29845f8dacac09ce3093d75c0d92330c24389e8)) +* Deprecation DEPPS9: LiveQuery `fields` option is renamed to `keys` ([#8852](https://github.com/parse-community/parse-server/issues/8852)) ([38983e8](https://github.com/parse-community/parse-server/commit/38983e8e9b5cdbd006f311a2338103624137d013)) +* Node process exits with error code 1 on uncaught exception to allow custom uncaught exception handling ([#8894](https://github.com/parse-community/parse-server/issues/8894)) ([70c280c](https://github.com/parse-community/parse-server/commit/70c280ca578ff28b5acf92f37fbe06d42a5b34ca)) +* Switch GraphQL server from Yoga v2 to Apollo v4 ([#8959](https://github.com/parse-community/parse-server/issues/8959)) ([105ae7c](https://github.com/parse-community/parse-server/commit/105ae7c8a57d5a650b243174a80c26bf6db16e28)) +* Upgrade Parse Server Push Adapter to 5.0.2 ([#8813](https://github.com/parse-community/parse-server/issues/8813)) ([6ef1986](https://github.com/parse-community/parse-server/commit/6ef1986c03a1d84b7e11c05851e5bf9688d88740)) +* Upgrade to Parse JS SDK 5 ([#9022](https://github.com/parse-community/parse-server/issues/9022)) ([ad4aa83](https://github.com/parse-community/parse-server/commit/ad4aa83983205a0e27639f6ee6a4a5963b67e4b8)) + +### Performance Improvements + +* Improved IP validation performance for `masterKeyIPs`, `maintenanceKeyIPs` ([#8510](https://github.com/parse-community/parse-server/issues/8510)) ([b87daba](https://github.com/parse-community/parse-server/commit/b87daba0671a1b0b7b8d63bc671d665c91a04522)) + + +### BREAKING CHANGES + +* The Parse Server option `allowClientClassCreation` defaults to `false`. ([29624e0](29624e0)) +* A request using the master key will now be rejected as unauthorized if the IP from which the request originates is not set in the Parse Server option `masterKeyIps`, even if the request does not require the master key permission, for example for a public object in a public class class. ([a7b5b38](a7b5b38)) +* Node process now exits with code 1 on uncaught exceptions, enabling custom handlers that were blocked by Parse Server's default behavior of re-throwing errors. This change may lead to automatic process restarts by the environment, unlike before. ([70c280c](70c280c)) +* Authentication adapters are disabled by default; to use an authentication adapter it needs to be explicitly enabled in the Parse Server authentication adapter option `auth..enabled: true` ([0cf58eb](0cf58eb)) +* Parse Server option `allowExpiredAuthDataToken` defaults to `false`; a 3rd party authentication token will be validated every time the user tries to log in and the login will fail if the token has expired; the effect of this change may differ for different authentication adapters, depending on the token lifetime and the token refresh logic of the adapter ([e29845f](e29845f)) +* LiveQuery `fields` option is renamed to `keys` ([38983e8](38983e8)) +* Cloud Code file trigger syntax has been aligned with object trigger syntax, for example `Parse.Cloud.beforeDeleteFile'` has been changed to `Parse.Cloud.beforeDelete(Parse.File, (request) => {})'` ([4e6a375](4e6a375)) +* Removes support for Node 14 and 16 ([ced4872](ced4872)) +* Removes support for Postgres 11 and 12 ([99489b2](99489b2)) +* The `Parse.User` passed as argument if `verifyUserEmails` is set to a function is renamed from `user` to `object` for consistency with invocations of `verifyUserEmails` on signup or login; the user object is not a plain JavaScript object anymore but an instance of `Parse.User` ([8adcbee](8adcbee)) +* `Parse.Session.current()` no longer throws an error if the session token is expired, but instead returns the session token with its expiration date to allow checking its validity ([f9dde4a](f9dde4a)) +* `Parse.Query` no longer supports the BSON type `code`; although this feature was never officially documented, its removal is announced as a breaking change to protect deployments where it might be in use. ([3de8494](3de8494)) + +# [6.5.0-beta.1](https://github.com/parse-community/parse-server/compare/6.4.0...6.5.0-beta.1) (2023-11-16) + + +### Bug Fixes + +* Context not passed to Cloud Code Trigger `beforeFind` when using `Parse.Query.include` ([#8765](https://github.com/parse-community/parse-server/issues/8765)) ([7d32d89](https://github.com/parse-community/parse-server/commit/7d32d8934f3ae7af7a7d8b9cc6a829c7d73973d3)) +* Parse Server option `fileUpload.fileExtensions` fails to determine file extension if filename contains multiple dots ([#8754](https://github.com/parse-community/parse-server/issues/8754)) ([3d6d50e](https://github.com/parse-community/parse-server/commit/3d6d50e0afff18b95fb906914e2cebd3839b517a)) +* Security bump @babel/traverse from 7.20.5 to 7.23.2 ([#8777](https://github.com/parse-community/parse-server/issues/8777)) ([2d6b3d1](https://github.com/parse-community/parse-server/commit/2d6b3d18499179e99be116f25c0850d3f449509c)) +* Security upgrade graphql from 16.6.0 to 16.8.1 ([#8758](https://github.com/parse-community/parse-server/issues/8758)) ([71dfd8a](https://github.com/parse-community/parse-server/commit/71dfd8a7ece8c0dd1a66d03bb9420cfd39f4f9b1)) + +### Features + +* Add `$setOnInsert` operator to `Parse.Server.database.update` ([#8791](https://github.com/parse-community/parse-server/issues/8791)) ([f630a45](https://github.com/parse-community/parse-server/commit/f630a45aa5e87bc73a81fded061400c199b71a29)) +* Add compatibility for MongoDB Atlas Serverless and AWS Amazon DocumentDB with collation options `enableCollationCaseComparison`, `transformEmailToLowercase`, `transformUsernameToLowercase` ([#8805](https://github.com/parse-community/parse-server/issues/8805)) ([09fbeeb](https://github.com/parse-community/parse-server/commit/09fbeebba8870e7cf371fb84371a254c7b368620)) +* Add context to Cloud Code Triggers `beforeLogin` and `afterLogin` ([#8724](https://github.com/parse-community/parse-server/issues/8724)) ([a9c34ef](https://github.com/parse-community/parse-server/commit/a9c34ef1e2c78a42fb8b5fa8d569b7677c74919d)) +* Allow setting `createdAt` and `updatedAt` during `Parse.Object` creation with maintenance key ([#8696](https://github.com/parse-community/parse-server/issues/8696)) ([77bbfb3](https://github.com/parse-community/parse-server/commit/77bbfb3f186f5651c33ba152f04cff95128eaf2d)) + +# [6.4.0-beta.1](https://github.com/parse-community/parse-server/compare/6.3.0...6.4.0-beta.1) (2023-09-16) + + +### Bug Fixes + +* Parse Server option `fileUpload.fileExtensions` does not work with an array of extensions ([#8688](https://github.com/parse-community/parse-server/issues/8688)) ([6a4a00c](https://github.com/parse-community/parse-server/commit/6a4a00ca7af1163ea74b047b85cd6817366b824b)) +* Redis 4 does not reconnect after unhandled error ([#8706](https://github.com/parse-community/parse-server/issues/8706)) ([2b3d4e5](https://github.com/parse-community/parse-server/commit/2b3d4e5d3c85cd142f85af68dec51a8523548d49)) +* Remove config logging when launching Parse Server via CLI ([#8710](https://github.com/parse-community/parse-server/issues/8710)) ([ae68f0c](https://github.com/parse-community/parse-server/commit/ae68f0c31b741eeb83379c905c7ddfaa124436ec)) +* Server does not start via CLI when `auth` option is set ([#8666](https://github.com/parse-community/parse-server/issues/8666)) ([4e2000b](https://github.com/parse-community/parse-server/commit/4e2000bc563324389584ace3c090a5c1a7796a64)) + +### Features + +* Add conditional email verification via dynamic Parse Server options `verifyUserEmails`, `sendUserEmailVerification` that now accept functions ([#8425](https://github.com/parse-community/parse-server/issues/8425)) ([44acd6d](https://github.com/parse-community/parse-server/commit/44acd6d9ed157ad4842200c9d01f9c77a05fec3a)) +* Add property `Parse.Server.version` to determine current version of Parse Server in Cloud Code ([#8670](https://github.com/parse-community/parse-server/issues/8670)) ([a9d376b](https://github.com/parse-community/parse-server/commit/a9d376b61f5b07806eafbda91c4e36c322f09298)) +* Add TOTP authentication adapter ([#8457](https://github.com/parse-community/parse-server/issues/8457)) ([cc079a4](https://github.com/parse-community/parse-server/commit/cc079a40f6849a0e9bc6fdc811e8649ecb67b589)) + +### Performance Improvements + +* Improve performance of recursive pointer iterations ([#8741](https://github.com/parse-community/parse-server/issues/8741)) ([45a3ed0](https://github.com/parse-community/parse-server/commit/45a3ed0fcf2c0170607505a1550fb15896e705fd)) + +# [6.3.0-beta.1](https://github.com/parse-community/parse-server/compare/6.2.0...6.3.0-beta.1) (2023-06-10) + + +### Bug Fixes + +* Cloud Code Trigger `afterSave` executes even if not set ([#8520](https://github.com/parse-community/parse-server/issues/8520)) ([afd0515](https://github.com/parse-community/parse-server/commit/afd0515e207bd947840579d3f245980dffa6f804)) +* GridFS file storage doesn't work with certain `enableSchemaHooks` settings ([#8467](https://github.com/parse-community/parse-server/issues/8467)) ([d4cda4b](https://github.com/parse-community/parse-server/commit/d4cda4b26c9bde8c812549b8780bea1cfabdb394)) +* Inaccurate table total row count for PostgreSQL ([#8511](https://github.com/parse-community/parse-server/issues/8511)) ([0823a02](https://github.com/parse-community/parse-server/commit/0823a02fbf80bc88dc403bc47e9f5c6597ea78b4)) +* LiveQuery server is not shut down properly when `handleShutdown` is called ([#8491](https://github.com/parse-community/parse-server/issues/8491)) ([967700b](https://github.com/parse-community/parse-server/commit/967700bdbc94c74f75ba84d2b3f4b9f3fd2dca0b)) +* Rate limit feature is incompatible with Node 14 ([#8578](https://github.com/parse-community/parse-server/issues/8578)) ([f911f2c](https://github.com/parse-community/parse-server/commit/f911f2cd3a8c45cd326272dcd681532764a3761e)) +* Unnecessary log entries by `extendSessionOnUse` ([#8562](https://github.com/parse-community/parse-server/issues/8562)) ([fd6a007](https://github.com/parse-community/parse-server/commit/fd6a0077f2e5cf83d65e52172ae5a950ab0f1eae)) + +### Features + +* `extendSessionOnUse` to automatically renew Parse Sessions ([#8505](https://github.com/parse-community/parse-server/issues/8505)) ([6f885d3](https://github.com/parse-community/parse-server/commit/6f885d36b94902fdfea873fc554dee83589e6029)) +* Add new Parse Server option `preventSignupWithUnverifiedEmail` to prevent returning a user without session token on sign-up with unverified email address ([#8451](https://github.com/parse-community/parse-server/issues/8451)) ([82da308](https://github.com/parse-community/parse-server/commit/82da30842a55980aa90cb7680fbf6db37ee16dab)) +* Add option to change the log level of logs emitted by Cloud Functions ([#8530](https://github.com/parse-community/parse-server/issues/8530)) ([2caea31](https://github.com/parse-community/parse-server/commit/2caea310be412d82b04a85716bc769ccc410316d)) +* Add support for `$eq` query constraint in LiveQuery ([#8614](https://github.com/parse-community/parse-server/issues/8614)) ([656d673](https://github.com/parse-community/parse-server/commit/656d673cf5dea354e4f2b3d4dc2b29a41d311b3e)) +* Add zones for rate limiting by `ip`, `user`, `session`, `global` ([#8508](https://github.com/parse-community/parse-server/issues/8508)) ([03fba97](https://github.com/parse-community/parse-server/commit/03fba97e0549bfcaeee9f2fa4c9905dbcc91840e)) +* Allow `Parse.Object` pointers in Cloud Code arguments ([#8490](https://github.com/parse-community/parse-server/issues/8490)) ([28aeda3](https://github.com/parse-community/parse-server/commit/28aeda3f160efcbbcf85a85484a8d26567fa9761)) + +### Reverts + +* fix: Inaccurate table total row count for PostgreSQL ([6722110](https://github.com/parse-community/parse-server/commit/6722110f203bc5fdcaa68cdf091cf9e7b48d1cff)) + +# [6.1.0-beta.2](https://github.com/parse-community/parse-server/compare/6.1.0-beta.1...6.1.0-beta.2) (2023-05-01) + + +### Bug Fixes + +* LiveQuery can return incorrectly formatted date ([#8456](https://github.com/parse-community/parse-server/issues/8456)) ([4ce135a](https://github.com/parse-community/parse-server/commit/4ce135a4fe930776044bc8fd786a4e17a0144e03)) +* Nested date is incorrectly decoded as empty object `{}` when fetching a Parse Object ([#8446](https://github.com/parse-community/parse-server/issues/8446)) ([22d2446](https://github.com/parse-community/parse-server/commit/22d2446dfea2bc339affc20535d181097e152acf)) +* Parameters missing in `afterFind` trigger of authentication adapters ([#8458](https://github.com/parse-community/parse-server/issues/8458)) ([ce34747](https://github.com/parse-community/parse-server/commit/ce34747e8af54cb0b6b975da38f779a5955d2d59)) +* Rate limiting across multiple servers via Redis not working ([#8469](https://github.com/parse-community/parse-server/issues/8469)) ([d9e347d](https://github.com/parse-community/parse-server/commit/d9e347d7413f30f58ffbb8397fc8b5ae23be6ff0)) + +### Features + +* Add `afterFind` trigger to authentication adapters ([#8444](https://github.com/parse-community/parse-server/issues/8444)) ([c793bb8](https://github.com/parse-community/parse-server/commit/c793bb88e7485743c7ceb65fe419cde75833ff33)) +* Add rate limiting across multiple servers via Redis ([#8394](https://github.com/parse-community/parse-server/issues/8394)) ([34833e4](https://github.com/parse-community/parse-server/commit/34833e42eec08b812b733be78df0535ab0e096b6)) +* Allow multiple origins for header `Access-Control-Allow-Origin` ([#8517](https://github.com/parse-community/parse-server/issues/8517)) ([4f15539](https://github.com/parse-community/parse-server/commit/4f15539ac244aa2d393ac5177f7604b43f69e271)) +* Export `AuthAdapter` to make it available for extension with custom authentication adapters ([#8443](https://github.com/parse-community/parse-server/issues/8443)) ([40c1961](https://github.com/parse-community/parse-server/commit/40c196153b8efa12ae384c1c0092b2ed60a260d6)) + +# [6.1.0-beta.1](https://github.com/parse-community/parse-server/compare/6.0.0...6.1.0-beta.1) (2023-03-02) + + +### Bug Fixes + +* Security upgrade jsonwebtoken to 9.0.0 ([#8420](https://github.com/parse-community/parse-server/issues/8420)) ([f5bfe45](https://github.com/parse-community/parse-server/commit/f5bfe4571e82b2b7440d41f3cff0d49937398164)) + +### Features + +* Add option `schemaCacheTtl` for schema cache pulling as alternative to `enableSchemaHooks` ([#8436](https://github.com/parse-community/parse-server/issues/8436)) ([b3b76de](https://github.com/parse-community/parse-server/commit/b3b76de71b1d4265689d052e7837c38ec1fa4323)) +* Add Parse Server option `resetPasswordSuccessOnInvalidEmail` to choose success or error response on password reset with invalid email ([#7551](https://github.com/parse-community/parse-server/issues/7551)) ([e5d610e](https://github.com/parse-community/parse-server/commit/e5d610e5e487ddab86409409ac3d7362aba8f59b)) +* Deprecate LiveQuery `fields` option in favor of `keys` for semantic consistency ([#8388](https://github.com/parse-community/parse-server/issues/8388)) ([a49e323](https://github.com/parse-community/parse-server/commit/a49e323d5ae640bff1c6603ec37fdaddb9328dd1)) + +# [6.0.0-beta.1](https://github.com/parse-community/parse-server/compare/5.4.0...6.0.0-beta.1) (2023-01-31) + + +### Bug Fixes + +* `ParseServer.verifyServerUrl` may fail if server response headers are missing; remove unnecessary logging ([#8391](https://github.com/parse-community/parse-server/issues/8391)) ([1c37a7c](https://github.com/parse-community/parse-server/commit/1c37a7cd0715949a70b220a629071c7dab7d5e7b)) +* Cloud Code trigger `beforeSave` does not work with `Parse.Role` ([#8320](https://github.com/parse-community/parse-server/issues/8320)) ([f29d972](https://github.com/parse-community/parse-server/commit/f29d9720e9b37918fd885c97a31e34c42750e724)) +* ES6 modules do not await the import of Cloud Code files ([#8368](https://github.com/parse-community/parse-server/issues/8368)) ([a7bd180](https://github.com/parse-community/parse-server/commit/a7bd180cddd784c8735622f22e012c342ad535fb)) +* Nested objects are encoded incorrectly for MongoDB ([#8209](https://github.com/parse-community/parse-server/issues/8209)) ([1412666](https://github.com/parse-community/parse-server/commit/1412666f75829612de6fb9d7ccae35761c9b75cb)) +* Parse Server option `masterKeyIps` does not include localhost by default for IPv6 ([#8322](https://github.com/parse-community/parse-server/issues/8322)) ([ab82635](https://github.com/parse-community/parse-server/commit/ab82635b0d4cf323a07ddee51fee587b43dce95c)) +* Rate limiter may reject requests that contain a session token ([#8399](https://github.com/parse-community/parse-server/issues/8399)) ([c114dc8](https://github.com/parse-community/parse-server/commit/c114dc8831055d74187b9dfb4c9eeb558520237c)) +* Remove Node 12 and Node 17 support ([#8279](https://github.com/parse-community/parse-server/issues/8279)) ([2546cc8](https://github.com/parse-community/parse-server/commit/2546cc8572bea6610cb9b3c7401d9afac0e3c1d6)) +* Schema without class level permissions may cause error ([#8409](https://github.com/parse-community/parse-server/issues/8409)) ([aa2cd51](https://github.com/parse-community/parse-server/commit/aa2cd51b703388d925e4572e5c2b2d883c68e49c)) +* The client IP address may be determined incorrectly in some cases; this fixes a security vulnerability in which the Parse Server option `masterKeyIps` may be circumvented, see [GHSA-vm5r-c87r-pf6x](https://github.com/parse-community/parse-server/security/advisories/GHSA-vm5r-c87r-pf6x) ([#8372](https://github.com/parse-community/parse-server/issues/8372)) ([892040d](https://github.com/parse-community/parse-server/commit/892040dc2f82a3e2abe2824e4b553521b6f894de)) +* Throwing error in Cloud Code Triggers `afterLogin`, `afterLogout` crashes server ([#8280](https://github.com/parse-community/parse-server/issues/8280)) ([130d290](https://github.com/parse-community/parse-server/commit/130d29074e3f763460e5685d0b9059e5a333caff)) + +### Features + +* Access the internal scope of Parse Server using the new `maintenanceKey`; the internal scope contains unofficial and undocumented fields (prefixed with underscore `_`) which are used internally by Parse Server; you may want to manipulate these fields for out-of-band changes such as data migration or correction tasks; changes within the internal scope of Parse Server may happen at any time without notice or changelog entry, it is therefore recommended to look at the source code of Parse Server to understand the effects of manipulating internal fields before using the key; it is discouraged to use the `maintenanceKey` for routine operations in a production environment; see [access scopes](https://github.com/parse-community/parse-server#access-scopes) ([#8212](https://github.com/parse-community/parse-server/issues/8212)) ([f3bcc93](https://github.com/parse-community/parse-server/commit/f3bcc9365cd6f08b0a32c132e8e5ff6d1b650863)) +* Adapt `verifyServerUrl` for new asynchronous Parse Server start-up states ([#8366](https://github.com/parse-community/parse-server/issues/8366)) ([ffa4974](https://github.com/parse-community/parse-server/commit/ffa4974158615fbff4a2692b9db41dcb50d3f77b)) +* Add `ParseQuery.watch` to trigger LiveQuery only on update of specific fields ([#8028](https://github.com/parse-community/parse-server/issues/8028)) ([fc92faa](https://github.com/parse-community/parse-server/commit/fc92faac75107b3392eeddd916c4c5b45e3c5e0c)) +* Add Node 19 support ([#8363](https://github.com/parse-community/parse-server/issues/8363)) ([a4990dc](https://github.com/parse-community/parse-server/commit/a4990dcd29abcb4442f3c424aff482a0a116160f)) +* Add option to change the log level of the logs emitted by triggers ([#8328](https://github.com/parse-community/parse-server/issues/8328)) ([8f3b694](https://github.com/parse-community/parse-server/commit/8f3b694e39d4a966567e50dbea4d62e954fa5c06)) +* Add request rate limiter based on IP address ([#8174](https://github.com/parse-community/parse-server/issues/8174)) ([6c79f6a](https://github.com/parse-community/parse-server/commit/6c79f6a69e25e47846e3b0685d6bdfd6b91086b1)) +* Asynchronous initialization of Parse Server ([#8232](https://github.com/parse-community/parse-server/issues/8232)) ([99fcf45](https://github.com/parse-community/parse-server/commit/99fcf45e55c368de2345b0c4d780e70e0adf0e15)) +* Improve authentication adapter interface to support multi-factor authentication (MFA), authentication challenges, and provide a more powerful interface for writing custom authentication adapters ([#8156](https://github.com/parse-community/parse-server/issues/8156)) ([5bbf9ca](https://github.com/parse-community/parse-server/commit/5bbf9cade9a527787fd1002072d4013ab5d8db2b)) +* Reduce Docker image size by improving stages ([#8359](https://github.com/parse-community/parse-server/issues/8359)) ([40810b4](https://github.com/parse-community/parse-server/commit/40810b48ebde8b1f21d2448a3a4de0585b1b5e34)) +* Remove deprecation `DEPPS1`: Native MongoDB syntax in aggregation pipeline ([#8362](https://github.com/parse-community/parse-server/issues/8362)) ([d0d30c4](https://github.com/parse-community/parse-server/commit/d0d30c4f1394f563724644a8fc81734be538a2c0)) +* Remove deprecation `DEPPS2`: Config option `directAccess` defaults to true ([#8284](https://github.com/parse-community/parse-server/issues/8284)) ([f535ee6](https://github.com/parse-community/parse-server/commit/f535ee6ec2abba63f702127258ca49fa5b4e08c9)) +* Remove deprecation `DEPPS3`: Config option `enforcePrivateUsers` defaults to `true` ([#8283](https://github.com/parse-community/parse-server/issues/8283)) ([ed499e3](https://github.com/parse-community/parse-server/commit/ed499e32a21bab9a874a9e5367dc71248ce836c4)) +* Remove deprecation `DEPPS4`: Remove convenience method for http request `Parse.Cloud.httpRequest` ([#8287](https://github.com/parse-community/parse-server/issues/8287)) ([2d79c08](https://github.com/parse-community/parse-server/commit/2d79c0835b6a9acaf20d5c943d9b4619bb96831c)) +* Remove support for MongoDB 4.0 ([#8292](https://github.com/parse-community/parse-server/issues/8292)) ([37245f6](https://github.com/parse-community/parse-server/commit/37245f62ce83516b6b95a54b850f0274ef680478)) +* Restrict use of `masterKey` to localhost by default ([#8281](https://github.com/parse-community/parse-server/issues/8281)) ([6c16021](https://github.com/parse-community/parse-server/commit/6c16021a1f03a70a6d9e68cb64df362d07f3b693)) +* Upgrade Node Package Manager lock file `package-lock.json` to version 2 ([#8285](https://github.com/parse-community/parse-server/issues/8285)) ([ee72467](https://github.com/parse-community/parse-server/commit/ee7246733d63e4bda20401f7b00262ff03299f20)) +* Upgrade Redis 3 to 4 ([#8293](https://github.com/parse-community/parse-server/issues/8293)) ([7d622f0](https://github.com/parse-community/parse-server/commit/7d622f06a4347e0ad2cba9a4ec07d8d4fb0f67bc)) +* Upgrade Redis 3 to 4 for LiveQuery ([#8333](https://github.com/parse-community/parse-server/issues/8333)) ([b2761fb](https://github.com/parse-community/parse-server/commit/b2761fb3786b519d9bbcf35be54309d2d35da1a9)) +* Upgrade to Parse JavaScript SDK 4 ([#8332](https://github.com/parse-community/parse-server/issues/8332)) ([9092874](https://github.com/parse-community/parse-server/commit/9092874a9a482a24dfdce1dce56615702999d6b8)) +* Write log entry when request with master key is rejected as outside of `masterKeyIps` ([#8350](https://github.com/parse-community/parse-server/issues/8350)) ([e22b73d](https://github.com/parse-community/parse-server/commit/e22b73d4b700c8ff745aa81726c6680082294b45)) + + +### BREAKING CHANGES + +* The Docker image does not contain the git dependency anymore; if you have been using git as a transitive dependency it now needs to be explicitly installed in your Docker file, for example with `RUN apk --no-cache add git` (#8359) ([40810b4](40810b4)) +* Fields in the internal scope of Parse Server (prefixed with underscore `_`) are only returned using the new `maintenanceKey`; previously the `masterKey` allowed reading of internal fields; see [access scopes](https://github.com/parse-community/parse-server#access-scopes) for a comparison of the keys' access permissions (#8212) ([f3bcc93](f3bcc93)) +* The method `ParseServer.verifyServerUrl` now returns a promise instead of a callback. ([ffa4974](ffa4974)) +* The MongoDB aggregation pipeline requires native MongoDB syntax instead of the custom Parse Server syntax; for example pipeline stage names require a leading dollar sign like `$match` and the MongoDB document ID is referenced using `_id` instead of `objectId` (#8362) ([d0d30c4](d0d30c4)) +* The mechanism to determine the client IP address has been rewritten; to correctly determine the IP address it is now required to set the Parse Server option `trustProxy` accordingly if Parse Server runs behind a proxy server, see the express framework's [trust proxy](https://expressjs.com/en/guide/behind-proxies.html) setting (#8372) ([892040d](892040d)) +* The Node Package Manager lock file `package-lock.json` is upgraded to version 2; while it is backwards with version 1 for the npm installer, consider this if you run any non-npm analysis tools that use the lock file (#8285) ([ee72467](ee72467)) +* This release introduces the asynchronous initialization of Parse Server to prevent mounting Parse Server before being ready to receive request; it changes how Parse Server is imported, initialized and started; it also removes the callback `serverStartComplete`; see the [Parse Server 6 migration guide](https://github.com/parse-community/parse-server/blob/alpha/6.0.0.md) for more details (#8232) ([99fcf45](99fcf45)) +* Nested objects are now properly stored in the database using JSON serialization; previously, due to a bug only top-level objects were serialized, but nested objects were saved as raw JSON; for example, a nested `Date` object was saved as a JSON object like `{ "__type": "Date", "iso": "2020-01-01T00:00:00.000Z" }` instead of its serialized representation `2020-01-01T00:00:00.000Z` (#8209) ([1412666](1412666)) +* The Parse Server option `enforcePrivateUsers` is set to `true` by default; in previous releases this option defaults to `false`; this change improves the default security configuration of Parse Server (#8283) ([ed499e3](ed499e3)) +* This release restricts the use of `masterKey` to localhost by default; if you are using Parse Dashboard on a different server to connect to Parse Server you need to add the IP address of the server that hosts Parse Dashboard to this option (#8281) ([6c16021](6c16021)) +* This release upgrades to Redis 4; if you are using the Redis cache adapter with Parse Server then this is a breaking change as the Redis client options have changed; see the [Redis migration guide](https://github.com/redis/node-redis/blob/redis%404.0.0/docs/v3-to-v4.md) for more details (#8293) ([7d622f0](7d622f0)) +* This release removes support for MongoDB 4.0; the new minimum supported MongoDB version is 4.2. which also removes support for the deprecated MongoDB MMAPv1 storage engine ([37245f6](37245f6)) +* Throwing an error in Cloud Code Triggers `afterLogin`, `afterLogout` returns a rejected promise; in previous releases it crashed the server if you did not handle the error on the Node.js process level; consider adapting your code if your app currently handles these errors on the Node.js process level with `process.on('unhandledRejection', ...)` ([130d290](130d290)) +* Config option `directAccess` defaults to true; set this to `false` in environments where multiple Parse Server instances run behind a load balancer and Parse requests within the current Node.js environment should be routed via the load balancer and distributed as HTTP requests among all instances via the `serverURL`. ([f535ee6](f535ee6)) +* The convenience method for HTTP requests `Parse.Cloud.httpRequest` is removed; use your preferred 3rd party library for making HTTP requests ([2d79c08](2d79c08)) +* This release removes Node 12 and Node 17 support ([2546cc8](2546cc8)) + +# [5.4.0-beta.1](https://github.com/parse-community/parse-server/compare/5.3.0...5.4.0-beta.1) (2022-10-29) + + +### Bug Fixes + +* authentication adapter app ID validation may be circumvented; this fixes a vulnerability that affects configurations which allow users to authenticate using the Parse Server authentication adapter for *Facebook* or *Spotify* and where the server-side authentication adapter configuration `appIds` is set as a string (e.g. `abc`) instead of an array of strings (e.g. `["abc"]`) ([GHSA-r657-33vp-gp22](https://github.com/parse-community/parse-server/security/advisories/GHSA-r657-33vp-gp22)) [skip release] ([#8187](https://github.com/parse-community/parse-server/issues/8187)) ([8c8ec71](https://github.com/parse-community/parse-server/commit/8c8ec715739e0f851338cfed794409ebac66c51b)) +* brute force guessing of user sensitive data via search patterns (GHSA-2m6g-crv8-p3c6) ([#8146](https://github.com/parse-community/parse-server/issues/8146)) [skip release] ([4c0c7c7](https://github.com/parse-community/parse-server/commit/4c0c7c77b76257878b9bcb05ff9de01c9d790262)) +* certificate in Apple Game Center auth adapter not validated [skip release] ([#8058](https://github.com/parse-community/parse-server/issues/8058)) ([75af9a2](https://github.com/parse-community/parse-server/commit/75af9a26cc8e9e88a33d1e452c93a0ee6e509f17)) +* graphQL query ignores condition `equalTo` with value `false` ([#8032](https://github.com/parse-community/parse-server/issues/8032)) ([7f5a15d](https://github.com/parse-community/parse-server/commit/7f5a15d5df0dfa3515e9f73709d6a49663545f9b)) +* internal indices for classes `_Idempotency` and `_Role` are not protected in defined schema ([#8121](https://github.com/parse-community/parse-server/issues/8121)) ([c16f529](https://github.com/parse-community/parse-server/commit/c16f529f74f92154401bf662f634b3c5fa45e18e)) +* invalid file request not properly handled [skip release] ([#8062](https://github.com/parse-community/parse-server/issues/8062)) ([4c9e956](https://github.com/parse-community/parse-server/commit/4c9e95674ad081f13062e8cd30b77b1962d5df57)) +* liveQuery with `containedIn` not working when object field is an array ([#8128](https://github.com/parse-community/parse-server/issues/8128)) ([1d9605b](https://github.com/parse-community/parse-server/commit/1d9605bc93009263d3811df4d4249034ba6eb8c4)) +* protected fields exposed via LiveQuery (GHSA-crrq-vr9j-fxxh) [skip release] ([#8076](https://github.com/parse-community/parse-server/issues/8076)) ([9fd4516](https://github.com/parse-community/parse-server/commit/9fd4516cde5c742f9f29dd05468b4a43a85639a6)) +* push notifications `badge` doesn't update with Installation beforeSave trigger ([#8162](https://github.com/parse-community/parse-server/issues/8162)) ([3c75c2b](https://github.com/parse-community/parse-server/commit/3c75c2ba4851fae96a8c19b11a3efde03816c9a1)) +* query aggregation pipeline cannot handle value of type `Date` when `directAccess: true` ([#8167](https://github.com/parse-community/parse-server/issues/8167)) ([e424137](https://github.com/parse-community/parse-server/commit/e4241374061caef66538de15112fb6bbafb1f5bb)) +* relation constraints in compound queries `Parse.Query.or`, `Parse.Query.and` not working ([#8203](https://github.com/parse-community/parse-server/issues/8203)) ([28f0d26](https://github.com/parse-community/parse-server/commit/28f0d2667787d2ac68726607b811d6f0ef62b9f1)) +* security upgrade undici from 5.6.0 to 5.8.0 ([#8108](https://github.com/parse-community/parse-server/issues/8108)) ([4aa016b](https://github.com/parse-community/parse-server/commit/4aa016b7322467422b9fdf05d8e29b9ecf910da7)) +* server crashes when receiving file download request with invalid byte range; this fixes a security vulnerability that allows an attacker to impact the availability of the server instance; the fix improves parsing of the range parameter to properly handle invalid range requests ([GHSA-h423-w6qv-2wj3](https://github.com/parse-community/parse-server/security/advisories/GHSA-h423-w6qv-2wj3)) [skip release] ([#8238](https://github.com/parse-community/parse-server/issues/8238)) ([c03908f](https://github.com/parse-community/parse-server/commit/c03908f74e5c9eed834874a89df6c89c1a1e849f)) +* session object properties can be updated by foreign user; this fixes a security vulnerability in which a foreign user can write to the session object of another user if the session object ID is known; the fix prevents writing to foreign session objects ([GHSA-6w4q-23cf-j9jp](https://github.com/parse-community/parse-server/security/advisories/GHSA-6w4q-23cf-j9jp)) [skip release] ([#8180](https://github.com/parse-community/parse-server/issues/8180)) ([37fed30](https://github.com/parse-community/parse-server/commit/37fed3062ccc3ef1dfd49a9fc53318e72b3e4aff)) +* sorting by non-existing value throws `INVALID_SERVER_ERROR` on Postgres ([#8157](https://github.com/parse-community/parse-server/issues/8157)) ([3b775a1](https://github.com/parse-community/parse-server/commit/3b775a1fb8a1878714e3451191438963d688f1b0)) +* updating object includes unchanged keys in client response for certain key types ([#8159](https://github.com/parse-community/parse-server/issues/8159)) ([37af1d7](https://github.com/parse-community/parse-server/commit/37af1d78fce5a15039ffe3af7b323c1f1e8582fc)) + +### Features + +* add convenience access to Parse Server configuration in Cloud Code via `Parse.Server` ([#8244](https://github.com/parse-community/parse-server/issues/8244)) ([9f11115](https://github.com/parse-community/parse-server/commit/9f111158edf7fd57a65db0c4f9244b37e58cf293)) +* add option to change the default value of the `Parse.Query.limit()` constraint ([#8152](https://github.com/parse-community/parse-server/issues/8152)) ([0388956](https://github.com/parse-community/parse-server/commit/038895680894984e569dff54bf5c7b31094f3891)) +* add support for MongoDB 6 ([#8242](https://github.com/parse-community/parse-server/issues/8242)) ([aba0081](https://github.com/parse-community/parse-server/commit/aba0081ce1a166a93de57f3928c19a05562b5cc1)) +* add support for Postgres 15 ([#8215](https://github.com/parse-community/parse-server/issues/8215)) ([2feb6c4](https://github.com/parse-community/parse-server/commit/2feb6c46080946c984daa351187fa07cd582355d)) +* liveQuery support for unsorted distance queries ([#8221](https://github.com/parse-community/parse-server/issues/8221)) ([0f763da](https://github.com/parse-community/parse-server/commit/0f763da17d646b2fec2cd980d3857e46072a8a07)) + +# [5.3.0-beta.1](https://github.com/parse-community/parse-server/compare/5.2.1...5.3.0-beta.1) (2022-06-17) + + +### Bug Fixes + +* afterSave trigger removes pointer in Parse object ([#7913](https://github.com/parse-community/parse-server/issues/7913)) ([47d796e](https://github.com/parse-community/parse-server/commit/47d796ea58f65e71612ce37149be692abc9ea97f)) +* auto-release process may fail if optional back-merging task fails ([#8051](https://github.com/parse-community/parse-server/issues/8051)) ([cf925e7](https://github.com/parse-community/parse-server/commit/cf925e75e87a6989f41e2e2abb2aba4332b1e79f)) +* custom database options are not passed to MongoDB GridFS ([#7911](https://github.com/parse-community/parse-server/issues/7911)) ([b1e5565](https://github.com/parse-community/parse-server/commit/b1e5565b22f2eff229571fe9a9500314bd30965b)) +* depreciate allowClientClassCreation defaulting to true ([#7925](https://github.com/parse-community/parse-server/issues/7925)) ([38ed96a](https://github.com/parse-community/parse-server/commit/38ed96ace534d639db007aa7dd5387b2da8f03ae)) +* errors in GraphQL do not show the original error but a general `Unexpected Error` ([#8045](https://github.com/parse-community/parse-server/issues/8045)) ([0d81887](https://github.com/parse-community/parse-server/commit/0d818879c217f9c56100a5f59868fa37e6d24b71)) +* interrupted WebSocket connection not closed by LiveQuery server ([#8012](https://github.com/parse-community/parse-server/issues/8012)) ([2d5221e](https://github.com/parse-community/parse-server/commit/2d5221e48012fb7781c0406d543a922d313075ea)) +* live query role cache does not clear when a user is added to a role ([#8026](https://github.com/parse-community/parse-server/issues/8026)) ([199dfc1](https://github.com/parse-community/parse-server/commit/199dfc17226d85a78ab85f24362cce740f4ada39)) +* peer dependency mismatch for GraphQL dependencies ([#7934](https://github.com/parse-community/parse-server/issues/7934)) ([0a6faa8](https://github.com/parse-community/parse-server/commit/0a6faa81fa97f8620e7fd05e8c7bbdb4b7da9578)) +* return correct response when revert is used in beforeSave ([#7839](https://github.com/parse-community/parse-server/issues/7839)) ([19900fc](https://github.com/parse-community/parse-server/commit/19900fcdf8c9f29a674fb62cf6e4b3341d796891)) +* security upgrade @parse/fs-files-adapter from 1.2.1 to 1.2.2 ([#7948](https://github.com/parse-community/parse-server/issues/7948)) ([3a70fda](https://github.com/parse-community/parse-server/commit/3a70fda6798d4143f21046439b5eaf232a31bdb6)) +* security upgrade moment from 2.29.1 to 2.29.2 ([#7931](https://github.com/parse-community/parse-server/issues/7931)) ([731c550](https://github.com/parse-community/parse-server/commit/731c5507144bbacff236097e7a2a03bfe54f6e10)) +* security upgrade parse push adapter from 4.1.0 to 4.1.2 ([#7893](https://github.com/parse-community/parse-server/issues/7893)) ([93667b4](https://github.com/parse-community/parse-server/commit/93667b4e8402bf13b46c4d3ef12cec6532fd9da7)) +* websocket connection of LiveQuery interrupts frequently ([#8048](https://github.com/parse-community/parse-server/issues/8048)) ([03caae1](https://github.com/parse-community/parse-server/commit/03caae1e611f28079cdddbbe433daaf69e3f595c)) + +### Features + +* add MongoDB 5.1 compatibility ([#7682](https://github.com/parse-community/parse-server/issues/7682)) ([022a856](https://github.com/parse-community/parse-server/commit/022a85619d8a2c57a2f2938e245e4d8a47c15276)) +* add MongoDB 5.2 support ([#7894](https://github.com/parse-community/parse-server/issues/7894)) ([5bfa716](https://github.com/parse-community/parse-server/commit/5bfa7160d9e35b237cbae1016ed86724aa99f8d7)) +* add support for Node 17 and 18 ([#7896](https://github.com/parse-community/parse-server/issues/7896)) ([3e9f292](https://github.com/parse-community/parse-server/commit/3e9f292d840334244934cee9a34545ac86313549)) +* align file trigger syntax with class trigger; use the new syntax `Parse.Cloud.beforeSave(Parse.File, (request) => {})`, the old syntax `Parse.Cloud.beforeSaveFile((request) => {})` has been deprecated ([#7966](https://github.com/parse-community/parse-server/issues/7966)) ([c6dcad8](https://github.com/parse-community/parse-server/commit/c6dcad8d167d44912dbd416d328519314c0809bd)) +* replace GraphQL Apollo with GraphQL Yoga ([#7967](https://github.com/parse-community/parse-server/issues/7967)) ([1aa2204](https://github.com/parse-community/parse-server/commit/1aa2204aebfdbe273d54d6d56c6029f7c34aab14)) +* selectively enable / disable default authentication adapters ([#7953](https://github.com/parse-community/parse-server/issues/7953)) ([c1e808f](https://github.com/parse-community/parse-server/commit/c1e808f9e807fc49508acbde0d8b3f2b901a1638)) +* upgrade mongodb from 4.4.1 to 4.5.0 ([#7991](https://github.com/parse-community/parse-server/issues/7991)) ([e692b5d](https://github.com/parse-community/parse-server/commit/e692b5dd8214cdb0ce79bedd30d9aa3cf4de76a5)) + +### Performance Improvements + +* reduce database operations when using the constant parameter in Cloud Function validation ([#7892](https://github.com/parse-community/parse-server/issues/7892)) ([041197f](https://github.com/parse-community/parse-server/commit/041197fb4ca1cd7cf18dc426ce38647267823668)) + +# [5.2.0-beta.2](https://github.com/parse-community/parse-server/compare/5.2.0-beta.1...5.2.0-beta.2) (2022-03-24) + + +### Bug Fixes + +* security bump minimist from 1.2.5 to 1.2.6 ([#7884](https://github.com/parse-community/parse-server/issues/7884)) ([c5cf282](https://github.com/parse-community/parse-server/commit/c5cf282d11ffdc023764f8e7539a2bd6bc246fe1)) +* sensitive keyword detection may produce false positives ([#7881](https://github.com/parse-community/parse-server/issues/7881)) ([0d6f9e9](https://github.com/parse-community/parse-server/commit/0d6f9e951d9e186e95e96d8869066ce7022bad02)) + +# [5.2.0-beta.1](https://github.com/parse-community/parse-server/compare/5.1.1...5.2.0-beta.1) (2022-03-23) + + +### Features + +* improved LiveQuery error logging with additional information ([#7837](https://github.com/parse-community/parse-server/issues/7837)) ([443a509](https://github.com/parse-community/parse-server/commit/443a5099059538d379fe491793a5871fcbb4f377)) + +# [5.0.0-beta.10](https://github.com/parse-community/parse-server/compare/5.0.0-beta.9...5.0.0-beta.10) (2022-03-15) + + +### Bug Fixes + +* adding or modifying a nested property requires addField permissions ([#7679](https://github.com/parse-community/parse-server/issues/7679)) ([6a6248b](https://github.com/parse-community/parse-server/commit/6a6248b6cb2e732d17131e18e659943b894ed2f1)) +* bump nanoid from 3.1.25 to 3.2.0 ([#7781](https://github.com/parse-community/parse-server/issues/7781)) ([f5f63bf](https://github.com/parse-community/parse-server/commit/f5f63bfc64d3481ed944ceb5e9f50b33dccd1ce9)) +* bump node-fetch from 2.6.1 to 3.1.1 ([#7782](https://github.com/parse-community/parse-server/issues/7782)) ([9082351](https://github.com/parse-community/parse-server/commit/90823514113a1a085ebc818f7109b3fd7591346f)) +* node engine compatibility did not include node 16 ([#7739](https://github.com/parse-community/parse-server/issues/7739)) ([ea7c014](https://github.com/parse-community/parse-server/commit/ea7c01400f992a1263543706fe49b6174758a2d6)) +* node engine range has no upper limit to exclude incompatible node versions ([#7692](https://github.com/parse-community/parse-server/issues/7692)) ([573558d](https://github.com/parse-community/parse-server/commit/573558d3adcbcc6222c92003829867e1a73eef94)) +* package.json & package-lock.json to reduce vulnerabilities ([#7823](https://github.com/parse-community/parse-server/issues/7823)) ([5ca2288](https://github.com/parse-community/parse-server/commit/5ca228882332b65f3ac05407e6e4da1ee3ef3749)) +* schema cache not cleared in some cases ([#7678](https://github.com/parse-community/parse-server/issues/7678)) ([5af6e5d](https://github.com/parse-community/parse-server/commit/5af6e5dfaa129b1a350afcba4fb381b21c4cc35d)) +* security upgrade follow-redirects from 1.14.6 to 1.14.7 ([#7769](https://github.com/parse-community/parse-server/issues/7769)) ([8f5a861](https://github.com/parse-community/parse-server/commit/8f5a8618cfa7ed9a2a239a095abffa8f3fd8d31a)) +* security upgrade follow-redirects from 1.14.7 to 1.14.8 ([#7801](https://github.com/parse-community/parse-server/issues/7801)) ([70088a9](https://github.com/parse-community/parse-server/commit/70088a95a78393da2a4ac68be81e63107747626a)) +* security vulnerability that allows remote code execution (GHSA-p6h4-93qp-jhcm) ([#7844](https://github.com/parse-community/parse-server/issues/7844)) ([e569f40](https://github.com/parse-community/parse-server/commit/e569f402b1fd8648fb0d1523b71b2a03273902a5)) +* server crash using GraphQL due to missing @apollo/client peer dependency ([#7787](https://github.com/parse-community/parse-server/issues/7787)) ([08089d6](https://github.com/parse-community/parse-server/commit/08089d6fcbb215412448ce7d92b21b9fe6c929f2)) +* unable to use objectId size higher than 19 on GraphQL API ([#7627](https://github.com/parse-community/parse-server/issues/7627)) ([ed86c80](https://github.com/parse-community/parse-server/commit/ed86c807721cc52a1a5a9dea0b768717eec269ed)) +* upgrade mime from 2.5.2 to 3.0.0 ([#7725](https://github.com/parse-community/parse-server/issues/7725)) ([f5ef98b](https://github.com/parse-community/parse-server/commit/f5ef98bde32083403c0e30a12162fcc1e52cac37)) +* upgrade parse from 3.3.1 to 3.4.0 ([#7723](https://github.com/parse-community/parse-server/issues/7723)) ([d4c1f47](https://github.com/parse-community/parse-server/commit/d4c1f473073764cb0570c633fc4a30669c2ce889)) +* upgrade winston from 3.5.0 to 3.5.1 ([#7820](https://github.com/parse-community/parse-server/issues/7820)) ([4af253d](https://github.com/parse-community/parse-server/commit/4af253d1f8654a6f57b5137ad310cdacadc922cc)) + +### Features + +* add Cloud Code context to `ParseObject.fetch` ([#7779](https://github.com/parse-community/parse-server/issues/7779)) ([315290d](https://github.com/parse-community/parse-server/commit/315290d16110110938f80a6b779cc2d1db58c552)) +* add Idempotency to Postgres ([#7750](https://github.com/parse-community/parse-server/issues/7750)) ([0c3feaa](https://github.com/parse-community/parse-server/commit/0c3feaaa1751964c0db89f25674935c3354b1538)) +* add support for Node 16 ([#7707](https://github.com/parse-community/parse-server/issues/7707)) ([45cc58c](https://github.com/parse-community/parse-server/commit/45cc58c7e5e640a46c5d508019a3aa81242964b1)) +* bump required node engine to >=12.22.10 ([#7846](https://github.com/parse-community/parse-server/issues/7846)) ([5ace99d](https://github.com/parse-community/parse-server/commit/5ace99d542a11e422af46d9fd6b1d3d2513b34cf)) +* support `postgresql` protocol in database URI ([#7757](https://github.com/parse-community/parse-server/issues/7757)) ([caf4a23](https://github.com/parse-community/parse-server/commit/caf4a2341f554b28e3918c53e7e897a3ca47bf8b)) +* support relativeTime query constraint on Postgres ([#7747](https://github.com/parse-community/parse-server/issues/7747)) ([16b1b2a](https://github.com/parse-community/parse-server/commit/16b1b2a19714535ca805f2dbb3b561d8f6a519a7)) +* upgrade to MongoDB Node.js driver 4.x for MongoDB 5.0 support ([#7794](https://github.com/parse-community/parse-server/issues/7794)) ([f88aa2a](https://github.com/parse-community/parse-server/commit/f88aa2a62a533e5344d1c13dd38c5a0b283a480a)) + +### Reverts + +* refactor: allow ES import for cloud string if package type is module ([b64640c](https://github.com/parse-community/parse-server/commit/b64640c5705f733798783e68d216e957044ef23c)) +* update node engine to 2.22.0 ([#7827](https://github.com/parse-community/parse-server/issues/7827)) ([f235412](https://github.com/parse-community/parse-server/commit/f235412c1b6c2b173b7531f285429ea7214b56a2)) + + +### BREAKING CHANGES + +* This requires Node.js version >=12.22.10. ([5ace99d](5ace99d)) +* The MongoDB GridStore adapter has been removed. By default, Parse Server already uses GridFS, so if you do not manually use the GridStore adapter, you can ignore this change. ([f88aa2a](f88aa2a)) +* Removes official Node 15 support which has reached it end-of-life date. ([45cc58c](45cc58c)) + +# [5.0.0-beta.9](https://github.com/parse-community/parse-server/compare/5.0.0-beta.8...5.0.0-beta.9) (2022-03-12) + + +### Features + +* bump required node engine to >=12.22.10 ([#7848](https://github.com/parse-community/parse-server/issues/7848)) ([23a3488](https://github.com/parse-community/parse-server/commit/23a3488f15511fafbe0e1d7ff0ef8355f9cb0215)) + + +### BREAKING CHANGES + +* This requires Node.js version >=12.22.10. ([23a3488](23a3488)) + +# [5.0.0-beta.8](https://github.com/parse-community/parse-server/compare/5.0.0-beta.7...5.0.0-beta.8) (2022-03-12) + + +### Bug Fixes + +* security vulnerability that allows remote code execution (GHSA-p6h4-93qp-jhcm) ([#7843](https://github.com/parse-community/parse-server/issues/7843)) ([971adb5](https://github.com/parse-community/parse-server/commit/971adb54387b0ede31be05ca407d5f35b4575c83)) + +# [5.0.0-beta.7](https://github.com/parse-community/parse-server/compare/5.0.0-beta.6...5.0.0-beta.7) (2022-02-10) + + +### Bug Fixes + +* security upgrade follow-redirects from 1.14.7 to 1.14.8 ([#7802](https://github.com/parse-community/parse-server/issues/7802)) ([7029b27](https://github.com/parse-community/parse-server/commit/7029b274ca87bc8058617f29865d683dc3b351a1)) + +# [5.0.0-beta.6](https://github.com/parse-community/parse-server/compare/5.0.0-beta.5...5.0.0-beta.6) (2022-01-13) + + +### Bug Fixes + +* security upgrade follow-redirects from 1.14.2 to 1.14.7 ([#7772](https://github.com/parse-community/parse-server/issues/7772)) ([4bd34b1](https://github.com/parse-community/parse-server/commit/4bd34b189bc9f5aa2e70b7e7c1a456e91b6de773)) + +# [5.0.0-beta.5](https://github.com/parse-community/parse-server/compare/5.0.0-beta.4...5.0.0-beta.5) (2022-01-13) + + +### Bug Fixes + +* schema cache not cleared in some cases ([#7771](https://github.com/parse-community/parse-server/issues/7771)) ([3b92fa1](https://github.com/parse-community/parse-server/commit/3b92fa1ca9e8889127a32eba913d68309397ca2c)) + +# [5.0.0-beta.4](https://github.com/parse-community/parse-server/compare/5.0.0-beta.3...5.0.0-beta.4) (2021-11-27) + + +### Bug Fixes + +* unable to use objectId size higher than 19 on GraphQL API ([#7722](https://github.com/parse-community/parse-server/issues/7722)) ([8ee0445](https://github.com/parse-community/parse-server/commit/8ee0445c0aeeb88dff2559b46ade408071d22143)) + +# [5.0.0-beta.3](https://github.com/parse-community/parse-server/compare/5.0.0-beta.2...5.0.0-beta.3) (2021-11-12) + + +### Bug Fixes + +* node engine range has no upper limit to exclude incompatible node versions ([#7693](https://github.com/parse-community/parse-server/issues/7693)) ([6a54dac](https://github.com/parse-community/parse-server/commit/6a54dac24d9fb63a44f311b8d414f4aa64140f32)) + +# [5.0.0-beta.2](https://github.com/parse-community/parse-server/compare/5.0.0-beta.1...5.0.0-beta.2) (2021-11-10) + + +### Reverts + +* refactor: allow ES import for cloud string if package type is module ([#7691](https://github.com/parse-community/parse-server/issues/7691)) ([200d4ba](https://github.com/parse-community/parse-server/commit/200d4ba9a527016a65668738c7728696f443bd53)) + +# [5.0.0-beta.1](https://github.com/parse-community/parse-server/compare/4.5.0...5.0.0-beta.1) (2021-11-01) + +### BREAKING CHANGES +- Improved schema caching through database real-time hooks. Reduces DB queries, decreases Parse Query execution time and fixes a potential schema memory leak. If multiple Parse Server instances connect to the same DB (for example behind a load balancer), set the [Parse Server Option](https://parseplatform.org/parse-server/api/master/ParseServerOptions.html) `databaseOptions.enableSchemaHooks: true` to enable this feature and keep the schema in sync across all instances. Failing to do so will cause a schema change to not propagate to other instances and re-syncing will only happen when these instances restart. The options `enableSingleSchemaCache` and `schemaCacheTTL` have been removed. To use this feature with MongoDB, a replica set cluster with [change stream](https://docs.mongodb.com/manual/changeStreams/#availability) support is required. (Diamond Lewis, SebC) [#7214](https://github.com/parse-community/parse-server/issues/7214) +- Added file upload restriction. File upload is now only allowed for authenticated users by default for improved security. To allow file upload also for Anonymous Users or Public, set the `fileUpload` parameter in the [Parse Server Options](https://parseplatform.org/parse-server/api/master/ParseServerOptions.html) (dblythy, Manuel Trezza) [#7071](https://github.com/parse-community/parse-server/pull/7071) +- Removed [parse-server-simple-mailgun-adapter](https://github.com/parse-community/parse-server-simple-mailgun-adapter) dependency; to continue using the adapter it has to be explicitly installed (Manuel Trezza) [#7321](https://github.com/parse-community/parse-server/pull/7321) +- Remove support for MongoDB 3.6 which has reached its End-of-Life date and PostgreSQL 10 (Manuel Trezza) [#7315](https://github.com/parse-community/parse-server/pull/7315) +- Remove support for Node 10 which has reached its End-of-Life date (Manuel Trezza) [#7314](https://github.com/parse-community/parse-server/pull/7314) +- Remove S3 Files Adapter from Parse Server, instead install separately as `@parse/s3-files-adapter` (Manuel Trezza) [#7324](https://github.com/parse-community/parse-server/pull/7324) +- Remove Session field `restricted`; the field was a code artifact from a feature that never existed in Open Source Parse Server; if you have been using this field for custom purposes, consider that for new Parse Server installations the field does not exist anymore in the schema, and for existing installations the field default value `false` will not be set anymore when creating a new session (Manuel Trezza) [#7543](https://github.com/parse-community/parse-server/pull/7543) +- ci: add node engine version check (Manuel Trezza) [#7574](https://github.com/parse-community/parse-server/pull/7574) +- To delete a field via the GraphQL API, the field value has to be set to `null`. Previously, setting a field value to `null` would save a null value in the database, which was not according to the [GraphQL specs](https://spec.graphql.org/June2018/#sec-Null-Value). To delete a file field use `file: null`, the previous way of using `file: { file: null }` has become obsolete. ([626fad2](626fad2)) + +### Notable Changes +- Alphabetical ordered GraphQL API, improved GraphQL Schema cache system and fix GraphQL input reassign issue (Moumouls) [#7344](https://github.com/parse-community/parse-server/issues/7344) +- Added Parse Server Security Check to report weak security settings (Manuel Trezza, dblythy) [#7247](https://github.com/parse-community/parse-server/issues/7247) +- EXPERIMENTAL: Added new page router with placeholder rendering and localization of custom and feature pages such as password reset and email verification (Manuel Trezza) [#7128](https://github.com/parse-community/parse-server/pull/7128) +- EXPERIMENTAL: Added custom routes to easily customize flows for password reset, email verification or build entirely new flows (Manuel Trezza) [#7231](https://github.com/parse-community/parse-server/pull/7231) +- Added Deprecation Policy to govern the introduction of breaking changes in a phased pattern that is more predictable for developers (Manuel Trezza) [#7199](https://github.com/parse-community/parse-server/pull/7199) +- Add REST API endpoint `/loginAs` to create session of any user with master key; allows to impersonate another user. (GormanFletcher) [#7406](https://github.com/parse-community/parse-server/pull/7406) +- Add official support for MongoDB 5.0 (Manuel Trezza) [#7469](https://github.com/parse-community/parse-server/pull/7469) +- Added Parse Server Configuration `enforcePrivateUsers`, which will remove public access by default on new Parse.Users (dblythy) [#7319](https://github.com/parse-community/parse-server/pull/7319) +* add support for Postgres 14 ([#7644](https://github.com/parse-community/parse-server/issues/7644)) ([090350a](https://github.com/parse-community/parse-server/commit/090350a7a0fac945394ca1cb24b290316ef06aa7)) +* add user-defined schema and migrations ([#7418](https://github.com/parse-community/parse-server/issues/7418)) ([25d5c30](https://github.com/parse-community/parse-server/commit/25d5c30be2111be332eb779eb0697774a17da7af)) +* setting a field to null does not delete it via GraphQL API ([#7649](https://github.com/parse-community/parse-server/issues/7649)) ([626fad2](https://github.com/parse-community/parse-server/commit/626fad2e71017dcc62196c487de5f908fa43000b)) +* combined `and` query with relational query condition returns incorrect results ([#7593](https://github.com/parse-community/parse-server/issues/7593)) ([174886e](https://github.com/parse-community/parse-server/commit/174886e385e091c6bbd4a84891ef95f80b50d05c)) + +### Other Changes +- Support native mongodb syntax in aggregation pipelines (Raschid JF Rafeally) [#7339](https://github.com/parse-community/parse-server/pull/7339) +- Fix error when a not yet inserted job is updated (Antonio Davi Macedo Coelho de Castro) [#7196](https://github.com/parse-community/parse-server/pull/7196) +- request.context for afterFind triggers (dblythy) [#7078](https://github.com/parse-community/parse-server/pull/7078) +- Winston Logger interpolating stdout to console (dplewis) [#7114](https://github.com/parse-community/parse-server/pull/7114) +- Added convenience method `Parse.Cloud.sendEmail(...)` to send email via email adapter in Cloud Code (dblythy) [#7089](https://github.com/parse-community/parse-server/pull/7089) +- LiveQuery support for $and, $nor, $containedBy, $geoWithin, $geoIntersects queries (dplewis) [#7113](https://github.com/parse-community/parse-server/pull/7113) +- Supporting patterns in LiveQuery server's config parameter `classNames` (Nes-si) [#7131](https://github.com/parse-community/parse-server/pull/7131) +- Added `requireAnyUserRoles` and `requireAllUserRoles` for Parse Cloud validator (dblythy) [#7097](https://github.com/parse-community/parse-server/pull/7097) +- Support Facebook Limited Login (miguel-s) [#7219](https://github.com/parse-community/parse-server/pull/7219) +- Removed Stage name check on aggregate pipelines (BRETT71) [#7237](https://github.com/parse-community/parse-server/pull/7237) +- Retry transactions on MongoDB when it fails due to transient error (Antonio Davi Macedo Coelho de Castro) [#7187](https://github.com/parse-community/parse-server/pull/7187) +- Bump tests to use Mongo 4.4.4 (Antonio Davi Macedo Coelho de Castro) [#7184](https://github.com/parse-community/parse-server/pull/7184) +- Added new account lockout policy option `accountLockout.unlockOnPasswordReset` to automatically unlock account on password reset (Manuel Trezza) [#7146](https://github.com/parse-community/parse-server/pull/7146) +- Test Parse Server continuously against all recent MongoDB versions that have not reached their end-of-life support date, added MongoDB compatibility table to Parse Server docs (Manuel Trezza) [#7161](https://github.com/parse-community/parse-server/pull/7161) +- Test Parse Server continuously against all recent Node.js versions that have not reached their end-of-life support date, added Node.js compatibility table to Parse Server docs (Manuel Trezza) [7161](https://github.com/parse-community/parse-server/pull/7177) +- Throw error on invalid Cloud Function validation configuration (dblythy) [#7154](https://github.com/parse-community/parse-server/pull/7154) +- Allow Cloud Validator `options` to be async (dblythy) [#7155](https://github.com/parse-community/parse-server/pull/7155) +- Optimize queries on classes with pointer permissions (Pedro Diaz) [#7061](https://github.com/parse-community/parse-server/pull/7061) +- Test Parse Server continuously against all relevant Postgres versions (minor versions), added Postgres compatibility table to Parse Server docs (Corey Baker) [#7176](https://github.com/parse-community/parse-server/pull/7176) +- Randomize test suite (Diamond Lewis) [#7265](https://github.com/parse-community/parse-server/pull/7265) +- LDAP: Properly unbind client on group search error (Diamond Lewis) [#7265](https://github.com/parse-community/parse-server/pull/7265) +- Improve data consistency in Push and Job Status update (Diamond Lewis) [#7267](https://github.com/parse-community/parse-server/pull/7267) +- Excluding keys that have trailing edges.node when performing GraphQL resolver (Chris Bland) [#7273](https://github.com/parse-community/parse-server/pull/7273) +- Added centralized feature deprecation with standardized warning logs (Manuel Trezza) [#7303](https://github.com/parse-community/parse-server/pull/7303) +- Use Node.js 15.13.0 in CI (Olle Jonsson) [#7312](https://github.com/parse-community/parse-server/pull/7312) +- Fix file upload issue for S3 compatible storage (Linode, DigitalOcean) by avoiding empty tags property when creating a file (Ali Oguzhan Yildiz) [#7300](https://github.com/parse-community/parse-server/pull/7300) +- Add building Docker image as CI check (Manuel Trezza) [#7332](https://github.com/parse-community/parse-server/pull/7332) +- Add NPM package-lock version check to CI (Manuel Trezza) [#7333](https://github.com/parse-community/parse-server/pull/7333) +- Fix incorrect LiveQuery events triggered for multiple subscriptions on the same class with different events [#7341](https://github.com/parse-community/parse-server/pull/7341) +- Fix select and excludeKey queries to properly accept JSON string arrays. Also allow nested fields in exclude (Corey Baker) [#7242](https://github.com/parse-community/parse-server/pull/7242) +- Fix LiveQuery server crash when using $all query operator on a missing object key (Jason Posthuma) [#7421](https://github.com/parse-community/parse-server/pull/7421) +- Added runtime deprecation warnings (Manuel Trezza) [#7451](https://github.com/parse-community/parse-server/pull/7451) +- Add ability to pass context of an object via a header, X-Parse-Cloud-Context, for Cloud Code triggers. The header addition allows client SDK's to add context without injecting _context in the body of JSON objects (Corey Baker) [#7437](https://github.com/parse-community/parse-server/pull/7437) +- Add CI check to add changelog entry (Manuel Trezza) [#7512](https://github.com/parse-community/parse-server/pull/7512) +- Refactor: uniform issue templates across repos (Manuel Trezza) [#7528](https://github.com/parse-community/parse-server/pull/7528) +- ci: bump ci environment (Manuel Trezza) [#7539](https://github.com/parse-community/parse-server/pull/7539) +- CI now pushes docker images to Docker Hub (Corey Baker) [#7548](https://github.com/parse-community/parse-server/pull/7548) +- Allow afterFind and afterLiveQueryEvent to set unsaved pointers and keys (dblythy) [#7310](https://github.com/parse-community/parse-server/pull/7310) +- Allow setting descending sort to full text queries (dblythy) [#7496](https://github.com/parse-community/parse-server/pull/7496) +- Allow cloud string for ES modules (Daniel Blyth) [#7560](https://github.com/parse-community/parse-server/pull/7560) +- docs: Introduce deprecation ID for reference in comments and online search (Manuel Trezza) [#7562](https://github.com/parse-community/parse-server/pull/7562) +- refactor: deprecate `Parse.Cloud.httpRequest`; it is recommended to use a HTTP library instead. (Daniel Blyth) [#7595](https://github.com/parse-community/parse-server/pull/7595) +- refactor: Modernize HTTPRequest tests (brandongregoryscott) [#7604](https://github.com/parse-community/parse-server/pull/7604) +- Allow liveQuery on Session class (Daniel Blyth) [#7554](https://github.com/parse-community/parse-server/pull/7554) diff --git a/changelogs/CHANGELOG_release.md b/changelogs/CHANGELOG_release.md new file mode 100644 index 0000000000..55a117ecc8 --- /dev/null +++ b/changelogs/CHANGELOG_release.md @@ -0,0 +1,2792 @@ +# [9.9.0](https://github.com/parse-community/parse-server/compare/9.8.0...9.9.0) (2026-05-01) + + +### Bug Fixes + +* Context mutations leak across requests in `ParseServerRESTController` ([#10291](https://github.com/parse-community/parse-server/issues/10291)) ([60a58ec](https://github.com/parse-community/parse-server/commit/60a58ec11a8bb67aaf217b1e7362b89d742b66da)) +* MFA SMS one-time password accepted twice under concurrent login ([GHSA-jpq4-7fmq-q5fj](https://github.com/parse-community/parse-server/security/advisories/GHSA-jpq4-7fmq-q5fj)) ([#10448](https://github.com/parse-community/parse-server/issues/10448)) ([725be0d](https://github.com/parse-community/parse-server/commit/725be0d602baa619492606e7b3f6829082d93a4c)) + +### Features + +* Add `rawValues` and `rawFieldNames` options for aggregation queries ([#10438](https://github.com/parse-community/parse-server/issues/10438)) ([f26700e](https://github.com/parse-community/parse-server/commit/f26700e39d1980940467bee0d26ca3deb88e3924)) +* Add installation deviceToken deduplication options ([#10451](https://github.com/parse-community/parse-server/issues/10451)) ([9fee1a0](https://github.com/parse-community/parse-server/commit/9fee1a07080ab8bda2a3d4798881bcc288e5b37a)) + +# [9.8.0](https://github.com/parse-community/parse-server/compare/9.7.0...9.8.0) (2026-04-12) + + +### Bug Fixes + +* Bump lodash from 4.17.23 to 4.18.1 ([#10393](https://github.com/parse-community/parse-server/issues/10393)) ([19716ad](https://github.com/parse-community/parse-server/commit/19716ad9afe9400ad2440c0ed3c5fbfe376a8585)) +* Endpoint `/sessions/me` bypasses `_Session` `protectedFields` ([GHSA-g4v2-qx3q-4p64](https://github.com/parse-community/parse-server/security/advisories/GHSA-g4v2-qx3q-4p64)) ([#10406](https://github.com/parse-community/parse-server/issues/10406)) ([d507575](https://github.com/parse-community/parse-server/commit/d5075758f6c3ae9d806671de196fd8b419bc517e)) +* Endpoint `/upgradeToRevocableSession` ignores `_Session` `protectedFields` ([#10408](https://github.com/parse-community/parse-server/issues/10408)) ([c136e2b](https://github.com/parse-community/parse-server/commit/c136e2b7ab74609a5127fb68fc5ba40fef440f48)) +* Endpoints `/login` and `/verifyPassword` ignore `_User` `protectedFields` ([#10409](https://github.com/parse-community/parse-server/issues/10409)) ([8a3db3b](https://github.com/parse-community/parse-server/commit/8a3db3b9666ea998a8843c629e1af55b105e22e0)) +* Facebook Standard Login missing app ID validation ([#10429](https://github.com/parse-community/parse-server/issues/10429)) ([fd31159](https://github.com/parse-community/parse-server/commit/fd31159859ed90f57eb3713f82c9f5b04b20a28c)) +* File upload Content-Type override via extension mismatch ([GHSA-vr5f-2r24-w5hc](https://github.com/parse-community/parse-server/security/advisories/GHSA-vr5f-2r24-w5hc)) ([#10383](https://github.com/parse-community/parse-server/issues/10383)) ([dd7cc41](https://github.com/parse-community/parse-server/commit/dd7cc41a952b9ec6fa655a5655f106cca27d65c7)) +* Login timing side-channel reveals user existence ([GHSA-mmpq-5hcv-hf2v](https://github.com/parse-community/parse-server/security/advisories/GHSA-mmpq-5hcv-hf2v)) ([#10398](https://github.com/parse-community/parse-server/issues/10398)) ([531b9ab](https://github.com/parse-community/parse-server/commit/531b9ab6dda4268ede365367fcdc6d98e737ccc3)) +* Maintenance key IP mismatch silently downgrades to regular auth instead of rejecting ([#10391](https://github.com/parse-community/parse-server/issues/10391)) ([7d8b367](https://github.com/parse-community/parse-server/commit/7d8b367e0b3ef9e9dd6735408068895ead873a0c)) +* Master key does not bypass `protectedFields` on various endpoints ([#10412](https://github.com/parse-community/parse-server/issues/10412)) ([c0889c8](https://github.com/parse-community/parse-server/commit/c0889c8575ee6c6ee01c79cd1ae457124e2a08b3)) +* Nested batch sub-requests cause unclear error ([#10371](https://github.com/parse-community/parse-server/issues/10371)) ([6635096](https://github.com/parse-community/parse-server/commit/66350964c8a200eb9e4540f6fcdc0fe0099c5ff6)) +* Session field guard bypass via falsy values for ACL and user fields ([#10382](https://github.com/parse-community/parse-server/issues/10382)) ([ead12bd](https://github.com/parse-community/parse-server/commit/ead12bd1df7f11013d9266e41014dcb143351341)) +* Streaming file download bypasses afterFind file trigger authorization ([GHSA-hpm8-9qx6-jvwv](https://github.com/parse-community/parse-server/security/advisories/GHSA-hpm8-9qx6-jvwv)) ([#10361](https://github.com/parse-community/parse-server/issues/10361)) ([a0b0c69](https://github.com/parse-community/parse-server/commit/a0b0c69fc44f87f80d793d257344e7dcbf676e22)) + +### Features + +* Add `requestComplexity.allowRegex` option to disable `$regex` query operator ([#10418](https://github.com/parse-community/parse-server/issues/10418)) ([18482e3](https://github.com/parse-community/parse-server/commit/18482e386c1e723da2df3137f61fa5e2bc8983a6)) +* Add `requestComplexity.subqueryLimit` option to limit subquery results ([#10420](https://github.com/parse-community/parse-server/issues/10420)) ([bf40004](https://github.com/parse-community/parse-server/commit/bf40004d258f114c06a3085052ca094384b52b43)) +* Add route block with new server option `routeAllowList` ([#10389](https://github.com/parse-community/parse-server/issues/10389)) ([f2d06e7](https://github.com/parse-community/parse-server/commit/f2d06e7b95242268607bfa5205b4e86ba7c7698e)) +* Add server option `fileDownload` to restrict file download ([#10394](https://github.com/parse-community/parse-server/issues/10394)) ([fc117ef](https://github.com/parse-community/parse-server/commit/fc117efa4dc233ad6dfee6f46d80991b10927ba8)) +* Add support for invoking Cloud Function with `multipart/form-data` protocol ([#10395](https://github.com/parse-community/parse-server/issues/10395)) ([a3f36a2](https://github.com/parse-community/parse-server/commit/a3f36a2ddb981d9868ddf26b128e24b2d58214bd)) + +# [9.7.0](https://github.com/parse-community/parse-server/compare/9.6.1...9.7.0) (2026-03-30) + + +### Bug Fixes + +* Auth data exposed via verify password endpoint ([GHSA-wp76-gg32-8258](https://github.com/parse-community/parse-server/security/advisories/GHSA-wp76-gg32-8258)) ([#10323](https://github.com/parse-community/parse-server/issues/10323)) ([770be86](https://github.com/parse-community/parse-server/commit/770be8647424d92f5425c41fa81065ffbbb171ed)) +* Batch login sub-request rate limit uses IP-based keying ([#10349](https://github.com/parse-community/parse-server/issues/10349)) ([63c37c4](https://github.com/parse-community/parse-server/commit/63c37c49c7a72dc617635da8859004503021b8fd)) +* Cloud Code trigger context vulnerable to prototype pollution ([#10352](https://github.com/parse-community/parse-server/issues/10352)) ([d5f5128](https://github.com/parse-community/parse-server/commit/d5f5128ade49749856d8ad5f9750ffd26d44836a)) +* Cloud function validator bypass via prototype chain traversal ([GHSA-vpj2-qq7w-5qq6](https://github.com/parse-community/parse-server/security/advisories/GHSA-vpj2-qq7w-5qq6)) ([#10342](https://github.com/parse-community/parse-server/issues/10342)) ([dc59e27](https://github.com/parse-community/parse-server/commit/dc59e272665644083c5b7f6862d88ce1ef0b2674)) +* Duplicate session destruction can cause unhandled promise rejection ([#10319](https://github.com/parse-community/parse-server/issues/10319)) ([92791c1](https://github.com/parse-community/parse-server/commit/92791c1d1d4b042a0e615ba45dcef491b904eccf)) +* GraphQL API endpoint ignores CORS origin restriction ([GHSA-q3p6-g7c4-829c](https://github.com/parse-community/parse-server/security/advisories/GHSA-q3p6-g7c4-829c)) ([#10334](https://github.com/parse-community/parse-server/issues/10334)) ([4dd0d3d](https://github.com/parse-community/parse-server/commit/4dd0d3d8be1c39664c74ad10bb0abaa76bc41203)) +* GraphQL complexity validator exponential fragment traversal DoS ([GHSA-mfj6-6p54-m98c](https://github.com/parse-community/parse-server/security/advisories/GHSA-mfj6-6p54-m98c)) ([#10344](https://github.com/parse-community/parse-server/issues/10344)) ([f759bda](https://github.com/parse-community/parse-server/commit/f759bda075298ec44e2b4fb57659a0c56620483b)) +* LiveQuery protected field leak via shared mutable state across concurrent subscribers ([GHSA-m983-v2ff-wq65](https://github.com/parse-community/parse-server/security/advisories/GHSA-m983-v2ff-wq65)) ([#10330](https://github.com/parse-community/parse-server/issues/10330)) ([776c71c](https://github.com/parse-community/parse-server/commit/776c71c3078e77d38c94937f463741793609d055)) +* LiveQuery protected-field guard bypass via array-like logical operator value ([GHSA-mmg8-87c5-jrc2](https://github.com/parse-community/parse-server/security/advisories/GHSA-mmg8-87c5-jrc2)) ([#10350](https://github.com/parse-community/parse-server/issues/10350)) ([f63fd1a](https://github.com/parse-community/parse-server/commit/f63fd1a3fe0a7c1c5fe809f01b0e04759e8c9b98)) +* Maintenance key blocked from querying protected fields ([#10290](https://github.com/parse-community/parse-server/issues/10290)) ([7c8b213](https://github.com/parse-community/parse-server/commit/7c8b213d96f1fd79f27d3a2bc01bef8bcaf588cd)) +* MFA single-use token bypass via concurrent authData login requests ([GHSA-w73w-g5xw-rwhf](https://github.com/parse-community/parse-server/security/advisories/GHSA-w73w-g5xw-rwhf)) ([#10326](https://github.com/parse-community/parse-server/issues/10326)) ([e7efbeb](https://github.com/parse-community/parse-server/commit/e7efbebba398ce6abe5b6b6fb9829c6ebe310fbf)) +* Missing error messages in Parse errors ([#10304](https://github.com/parse-community/parse-server/issues/10304)) ([f128048](https://github.com/parse-community/parse-server/commit/f12804800bc9232de02b4314e886bab6b169f041)) +* Postgres query on non-existent column throws internal server error ([#10308](https://github.com/parse-community/parse-server/issues/10308)) ([c5c4325](https://github.com/parse-community/parse-server/commit/c5c43259d1f98af5bbbbc44d9daf7c0f1f8168d3)) +* Session field immutability bypass via falsy-value guard ([GHSA-f6j3-w9v3-cq22](https://github.com/parse-community/parse-server/security/advisories/GHSA-f6j3-w9v3-cq22)) ([#10347](https://github.com/parse-community/parse-server/issues/10347)) ([9080296](https://github.com/parse-community/parse-server/commit/90802969fc713b7bc9733d7255c7519a6ed75d21)) + +### Features + +* Add `protectedFieldsSaveResponseExempt` option to strip protected fields from save responses ([#10289](https://github.com/parse-community/parse-server/issues/10289)) ([4f7cb53](https://github.com/parse-community/parse-server/commit/4f7cb53bd114554cf9e6d7855b5e8911cb87544b)) +* Add `protectedFieldsTriggerExempt` option to exempt Cloud Code triggers from `protectedFields` ([#10288](https://github.com/parse-community/parse-server/issues/10288)) ([1610f98](https://github.com/parse-community/parse-server/commit/1610f98316f7cb1120a7e20be7a1570b0e116df7)) +* Add support for `partialFilterExpression` in MongoDB storage adapter ([#10346](https://github.com/parse-community/parse-server/issues/10346)) ([8dd7bf2](https://github.com/parse-community/parse-server/commit/8dd7bf2f61c07b0467d6dbc7aad5142db6694339)) +* Extend storage adapter interface to optionally return `matchedCount` and `modifiedCount` from `DatabaseController.update` with `many: true` ([#10353](https://github.com/parse-community/parse-server/issues/10353)) ([aea7596](https://github.com/parse-community/parse-server/commit/aea7596cd2336c1c179ae130efd550f1596f5f3a)) + +## [9.6.1](https://github.com/parse-community/parse-server/compare/9.6.0...9.6.1) (2026-03-22) + + +### Bug Fixes + +* User cannot retrieve own email with `protectedFieldsOwnerExempt: false` despite `email` not in `protectedFields` ([#10284](https://github.com/parse-community/parse-server/issues/10284)) ([4a65d77](https://github.com/parse-community/parse-server/commit/4a65d77ea3fd2ccb121d4bd28e92435295203bf7)) + +# [9.6.0](https://github.com/parse-community/parse-server/compare/9.5.1...9.6.0) (2026-03-22) + + +### Bug Fixes + +* LiveQuery `regexTimeout` default value not applied ([#10156](https://github.com/parse-community/parse-server/issues/10156)) ([416cfbc](https://github.com/parse-community/parse-server/commit/416cfbcd73f0da398e577a188c7976716a3c27ab)) +* Account lockout race condition allows bypassing threshold via concurrent requests ([#10266](https://github.com/parse-community/parse-server/issues/10266)) ([ff70fee](https://github.com/parse-community/parse-server/commit/ff70fee7e18d7e627b590f7f5717a58ee91cfecb)) +* Account takeover via operator injection in authentication data identifier ([GHSA-5fw2-8jcv-xh87](https://github.com/parse-community/parse-server/security/advisories/GHSA-5fw2-8jcv-xh87)) ([#10185](https://github.com/parse-community/parse-server/issues/10185)) ([0d0a554](https://github.com/parse-community/parse-server/commit/0d0a5543b35c35c12f69d5182693e50182b6faad)) +* Add configurable batch request sub-request limit via option `requestComplexity.batchRequestLimit` ([#10265](https://github.com/parse-community/parse-server/issues/10265)) ([164ed0d](https://github.com/parse-community/parse-server/commit/164ed0dd1206e96ce42e46058016a7d7eaf84d85)) +* Auth data exposed via /users/me endpoint ([GHSA-37mj-c2wf-cx96](https://github.com/parse-community/parse-server/security/advisories/GHSA-37mj-c2wf-cx96)) ([#10278](https://github.com/parse-community/parse-server/issues/10278)) ([875cf10](https://github.com/parse-community/parse-server/commit/875cf10ac979bd60f70e7a0c534e2bc194d6982f)) +* Auth provider validation bypass on login via partial authData ([GHSA-pfj7-wv7c-22pr](https://github.com/parse-community/parse-server/security/advisories/GHSA-pfj7-wv7c-22pr)) ([#10246](https://github.com/parse-community/parse-server/issues/10246)) ([98f4ba5](https://github.com/parse-community/parse-server/commit/98f4ba5bcf2c199bfe6225f672e8edcd08ba732d)) +* Block dot-notation updates to authData sub-fields and harden login provider checks ([#10223](https://github.com/parse-community/parse-server/issues/10223)) ([12c24c6](https://github.com/parse-community/parse-server/commit/12c24c6c6c578219703aaea186625f8f36c0d020)) +* Bypass of class-level permissions in LiveQuery ([GHSA-7ch5-98q2-7289](https://github.com/parse-community/parse-server/security/advisories/GHSA-7ch5-98q2-7289)) ([#10133](https://github.com/parse-community/parse-server/issues/10133)) ([98188d9](https://github.com/parse-community/parse-server/commit/98188d92c0b05ef498fa066588da1740de047bde)) +* Classes `_GraphQLConfig` and `_Audience` master key bypass via generic class routes ([GHSA-7xg7-rqf6-pw6c](https://github.com/parse-community/parse-server/security/advisories/GHSA-7xg7-rqf6-pw6c)) ([#10151](https://github.com/parse-community/parse-server/issues/10151)) ([1de4e43](https://github.com/parse-community/parse-server/commit/1de4e43ca2c894f1c0c1ca5611f5b491e8d24d40)) +* Cloud function dispatch crashes server via prototype chain traversal ([GHSA-4263-jgmp-7pf4](https://github.com/parse-community/parse-server/security/advisories/GHSA-4263-jgmp-7pf4)) ([#10210](https://github.com/parse-community/parse-server/issues/10210)) ([286373d](https://github.com/parse-community/parse-server/commit/286373dddfef5ef90505be5d954297daed32458c)) +* Concurrent signup with same authentication creates duplicate users ([#10149](https://github.com/parse-community/parse-server/issues/10149)) ([853bfe1](https://github.com/parse-community/parse-server/commit/853bfe1bd3b104aefbcf87cf0cac391c9772ab9d)) +* Create CLP not enforced before user field validation on signup ([#10268](https://github.com/parse-community/parse-server/issues/10268)) ([a0530c2](https://github.com/parse-community/parse-server/commit/a0530c251a9e15198c60c1c15c6cc0802a1dd18c)) +* Denial of service via unindexed database query for unconfigured auth providers ([GHSA-g4cf-xj29-wqqr](https://github.com/parse-community/parse-server/security/advisories/GHSA-g4cf-xj29-wqqr)) ([#10270](https://github.com/parse-community/parse-server/issues/10270)) ([fbac847](https://github.com/parse-community/parse-server/commit/fbac847499e57f243315c5fc7135be1d58bb8e54)) +* Denial-of-service via unbounded query complexity in REST and GraphQL API ([GHSA-cmj3-wx7h-ffvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-cmj3-wx7h-ffvg)) ([#10130](https://github.com/parse-community/parse-server/issues/10130)) ([0ae9c25](https://github.com/parse-community/parse-server/commit/0ae9c25bc13847d547871511749b58b575b96333)) +* Email verification resend page leaks user existence (GHSA-h29g-q5c2-9h4f) ([#10238](https://github.com/parse-community/parse-server/issues/10238)) ([fbda4cb](https://github.com/parse-community/parse-server/commit/fbda4cb0c5cbc8fad08a216823b6b64d4ae289c3)) +* Empty authData bypasses credential requirement on signup ([GHSA-wjqw-r9x4-j59v](https://github.com/parse-community/parse-server/security/advisories/GHSA-wjqw-r9x4-j59v)) ([#10219](https://github.com/parse-community/parse-server/issues/10219)) ([5dcbf41](https://github.com/parse-community/parse-server/commit/5dcbf41249f1b67c72296934bc4f8538f3b1d821)) +* GraphQL WebSocket endpoint bypasses security middleware ([GHSA-p2x3-8689-cwpg](https://github.com/parse-community/parse-server/security/advisories/GHSA-p2x3-8689-cwpg)) ([#10189](https://github.com/parse-community/parse-server/issues/10189)) ([3ffba75](https://github.com/parse-community/parse-server/commit/3ffba757bfc836bd034e1369f4f64304e110e375)) +* Incomplete JSON key escaping in PostgreSQL Increment on nested Object fields ([#10261](https://github.com/parse-community/parse-server/issues/10261)) ([a692873](https://github.com/parse-community/parse-server/commit/a6928737dd40a3310f6e419f223cf93fdd442f2b)) +* Input type validation for query operators and batch path ([#10230](https://github.com/parse-community/parse-server/issues/10230)) ([a628911](https://github.com/parse-community/parse-server/commit/a6289118d268d5dd5c453a22e99a48d36dcc81da)) +* Instance comparison with `instanceof` is not realm-safe ([#10225](https://github.com/parse-community/parse-server/issues/10225)) ([51efb1e](https://github.com/parse-community/parse-server/commit/51efb1efb9fa3f2d578de63f61b20c6a4fbcbd9a)) +* LDAP injection via unsanitized user input in DN and group filter construction ([GHSA-7m6r-fhh7-r47c](https://github.com/parse-community/parse-server/security/advisories/GHSA-7m6r-fhh7-r47c)) ([#10154](https://github.com/parse-community/parse-server/issues/10154)) ([5bbca7b](https://github.com/parse-community/parse-server/commit/5bbca7b862840909bb130920c33794abebbc15d4)) +* LiveQuery bypasses CLP pointer permission enforcement ([GHSA-fph2-r4qg-9576](https://github.com/parse-community/parse-server/security/advisories/GHSA-fph2-r4qg-9576)) ([#10250](https://github.com/parse-community/parse-server/issues/10250)) ([6c3317a](https://github.com/parse-community/parse-server/commit/6c3317aca6eb618ac48f999021ae3ef7766ad1ea)) +* LiveQuery subscription query depth bypass ([GHSA-6qh5-m6g3-xhq6](https://github.com/parse-community/parse-server/security/advisories/GHSA-6qh5-m6g3-xhq6)) ([#10259](https://github.com/parse-community/parse-server/issues/10259)) ([2126fe4](https://github.com/parse-community/parse-server/commit/2126fe4e12f9b399dc6b4b6a3fa70cb1825f159b)) +* LiveQuery subscription with invalid regular expression crashes server ([GHSA-827p-g5x5-h86c](https://github.com/parse-community/parse-server/security/advisories/GHSA-827p-g5x5-h86c)) ([#10197](https://github.com/parse-community/parse-server/issues/10197)) ([0ae0eee](https://github.com/parse-community/parse-server/commit/0ae0eeee524204325e09efcb315c50096aaf20f8)) +* Locale parameter path traversal in pages router ([#10242](https://github.com/parse-community/parse-server/issues/10242)) ([01fb6a9](https://github.com/parse-community/parse-server/commit/01fb6a972cf2437ba965dff590afec50184cf6e1)) +* MFA recovery code single-use bypass via concurrent requests ([GHSA-2299-ghjr-6vjp](https://github.com/parse-community/parse-server/security/advisories/GHSA-2299-ghjr-6vjp)) ([#10275](https://github.com/parse-community/parse-server/issues/10275)) ([5e70094](https://github.com/parse-community/parse-server/commit/5e70094250a36bfcc14ecd49592be2b94fba66ff)) +* MFA recovery codes not consumed after use ([GHSA-4hf6-3x24-c9m8](https://github.com/parse-community/parse-server/security/advisories/GHSA-4hf6-3x24-c9m8)) ([#10170](https://github.com/parse-community/parse-server/issues/10170)) ([18abdd9](https://github.com/parse-community/parse-server/commit/18abdd960baf97cf5dce5cd46ca6b0b874218d94)) +* Missing audience validation in Keycloak authentication adapter ([GHSA-48mh-j4p5-7j9v](https://github.com/parse-community/parse-server/security/advisories/GHSA-48mh-j4p5-7j9v)) ([#10137](https://github.com/parse-community/parse-server/issues/10137)) ([78ef1a1](https://github.com/parse-community/parse-server/commit/78ef1a175d3b8da83d33fd5c69830b12d366212f)) +* Normalize HTTP method case in `allowMethodOverride` middleware ([#10262](https://github.com/parse-community/parse-server/issues/10262)) ([a248e8c](https://github.com/parse-community/parse-server/commit/a248e8cc99d857466aa5a5d3a472795a238acbc2)) +* NoSQL injection via token type in password reset and email verification endpoints ([GHSA-vgjh-hmwf-c588](https://github.com/parse-community/parse-server/security/advisories/GHSA-vgjh-hmwf-c588)) ([#10128](https://github.com/parse-community/parse-server/issues/10128)) ([b2f2317](https://github.com/parse-community/parse-server/commit/b2f23172e4983e4597226ef80ccc75d3054d31ad)) +* OAuth2 adapter app ID validation sends wrong token to introspection endpoint ([GHSA-69xg-f649-w5g2](https://github.com/parse-community/parse-server/security/advisories/GHSA-69xg-f649-w5g2)) ([#10187](https://github.com/parse-community/parse-server/issues/10187)) ([7f9f854](https://github.com/parse-community/parse-server/commit/7f9f854be7a5c1bc2263ed516b651b16b438cd5d)) +* OAuth2 adapter shares mutable state across providers via singleton instance ([GHSA-2cjm-2gwv-m892](https://github.com/parse-community/parse-server/security/advisories/GHSA-2cjm-2gwv-m892)) ([#10183](https://github.com/parse-community/parse-server/issues/10183)) ([6009bc1](https://github.com/parse-community/parse-server/commit/6009bc15c8c19db436dba8078fd59244c955d7ad)) +* Parse Server OAuth2 authentication adapter account takeover via identity spoofing ([GHSA-fr88-w35c-r596](https://github.com/parse-community/parse-server/security/advisories/GHSA-fr88-w35c-r596)) ([#10145](https://github.com/parse-community/parse-server/issues/10145)) ([9cfd06e](https://github.com/parse-community/parse-server/commit/9cfd06e0d055ba96f965a0684995807adfe32b75)) +* Parse Server role escalation and CLP bypass via direct `_Join table write ([GHSA-5f92-jrq3-28rc](https://github.com/parse-community/parse-server/security/advisories/GHSA-5f92-jrq3-28rc)) ([#10141](https://github.com/parse-community/parse-server/issues/10141)) ([22faa08](https://github.com/parse-community/parse-server/commit/22faa08a7b89b15c3c96da2af9387bd44cbec088)) +* Parse Server session token exfiltration via `redirectClassNameForKey` query parameter ([GHSA-6r2j-cxgf-495f](https://github.com/parse-community/parse-server/security/advisories/GHSA-6r2j-cxgf-495f)) ([#10143](https://github.com/parse-community/parse-server/issues/10143)) ([70b7b07](https://github.com/parse-community/parse-server/commit/70b7b070e1135949dd80ecf382f34db0bfdbb71e)) +* Password reset token single-use bypass via concurrent requests ([GHSA-r3xq-68wh-gwvh](https://github.com/parse-community/parse-server/security/advisories/GHSA-r3xq-68wh-gwvh)) ([#10216](https://github.com/parse-community/parse-server/issues/10216)) ([84db0a0](https://github.com/parse-community/parse-server/commit/84db0a083bf7cc5ab8e0b56515d9305c4af55d5b)) +* Protected field change detection oracle via LiveQuery watch parameter ([GHSA-qpc3-fg4j-8hgm](https://github.com/parse-community/parse-server/security/advisories/GHSA-qpc3-fg4j-8hgm)) ([#10253](https://github.com/parse-community/parse-server/issues/10253)) ([0c0a0a5](https://github.com/parse-community/parse-server/commit/0c0a0a5a37ca821d2553119f2cb3be35322eda4b)) +* Protected fields bypass via dot-notation in query and sort ([GHSA-r2m8-pxm9-9c4g](https://github.com/parse-community/parse-server/security/advisories/GHSA-r2m8-pxm9-9c4g)) ([#10167](https://github.com/parse-community/parse-server/issues/10167)) ([8f54c54](https://github.com/parse-community/parse-server/commit/8f54c5437b4f3e184956cfbb8dd46840a4357344)) +* Protected fields bypass via LiveQuery subscription WHERE clause ([GHSA-j7mm-f4rv-6q6q](https://github.com/parse-community/parse-server/security/advisories/GHSA-j7mm-f4rv-6q6q)) ([#10175](https://github.com/parse-community/parse-server/issues/10175)) ([4d48847](https://github.com/parse-community/parse-server/commit/4d48847e9909c70761be381d3c3cddcfa9f0fca3)) +* Protected fields bypass via logical query operators ([GHSA-72hp-qff8-4pvv](https://github.com/parse-community/parse-server/security/advisories/GHSA-72hp-qff8-4pvv)) ([#10140](https://github.com/parse-community/parse-server/issues/10140)) ([be1d65d](https://github.com/parse-community/parse-server/commit/be1d65dac5d2718491e38727f96f205e43463e4c)) +* Protected fields leak via LiveQuery afterEvent trigger ([GHSA-5hmj-jcgp-6hff](https://github.com/parse-community/parse-server/security/advisories/GHSA-5hmj-jcgp-6hff)) ([#10232](https://github.com/parse-community/parse-server/issues/10232)) ([6648500](https://github.com/parse-community/parse-server/commit/6648500428f33fb8ba336757702644d94ca0796a)) +* Query condition depth bypass via pre-validation transform pipeline ([GHSA-9fjp-q3c4-6w3j](https://github.com/parse-community/parse-server/security/advisories/GHSA-9fjp-q3c4-6w3j)) ([#10257](https://github.com/parse-community/parse-server/issues/10257)) ([85994ef](https://github.com/parse-community/parse-server/commit/85994eff9e7b34cac7e1a2f5791985022a1461d1)) +* Rate limit bypass via batch request endpoint ([GHSA-775h-3xrc-c228](https://github.com/parse-community/parse-server/security/advisories/GHSA-775h-3xrc-c228)) ([#10147](https://github.com/parse-community/parse-server/issues/10147)) ([2766f4f](https://github.com/parse-community/parse-server/commit/2766f4f7a2ce3afde4e1628907cdc556b6d6355c)) +* Rate limit bypass via HTTP method override and batch method spoofing ([#10234](https://github.com/parse-community/parse-server/issues/10234)) ([7d72d26](https://github.com/parse-community/parse-server/commit/7d72d264c03b63b463664d545c8c57f4851e4287)) +* Rate limit user zone key fallback and batch request bypass ([#10214](https://github.com/parse-community/parse-server/issues/10214)) ([434ecbe](https://github.com/parse-community/parse-server/commit/434ecbec702e74fe8d151fbfc5ec0779f77a25f2)) +* Revert accidental breaking default values for query complexity limits ([#10205](https://github.com/parse-community/parse-server/issues/10205)) ([ab8dd54](https://github.com/parse-community/parse-server/commit/ab8dd54d8bcfea996aa60f0b9fac67dedb79d0e6)) +* Sanitize control characters in page parameter response headers ([#10237](https://github.com/parse-community/parse-server/issues/10237)) ([337ffd6](https://github.com/parse-community/parse-server/commit/337ffd65ccf94495a54cd883c5e8fa7a3892606c)) +* Schema poisoning via prototype pollution in deep copy ([GHSA-9ccr-fpp6-78qf](https://github.com/parse-community/parse-server/security/advisories/GHSA-9ccr-fpp6-78qf)) ([#10200](https://github.com/parse-community/parse-server/issues/10200)) ([b321423](https://github.com/parse-community/parse-server/commit/b321423867f5e779b4750f97c4e42d408499fc3b)) +* Security fix fast-xml-parser from 5.5.5 to 5.5.6 ([#10235](https://github.com/parse-community/parse-server/issues/10235)) ([f521576](https://github.com/parse-community/parse-server/commit/f521576143336334aad2cbac82c3f368afe8f706)) +* Security upgrade fast-xml-parser from 5.3.7 to 5.4.2 ([#10086](https://github.com/parse-community/parse-server/issues/10086)) ([b04ca5e](https://github.com/parse-community/parse-server/commit/b04ca5eec41065caccc7f7dbed8a0595f0364914)) +* Server crash via deeply nested query condition operators ([GHSA-9xp9-j92r-p88v](https://github.com/parse-community/parse-server/security/advisories/GHSA-9xp9-j92r-p88v)) ([#10202](https://github.com/parse-community/parse-server/issues/10202)) ([f44e306](https://github.com/parse-community/parse-server/commit/f44e3061471c9d527b7c0894bbd86f1823de52c4)) +* Session creation endpoint allows overwriting server-generated session fields ([GHSA-5v7g-9h8f-8pgg](https://github.com/parse-community/parse-server/security/advisories/GHSA-5v7g-9h8f-8pgg)) ([#10195](https://github.com/parse-community/parse-server/issues/10195)) ([7ccfb97](https://github.com/parse-community/parse-server/commit/7ccfb972d4a6679726f3a0b3cc8d6a8f1838273c)) +* Session token expiration unchecked on cache hit ([#10194](https://github.com/parse-community/parse-server/issues/10194)) ([a944203](https://github.com/parse-community/parse-server/commit/a944203b268cf467ab4c720928f744d0c889b1e5)) +* Session update endpoint allows overwriting server-generated session fields ([GHSA-jc39-686j-wp6q](https://github.com/parse-community/parse-server/security/advisories/GHSA-jc39-686j-wp6q)) ([#10263](https://github.com/parse-community/parse-server/issues/10263)) ([ea68fc0](https://github.com/parse-community/parse-server/commit/ea68fc0b22a6056c9675149469ff57817f7cf984)) +* SQL injection via `Increment` operation on nested object field in PostgreSQL ([GHSA-q3vj-96h2-gwvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-q3vj-96h2-gwvg)) ([#10161](https://github.com/parse-community/parse-server/issues/10161)) ([8f82282](https://github.com/parse-community/parse-server/commit/8f822826a48169528a66626118bbaead3064b055)) +* SQL injection via aggregate and distinct field names in PostgreSQL adapter ([GHSA-p2w6-rmh7-w8q3](https://github.com/parse-community/parse-server/security/advisories/GHSA-p2w6-rmh7-w8q3)) ([#10272](https://github.com/parse-community/parse-server/issues/10272)) ([bdddab5](https://github.com/parse-community/parse-server/commit/bdddab5f8b61a40cb8fc62dd895887bdd2f3838e)) +* SQL injection via dot-notation field name in PostgreSQL ([GHSA-qpr4-jrj4-6f27](https://github.com/parse-community/parse-server/security/advisories/GHSA-qpr4-jrj4-6f27)) ([#10159](https://github.com/parse-community/parse-server/issues/10159)) ([ea538a4](https://github.com/parse-community/parse-server/commit/ea538a4ba320f5ead4e784de5de815edf765a9f5)) +* SQL Injection via dot-notation sub-key name in `Increment` operation on PostgreSQL ([GHSA-gqpp-xgvh-9h7h](https://github.com/parse-community/parse-server/security/advisories/GHSA-gqpp-xgvh-9h7h)) ([#10165](https://github.com/parse-community/parse-server/issues/10165)) ([169d692](https://github.com/parse-community/parse-server/commit/169d69257dda670daf0b20a967d0598a90510c82)) +* SQL injection via query field name when using PostgreSQL ([GHSA-c442-97qw-j6c6](https://github.com/parse-community/parse-server/security/advisories/GHSA-c442-97qw-j6c6)) ([#10181](https://github.com/parse-community/parse-server/issues/10181)) ([be281b1](https://github.com/parse-community/parse-server/commit/be281b1ed9c6b7abf992e5583fc2db7875031172)) +* Stored cross-site scripting (XSS) via SVG file upload ([GHSA-hcj7-6gxh-24ww](https://github.com/parse-community/parse-server/security/advisories/GHSA-hcj7-6gxh-24ww)) ([#10136](https://github.com/parse-community/parse-server/issues/10136)) ([93b784d](https://github.com/parse-community/parse-server/commit/93b784d21a8be13c6db1e8f0baeb0feda1fe12be)) +* Stored XSS filter bypass via Content-Type MIME parameter and missing XML extension blocklist entries ([GHSA-42ph-pf9q-cr72](https://github.com/parse-community/parse-server/security/advisories/GHSA-42ph-pf9q-cr72)) ([#10191](https://github.com/parse-community/parse-server/issues/10191)) ([4f53ab3](https://github.com/parse-community/parse-server/commit/4f53ab3cad5502a51a509d53f999e00ff7217b8d)) +* Stored XSS via file upload of HTML-renderable file types ([GHSA-v5hf-f4c3-m5rv](https://github.com/parse-community/parse-server/security/advisories/GHSA-v5hf-f4c3-m5rv)) ([#10162](https://github.com/parse-community/parse-server/issues/10162)) ([03287cf](https://github.com/parse-community/parse-server/commit/03287cf83bc05ee08bb29885d38a86e722cc3bf9)) +* User enumeration via email verification endpoint ([GHSA-w54v-hf9p-8856](https://github.com/parse-community/parse-server/security/advisories/GHSA-w54v-hf9p-8856)) ([#10172](https://github.com/parse-community/parse-server/issues/10172)) ([936abd4](https://github.com/parse-community/parse-server/commit/936abd4905e501838e8d46503da66ce9fe6a4f9d)) +* Validate authData provider values in challenge endpoint ([#10224](https://github.com/parse-community/parse-server/issues/10224)) ([e5e1f5b](https://github.com/parse-community/parse-server/commit/e5e1f5bbc008c869614a13ab540f72af57adda8f)) +* Validate body field types in request middleware ([#10209](https://github.com/parse-community/parse-server/issues/10209)) ([df69046](https://github.com/parse-community/parse-server/commit/df690463f8066dcde17a2e90e53dfbd7e86ff0bd)) +* Validate session in middleware for non-GET requests to `/sessions/me` ([#10213](https://github.com/parse-community/parse-server/issues/10213)) ([2a9fdab](https://github.com/parse-community/parse-server/commit/2a9fdab3672e702ce296fc83c99902da37e53e29)) +* Validate token type in PagesRouter to prevent type confusion errors ([#10212](https://github.com/parse-community/parse-server/issues/10212)) ([386a989](https://github.com/parse-community/parse-server/commit/386a989bd2d5b9a48e4830a87ecb01f8ef22d903)) + +### Features + +* Add `enableProductPurchaseLegacyApi` option to disable legacy IAP validation ([#10228](https://github.com/parse-community/parse-server/issues/10228)) ([622ee85](https://github.com/parse-community/parse-server/commit/622ee85dc27a4ef721c1d4f61d3ed881a064da0b)) +* Add `protectedFieldsOwnerExempt` option to control `_User` class owner exemption for `protectedFields` ([#10280](https://github.com/parse-community/parse-server/issues/10280)) ([d5213f8](https://github.com/parse-community/parse-server/commit/d5213f88054fbe066692b7a4661c1b2242aaeddb)) +* Add `X-Content-Type-Options: nosniff` header and customizable response headers for files via `Parse.Cloud.afterFind(Parse.File)` ([#10158](https://github.com/parse-community/parse-server/issues/10158)) ([28d11a3](https://github.com/parse-community/parse-server/commit/28d11a33bcdb0f89604e2289018a6f4729d4ba67)) + +## [9.5.1](https://github.com/parse-community/parse-server/compare/9.5.0...9.5.1) (2026-03-07) + + +### Bug Fixes + +* Denial of Service (DoS) and Cloud Function Dispatch Bypass via Prototype Chain Resolution ([GHSA-5j86-7r7m-p8h6](https://github.com/parse-community/parse-server/security/advisories/GHSA-5j86-7r7m-p8h6)) ([#10125](https://github.com/parse-community/parse-server/issues/10125)) ([560e6e7](https://github.com/parse-community/parse-server/commit/560e6e77c7625da0655b2d01dc2d10632a80f591)) +* Denylist `requestKeywordDenylist` keyword scan bypass through nested object placement ([GHSA-q342-9w2p-57fp](https://github.com/parse-community/parse-server/security/advisories/GHSA-q342-9w2p-57fp)) ([#10123](https://github.com/parse-community/parse-server/issues/10123)) ([4a44247](https://github.com/parse-community/parse-server/commit/4a44247a649a40ef3f1db8261a0e780080f494ba)) + +# [9.5.0](https://github.com/parse-community/parse-server/compare/9.4.1...9.5.0) (2026-03-07) + + +### Bug Fixes + +* `PagesRouter` path traversal allows reading files outside configured pages directory ([GHSA-hm3f-q6rw-m6wh](https://github.com/parse-community/parse-server/security/advisories/GHSA-hm3f-q6rw-m6wh)) ([#10104](https://github.com/parse-community/parse-server/issues/10104)) ([e772543](https://github.com/parse-community/parse-server/commit/e772543ad8d01bce83664566551893dffc5b8117)) +* Endpoint `/loginAs` allows `readOnlyMasterKey` to gain full read and write access as any user ([GHSA-79wj-8rqv-jvp5](https://github.com/parse-community/parse-server/security/advisories/GHSA-79wj-8rqv-jvp5)) ([#10098](https://github.com/parse-community/parse-server/issues/10098)) ([bc20945](https://github.com/parse-community/parse-server/commit/bc20945fc7cdb2e56d7c46d537d8f4baf7231303)) +* File creation and deletion bypasses `readOnlyMasterKey` write restriction ([GHSA-xfh7-phr7-gr2x](https://github.com/parse-community/parse-server/security/advisories/GHSA-xfh7-phr7-gr2x)) ([#10095](https://github.com/parse-community/parse-server/issues/10095)) ([036365a](https://github.com/parse-community/parse-server/commit/036365af6dedd10746327f46bf69408b5c56439e)) +* File metadata endpoint bypasses `beforeFind` / `afterFind` trigger authorization ([GHSA-hwx8-q9cg-mqmc](https://github.com/parse-community/parse-server/security/advisories/GHSA-hwx8-q9cg-mqmc)) ([#10106](https://github.com/parse-community/parse-server/issues/10106)) ([72e7707](https://github.com/parse-community/parse-server/commit/72e7707ac17b9df888cc20732583411544adcd36)) +* GraphQL `__type` introspection bypass via inline fragments when public introspection is disabled ([GHSA-q5q9-2rhp-33qw](https://github.com/parse-community/parse-server/security/advisories/GHSA-q5q9-2rhp-33qw)) ([#10111](https://github.com/parse-community/parse-server/issues/10111)) ([61261a5](https://github.com/parse-community/parse-server/commit/61261a5aa15c95a22a87a5a9c53077059ad49d15)) +* JWT audience validation bypass in Google, Apple, and Facebook authentication adapters ([GHSA-x6fw-778m-wr9v](https://github.com/parse-community/parse-server/security/advisories/GHSA-x6fw-778m-wr9v)) ([#10113](https://github.com/parse-community/parse-server/issues/10113)) ([9f8d3f3](https://github.com/parse-community/parse-server/commit/9f8d3f3d5591c17f9857bad035950fdff75d0ce6)) +* Malformed `$regex` query leaks database error details in API response ([GHSA-9cp7-3q5w-j92g](https://github.com/parse-community/parse-server/security/advisories/GHSA-9cp7-3q5w-j92g)) ([#10101](https://github.com/parse-community/parse-server/issues/10101)) ([9792d24](https://github.com/parse-community/parse-server/commit/9792d24b963f3b45e5ade2bbceb6f5c0b5d0251c)) +* Regular Expression Denial of Service (ReDoS) via `$regex` query in LiveQuery ([GHSA-mf3j-86qx-cq5j](https://github.com/parse-community/parse-server/security/advisories/GHSA-mf3j-86qx-cq5j)) ([#10118](https://github.com/parse-community/parse-server/issues/10118)) ([5e113c2](https://github.com/parse-community/parse-server/commit/5e113c2128239b26551f77e127d0120502dc152a)) + +### Features + +* Add `Parse.File` option `maxUploadSize` to override the Parse Server option `maxUploadSize` per file upload ([#10093](https://github.com/parse-community/parse-server/issues/10093)) ([3d8807b](https://github.com/parse-community/parse-server/commit/3d8807b4eceafab92ac9c23516d564f5fce6cb8e)) +* Add security check for server option `mountPlayground` for GraphQL development ([#10103](https://github.com/parse-community/parse-server/issues/10103)) ([2ae5db1](https://github.com/parse-community/parse-server/commit/2ae5db142574b0e62f4263e2fa9a9831c966b478)) +* Add server option `readOnlyMasterKeyIps` to restrict `readOnlyMasterKey` by IP ([#10115](https://github.com/parse-community/parse-server/issues/10115)) ([cbff6b4](https://github.com/parse-community/parse-server/commit/cbff6b42a0b4f02552457f04a8757ac2376d3e04)) +* Add support for `Parse.File.setDirectory`, `setMetadata`, `setTags` with stream-based file upload ([#10092](https://github.com/parse-community/parse-server/issues/10092)) ([ca666b0](https://github.com/parse-community/parse-server/commit/ca666b02fcc2229180621a42694c0838f700c06d)) +* Allow to identify `readOnlyMasterKey` invocation of Cloud Function via `request.isReadOnly` ([#10100](https://github.com/parse-community/parse-server/issues/10100)) ([2c48751](https://github.com/parse-community/parse-server/commit/2c48751c6de36ec090ac6ab08e289876561ed324)) +* Deprecate GraphQL Playground that exposes master key in HTTP response ([#10112](https://github.com/parse-community/parse-server/issues/10112)) ([d54d800](https://github.com/parse-community/parse-server/commit/d54d800f596f1937701f5bd57c81104f102bc3ae)) + +## [9.4.1](https://github.com/parse-community/parse-server/compare/9.4.0...9.4.1) (2026-03-04) + + +### Bug Fixes + +* Cloud Hooks and Cloud Jobs bypass `readOnlyMasterKey` write restriction ([GHSA-vc89-5g3r-cmhh](https://github.com/parse-community/parse-server/security/advisories/GHSA-vc89-5g3r-cmhh)) ([#10088](https://github.com/parse-community/parse-server/issues/10088)) ([9a3dd4d](https://github.com/parse-community/parse-server/commit/9a3dd4d2d55ad506348062b43a7fe42e22a57fe9)) +* MongoDB default batch size changed from 1000 to 100 without announcement ([#10085](https://github.com/parse-community/parse-server/issues/10085)) ([8f17397](https://github.com/parse-community/parse-server/commit/8f1739788d434c91109f049a438c32bdd4fc26a5)) + +### Performance Improvements + +* Upgrade to mongodb 7.1.0 ([#10087](https://github.com/parse-community/parse-server/issues/10087)) ([bebf2fd](https://github.com/parse-community/parse-server/commit/bebf2fd62b51cfc35c271ad4c76b8f552f886ce8)) + +# [9.4.0](https://github.com/parse-community/parse-server/compare/9.3.1...9.4.0) (2026-03-01) + + +### Bug Fixes + +* `PagesRouter` header parameters are not URL-encoded to support non-ASCII characters in app name ([#10078](https://github.com/parse-community/parse-server/issues/10078)) ([c92660b](https://github.com/parse-community/parse-server/commit/c92660bd9a776eec81e4ef18217916b931c267a1)) + +### Features + +* Add support for `Parse.File.setDirectory()` with master key to save file in directory ([#10076](https://github.com/parse-community/parse-server/issues/10076)) ([17d987c](https://github.com/parse-community/parse-server/commit/17d987c95accdb2d75f63aed25abd919b0999589)) + +## [9.3.1](https://github.com/parse-community/parse-server/compare/9.3.0...9.3.1) (2026-02-25) + + +### Bug Fixes + +* GraphQL introspection disabled in `NODE_ENV=production` even with master key ([#10071](https://github.com/parse-community/parse-server/issues/10071)) ([a5269f0](https://github.com/parse-community/parse-server/commit/a5269f077666537fad1d2eeefee82a36a148255c)) +* JWT Algorithm Confusion in Google Auth Adapter ([GHSA-4q3h-vp4r-prv2](https://github.com/parse-community/parse-server/security/advisories/GHSA-4q3h-vp4r-prv2)) ([#10072](https://github.com/parse-community/parse-server/issues/10072)) ([9d5942d](https://github.com/parse-community/parse-server/commit/9d5942d50e55c822924c27b05aa98f1393e7a330)) +* Remove obsolete Parse Server option `pages.enableRouter` ([#10070](https://github.com/parse-community/parse-server/issues/10070)) ([00b3b72](https://github.com/parse-community/parse-server/commit/00b3b7297d806b4b40d7c08dd987b748e018e4b6)) +* Type error in docs creation ([#10069](https://github.com/parse-community/parse-server/issues/10069)) ([02a277f](https://github.com/parse-community/parse-server/commit/02a277f1e937fd3e6bd85bdb49870bf3f47678a0)) + +# [9.3.0](https://github.com/parse-community/parse-server/compare/9.2.0...9.3.0) (2026-02-21) + + +### Bug Fixes + +* `Parse.Query.select('authData')` for `_User` class doesn't return auth data ([#10055](https://github.com/parse-community/parse-server/issues/10055)) ([44a5bb1](https://github.com/parse-community/parse-server/commit/44a5bb105e11e6918e899e0f1427b0adb38d6d67)) +* AuthData validation incorrectly triggered on unchanged providers ([#10025](https://github.com/parse-community/parse-server/issues/10025)) ([d3d6e9e](https://github.com/parse-community/parse-server/commit/d3d6e9e22a212885690853cbbb84bb8c53da5646)) +* Default ACL overwrites custom ACL on `Parse.Object` update ([#10061](https://github.com/parse-community/parse-server/issues/10061)) ([4ef89d9](https://github.com/parse-community/parse-server/commit/4ef89d912c08bb24500a4d4142a3220f024a2d34)) +* Default HTML pages for password reset, email verification not found ([#10034](https://github.com/parse-community/parse-server/issues/10034)) ([e299107](https://github.com/parse-community/parse-server/commit/e29910764daef3c03ed1b09eee19cedc3b12a86a)) +* Default HTML pages for password reset, email verification not found ([#10041](https://github.com/parse-community/parse-server/issues/10041)) ([a4265bb](https://github.com/parse-community/parse-server/commit/a4265bb1241551b7147e8aee08c36e1f8ab09ba4)) +* Incorrect dependency chain of `Parse` uses browser build instead of Node build ([#10067](https://github.com/parse-community/parse-server/issues/10067)) ([1a2521d](https://github.com/parse-community/parse-server/commit/1a2521d930b855845aa13fde700b2e8170ff65a1)) +* Unlinking auth provider triggers auth data validation ([#10045](https://github.com/parse-community/parse-server/issues/10045)) ([b6b6327](https://github.com/parse-community/parse-server/commit/b6b632755263417c2a3c3a31381eedc516723740)) + +### Features + +* Add `Parse.File.url` validation with config `fileUpload.allowedFileUrlDomains` against SSRF attacks ([#10044](https://github.com/parse-community/parse-server/issues/10044)) ([4c9c948](https://github.com/parse-community/parse-server/commit/4c9c9489f062bec6d751b23f4a68aea2a63936bd)) +* Add event information to `verifyUserEmails`, `preventLoginWithUnverifiedEmail` to identify invoking signup / login action and auth provider ([#9963](https://github.com/parse-community/parse-server/issues/9963)) ([ed98c15](https://github.com/parse-community/parse-server/commit/ed98c15f90f2fa6a66780941fd3705b805d6eb14)) +* Add support for streaming file upload via `Buffer`, `Readable`, `ReadableStream` ([#10065](https://github.com/parse-community/parse-server/issues/10065)) ([f0feb48](https://github.com/parse-community/parse-server/commit/f0feb48d0fb697a161693721eadd09d740336283)) +* Upgrade to parse 8.2.0, @parse/push-adapter 8.3.0 ([#10066](https://github.com/parse-community/parse-server/issues/10066)) ([8b5a14e](https://github.com/parse-community/parse-server/commit/8b5a14ecaf0b58b899651fb97d43e0e5d9be506d)) + +# [9.2.0](https://github.com/parse-community/parse-server/compare/9.1.1...9.2.0) (2026-02-05) + + +### Bug Fixes + +* MongoDB timeout errors unhandled and potentially revealing internal data ([#10020](https://github.com/parse-community/parse-server/issues/10020)) ([1d3336d](https://github.com/parse-community/parse-server/commit/1d3336d128671c974b419b9b34db35ada7d1a44d)) +* Security upgrade @apollo/server from 5.0.0 to 5.4.0 ([#10035](https://github.com/parse-community/parse-server/issues/10035)) ([9f368ff](https://github.com/parse-community/parse-server/commit/9f368ff9ca322c61cdcfab735e5b5240d1c8f917)) + +### Features + +* Add option `databaseOptions.clientMetadata` to send custom metadata to database server for logging and debugging ([#10017](https://github.com/parse-community/parse-server/issues/10017)) ([756c204](https://github.com/parse-community/parse-server/commit/756c204220a2c7be3770b7d4a49f11e8903323db)) +* Upgrade mongodb from 6.20.0 to 7.0.0 ([#10027](https://github.com/parse-community/parse-server/issues/10027)) ([14b3fce](https://github.com/parse-community/parse-server/commit/14b3fce203be0abaf29c27c123cba47f35d09c68)) +* Upgrade to parse 8.0.3 and @parse/push-adapter 8.2.0 ([#10021](https://github.com/parse-community/parse-server/issues/10021)) ([9833fdb](https://github.com/parse-community/parse-server/commit/9833fdb111c373dc75fc74ea5f9209408186a475)) + +## [9.1.1](https://github.com/parse-community/parse-server/compare/9.1.0...9.1.1) (2025-12-16) + + +### Bug Fixes + +* Server-Side Request Forgery (SSRF) in Instagram auth adapter [GHSA-3f5f-xgrj-97pf](https://github.com/parse-community/parse-server/security/advisories/GHSA-3f5f-xgrj-97pf) ([#9988](https://github.com/parse-community/parse-server/issues/9988)) ([fbcc938](https://github.com/parse-community/parse-server/commit/fbcc938b5ade5ff4c30598ac51272ef7ecef0616)) + +# [9.1.0](https://github.com/parse-community/parse-server/compare/9.0.0...9.1.0) (2025-12-14) + + +### Bug Fixes + +* Cross-Site Scripting (XSS) via HTML pages for password reset and email verification [GHSA-jhgf-2h8h-ggxv](https://github.com/parse-community/parse-server/security/advisories/GHSA-jhgf-2h8h-ggxv) ([#9985](https://github.com/parse-community/parse-server/issues/9985)) ([3074eb7](https://github.com/parse-community/parse-server/commit/3074eb70f5b58bf72b528ae7b7804ed2d90455ce)) + +### Features + +* Add option `logLevels.signupUsernameTaken` to change log level of username already exists sign-up rejection ([#9962](https://github.com/parse-community/parse-server/issues/9962)) ([f18f307](https://github.com/parse-community/parse-server/commit/f18f3073d70a292bc70b5d572ef58e4845de89ca)) +* Add support for custom HTTP status code and headers to Cloud Function response with Express-style syntax ([#9980](https://github.com/parse-community/parse-server/issues/9980)) ([8eeab8d](https://github.com/parse-community/parse-server/commit/8eeab8dc57edef3751aa188d8247f296a270b083)) +* Log more debug info when failing to set duplicate value for field with unique values ([#9919](https://github.com/parse-community/parse-server/issues/9919)) ([a23b192](https://github.com/parse-community/parse-server/commit/a23b1924668920f3c92fec0566b57091d0e8aae8)) + +# [9.0.0](https://github.com/parse-community/parse-server/compare/8.6.0...9.0.0) (2025-12-14) + + +### Bug Fixes + +* Upgrade to GraphQL Apollo Server 5 and restrict GraphQL introspection ([#9888](https://github.com/parse-community/parse-server/issues/9888)) ([87c7f07](https://github.com/parse-community/parse-server/commit/87c7f076eb84c9540f79f06c27fe13e102dc6295)) + +### Features + +* Deprecation DEPPS10: Encode `Parse.Object` in Cloud Function and remove option `encodeParseObjectInCloudFunction` ([#9973](https://github.com/parse-community/parse-server/issues/9973)) ([a2d3dbe](https://github.com/parse-community/parse-server/commit/a2d3dbe972e2e02ac599bfffe1ae6cd9768b02ca)) +* Deprecation DEPPS11: Replace `PublicAPIRouter` with `PagesRouter` ([#9974](https://github.com/parse-community/parse-server/issues/9974)) ([8f877d4](https://github.com/parse-community/parse-server/commit/8f877d42c02a6492b97c61e75ab77a896878f866)) +* Deprecation DEPPS113: Config option `enableInsecureAuthAdapters` defaults to `false` ([#9982](https://github.com/parse-community/parse-server/issues/9982)) ([22d4622](https://github.com/parse-community/parse-server/commit/22d4622230b74839ed408a02bfcabb7b37b85aba)) +* Deprecation DEPPS12: Database option `allowPublicExplain` defaults to `false` ([#9975](https://github.com/parse-community/parse-server/issues/9975)) ([c1c7e69](https://github.com/parse-community/parse-server/commit/c1c7e6976d868ccbc7dff325edce78ddfa999bb9)) +* Increase required minimum MongoDB version to `7.0.16` ([#9971](https://github.com/parse-community/parse-server/issues/9971)) ([7bb548b](https://github.com/parse-community/parse-server/commit/7bb548bf81b3cebc9ec92ef9e5e6faf8f9edbd3b)) +* Increase required minimum Node version to `20.19.0` ([#9970](https://github.com/parse-community/parse-server/issues/9970)) ([633964d](https://github.com/parse-community/parse-server/commit/633964d32e249d8cc16c58de7ddd9b7637c69fb1)) +* Increase required minimum version to Postgres `16`, PostGIS `3.5` ([#9972](https://github.com/parse-community/parse-server/issues/9972)) ([7483add](https://github.com/parse-community/parse-server/commit/7483add73934e7d16098ccfb672cc45b3f7c7fbe)) +* Update route patterns to use path-to-regexp v8 syntax ([#9942](https://github.com/parse-community/parse-server/issues/9942)) ([fa8723b](https://github.com/parse-community/parse-server/commit/fa8723b3d1e895602d1187540818bbdb446259ba)) +* Upgrade to @parse/push-adapter 8.1.0 ([#9938](https://github.com/parse-community/parse-server/issues/9938)) ([d5e76b0](https://github.com/parse-community/parse-server/commit/d5e76b01db2b4eeb22a0bb5a04347a89209aa822)) +* Upgrade to parse 8.0.0 ([#9976](https://github.com/parse-community/parse-server/issues/9976)) ([f9970d4](https://github.com/parse-community/parse-server/commit/f9970d4bb253494392fb4cc366f222119927f082)) + + +### BREAKING CHANGES + +* This release changes the config option `enableInsecureAuthAdapters` default to `false` (Deprecation DEPPS13). ([22d4622](22d4622)) +* This release changes the MongoDB database option `allowPublicExplain` default to `false` (Deprecation DEPPS12). ([c1c7e69](c1c7e69)) +* This release replaces `PublicAPIRouter` with `PagesRouter` (Deprecation DEPPS11). ([8f877d4](8f877d4)) +* This release encodes `Parse.Object` in Cloud Function and removes option `encodeParseObjectInCloudFunction` (Deprecation DEPPS10). ([a2d3dbe](a2d3dbe)) +* This releases increases the required minimum version to Postgres `16`, PostGIS `3.5`. ([7483add](7483add)) +* Route pattern syntax across cloud routes and rate-limiting now use the new path-to-regexp v8 syntax; see the [migration guide](https://github.com/parse-community/parse-server/blob/alpha/9.0.0.md) for more details. ([fa8723b](fa8723b)) +* This releases increases the required minimum MongoDB version to `7.0.16`. ([7bb548b](7bb548b)) +* Upgrade to Apollo Server 5 and GraphQL express 5 integration; GraphQL introspection now requires using `masterKey` or setting `graphQLPublicIntrospection: true`. ([87c7f07](87c7f07)) +* This releases increases the required minimum Node version to `20.19.0`. ([633964d](633964d)) + +# [8.6.0](https://github.com/parse-community/parse-server/compare/8.5.0...8.6.0) (2025-12-10) + + +### Bug Fixes + +* Remove elevated permissions in GitHub CI performance benchmark ([#9966](https://github.com/parse-community/parse-server/issues/9966)) ([6b9f896](https://github.com/parse-community/parse-server/commit/6b9f8963cc3debf59cd9c5dfc5422aff9404ce9d)) + +### Features + +* Add GraphQL query `cloudConfig` to retrieve and mutation `updateCloudConfig` to update Cloud Config ([#9947](https://github.com/parse-community/parse-server/issues/9947)) ([3ca85cd](https://github.com/parse-community/parse-server/commit/3ca85cd4a632f234c9d3d731331c0524dfe54075)) + +# [8.5.0](https://github.com/parse-community/parse-server/compare/8.4.0...8.5.0) (2025-12-01) + + +### Bug Fixes + +* `GridFSBucketAdapter` throws when using some Parse Server specific options in MongoDB database options ([#9915](https://github.com/parse-community/parse-server/issues/9915)) ([d3d4003](https://github.com/parse-community/parse-server/commit/d3d4003570b9872f2b0f5a25fc06ce4c4132860d)) +* Deprecation warning logged at server launch for nested Parse Server option even if option is explicitly set ([#9934](https://github.com/parse-community/parse-server/issues/9934)) ([c22cb0a](https://github.com/parse-community/parse-server/commit/c22cb0ae58e64cd0e4597ab9610d57a1155c44a2)) +* Parse Server option `rateLimit.zone` does not use default value `ip` ([#9941](https://github.com/parse-community/parse-server/issues/9941)) ([12beb8f](https://github.com/parse-community/parse-server/commit/12beb8f6ee5d3002fec017bb4525eb3f1375f806)) +* Queries with object field `authData.provider.id` are incorrectly transformed to `_auth_data_provider.id` for custom classes ([#9932](https://github.com/parse-community/parse-server/issues/9932)) ([7b9fa18](https://github.com/parse-community/parse-server/commit/7b9fa18f968ec084ea0b35dad2b5ba0451d59787)) +* Race condition can cause multiple Apollo server initializations under load ([#9929](https://github.com/parse-community/parse-server/issues/9929)) ([7d5e9fc](https://github.com/parse-community/parse-server/commit/7d5e9fcf3ceb0abad8ab49c75bc26f521a0f1bde)) +* Server internal error details leaking in error messages returned to clients ([#9937](https://github.com/parse-community/parse-server/issues/9937)) ([50edb5a](https://github.com/parse-community/parse-server/commit/50edb5ab4bb4a6ce474bfb7cf159d918933753b8)) + +### Features + +* Add `beforePasswordResetRequest` hook ([#9906](https://github.com/parse-community/parse-server/issues/9906)) ([94cee5b](https://github.com/parse-community/parse-server/commit/94cee5bfafca10c914c73cf17fcdb627a9f0837b)) +* Add MongoDB client event logging via database option `logClientEvents` ([#9914](https://github.com/parse-community/parse-server/issues/9914)) ([b760733](https://github.com/parse-community/parse-server/commit/b760733b98bcfc9c09ac9780066602e1fda108fe)) +* Add Parse Server option `allowPublicExplain` to allow `Parse.Query.explain` without master key ([#9890](https://github.com/parse-community/parse-server/issues/9890)) ([4456b02](https://github.com/parse-community/parse-server/commit/4456b02280c2d8dd58b7250e9e67f1a8647b3452)) +* Add Parse Server option `enableSanitizedErrorResponse` to remove detailed error messages from responses sent to clients ([#9944](https://github.com/parse-community/parse-server/issues/9944)) ([4752197](https://github.com/parse-community/parse-server/commit/47521974aeafcf41102be62f19612a4ab0a4837f)) +* Add support for MongoDB driver options `serverSelectionTimeoutMS`, `maxIdleTimeMS`, `heartbeatFrequencyMS` ([#9910](https://github.com/parse-community/parse-server/issues/9910)) ([1b661e9](https://github.com/parse-community/parse-server/commit/1b661e98c86a1db79e076a7297cd9199a72ae1ac)) +* Add support for more MongoDB driver options ([#9911](https://github.com/parse-community/parse-server/issues/9911)) ([cff451e](https://github.com/parse-community/parse-server/commit/cff451eabdc380affa600ed711de66f7bd1d00aa)) +* Allow option `publicServerURL` to be set dynamically as asynchronous function ([#9803](https://github.com/parse-community/parse-server/issues/9803)) ([460a65c](https://github.com/parse-community/parse-server/commit/460a65cf612f4c86af8038cafcc7e7ffe9eb8440)) +* Upgrade to parse 7.1.1 ([#9954](https://github.com/parse-community/parse-server/issues/9954)) ([fa57d69](https://github.com/parse-community/parse-server/commit/fa57d69cbec525189da98d7136c1c0e9eaf74338)) +* Upgrade to parse 7.1.2 ([#9955](https://github.com/parse-community/parse-server/issues/9955)) ([5c644a5](https://github.com/parse-community/parse-server/commit/5c644a55ac25986f214b68ba4bcbe7a62ad6d6d1)) + +### Performance Improvements + +* `Parse.Query.include` now fetches pointers at same level in parallel ([#9861](https://github.com/parse-community/parse-server/issues/9861)) ([dafea21](https://github.com/parse-community/parse-server/commit/dafea21eb39b0fdc2b52bb8a14f7b61e3f2b8d13)) +* Remove unused dependencies ([#9943](https://github.com/parse-community/parse-server/issues/9943)) ([d4c6de0](https://github.com/parse-community/parse-server/commit/d4c6de0096b3ac95289c6bddfe25eb397d790e41)) +* Upgrade MongoDB driver to 6.20.0 ([#9887](https://github.com/parse-community/parse-server/issues/9887)) ([3c9af48](https://github.com/parse-community/parse-server/commit/3c9af48edd999158443b797e388e29495953799e)) + +# [8.4.0](https://github.com/parse-community/parse-server/compare/8.3.0...8.4.0) (2025-11-05) + + +### Bug Fixes + +* Add problematic MIME types to default value of Parse Server option `fileUpload.fileExtensions` ([#9902](https://github.com/parse-community/parse-server/issues/9902)) ([fa245cb](https://github.com/parse-community/parse-server/commit/fa245cbb5f5b7a0dad962b2ce0524fa4dafcb5f7)) +* Uploading a file by providing an origin URL allows for Server-Side Request Forgery (SSRF); fixes vulnerability [GHSA-x4qj-2f4q-r4rx](https://github.com/parse-community/parse-server/security/advisories/GHSA-x4qj-2f4q-r4rx) ([#9903](https://github.com/parse-community/parse-server/issues/9903)) ([9776386](https://github.com/parse-community/parse-server/commit/97763863b72689a29ad7a311dfb590c3e3c50585)) + +### Features + +* Add support for Node 24 ([#9901](https://github.com/parse-community/parse-server/issues/9901)) ([25dfe19](https://github.com/parse-community/parse-server/commit/25dfe19fef02fd44224e4a6d198584a694a1aa52)) + +# [8.3.0](https://github.com/parse-community/parse-server/compare/8.2.5...8.3.0) (2025-11-01) + + +### Bug Fixes + +* Error in `afterSave` trigger for `Parse.Role` due to `name` field ([#9883](https://github.com/parse-community/parse-server/issues/9883)) ([eb052d8](https://github.com/parse-community/parse-server/commit/eb052d8e6abe1ae32505fd068d5445eaf950a770)) +* Indexes `_email_verify_token` for email verification and `_perishable_token` password reset are not created automatically ([#9893](https://github.com/parse-community/parse-server/issues/9893)) ([62dd3c5](https://github.com/parse-community/parse-server/commit/62dd3c565ab70765cb1c547996b616b72e9bb800)) +* Security upgrade to parse 7.0.1 ([#9877](https://github.com/parse-community/parse-server/issues/9877)) ([abfa94c](https://github.com/parse-community/parse-server/commit/abfa94cd6de2c4e76337931c8ea8311c4ccf2a1a)) +* Server URL verification before server is ready ([#9882](https://github.com/parse-community/parse-server/issues/9882)) ([178bd5c](https://github.com/parse-community/parse-server/commit/178bd5c5e258d9501c9ac4d35a3a105ab64be67e)) +* Stale data read in validation query on `Parse.Object` update causes inconsistency between validation read and subsequent update write operation ([#9859](https://github.com/parse-community/parse-server/issues/9859)) ([f49efaf](https://github.com/parse-community/parse-server/commit/f49efaf5bb1d6b19f6d6712f7cdf073855c95c6e)) +* Warning logged when setting option `databaseOptions.disableIndexFieldValidation` ([#9880](https://github.com/parse-community/parse-server/issues/9880)) ([1815b01](https://github.com/parse-community/parse-server/commit/1815b019b52565d2bc87be2596a49aea7600aeba)) + +### Features + +* Add option `keepUnknownIndexes` to retain indexes which are not specified in schema ([#9857](https://github.com/parse-community/parse-server/issues/9857)) ([89fad46](https://github.com/parse-community/parse-server/commit/89fad468c3a43772879c06c4d939a83b72517a8e)) +* Add options to skip automatic creation of internal database indexes on server start ([#9897](https://github.com/parse-community/parse-server/issues/9897)) ([ea91aca](https://github.com/parse-community/parse-server/commit/ea91aca1420c33e038516a321b2640709589f886)) +* Add Parse Server option `verifyServerUrl` to disable server URL verification on server launch ([#9881](https://github.com/parse-community/parse-server/issues/9881)) ([b298ccc](https://github.com/parse-community/parse-server/commit/b298cccd9fb4f664b9d83894faad6d1ea7a3c964)) +* Add regex option `u` for unicode support in `Parse.Query.matches` for MongoDB ([#9867](https://github.com/parse-community/parse-server/issues/9867)) ([7cb962a](https://github.com/parse-community/parse-server/commit/7cb962a02845f3dded61baffd84515f94b66ee50)) +* Add request context middleware for config and dependency injection in hooks ([#8480](https://github.com/parse-community/parse-server/issues/8480)) ([64f104e](https://github.com/parse-community/parse-server/commit/64f104e5c5f8863098e801eee632c14fcbd9b6f9)) +* Add support for Postgres 18 ([#9870](https://github.com/parse-community/parse-server/issues/9870)) ([d275c18](https://github.com/parse-community/parse-server/commit/d275c1806e0a5a037cc06cde7eefff3e12c91d7d)) +* Allow returning objects in `Parse.Cloud.beforeFind` without invoking database query ([#9770](https://github.com/parse-community/parse-server/issues/9770)) ([0b47407](https://github.com/parse-community/parse-server/commit/0b4740714c29ba99672bc535619ee3516abd356f)) +* Disable index-field validation to create index for fields that don't yet exist ([#8137](https://github.com/parse-community/parse-server/issues/8137)) ([1b23475](https://github.com/parse-community/parse-server/commit/1b2347524ca84ade0f6badf175a815fc8a7bef49)) + +## [8.2.5](https://github.com/parse-community/parse-server/compare/8.2.4...8.2.5) (2025-10-02) + + +### Bug Fixes + +* GraphQL playground shows blank page ([#9858](https://github.com/parse-community/parse-server/issues/9858)) ([7b5395c](https://github.com/parse-community/parse-server/commit/7b5395c5d481235c022d96603280672366a50715)) + +## [8.2.4](https://github.com/parse-community/parse-server/compare/8.2.3...8.2.4) (2025-09-01) + + +### Bug Fixes + +* Security upgrade form-data from 4.0.3 to 4.0.4 ([#9829](https://github.com/parse-community/parse-server/issues/9829)) ([c2c593f](https://github.com/parse-community/parse-server/commit/c2c593f437c33f37101b4f3bb1287eef31dbbc3b)) + +## [8.2.3](https://github.com/parse-community/parse-server/compare/8.2.2...8.2.3) (2025-08-01) + + +### Bug Fixes + +* MongoDB aggregation pipeline with `$dateSubtract` from `$$NOW` returns no results ([#9822](https://github.com/parse-community/parse-server/issues/9822)) ([847a274](https://github.com/parse-community/parse-server/commit/847a274cdb8c22f8e0fc249162e5e2c9e29a594a)) + +## [8.2.2](https://github.com/parse-community/parse-server/compare/8.2.1...8.2.2) (2025-07-10) + + +### Bug Fixes + +* Data schema exposed via GraphQL API public introspection (GHSA-48q3-prgv-gm4w) ([#9819](https://github.com/parse-community/parse-server/issues/9819)) ([c58b2eb](https://github.com/parse-community/parse-server/commit/c58b2eb6eb48af9d3c2e69b4829810a021347b40)) + +## [8.2.1](https://github.com/parse-community/parse-server/compare/8.2.0...8.2.1) (2025-06-01) + + +### Bug Fixes + +* `Parse.Query.containedIn` and `matchesQuery` do not work with nested objects ([#9738](https://github.com/parse-community/parse-server/issues/9738)) ([0db3a6f](https://github.com/parse-community/parse-server/commit/0db3a6ff27a129427770e314a792cc586e4255b5)) + +### Performance Improvements + +* Remove saving Parse Cloud Job request parameters in internal collection `_JobStatus` ([#8343](https://github.com/parse-community/parse-server/issues/8343)) ([e98733c](https://github.com/parse-community/parse-server/commit/e98733cbac9451521a3acc388d2f9d29eb4610e0)) + +# [8.2.0](https://github.com/parse-community/parse-server/compare/8.1.0...8.2.0) (2025-05-01) + + +### Features + +* Add TypeScript definitions ([#9693](https://github.com/parse-community/parse-server/issues/9693)) ([e86718f](https://github.com/parse-community/parse-server/commit/e86718fc59c7c8e6f3c6abd0feb7d1a68ca76c23)) + +### Performance Improvements + +* Add details to error message in `Parse.Query.aggregate` ([#9689](https://github.com/parse-community/parse-server/issues/9689)) ([9de6999](https://github.com/parse-community/parse-server/commit/9de6999e257d839b68bbca282447777edfdb1ddf)) + +# [8.1.0](https://github.com/parse-community/parse-server/compare/8.0.2...8.1.0) (2025-04-04) + + +### Bug Fixes + +* Parse Server doesn't shutdown gracefully ([#9634](https://github.com/parse-community/parse-server/issues/9634)) ([aed918d](https://github.com/parse-community/parse-server/commit/aed918d3109e739f7231d481b5f48c68fc01cf04)) + +### Features + +* Add Cloud Code triggers `Parse.Cloud.beforeFind(Parse.File)`and `Parse.Cloud.afterFind(Parse.File)` ([#8700](https://github.com/parse-community/parse-server/issues/8700)) ([b2beaa8](https://github.com/parse-community/parse-server/commit/b2beaa86ff543a7aa4ad274c7a23bc4aa302c3fa)) +* Add default ACL ([#8701](https://github.com/parse-community/parse-server/issues/8701)) ([12b5d78](https://github.com/parse-community/parse-server/commit/12b5d781dc3f8c43c0c566dffa9308d02a7d8043)) +* Upgrade Parse JS SDK from 6.0.0 to 6.1.0 ([#9686](https://github.com/parse-community/parse-server/issues/9686)) ([f49c371](https://github.com/parse-community/parse-server/commit/f49c371c1373d41e68b091e65f33a71ff6fc6dd0)) + +## [8.0.2](https://github.com/parse-community/parse-server/compare/8.0.1...8.0.2) (2025-03-21) + + +### Bug Fixes + +* Authentication provider credentials are usable across Parse Server apps; fixes security vulnerability [GHSA-837q-jhwx-cmpv](https://github.com/parse-community/parse-server/security/advisories/GHSA-837q-jhwx-cmpv) ([#9667](https://github.com/parse-community/parse-server/issues/9667)) ([5ef0440](https://github.com/parse-community/parse-server/commit/5ef0440c8e763854e62341acaeb6dc4ade3ba82f)) + +## [8.0.1](https://github.com/parse-community/parse-server/compare/8.0.0...8.0.1) (2025-03-17) + + +### Bug Fixes + +* Security upgrade node from 20.18.2-alpine3.20 to 20.19.0-alpine3.20 ([#9652](https://github.com/parse-community/parse-server/issues/9652)) ([2be1a19](https://github.com/parse-community/parse-server/commit/2be1a19a13d6f0f8e3eb4e399a6279ff4d01db76)) +* Using Parse Server option `extendSessionOnUse` does not correctly clear memory and functions as a debounce instead of a throttle ([#8683](https://github.com/parse-community/parse-server/issues/8683)) ([6258a6a](https://github.com/parse-community/parse-server/commit/6258a6a11235dc642c71074d24e19c055294d26d)) + +# [8.0.0](https://github.com/parse-community/parse-server/compare/7.4.0...8.0.0) (2025-03-04) + + +### Bug Fixes + +* LiveQueryServer crashes using cacheAdapter on disconnect from Redis 4 server ([#9616](https://github.com/parse-community/parse-server/issues/9616)) ([bbc6bd4](https://github.com/parse-community/parse-server/commit/bbc6bd4b3f493170c13ad3314924cbf1f379eca4)) +* Push adapter not loading on some versions of Node 22 ([#9524](https://github.com/parse-community/parse-server/issues/9524)) ([ff7f671](https://github.com/parse-community/parse-server/commit/ff7f671c79f5dcdc44e4319a10f3654e12662c23)) +* Remove username from email verification and password reset process ([#8488](https://github.com/parse-community/parse-server/issues/8488)) ([d21dd97](https://github.com/parse-community/parse-server/commit/d21dd973363f9c5eca86a1007cb67e445b0d2e02)) +* Security upgrade node from 20.17.0-alpine3.20 to 20.18.2-alpine3.20 ([#9583](https://github.com/parse-community/parse-server/issues/9583)) ([8f85ae2](https://github.com/parse-community/parse-server/commit/8f85ae205474f65414c0536754de12c87dbbf82a)) + +### Features + +* Add dynamic master key by setting Parse Server option `masterKey` to a function ([#9582](https://github.com/parse-community/parse-server/issues/9582)) ([6f1d161](https://github.com/parse-community/parse-server/commit/6f1d161a2f263a166981f9544cf2aadce65afe23)) +* Add support for MongoDB `databaseOptions` keys `autoSelectFamily`, `autoSelectFamilyAttemptTimeout` ([#9579](https://github.com/parse-community/parse-server/issues/9579)) ([5966068](https://github.com/parse-community/parse-server/commit/5966068e963e7a79eac8fba8720ee7d83578f207)) +* Add support for MongoDB `databaseOptions` keys `minPoolSize`, `connectTimeoutMS`, `socketTimeoutMS` ([#9522](https://github.com/parse-community/parse-server/issues/9522)) ([91618fe](https://github.com/parse-community/parse-server/commit/91618fe738217b937cbfcec35969679e0adb7676)) +* Add TypeScript support ([#9550](https://github.com/parse-community/parse-server/issues/9550)) ([59e46d0](https://github.com/parse-community/parse-server/commit/59e46d0aea3e6529994d98160d993144b8075291)) +* Change default value of Parse Server option `encodeParseObjectInCloudFunction` to `true` ([#9527](https://github.com/parse-community/parse-server/issues/9527)) ([5c5ad69](https://github.com/parse-community/parse-server/commit/5c5ad69b4a917b7ed7c328a8255144e105c40b08)) +* Deprecate `PublicAPIRouter` in favor of `PagesRouter` ([#9526](https://github.com/parse-community/parse-server/issues/9526)) ([7f66629](https://github.com/parse-community/parse-server/commit/7f666292e8b9692966672486b7108edefc356309)) +* Increase required minimum MongoDB versions to `6.0.19`, `7.0.16`, `8.0.4` ([#9531](https://github.com/parse-community/parse-server/issues/9531)) ([871e508](https://github.com/parse-community/parse-server/commit/871e5082a9fd768cee3012e26d3c8ddff5c2952c)) +* Increase required minimum Node versions to `18.20.4`, `20.18.0`, `22.12.0` ([#9521](https://github.com/parse-community/parse-server/issues/9521)) ([4e151cd](https://github.com/parse-community/parse-server/commit/4e151cd0a52191809452f197b2f29c3a12525b67)) +* Increase required minimum versions to Postgres `15`, PostGIS `3.3` ([#9538](https://github.com/parse-community/parse-server/issues/9538)) ([89c9b54](https://github.com/parse-community/parse-server/commit/89c9b5485a07a411fb35de4f8cf0467e7eb01f85)) +* Upgrade to express 5.0.1 ([#9530](https://github.com/parse-community/parse-server/issues/9530)) ([e0480df](https://github.com/parse-community/parse-server/commit/e0480dfa8d97946e57eac6b74d937978f8454b3a)) +* Upgrade to Parse JS SDK 6.0.0 ([#9624](https://github.com/parse-community/parse-server/issues/9624)) ([bf9db75](https://github.com/parse-community/parse-server/commit/bf9db75e8685def1407034944725e758bc926c26)) + + +### BREAKING CHANGES + +* This upgrades the internally used Express framework from version 4 to 5, which may be a breaking change. If Parse Server is set up to be mounted on an Express application, we recommend to also use version 5 of the Express framework to avoid any compatibility issues. Note that even if there are no issues after upgrading, future releases of Parse Server may introduce issues if Parse Server internally relies on Express 5-specific features which are unsupported by the Express version on which it is mounted. See the Express [migration guide](https://expressjs.com/en/guide/migrating-5.html) and [release announcement](https://expressjs.com/2024/10/15/v5-release.html#breaking-changes) for more info. ([e0480df](e0480df)) +* This upgrades to the Parse JS SDK 6.0.0. See the [change log](https://github.com/parse-community/Parse-SDK-JS/releases/tag/6.0.0) of the Parse JS SDK for breaking changes and more details. ([bf9db75](bf9db75)) +* This removes the username from the email verification and password reset process to prevent storing personally identifiable information (PII) in server and infrastructure logs. Customized HTML pages or emails related to email verification and password reset may need to be adapted accordingly. See the new templates that come bundled with Parse Server and the [migration guide](https://github.com/parse-community/parse-server/blob/alpha/8.0.0.md) for more details. ([d21dd97](d21dd97)) +* This releases increases the required minimum versions to Postgres `15`, PostGIS `3.3` and removes support for Postgres `13`, `14`, PostGIS `3.1`, `3.2`. ([89c9b54](89c9b54)) +* The default value of Parse Server option `encodeParseObjectInCloudFunction` changes to `true`; the option has been deprecated and will be removed in a future version. ([5c5ad69](5c5ad69)) +* This releases increases the required minimum MongoDB versions to `6.0.19`, `7.0.16`, `8.0.4` and removes support for MongoDB `4`, `5`. ([871e508](871e508)) +* This releases increases the required minimum Node versions to 18.20.4, 20.18.0, 22.12.0 and removes unofficial support for Node 19. ([4e151cd](4e151cd)) + +# [7.4.0](https://github.com/parse-community/parse-server/compare/7.3.0...7.4.0) (2024-12-23) + + +### Bug Fixes + +* `Parse.Query.distinct` fails due to invalid aggregate stage 'hint' ([#9295](https://github.com/parse-community/parse-server/issues/9295)) ([5f66c6a](https://github.com/parse-community/parse-server/commit/5f66c6a075cbe1cdaf9d1b108ee65af8ae596b89)) +* Security upgrade cross-spawn from 7.0.3 to 7.0.6 ([#9444](https://github.com/parse-community/parse-server/issues/9444)) ([3d034e0](https://github.com/parse-community/parse-server/commit/3d034e0a993e3e5bd9bb96a7e382bb3464f1eb68)) +* Security upgrade fast-xml-parser from 4.4.0 to 4.4.1 ([#9262](https://github.com/parse-community/parse-server/issues/9262)) ([992d39d](https://github.com/parse-community/parse-server/commit/992d39d508f230c774dcb764d1d907ec8887e6c5)) +* Security upgrade node from 20.14.0-alpine3.20 to 20.17.0-alpine3.20 ([#9300](https://github.com/parse-community/parse-server/issues/9300)) ([15bb17d](https://github.com/parse-community/parse-server/commit/15bb17d87153bf0d38f08fe4c720da29a204b36b)) + +### Features + +* Add support for MongoDB 8 ([#9269](https://github.com/parse-community/parse-server/issues/9269)) ([4756c66](https://github.com/parse-community/parse-server/commit/4756c66cd9f55afa1621d1a3f6fa850ed605cb53)) +* Add support for PostGIS 3.5 ([#9354](https://github.com/parse-community/parse-server/issues/9354)) ([8ea3538](https://github.com/parse-community/parse-server/commit/8ea35382db3436d54ab59bd30706705564b0985c)) +* Add support for Postgres 17 ([#9324](https://github.com/parse-community/parse-server/issues/9324)) ([fa2ee31](https://github.com/parse-community/parse-server/commit/fa2ee3196e4319a142b3838bb947c98dcba5d5cb)) +* Upgrade @parse/push-adapter from 6.7.1 to 6.8.0 ([#9489](https://github.com/parse-community/parse-server/issues/9489)) ([286aa66](https://github.com/parse-community/parse-server/commit/286aa664ac8830d36c3e70d2316917d15f0b6df5)) + +# [7.3.0](https://github.com/parse-community/parse-server/compare/7.2.0...7.3.0) (2024-10-03) + + +### Bug Fixes + +* Custom object ID allows to acquire role privileges ([GHSA-8xq9-g7ch-35hg](https://github.com/parse-community/parse-server/security/advisories/GHSA-8xq9-g7ch-35hg)) ([#9317](https://github.com/parse-community/parse-server/issues/9317)) ([13ee52f](https://github.com/parse-community/parse-server/commit/13ee52f0d19ef3a3524b3d79aea100e587eb3cfc)) +* Parse Server `databaseOptions` nested keys incorrectly identified as invalid ([#9213](https://github.com/parse-community/parse-server/issues/9213)) ([77206d8](https://github.com/parse-community/parse-server/commit/77206d804443cfc1618c24f8961bd677de9920c0)) +* Parse Server installation fails due to post install script incorrectly parsing required min. Node version ([#9216](https://github.com/parse-community/parse-server/issues/9216)) ([0fa82a5](https://github.com/parse-community/parse-server/commit/0fa82a54fe38ec14e8054339285d3db71a8624c8)) +* Parse Server option `maxLogFiles` doesn't recognize day duration literals such as `1d` to mean 1 day ([#9215](https://github.com/parse-community/parse-server/issues/9215)) ([0319cee](https://github.com/parse-community/parse-server/commit/0319cee2dbf65e90bad377af1ed14ea25c595bf5)) +* Security upgrade path-to-regexp from 6.2.1 to 6.3.0 ([#9314](https://github.com/parse-community/parse-server/issues/9314)) ([8b7fe69](https://github.com/parse-community/parse-server/commit/8b7fe699c1c376ecd8cc1c97cce8e704ee41f28a)) + +### Features + +* Add atomic operations for Cloud Config parameters ([#9219](https://github.com/parse-community/parse-server/issues/9219)) ([35cadf9](https://github.com/parse-community/parse-server/commit/35cadf9b8324879fb7309ba5d7ea46f2c722d614)) +* Add Cloud Code triggers `Parse.Cloud.beforeSave` and `Parse.Cloud.afterSave` for Parse Config ([#9232](https://github.com/parse-community/parse-server/issues/9232)) ([90a1e4a](https://github.com/parse-community/parse-server/commit/90a1e4a200423d644efb3f0ba2fba4b99f5cf954)) +* Add Node 22 support ([#9187](https://github.com/parse-community/parse-server/issues/9187)) ([7778471](https://github.com/parse-community/parse-server/commit/7778471999c7e42236ce404229660d80ecc2acd6)) +* Add support for asynchronous invocation of `FilesAdapter.getFileLocation` ([#9271](https://github.com/parse-community/parse-server/issues/9271)) ([1a2da40](https://github.com/parse-community/parse-server/commit/1a2da4055abe831b3017172fb75e16d7a8093873)) + +# [7.2.0](https://github.com/parse-community/parse-server/compare/7.1.0...7.2.0) (2024-07-09) + + +### Bug Fixes + +* Invalid push notification tokens are not cleaned up from database for FCM API v2 ([#9173](https://github.com/parse-community/parse-server/issues/9173)) ([284da09](https://github.com/parse-community/parse-server/commit/284da09f4546356b37511a589fb5f64a3efffe79)) + +### Features + +* Add support for dot notation on array fields of Parse Object ([#9115](https://github.com/parse-community/parse-server/issues/9115)) ([cf4c880](https://github.com/parse-community/parse-server/commit/cf4c8807b9da87a0a5f9c94e5bdfcf17cda80cf4)) +* Upgrade to @parse/push-adapter 6.4.0 ([#9182](https://github.com/parse-community/parse-server/issues/9182)) ([ef1634b](https://github.com/parse-community/parse-server/commit/ef1634bf1f360429108d29b08032fc7961ff96a1)) +* Upgrade to Parse JS SDK 5.3.0 ([#9180](https://github.com/parse-community/parse-server/issues/9180)) ([dca187f](https://github.com/parse-community/parse-server/commit/dca187f91b93cbb362b22a3fb9ee38451799ff13)) + +# [7.1.0](https://github.com/parse-community/parse-server/compare/7.0.0...7.1.0) (2024-06-30) + + +### Bug Fixes + +* `Parse.Cloud.startJob` and `Parse.Push.send` not returning status ID when setting Parse Server option `directAccess: true` ([#8766](https://github.com/parse-community/parse-server/issues/8766)) ([5b0efb2](https://github.com/parse-community/parse-server/commit/5b0efb22efe94c47f243cf8b1e6407ed5c5a67d3)) +* `Required` option not handled correctly for special fields (File, GeoPoint, Polygon) on GraphQL API mutations ([#8915](https://github.com/parse-community/parse-server/issues/8915)) ([907ad42](https://github.com/parse-community/parse-server/commit/907ad4267c228d26cfcefe7848b30ce85ba7ff8f)) +* Facebook Limited Login not working due to incorrect domain in JWT validation ([#9122](https://github.com/parse-community/parse-server/issues/9122)) ([9d0bd2b](https://github.com/parse-community/parse-server/commit/9d0bd2badd6e5f7429d1af00b118225752e5d86a)) +* Live query throws error when constraint `notEqualTo` is set to `null` ([#8835](https://github.com/parse-community/parse-server/issues/8835)) ([11d3e48](https://github.com/parse-community/parse-server/commit/11d3e484df862224c15d20f6171514948981ea90)) +* Parse Server option `extendSessionOnUse` not working for session lengths < 24 hours ([#9113](https://github.com/parse-community/parse-server/issues/9113)) ([0a054e6](https://github.com/parse-community/parse-server/commit/0a054e6b541fd5ab470bf025665f5f7d2acedaa0)) +* Rate limiting can fail when using Parse Server option `rateLimit.redisUrl` with clusters ([#8632](https://github.com/parse-community/parse-server/issues/8632)) ([c277739](https://github.com/parse-community/parse-server/commit/c27773962399f8e27691e3b8087e7e1d59516efd)) +* SQL injection when using Parse Server with PostgreSQL; fixes security vulnerability [GHSA-c2hr-cqg6-8j6r](https://github.com/parse-community/parse-server/security/advisories/GHSA-c2hr-cqg6-8j6r) ([#9167](https://github.com/parse-community/parse-server/issues/9167)) ([2edf1e4](https://github.com/parse-community/parse-server/commit/2edf1e4c0363af01e97a7fbc97694f851b7d1ff3)) + +### Features + +* Add `silent` log level for Cloud Code ([#8803](https://github.com/parse-community/parse-server/issues/8803)) ([5f81efb](https://github.com/parse-community/parse-server/commit/5f81efb42964c4c2fa8bcafee9446a0122e3ce21)) +* Add server security check status `security.enableCheck` to Features Router ([#8679](https://github.com/parse-community/parse-server/issues/8679)) ([b07ec15](https://github.com/parse-community/parse-server/commit/b07ec153825882e97cc48dc84072c7f549f3238b)) +* Prevent Parse Server start in case of unknown option in server configuration ([#8987](https://github.com/parse-community/parse-server/issues/8987)) ([8758e6a](https://github.com/parse-community/parse-server/commit/8758e6abb9dbb68757bddcbd332ad25100c24a0e)) +* Upgrade to @parse/push-adapter 6.0.0 ([#9066](https://github.com/parse-community/parse-server/issues/9066)) ([18bdbf8](https://github.com/parse-community/parse-server/commit/18bdbf89c53a57648891ef582614ba7c2941e587)) +* Upgrade to @parse/push-adapter 6.2.0 ([#9127](https://github.com/parse-community/parse-server/issues/9127)) ([ca20496](https://github.com/parse-community/parse-server/commit/ca20496f28e5ec1294a7a23c8559df82b79b2a04)) +* Upgrade to Parse JS SDK 5.2.0 ([#9128](https://github.com/parse-community/parse-server/issues/9128)) ([665b8d5](https://github.com/parse-community/parse-server/commit/665b8d52d6cf5275179a5e1fb132c934edb53ecc)) + +# [7.0.0](https://github.com/parse-community/parse-server/compare/6.4.0...7.0.0) (2024-03-19) + + +### Bug Fixes + +* CacheAdapter does not connect when using a CacheAdapter with a JSON config ([#8633](https://github.com/parse-community/parse-server/issues/8633)) ([720d24e](https://github.com/parse-community/parse-server/commit/720d24e18540da35d50957f17be878316ec30318)) +* Conditional email verification not working in some cases if `verifyUserEmails`, `preventLoginWithUnverifiedEmail` set to functions ([#8838](https://github.com/parse-community/parse-server/issues/8838)) ([8e7a6b1](https://github.com/parse-community/parse-server/commit/8e7a6b1480c0117e6c73e7adc5a6619115a04e85)) +* Context not passed to Cloud Code Trigger `beforeFind` when using `Parse.Query.include` ([#8765](https://github.com/parse-community/parse-server/issues/8765)) ([7d32d89](https://github.com/parse-community/parse-server/commit/7d32d8934f3ae7af7a7d8b9cc6a829c7d73973d3)) +* Deny request if master key is not set in Parse Server option `masterKeyIps` regardless of ACL and CLP ([#8957](https://github.com/parse-community/parse-server/issues/8957)) ([a7b5b38](https://github.com/parse-community/parse-server/commit/a7b5b38418cbed9be3f4a7665f25b97f592663e1)) +* Docker image not published to Docker Hub on new release ([#8905](https://github.com/parse-community/parse-server/issues/8905)) ([a2ac8d1](https://github.com/parse-community/parse-server/commit/a2ac8d133c71cd7b61e5ef59c4be915cfea85db6)) +* Docker version releases by removing arm/v6 and arm/v7 support ([#8976](https://github.com/parse-community/parse-server/issues/8976)) ([1f62dd0](https://github.com/parse-community/parse-server/commit/1f62dd0f4e107b22a387692558a042ee26ce8703)) +* GraphQL file upload fails in case of use of pointer or relation ([#8721](https://github.com/parse-community/parse-server/issues/8721)) ([1aba638](https://github.com/parse-community/parse-server/commit/1aba6382c873fb489d4a898d301e6da9fb6aa61b)) +* Improve PostgreSQL injection detection; fixes security vulnerability [GHSA-6927-3vr9-fxf2](https://github.com/parse-community/parse-server/security/advisories/GHSA-6927-3vr9-fxf2) which affects Parse Server deployments using a Postgres database ([#8961](https://github.com/parse-community/parse-server/issues/8961)) ([cbefe77](https://github.com/parse-community/parse-server/commit/cbefe770a7260b54748a058b8a7389937dc35833)) +* Incomplete user object in `verifyEmail` function if both username and email are changed ([#8889](https://github.com/parse-community/parse-server/issues/8889)) ([1eb95ae](https://github.com/parse-community/parse-server/commit/1eb95aeb41a96250e582d79a703f6adcb403c08b)) +* Parse Server option `emailVerifyTokenReuseIfValid: true` generates new token on every email verification request ([#8885](https://github.com/parse-community/parse-server/issues/8885)) ([0023ce4](https://github.com/parse-community/parse-server/commit/0023ce448a5e9423337d0e1a25648bde1156bc95)) +* Parse Server option `fileExtensions` default value rejects file extensions that are less than 3 or more than 4 characters long ([#8699](https://github.com/parse-community/parse-server/issues/8699)) ([2760381](https://github.com/parse-community/parse-server/commit/276038118377c2b22381bcd8d30337203822121b)) +* Parse Server option `fileUpload.fileExtensions` fails to determine file extension if filename contains multiple dots ([#8754](https://github.com/parse-community/parse-server/issues/8754)) ([3d6d50e](https://github.com/parse-community/parse-server/commit/3d6d50e0afff18b95fb906914e2cebd3839b517a)) +* Security bump @babel/traverse from 7.20.5 to 7.23.2 ([#8777](https://github.com/parse-community/parse-server/issues/8777)) ([2d6b3d1](https://github.com/parse-community/parse-server/commit/2d6b3d18499179e99be116f25c0850d3f449509c)) +* Security upgrade graphql from 16.6.0 to 16.8.1 ([#8758](https://github.com/parse-community/parse-server/issues/8758)) ([71dfd8a](https://github.com/parse-community/parse-server/commit/71dfd8a7ece8c0dd1a66d03bb9420cfd39f4f9b1)) +* Server crashes on invalid Cloud Function or Cloud Job name; fixes security vulnerability [GHSA-6hh7-46r2-vf29](https://github.com/parse-community/parse-server/security/advisories/GHSA-6hh7-46r2-vf29) ([#9024](https://github.com/parse-community/parse-server/issues/9024)) ([9f6e342](https://github.com/parse-community/parse-server/commit/9f6e3429d3b326cf4e2994733c618d08032fac6e)) +* Server crashes when receiving an array of `Parse.Pointer` in the request body ([#8784](https://github.com/parse-community/parse-server/issues/8784)) ([66e3603](https://github.com/parse-community/parse-server/commit/66e36039d8af654cfa0284666c0ddd94975dcb52)) +* Username is `undefined` in email verification link on email change ([#8887](https://github.com/parse-community/parse-server/issues/8887)) ([e315c13](https://github.com/parse-community/parse-server/commit/e315c137bf41bedfa8f0df537f2c3f6ab45b7e60)) + +### Features + +* Add `$setOnInsert` operator to `Parse.Server.database.update` ([#8791](https://github.com/parse-community/parse-server/issues/8791)) ([f630a45](https://github.com/parse-community/parse-server/commit/f630a45aa5e87bc73a81fded061400c199b71a29)) +* Add `installationId` to arguments for `verifyUserEmails`, `preventLoginWithUnverifiedEmail` ([#8836](https://github.com/parse-community/parse-server/issues/8836)) ([a22dbe1](https://github.com/parse-community/parse-server/commit/a22dbe16d5ac0090608f6caaf0ebd134925b7fd4)) +* Add `installationId`, `ip`, `resendRequest` to arguments passed to `verifyUserEmails` on verification email request ([#8873](https://github.com/parse-community/parse-server/issues/8873)) ([8adcbee](https://github.com/parse-community/parse-server/commit/8adcbee11283d3e95179ca2047e2615f52c18806)) +* Add `Parse.User` as function parameter to Parse Server options `verifyUserEmails`, `preventLoginWithUnverifiedEmail` on login ([#8850](https://github.com/parse-community/parse-server/issues/8850)) ([972f630](https://github.com/parse-community/parse-server/commit/972f6300163b3cd7d95eeb95986e8322c95f821c)) +* Add compatibility for MongoDB Atlas Serverless and AWS Amazon DocumentDB with collation options `enableCollationCaseComparison`, `transformEmailToLowercase`, `transformUsernameToLowercase` ([#8805](https://github.com/parse-community/parse-server/issues/8805)) ([09fbeeb](https://github.com/parse-community/parse-server/commit/09fbeebba8870e7cf371fb84371a254c7b368620)) +* Add context to Cloud Code Triggers `beforeLogin` and `afterLogin` ([#8724](https://github.com/parse-community/parse-server/issues/8724)) ([a9c34ef](https://github.com/parse-community/parse-server/commit/a9c34ef1e2c78a42fb8b5fa8d569b7677c74919d)) +* Add password validation via POST request for user with unverified email using master key and option `ignoreEmailVerification` ([#8895](https://github.com/parse-community/parse-server/issues/8895)) ([633a9d2](https://github.com/parse-community/parse-server/commit/633a9d25e4253e2125bc93c02ee8a37e0f5f7b83)) +* Add support for MongoDB 7 ([#8761](https://github.com/parse-community/parse-server/issues/8761)) ([3de8494](https://github.com/parse-community/parse-server/commit/3de8494a221991dfd10a74e0a2dc89576265c9b7)) +* Add support for MongoDB query comment ([#8928](https://github.com/parse-community/parse-server/issues/8928)) ([2170962](https://github.com/parse-community/parse-server/commit/2170962a50fa353ed85eda3f11dce7ee3647b087)) +* Add support for Node 20, drop support for Node 14, 16 ([#8907](https://github.com/parse-community/parse-server/issues/8907)) ([ced4872](https://github.com/parse-community/parse-server/commit/ced487246ea0ef72a8aa014991f003209b34841e)) +* Add support for Postgres 16 ([#8898](https://github.com/parse-community/parse-server/issues/8898)) ([99489b2](https://github.com/parse-community/parse-server/commit/99489b22e4f0982e6cb39992974b51aa8d3a31e4)) +* Allow `Parse.Session.current` on expired session token instead of throwing error ([#8722](https://github.com/parse-community/parse-server/issues/8722)) ([f9dde4a](https://github.com/parse-community/parse-server/commit/f9dde4a9f8a90c63f71172c9bc515b0f6c6d2e4a)) +* Allow setting `createdAt` and `updatedAt` during `Parse.Object` creation with maintenance key ([#8696](https://github.com/parse-community/parse-server/issues/8696)) ([77bbfb3](https://github.com/parse-community/parse-server/commit/77bbfb3f186f5651c33ba152f04cff95128eaf2d)) +* Deprecation DEPPS5: Config option `allowClientClassCreation` defaults to `false` ([#8849](https://github.com/parse-community/parse-server/issues/8849)) ([29624e0](https://github.com/parse-community/parse-server/commit/29624e0fae17161cd412ae58d35a195cfa286cad)) +* Deprecation DEPPS6: Authentication adapters disabled by default ([#8858](https://github.com/parse-community/parse-server/issues/8858)) ([0cf58eb](https://github.com/parse-community/parse-server/commit/0cf58eb8d60c8e5f485764e154f3214c49eee430)) +* Deprecation DEPPS7: Remove deprecated Cloud Code file trigger syntax ([#8855](https://github.com/parse-community/parse-server/issues/8855)) ([4e6a375](https://github.com/parse-community/parse-server/commit/4e6a375b5184ae0f7aa256a921eca4021c609435)) +* Deprecation DEPPS8: Parse Server option `allowExpiredAuthDataToken` defaults to `false` ([#8860](https://github.com/parse-community/parse-server/issues/8860)) ([e29845f](https://github.com/parse-community/parse-server/commit/e29845f8dacac09ce3093d75c0d92330c24389e8)) +* Deprecation DEPPS9: LiveQuery `fields` option is renamed to `keys` ([#8852](https://github.com/parse-community/parse-server/issues/8852)) ([38983e8](https://github.com/parse-community/parse-server/commit/38983e8e9b5cdbd006f311a2338103624137d013)) +* Node process exits with error code 1 on uncaught exception to allow custom uncaught exception handling ([#8894](https://github.com/parse-community/parse-server/issues/8894)) ([70c280c](https://github.com/parse-community/parse-server/commit/70c280ca578ff28b5acf92f37fbe06d42a5b34ca)) +* Switch GraphQL server from Yoga v2 to Apollo v4 ([#8959](https://github.com/parse-community/parse-server/issues/8959)) ([105ae7c](https://github.com/parse-community/parse-server/commit/105ae7c8a57d5a650b243174a80c26bf6db16e28)) +* Upgrade Parse Server Push Adapter to 5.0.2 ([#8813](https://github.com/parse-community/parse-server/issues/8813)) ([6ef1986](https://github.com/parse-community/parse-server/commit/6ef1986c03a1d84b7e11c05851e5bf9688d88740)) +* Upgrade to Parse JS SDK 5 ([#9022](https://github.com/parse-community/parse-server/issues/9022)) ([ad4aa83](https://github.com/parse-community/parse-server/commit/ad4aa83983205a0e27639f6ee6a4a5963b67e4b8)) + +### Performance Improvements + +* Improved IP validation performance for `masterKeyIPs`, `maintenanceKeyIPs` ([#8510](https://github.com/parse-community/parse-server/issues/8510)) ([b87daba](https://github.com/parse-community/parse-server/commit/b87daba0671a1b0b7b8d63bc671d665c91a04522)) + + +### BREAKING CHANGES + +* The Parse Server option `allowClientClassCreation` defaults to `false`. ([29624e0](29624e0)) +* A request using the master key will now be rejected as unauthorized if the IP from which the request originates is not set in the Parse Server option `masterKeyIps`, even if the request does not require the master key permission, for example for a public object in a public class class. ([a7b5b38](a7b5b38)) +* Node process now exits with code 1 on uncaught exceptions, enabling custom handlers that were blocked by Parse Server's default behavior of re-throwing errors. This change may lead to automatic process restarts by the environment, unlike before. ([70c280c](70c280c)) +* Authentication adapters are disabled by default; to use an authentication adapter it needs to be explicitly enabled in the Parse Server authentication adapter option `auth..enabled: true` ([0cf58eb](0cf58eb)) +* Parse Server option `allowExpiredAuthDataToken` defaults to `false`; a 3rd party authentication token will be validated every time the user tries to log in and the login will fail if the token has expired; the effect of this change may differ for different authentication adapters, depending on the token lifetime and the token refresh logic of the adapter ([e29845f](e29845f)) +* LiveQuery `fields` option is renamed to `keys` ([38983e8](38983e8)) +* Cloud Code file trigger syntax has been aligned with object trigger syntax, for example `Parse.Cloud.beforeDeleteFile'` has been changed to `Parse.Cloud.beforeDelete(Parse.File, (request) => {})'` ([4e6a375](4e6a375)) +* Removes support for Node 14 and 16 ([ced4872](ced4872)) +* Removes support for Postgres 11 and 12 ([99489b2](99489b2)) +* The `Parse.User` passed as argument if `verifyUserEmails` is set to a function is renamed from `user` to `object` for consistency with invocations of `verifyUserEmails` on signup or login; the user object is not a plain JavaScript object anymore but an instance of `Parse.User` ([8adcbee](8adcbee)) +* `Parse.Session.current()` no longer throws an error if the session token is expired, but instead returns the session token with its expiration date to allow checking its validity ([f9dde4a](f9dde4a)) +* `Parse.Query` no longer supports the BSON type `code`; although this feature was never officially documented, its removal is announced as a breaking change to protect deployments where it might be in use. ([3de8494](3de8494)) + +# [6.4.0](https://github.com/parse-community/parse-server/compare/6.3.1...6.4.0) (2023-11-16) + + +### Bug Fixes + +* Parse Server option `fileUpload.fileExtensions` does not work with an array of extensions ([#8688](https://github.com/parse-community/parse-server/issues/8688)) ([6a4a00c](https://github.com/parse-community/parse-server/commit/6a4a00ca7af1163ea74b047b85cd6817366b824b)) +* Redis 4 does not reconnect after unhandled error ([#8706](https://github.com/parse-community/parse-server/issues/8706)) ([2b3d4e5](https://github.com/parse-community/parse-server/commit/2b3d4e5d3c85cd142f85af68dec51a8523548d49)) +* Remove config logging when launching Parse Server via CLI ([#8710](https://github.com/parse-community/parse-server/issues/8710)) ([ae68f0c](https://github.com/parse-community/parse-server/commit/ae68f0c31b741eeb83379c905c7ddfaa124436ec)) +* Server does not start via CLI when `auth` option is set ([#8666](https://github.com/parse-community/parse-server/issues/8666)) ([4e2000b](https://github.com/parse-community/parse-server/commit/4e2000bc563324389584ace3c090a5c1a7796a64)) + +### Features + +* Add conditional email verification via dynamic Parse Server options `verifyUserEmails`, `sendUserEmailVerification` that now accept functions ([#8425](https://github.com/parse-community/parse-server/issues/8425)) ([44acd6d](https://github.com/parse-community/parse-server/commit/44acd6d9ed157ad4842200c9d01f9c77a05fec3a)) +* Add property `Parse.Server.version` to determine current version of Parse Server in Cloud Code ([#8670](https://github.com/parse-community/parse-server/issues/8670)) ([a9d376b](https://github.com/parse-community/parse-server/commit/a9d376b61f5b07806eafbda91c4e36c322f09298)) +* Add TOTP authentication adapter ([#8457](https://github.com/parse-community/parse-server/issues/8457)) ([cc079a4](https://github.com/parse-community/parse-server/commit/cc079a40f6849a0e9bc6fdc811e8649ecb67b589)) + +### Performance Improvements + +* Improve performance of recursive pointer iterations ([#8741](https://github.com/parse-community/parse-server/issues/8741)) ([45a3ed0](https://github.com/parse-community/parse-server/commit/45a3ed0fcf2c0170607505a1550fb15896e705fd)) + +## [6.3.1](https://github.com/parse-community/parse-server/compare/6.3.0...6.3.1) (2023-10-20) + + +### Bug Fixes + +* Server crash when uploading file without extension; fixes security vulnerability [GHSA-792q-q67h-w579](https://github.com/parse-community/parse-server/security/advisories/GHSA-792q-q67h-w579) ([#8781](https://github.com/parse-community/parse-server/issues/8781)) ([fd86278](https://github.com/parse-community/parse-server/commit/fd86278919556d3682e7e2c856dfccd5beffbfc0)) + +# [6.3.0](https://github.com/parse-community/parse-server/compare/6.2.2...6.3.0) (2023-09-16) + + +### Bug Fixes + +* Cloud Code Trigger `afterSave` executes even if not set ([#8520](https://github.com/parse-community/parse-server/issues/8520)) ([afd0515](https://github.com/parse-community/parse-server/commit/afd0515e207bd947840579d3f245980dffa6f804)) +* GridFS file storage doesn't work with certain `enableSchemaHooks` settings ([#8467](https://github.com/parse-community/parse-server/issues/8467)) ([d4cda4b](https://github.com/parse-community/parse-server/commit/d4cda4b26c9bde8c812549b8780bea1cfabdb394)) +* Inaccurate table total row count for PostgreSQL ([#8511](https://github.com/parse-community/parse-server/issues/8511)) ([0823a02](https://github.com/parse-community/parse-server/commit/0823a02fbf80bc88dc403bc47e9f5c6597ea78b4)) +* LiveQuery server is not shut down properly when `handleShutdown` is called ([#8491](https://github.com/parse-community/parse-server/issues/8491)) ([967700b](https://github.com/parse-community/parse-server/commit/967700bdbc94c74f75ba84d2b3f4b9f3fd2dca0b)) +* Rate limit feature is incompatible with Node 14 ([#8578](https://github.com/parse-community/parse-server/issues/8578)) ([f911f2c](https://github.com/parse-community/parse-server/commit/f911f2cd3a8c45cd326272dcd681532764a3761e)) +* Unnecessary log entries by `extendSessionOnUse` ([#8562](https://github.com/parse-community/parse-server/issues/8562)) ([fd6a007](https://github.com/parse-community/parse-server/commit/fd6a0077f2e5cf83d65e52172ae5a950ab0f1eae)) + +### Features + +* `extendSessionOnUse` to automatically renew Parse Sessions ([#8505](https://github.com/parse-community/parse-server/issues/8505)) ([6f885d3](https://github.com/parse-community/parse-server/commit/6f885d36b94902fdfea873fc554dee83589e6029)) +* Add new Parse Server option `preventSignupWithUnverifiedEmail` to prevent returning a user without session token on sign-up with unverified email address ([#8451](https://github.com/parse-community/parse-server/issues/8451)) ([82da308](https://github.com/parse-community/parse-server/commit/82da30842a55980aa90cb7680fbf6db37ee16dab)) +* Add option to change the log level of logs emitted by Cloud Functions ([#8530](https://github.com/parse-community/parse-server/issues/8530)) ([2caea31](https://github.com/parse-community/parse-server/commit/2caea310be412d82b04a85716bc769ccc410316d)) +* Add support for `$eq` query constraint in LiveQuery ([#8614](https://github.com/parse-community/parse-server/issues/8614)) ([656d673](https://github.com/parse-community/parse-server/commit/656d673cf5dea354e4f2b3d4dc2b29a41d311b3e)) +* Add zones for rate limiting by `ip`, `user`, `session`, `global` ([#8508](https://github.com/parse-community/parse-server/issues/8508)) ([03fba97](https://github.com/parse-community/parse-server/commit/03fba97e0549bfcaeee9f2fa4c9905dbcc91840e)) +* Allow `Parse.Object` pointers in Cloud Code arguments ([#8490](https://github.com/parse-community/parse-server/issues/8490)) ([28aeda3](https://github.com/parse-community/parse-server/commit/28aeda3f160efcbbcf85a85484a8d26567fa9761)) + +### Reverts + +* fix: Inaccurate table total row count for PostgreSQL ([6722110](https://github.com/parse-community/parse-server/commit/6722110f203bc5fdcaa68cdf091cf9e7b48d1cff)) + +## [6.2.2](https://github.com/parse-community/parse-server/compare/6.2.1...6.2.2) (2023-09-04) + + +### Bug Fixes + +* Parse Pointer allows to access internal Parse Server classes and circumvent `beforeFind` query trigger; fixes security vulnerability [GHSA-fcv6-fg5r-jm9q](https://github.com/parse-community/parse-server/security/advisories/GHSA-fcv6-fg5r-jm9q) ([be4c7e2](https://github.com/parse-community/parse-server/commit/be4c7e23c63a2fb690685665cebed0de26be05c5)) + +## [6.2.1](https://github.com/parse-community/parse-server/compare/6.2.0...6.2.1) (2023-06-28) + + +### Bug Fixes + +* Remote code execution via MongoDB BSON parser through prototype pollution; fixes security vulnerability [GHSA-462x-c3jw-7vr6](https://github.com/parse-community/parse-server/security/advisories/GHSA-462x-c3jw-7vr6) ([#8674](https://github.com/parse-community/parse-server/issues/8674)) ([3dd99dd](https://github.com/parse-community/parse-server/commit/3dd99dd80e27e5e1d99b42844180546d90c7aa90)) + +# [6.2.0](https://github.com/parse-community/parse-server/compare/6.1.0...6.2.0) (2023-05-20) + + +### Features + +* Add new Parse Server option `fileUpload.fileExtensions` to restrict file upload by file extension; this fixes a security vulnerability in which a phishing attack could be performed using an uploaded HTML file; by default the new option only allows file extensions matching the regex pattern `^[^hH][^tT][^mM][^lL]?$`, which excludes HTML files; if your app currently depends on uploading files with HTML file extensions then this may be a breaking change and you could allow HTML file upload by setting the option to `['.*']` ([#8538](https://github.com/parse-community/parse-server/issues/8538)) ([a318e7b](https://github.com/parse-community/parse-server/commit/a318e7bbafcf7a3425b0a1b3c2dd30f526b4b6f9)) + +# [6.1.0](https://github.com/parse-community/parse-server/compare/6.0.0...6.1.0) (2023-05-01) + + +### Bug Fixes + +* LiveQuery can return incorrectly formatted date ([#8456](https://github.com/parse-community/parse-server/issues/8456)) ([4ce135a](https://github.com/parse-community/parse-server/commit/4ce135a4fe930776044bc8fd786a4e17a0144e03)) +* Nested date is incorrectly decoded as empty object `{}` when fetching a Parse Object ([#8446](https://github.com/parse-community/parse-server/issues/8446)) ([22d2446](https://github.com/parse-community/parse-server/commit/22d2446dfea2bc339affc20535d181097e152acf)) +* Parameters missing in `afterFind` trigger of authentication adapters ([#8458](https://github.com/parse-community/parse-server/issues/8458)) ([ce34747](https://github.com/parse-community/parse-server/commit/ce34747e8af54cb0b6b975da38f779a5955d2d59)) +* Rate limiting across multiple servers via Redis not working ([#8469](https://github.com/parse-community/parse-server/issues/8469)) ([d9e347d](https://github.com/parse-community/parse-server/commit/d9e347d7413f30f58ffbb8397fc8b5ae23be6ff0)) +* Security upgrade jsonwebtoken to 9.0.0 ([#8420](https://github.com/parse-community/parse-server/issues/8420)) ([f5bfe45](https://github.com/parse-community/parse-server/commit/f5bfe4571e82b2b7440d41f3cff0d49937398164)) + +### Features + +* Add `afterFind` trigger to authentication adapters ([#8444](https://github.com/parse-community/parse-server/issues/8444)) ([c793bb8](https://github.com/parse-community/parse-server/commit/c793bb88e7485743c7ceb65fe419cde75833ff33)) +* Add option `schemaCacheTtl` for schema cache pulling as alternative to `enableSchemaHooks` ([#8436](https://github.com/parse-community/parse-server/issues/8436)) ([b3b76de](https://github.com/parse-community/parse-server/commit/b3b76de71b1d4265689d052e7837c38ec1fa4323)) +* Add Parse Server option `resetPasswordSuccessOnInvalidEmail` to choose success or error response on password reset with invalid email ([#7551](https://github.com/parse-community/parse-server/issues/7551)) ([e5d610e](https://github.com/parse-community/parse-server/commit/e5d610e5e487ddab86409409ac3d7362aba8f59b)) +* Add rate limiting across multiple servers via Redis ([#8394](https://github.com/parse-community/parse-server/issues/8394)) ([34833e4](https://github.com/parse-community/parse-server/commit/34833e42eec08b812b733be78df0535ab0e096b6)) +* Allow multiple origins for header `Access-Control-Allow-Origin` ([#8517](https://github.com/parse-community/parse-server/issues/8517)) ([4f15539](https://github.com/parse-community/parse-server/commit/4f15539ac244aa2d393ac5177f7604b43f69e271)) +* Deprecate LiveQuery `fields` option in favor of `keys` for semantic consistency ([#8388](https://github.com/parse-community/parse-server/issues/8388)) ([a49e323](https://github.com/parse-community/parse-server/commit/a49e323d5ae640bff1c6603ec37fdaddb9328dd1)) +* Export `AuthAdapter` to make it available for extension with custom authentication adapters ([#8443](https://github.com/parse-community/parse-server/issues/8443)) ([40c1961](https://github.com/parse-community/parse-server/commit/40c196153b8efa12ae384c1c0092b2ed60a260d6)) + +# [6.0.0](https://github.com/parse-community/parse-server/compare/5.4.0...6.0.0) (2023-01-31) + + +### Bug Fixes + +* `ParseServer.verifyServerUrl` may fail if server response headers are missing; remove unnecessary logging ([#8391](https://github.com/parse-community/parse-server/issues/8391)) ([1c37a7c](https://github.com/parse-community/parse-server/commit/1c37a7cd0715949a70b220a629071c7dab7d5e7b)) +* Cloud Code trigger `beforeSave` does not work with `Parse.Role` ([#8320](https://github.com/parse-community/parse-server/issues/8320)) ([f29d972](https://github.com/parse-community/parse-server/commit/f29d9720e9b37918fd885c97a31e34c42750e724)) +* ES6 modules do not await the import of Cloud Code files ([#8368](https://github.com/parse-community/parse-server/issues/8368)) ([a7bd180](https://github.com/parse-community/parse-server/commit/a7bd180cddd784c8735622f22e012c342ad535fb)) +* Nested objects are encoded incorrectly for MongoDB ([#8209](https://github.com/parse-community/parse-server/issues/8209)) ([1412666](https://github.com/parse-community/parse-server/commit/1412666f75829612de6fb9d7ccae35761c9b75cb)) +* Parse Server option `masterKeyIps` does not include localhost by default for IPv6 ([#8322](https://github.com/parse-community/parse-server/issues/8322)) ([ab82635](https://github.com/parse-community/parse-server/commit/ab82635b0d4cf323a07ddee51fee587b43dce95c)) +* Rate limiter may reject requests that contain a session token ([#8399](https://github.com/parse-community/parse-server/issues/8399)) ([c114dc8](https://github.com/parse-community/parse-server/commit/c114dc8831055d74187b9dfb4c9eeb558520237c)) +* Remove Node 12 and Node 17 support ([#8279](https://github.com/parse-community/parse-server/issues/8279)) ([2546cc8](https://github.com/parse-community/parse-server/commit/2546cc8572bea6610cb9b3c7401d9afac0e3c1d6)) +* Schema without class level permissions may cause error ([#8409](https://github.com/parse-community/parse-server/issues/8409)) ([aa2cd51](https://github.com/parse-community/parse-server/commit/aa2cd51b703388d925e4572e5c2b2d883c68e49c)) +* The client IP address may be determined incorrectly in some cases; this fixes a security vulnerability in which the Parse Server option `masterKeyIps` may be circumvented, see [GHSA-vm5r-c87r-pf6x](https://github.com/parse-community/parse-server/security/advisories/GHSA-vm5r-c87r-pf6x) ([#8372](https://github.com/parse-community/parse-server/issues/8372)) ([892040d](https://github.com/parse-community/parse-server/commit/892040dc2f82a3e2abe2824e4b553521b6f894de)) +* Throwing error in Cloud Code Triggers `afterLogin`, `afterLogout` crashes server ([#8280](https://github.com/parse-community/parse-server/issues/8280)) ([130d290](https://github.com/parse-community/parse-server/commit/130d29074e3f763460e5685d0b9059e5a333caff)) + +### Features + +* Access the internal scope of Parse Server using the new `maintenanceKey`; the internal scope contains unofficial and undocumented fields (prefixed with underscore `_`) which are used internally by Parse Server; you may want to manipulate these fields for out-of-band changes such as data migration or correction tasks; changes within the internal scope of Parse Server may happen at any time without notice or changelog entry, it is therefore recommended to look at the source code of Parse Server to understand the effects of manipulating internal fields before using the key; it is discouraged to use the `maintenanceKey` for routine operations in a production environment; see [access scopes](https://github.com/parse-community/parse-server#access-scopes) ([#8212](https://github.com/parse-community/parse-server/issues/8212)) ([f3bcc93](https://github.com/parse-community/parse-server/commit/f3bcc9365cd6f08b0a32c132e8e5ff6d1b650863)) +* Adapt `verifyServerUrl` for new asynchronous Parse Server start-up states ([#8366](https://github.com/parse-community/parse-server/issues/8366)) ([ffa4974](https://github.com/parse-community/parse-server/commit/ffa4974158615fbff4a2692b9db41dcb50d3f77b)) +* Add `ParseQuery.watch` to trigger LiveQuery only on update of specific fields ([#8028](https://github.com/parse-community/parse-server/issues/8028)) ([fc92faa](https://github.com/parse-community/parse-server/commit/fc92faac75107b3392eeddd916c4c5b45e3c5e0c)) +* Add Node 19 support ([#8363](https://github.com/parse-community/parse-server/issues/8363)) ([a4990dc](https://github.com/parse-community/parse-server/commit/a4990dcd29abcb4442f3c424aff482a0a116160f)) +* Add option to change the log level of the logs emitted by triggers ([#8328](https://github.com/parse-community/parse-server/issues/8328)) ([8f3b694](https://github.com/parse-community/parse-server/commit/8f3b694e39d4a966567e50dbea4d62e954fa5c06)) +* Add request rate limiter based on IP address ([#8174](https://github.com/parse-community/parse-server/issues/8174)) ([6c79f6a](https://github.com/parse-community/parse-server/commit/6c79f6a69e25e47846e3b0685d6bdfd6b91086b1)) +* Asynchronous initialization of Parse Server ([#8232](https://github.com/parse-community/parse-server/issues/8232)) ([99fcf45](https://github.com/parse-community/parse-server/commit/99fcf45e55c368de2345b0c4d780e70e0adf0e15)) +* Improve authentication adapter interface to support multi-factor authentication (MFA), authentication challenges, and provide a more powerful interface for writing custom authentication adapters ([#8156](https://github.com/parse-community/parse-server/issues/8156)) ([5bbf9ca](https://github.com/parse-community/parse-server/commit/5bbf9cade9a527787fd1002072d4013ab5d8db2b)) +* Reduce Docker image size by improving stages ([#8359](https://github.com/parse-community/parse-server/issues/8359)) ([40810b4](https://github.com/parse-community/parse-server/commit/40810b48ebde8b1f21d2448a3a4de0585b1b5e34)) +* Remove deprecation `DEPPS1`: Native MongoDB syntax in aggregation pipeline ([#8362](https://github.com/parse-community/parse-server/issues/8362)) ([d0d30c4](https://github.com/parse-community/parse-server/commit/d0d30c4f1394f563724644a8fc81734be538a2c0)) +* Remove deprecation `DEPPS2`: Config option `directAccess` defaults to true ([#8284](https://github.com/parse-community/parse-server/issues/8284)) ([f535ee6](https://github.com/parse-community/parse-server/commit/f535ee6ec2abba63f702127258ca49fa5b4e08c9)) +* Remove deprecation `DEPPS3`: Config option `enforcePrivateUsers` defaults to `true` ([#8283](https://github.com/parse-community/parse-server/issues/8283)) ([ed499e3](https://github.com/parse-community/parse-server/commit/ed499e32a21bab9a874a9e5367dc71248ce836c4)) +* Remove deprecation `DEPPS4`: Remove convenience method for http request `Parse.Cloud.httpRequest` ([#8287](https://github.com/parse-community/parse-server/issues/8287)) ([2d79c08](https://github.com/parse-community/parse-server/commit/2d79c0835b6a9acaf20d5c943d9b4619bb96831c)) +* Remove support for MongoDB 4.0 ([#8292](https://github.com/parse-community/parse-server/issues/8292)) ([37245f6](https://github.com/parse-community/parse-server/commit/37245f62ce83516b6b95a54b850f0274ef680478)) +* Restrict use of `masterKey` to localhost by default ([#8281](https://github.com/parse-community/parse-server/issues/8281)) ([6c16021](https://github.com/parse-community/parse-server/commit/6c16021a1f03a70a6d9e68cb64df362d07f3b693)) +* Upgrade Node Package Manager lock file `package-lock.json` to version 2 ([#8285](https://github.com/parse-community/parse-server/issues/8285)) ([ee72467](https://github.com/parse-community/parse-server/commit/ee7246733d63e4bda20401f7b00262ff03299f20)) +* Upgrade Redis 3 to 4 ([#8293](https://github.com/parse-community/parse-server/issues/8293)) ([7d622f0](https://github.com/parse-community/parse-server/commit/7d622f06a4347e0ad2cba9a4ec07d8d4fb0f67bc)) +* Upgrade Redis 3 to 4 for LiveQuery ([#8333](https://github.com/parse-community/parse-server/issues/8333)) ([b2761fb](https://github.com/parse-community/parse-server/commit/b2761fb3786b519d9bbcf35be54309d2d35da1a9)) +* Upgrade to Parse JavaScript SDK 4 ([#8332](https://github.com/parse-community/parse-server/issues/8332)) ([9092874](https://github.com/parse-community/parse-server/commit/9092874a9a482a24dfdce1dce56615702999d6b8)) +* Write log entry when request with master key is rejected as outside of `masterKeyIps` ([#8350](https://github.com/parse-community/parse-server/issues/8350)) ([e22b73d](https://github.com/parse-community/parse-server/commit/e22b73d4b700c8ff745aa81726c6680082294b45)) + + +### BREAKING CHANGES + +* The Docker image does not contain the git dependency anymore; if you have been using git as a transitive dependency it now needs to be explicitly installed in your Docker file, for example with `RUN apk --no-cache add git` (#8359) ([40810b4](40810b4)) +* Fields in the internal scope of Parse Server (prefixed with underscore `_`) are only returned using the new `maintenanceKey`; previously the `masterKey` allowed reading of internal fields; see [access scopes](https://github.com/parse-community/parse-server#access-scopes) for a comparison of the keys' access permissions (#8212) ([f3bcc93](f3bcc93)) +* The method `ParseServer.verifyServerUrl` now returns a promise instead of a callback. ([ffa4974](ffa4974)) +* The MongoDB aggregation pipeline requires native MongoDB syntax instead of the custom Parse Server syntax; for example pipeline stage names require a leading dollar sign like `$match` and the MongoDB document ID is referenced using `_id` instead of `objectId` (#8362) ([d0d30c4](d0d30c4)) +* The mechanism to determine the client IP address has been rewritten; to correctly determine the IP address it is now required to set the Parse Server option `trustProxy` accordingly if Parse Server runs behind a proxy server, see the express framework's [trust proxy](https://expressjs.com/en/guide/behind-proxies.html) setting (#8372) ([892040d](892040d)) +* The Node Package Manager lock file `package-lock.json` is upgraded to version 2; while it is backwards with version 1 for the npm installer, consider this if you run any non-npm analysis tools that use the lock file (#8285) ([ee72467](ee72467)) +* This release introduces the asynchronous initialization of Parse Server to prevent mounting Parse Server before being ready to receive request; it changes how Parse Server is imported, initialized and started; it also removes the callback `serverStartComplete`; see the [Parse Server 6 migration guide](https://github.com/parse-community/parse-server/blob/alpha/6.0.0.md) for more details (#8232) ([99fcf45](99fcf45)) +* Nested objects are now properly stored in the database using JSON serialization; previously, due to a bug only top-level objects were serialized, but nested objects were saved as raw JSON; for example, a nested `Date` object was saved as a JSON object like `{ "__type": "Date", "iso": "2020-01-01T00:00:00.000Z" }` instead of its serialized representation `2020-01-01T00:00:00.000Z` (#8209) ([1412666](1412666)) +* The Parse Server option `enforcePrivateUsers` is set to `true` by default; in previous releases this option defaults to `false`; this change improves the default security configuration of Parse Server (#8283) ([ed499e3](ed499e3)) +* This release restricts the use of `masterKey` to localhost by default; if you are using Parse Dashboard on a different server to connect to Parse Server you need to add the IP address of the server that hosts Parse Dashboard to this option (#8281) ([6c16021](6c16021)) +* This release upgrades to Redis 4; if you are using the Redis cache adapter with Parse Server then this is a breaking change as the Redis client options have changed; see the [Redis migration guide](https://github.com/redis/node-redis/blob/redis%404.0.0/docs/v3-to-v4.md) for more details (#8293) ([7d622f0](7d622f0)) +* This release removes support for MongoDB 4.0; the new minimum supported MongoDB version is 4.2. which also removes support for the deprecated MongoDB MMAPv1 storage engine ([37245f6](37245f6)) +* Throwing an error in Cloud Code Triggers `afterLogin`, `afterLogout` returns a rejected promise; in previous releases it crashed the server if you did not handle the error on the Node.js process level; consider adapting your code if your app currently handles these errors on the Node.js process level with `process.on('unhandledRejection', ...)` ([130d290](130d290)) +* Config option `directAccess` defaults to true; set this to `false` in environments where multiple Parse Server instances run behind a load balancer and Parse requests within the current Node.js environment should be routed via the load balancer and distributed as HTTP requests among all instances via the `serverURL`. ([f535ee6](f535ee6)) +* The convenience method for HTTP requests `Parse.Cloud.httpRequest` is removed; use your preferred 3rd party library for making HTTP requests ([2d79c08](2d79c08)) +* This release removes Node 12 and Node 17 support ([2546cc8](2546cc8)) + +# [5.4.0](https://github.com/parse-community/parse-server/compare/5.3.3...5.4.0) (2022-11-19) + + +### Bug Fixes + +* graphQL query ignores condition `equalTo` with value `false` ([#8032](https://github.com/parse-community/parse-server/issues/8032)) ([7f5a15d](https://github.com/parse-community/parse-server/commit/7f5a15d5df0dfa3515e9f73709d6a49663545f9b)) +* internal indices for classes `_Idempotency` and `_Role` are not protected in defined schema ([#8121](https://github.com/parse-community/parse-server/issues/8121)) ([c16f529](https://github.com/parse-community/parse-server/commit/c16f529f74f92154401bf662f634b3c5fa45e18e)) +* liveQuery with `containedIn` not working when object field is an array ([#8128](https://github.com/parse-community/parse-server/issues/8128)) ([1d9605b](https://github.com/parse-community/parse-server/commit/1d9605bc93009263d3811df4d4249034ba6eb8c4)) +* push notifications `badge` doesn't update with Installation beforeSave trigger ([#8162](https://github.com/parse-community/parse-server/issues/8162)) ([3c75c2b](https://github.com/parse-community/parse-server/commit/3c75c2ba4851fae96a8c19b11a3efde03816c9a1)) +* query aggregation pipeline cannot handle value of type `Date` when `directAccess: true` ([#8167](https://github.com/parse-community/parse-server/issues/8167)) ([e424137](https://github.com/parse-community/parse-server/commit/e4241374061caef66538de15112fb6bbafb1f5bb)) +* relation constraints in compound queries `Parse.Query.or`, `Parse.Query.and` not working ([#8203](https://github.com/parse-community/parse-server/issues/8203)) ([28f0d26](https://github.com/parse-community/parse-server/commit/28f0d2667787d2ac68726607b811d6f0ef62b9f1)) +* security upgrade undici from 5.6.0 to 5.8.0 ([#8108](https://github.com/parse-community/parse-server/issues/8108)) ([4aa016b](https://github.com/parse-community/parse-server/commit/4aa016b7322467422b9fdf05d8e29b9ecf910da7)) +* sorting by non-existing value throws `INVALID_SERVER_ERROR` on Postgres ([#8157](https://github.com/parse-community/parse-server/issues/8157)) ([3b775a1](https://github.com/parse-community/parse-server/commit/3b775a1fb8a1878714e3451191438963d688f1b0)) +* updating object includes unchanged keys in client response for certain key types ([#8159](https://github.com/parse-community/parse-server/issues/8159)) ([37af1d7](https://github.com/parse-community/parse-server/commit/37af1d78fce5a15039ffe3af7b323c1f1e8582fc)) + +### Features + +* add convenience access to Parse Server configuration in Cloud Code via `Parse.Server` ([#8244](https://github.com/parse-community/parse-server/issues/8244)) ([9f11115](https://github.com/parse-community/parse-server/commit/9f111158edf7fd57a65db0c4f9244b37e58cf293)) +* add option to change the default value of the `Parse.Query.limit()` constraint ([#8152](https://github.com/parse-community/parse-server/issues/8152)) ([0388956](https://github.com/parse-community/parse-server/commit/038895680894984e569dff54bf5c7b31094f3891)) +* add support for MongoDB 6 ([#8242](https://github.com/parse-community/parse-server/issues/8242)) ([aba0081](https://github.com/parse-community/parse-server/commit/aba0081ce1a166a93de57f3928c19a05562b5cc1)) +* add support for Postgres 15 ([#8215](https://github.com/parse-community/parse-server/issues/8215)) ([2feb6c4](https://github.com/parse-community/parse-server/commit/2feb6c46080946c984daa351187fa07cd582355d)) +* liveQuery support for unsorted distance queries ([#8221](https://github.com/parse-community/parse-server/issues/8221)) ([0f763da](https://github.com/parse-community/parse-server/commit/0f763da17d646b2fec2cd980d3857e46072a8a07)) + +## [5.3.3](https://github.com/parse-community/parse-server/compare/5.3.2...5.3.3) (2022-11-09) + + +### Bug Fixes + +* Prototype pollution via Cloud Code Webhooks; fixes security vulnerability [GHSA-93vw-8fm5-p2jf](https://github.com/parse-community/parse-server/security/advisories/GHSA-93vw-8fm5-p2jf) ([#8305](https://github.com/parse-community/parse-server/issues/8305)) ([60c5a73](https://github.com/parse-community/parse-server/commit/60c5a73d257e0d536056b38bdafef8b7130524d8)) + +## [5.3.2](https://github.com/parse-community/parse-server/compare/5.3.1...5.3.2) (2022-11-09) + + +### Bug Fixes + +* Parse Server option `requestKeywordDenylist` can be bypassed via Cloud Code Webhooks or Triggers; fixes security vulnerability [GHSA-xprv-wvh7-qqqx](https://github.com/parse-community/parse-server/security/advisories/GHSA-xprv-wvh7-qqqx) ([#8302](https://github.com/parse-community/parse-server/issues/8302)) ([6728da1](https://github.com/parse-community/parse-server/commit/6728da1e3591db1e27031d335d64d8f25546a06f)) + +## [5.3.1](https://github.com/parse-community/parse-server/compare/5.3.0...5.3.1) (2022-11-07) + + +### Bug Fixes + +* Remote code execution via MongoDB BSON parser through prototype pollution; fixes security vulnerability [GHSA-prm5-8g2m-24gg](https://github.com/parse-community/parse-server/security/advisories/GHSA-prm5-8g2m-24gg) ([#8295](https://github.com/parse-community/parse-server/issues/8295)) ([50eed3c](https://github.com/parse-community/parse-server/commit/50eed3cffe80fadfb4bdac52b2783a18da2cfc4f)) + +# [5.3.0](https://github.com/parse-community/parse-server/compare/5.2.8...5.3.0) (2022-10-29) + + +### Bug Fixes + +* afterSave trigger removes pointer in Parse object ([#7913](https://github.com/parse-community/parse-server/issues/7913)) ([47d796e](https://github.com/parse-community/parse-server/commit/47d796ea58f65e71612ce37149be692abc9ea97f)) +* auto-release process may fail if optional back-merging task fails ([#8051](https://github.com/parse-community/parse-server/issues/8051)) ([cf925e7](https://github.com/parse-community/parse-server/commit/cf925e75e87a6989f41e2e2abb2aba4332b1e79f)) +* custom database options are not passed to MongoDB GridFS ([#7911](https://github.com/parse-community/parse-server/issues/7911)) ([b1e5565](https://github.com/parse-community/parse-server/commit/b1e5565b22f2eff229571fe9a9500314bd30965b)) +* depreciate allowClientClassCreation defaulting to true ([#7925](https://github.com/parse-community/parse-server/issues/7925)) ([38ed96a](https://github.com/parse-community/parse-server/commit/38ed96ace534d639db007aa7dd5387b2da8f03ae)) +* errors in GraphQL do not show the original error but a general `Unexpected Error` ([#8045](https://github.com/parse-community/parse-server/issues/8045)) ([0d81887](https://github.com/parse-community/parse-server/commit/0d818879c217f9c56100a5f59868fa37e6d24b71)) +* interrupted WebSocket connection not closed by LiveQuery server ([#8012](https://github.com/parse-community/parse-server/issues/8012)) ([2d5221e](https://github.com/parse-community/parse-server/commit/2d5221e48012fb7781c0406d543a922d313075ea)) +* live query role cache does not clear when a user is added to a role ([#8026](https://github.com/parse-community/parse-server/issues/8026)) ([199dfc1](https://github.com/parse-community/parse-server/commit/199dfc17226d85a78ab85f24362cce740f4ada39)) +* peer dependency mismatch for GraphQL dependencies ([#7934](https://github.com/parse-community/parse-server/issues/7934)) ([0a6faa8](https://github.com/parse-community/parse-server/commit/0a6faa81fa97f8620e7fd05e8c7bbdb4b7da9578)) +* return correct response when revert is used in beforeSave ([#7839](https://github.com/parse-community/parse-server/issues/7839)) ([19900fc](https://github.com/parse-community/parse-server/commit/19900fcdf8c9f29a674fb62cf6e4b3341d796891)) +* security upgrade @parse/fs-files-adapter from 1.2.1 to 1.2.2 ([#7948](https://github.com/parse-community/parse-server/issues/7948)) ([3a70fda](https://github.com/parse-community/parse-server/commit/3a70fda6798d4143f21046439b5eaf232a31bdb6)) +* security upgrade moment from 2.29.1 to 2.29.2 ([#7931](https://github.com/parse-community/parse-server/issues/7931)) ([731c550](https://github.com/parse-community/parse-server/commit/731c5507144bbacff236097e7a2a03bfe54f6e10)) +* security upgrade parse push adapter from 4.1.0 to 4.1.2 ([#7893](https://github.com/parse-community/parse-server/issues/7893)) ([93667b4](https://github.com/parse-community/parse-server/commit/93667b4e8402bf13b46c4d3ef12cec6532fd9da7)) +* websocket connection of LiveQuery interrupts frequently ([#8048](https://github.com/parse-community/parse-server/issues/8048)) ([03caae1](https://github.com/parse-community/parse-server/commit/03caae1e611f28079cdddbbe433daaf69e3f595c)) + +### Features + +* add MongoDB 5.1 compatibility ([#7682](https://github.com/parse-community/parse-server/issues/7682)) ([022a856](https://github.com/parse-community/parse-server/commit/022a85619d8a2c57a2f2938e245e4d8a47c15276)) +* add MongoDB 5.2 support ([#7894](https://github.com/parse-community/parse-server/issues/7894)) ([5bfa716](https://github.com/parse-community/parse-server/commit/5bfa7160d9e35b237cbae1016ed86724aa99f8d7)) +* add support for Node 17 and 18 ([#7896](https://github.com/parse-community/parse-server/issues/7896)) ([3e9f292](https://github.com/parse-community/parse-server/commit/3e9f292d840334244934cee9a34545ac86313549)) +* align file trigger syntax with class trigger; use the new syntax `Parse.Cloud.beforeSave(Parse.File, (request) => {})`, the old syntax `Parse.Cloud.beforeSaveFile((request) => {})` has been deprecated ([#7966](https://github.com/parse-community/parse-server/issues/7966)) ([c6dcad8](https://github.com/parse-community/parse-server/commit/c6dcad8d167d44912dbd416d328519314c0809bd)) +* replace GraphQL Apollo with GraphQL Yoga ([#7967](https://github.com/parse-community/parse-server/issues/7967)) ([1aa2204](https://github.com/parse-community/parse-server/commit/1aa2204aebfdbe273d54d6d56c6029f7c34aab14)) +* selectively enable / disable default authentication adapters ([#7953](https://github.com/parse-community/parse-server/issues/7953)) ([c1e808f](https://github.com/parse-community/parse-server/commit/c1e808f9e807fc49508acbde0d8b3f2b901a1638)) +* upgrade mongodb from 4.4.1 to 4.5.0 ([#7991](https://github.com/parse-community/parse-server/issues/7991)) ([e692b5d](https://github.com/parse-community/parse-server/commit/e692b5dd8214cdb0ce79bedd30d9aa3cf4de76a5)) + +### Performance Improvements + +* reduce database operations when using the constant parameter in Cloud Function validation ([#7892](https://github.com/parse-community/parse-server/issues/7892)) ([041197f](https://github.com/parse-community/parse-server/commit/041197fb4ca1cd7cf18dc426ce38647267823668)) + +## [5.2.8](https://github.com/parse-community/parse-server/compare/5.2.7...5.2.8) (2022-10-14) + + +### Bug Fixes + +* server crashes when receiving file download request with invalid byte range; this fixes a security vulnerability that allows an attacker to impact the availability of the server instance; the fix improves parsing of the range parameter to properly handle invalid range requests ([GHSA-h423-w6qv-2wj3](https://github.com/parse-community/parse-server/security/advisories/GHSA-h423-w6qv-2wj3)) ([#8235](https://github.com/parse-community/parse-server/issues/8235)) ([066f296](https://github.com/parse-community/parse-server/commit/066f29673ab4030b6b5b90c0c0326f7d3fe7612a)) + +## [5.2.7](https://github.com/parse-community/parse-server/compare/5.2.6...5.2.7) (2022-09-20) + + +### Bug Fixes + +* authentication adapter app ID validation may be circumvented; this fixes a vulnerability that affects configurations which allow users to authenticate using the Parse Server authentication adapter for *Facebook* or *Spotify* and where the server-side authentication adapter configuration `appIds` is set as a string (e.g. `abc`) instead of an array of strings (e.g. `["abc"]`) ([GHSA-r657-33vp-gp22](https://github.com/parse-community/parse-server/security/advisories/GHSA-r657-33vp-gp22)) ([#8185](https://github.com/parse-community/parse-server/issues/8185)) ([ecf0814](https://github.com/parse-community/parse-server/commit/ecf0814499bde31ab6082b6e42854aa65ad2e03e)) + +## [5.2.6](https://github.com/parse-community/parse-server/compare/5.2.5...5.2.6) (2022-09-20) + + +### Bug Fixes + +* session object properties can be updated by foreign user; this fixes a security vulnerability in which a foreign user can write to the session object of another user if the session object ID is known; the fix prevents writing to foreign session objects ([GHSA-6w4q-23cf-j9jp](https://github.com/parse-community/parse-server/security/advisories/GHSA-6w4q-23cf-j9jp)) ([#8182](https://github.com/parse-community/parse-server/issues/8182)) ([6d0b2f5](https://github.com/parse-community/parse-server/commit/6d0b2f534603301bb630d9c8e497af3bc7ff1d09)) + +## [5.2.5](https://github.com/parse-community/parse-server/compare/5.2.4...5.2.5) (2022-09-02) + + +### Bug Fixes + +* brute force guessing of user sensitive data via search patterns; this fixes a security vulnerability in which internal and protected fields may be used as query constraints to guess the value of these fields and obtain sensitive data (GHSA-2m6g-crv8-p3c6) ([#8144](https://github.com/parse-community/parse-server/issues/8144)) ([e39d51b](https://github.com/parse-community/parse-server/commit/e39d51bd329cd978589983bd659db46e1d45aad4)) + +## [5.2.4](https://github.com/parse-community/parse-server/compare/5.2.3...5.2.4) (2022-06-30) + + +### Bug Fixes + +* protected fields exposed via LiveQuery; this removes protected fields from the client response; this may be a breaking change if your app is currently expecting to receive these protected fields ([GHSA-crrq-vr9j-fxxh](https://github.com/parse-community/parse-server/security/advisories/GHSA-crrq-vr9j-fxxh)) (https://github.com/parse-community/parse-server/pull/8074) ([#8073](https://github.com/parse-community/parse-server/issues/8073)) ([309f64c](https://github.com/parse-community/parse-server/commit/309f64ced8700321df056fb3cc97f15007a00df1)) + +## [5.2.3](https://github.com/parse-community/parse-server/compare/5.2.2...5.2.3) (2022-06-17) + + +### Bug Fixes + +* invalid file request not properly handled; this fixes a security vulnerability in which an invalid file request can crash the server ([GHSA-xw6g-jjvf-wwf9](https://github.com/parse-community/parse-server/security/advisories/GHSA-xw6g-jjvf-wwf9)) ([#8060](https://github.com/parse-community/parse-server/issues/8060)) ([5be375d](https://github.com/parse-community/parse-server/commit/5be375dec2fa35425c1003ae81c55995ac72af92)) + +## [5.2.2](https://github.com/parse-community/parse-server/compare/5.2.1...5.2.2) (2022-06-17) + + +### Bug Fixes + +* certificate in Apple Game Center auth adapter not validated; this fixes a security vulnerability in which authentication could be bypassed using a fake certificate; if you are using the Apple Gamer Center auth adapter it is your responsibility to keep its root certificate up-to-date and we advice you read the security advisory ([GHSA-rh9j-f5f8-rvgc](https://github.com/parse-community/parse-server/security/advisories/GHSA-rh9j-f5f8-rvgc)) ([ba2b0a9](https://github.com/parse-community/parse-server/commit/ba2b0a9cb9a568817a114b132a4c2e0911d76df1)) + +## [5.2.1](https://github.com/parse-community/parse-server/compare/5.2.0...5.2.1) (2022-05-01) + + +### Bug Fixes + +* authentication bypass and denial of service (DoS) vulnerabilities in Apple Game Center auth adapter (GHSA-qf8x-vqjv-92gr) ([#7962](https://github.com/parse-community/parse-server/issues/7962)) ([af4a041](https://github.com/parse-community/parse-server/commit/af4a0417a9f3c1e99b3793806b4b18e04d9fa999)) + +# [5.2.0](https://github.com/parse-community/parse-server/compare/5.1.1...5.2.0) (2022-03-24) + + +### Bug Fixes + +* security bump minimist from 1.2.5 to 1.2.6 ([#7884](https://github.com/parse-community/parse-server/issues/7884)) ([c5cf282](https://github.com/parse-community/parse-server/commit/c5cf282d11ffdc023764f8e7539a2bd6bc246fe1)) +* sensitive keyword detection may produce false positives ([#7881](https://github.com/parse-community/parse-server/issues/7881)) ([0d6f9e9](https://github.com/parse-community/parse-server/commit/0d6f9e951d9e186e95e96d8869066ce7022bad02)) + +### Features + +* improved LiveQuery error logging with additional information ([#7837](https://github.com/parse-community/parse-server/issues/7837)) ([443a509](https://github.com/parse-community/parse-server/commit/443a5099059538d379fe491793a5871fcbb4f377)) + +## [5.1.1](https://github.com/parse-community/parse-server/compare/5.1.0...5.1.1) (2022-03-18) + + +### Reverts + +* ci: temporarily disable breaking change detection ([#7861](https://github.com/parse-community/parse-server/issues/7861)) ([effed92](https://github.com/parse-community/parse-server/commit/effed92cabd88676fdf9eca2e079a4d8be017f1b)) + +# [5.1.0](https://github.com/parse-community/parse-server/compare/5.0.0...5.1.0) (2022-03-18) + + +### Bug Fixes + +* adding or modifying a nested property requires addField permissions ([#7679](https://github.com/parse-community/parse-server/issues/7679)) ([6a6248b](https://github.com/parse-community/parse-server/commit/6a6248b6cb2e732d17131e18e659943b894ed2f1)) +* bump nanoid from 3.1.25 to 3.2.0 ([#7781](https://github.com/parse-community/parse-server/issues/7781)) ([f5f63bf](https://github.com/parse-community/parse-server/commit/f5f63bfc64d3481ed944ceb5e9f50b33dccd1ce9)) +* bump node-fetch from 2.6.1 to 3.1.1 ([#7782](https://github.com/parse-community/parse-server/issues/7782)) ([9082351](https://github.com/parse-community/parse-server/commit/90823514113a1a085ebc818f7109b3fd7591346f)) +* node engine compatibility did not include node 16 ([#7739](https://github.com/parse-community/parse-server/issues/7739)) ([ea7c014](https://github.com/parse-community/parse-server/commit/ea7c01400f992a1263543706fe49b6174758a2d6)) +* node engine range has no upper limit to exclude incompatible node versions ([#7692](https://github.com/parse-community/parse-server/issues/7692)) ([573558d](https://github.com/parse-community/parse-server/commit/573558d3adcbcc6222c92003829867e1a73eef94)) +* package.json & package-lock.json to reduce vulnerabilities ([#7823](https://github.com/parse-community/parse-server/issues/7823)) ([5ca2288](https://github.com/parse-community/parse-server/commit/5ca228882332b65f3ac05407e6e4da1ee3ef3749)) +* schema cache not cleared in some cases ([#7678](https://github.com/parse-community/parse-server/issues/7678)) ([5af6e5d](https://github.com/parse-community/parse-server/commit/5af6e5dfaa129b1a350afcba4fb381b21c4cc35d)) +* security upgrade follow-redirects from 1.14.6 to 1.14.7 ([#7769](https://github.com/parse-community/parse-server/issues/7769)) ([8f5a861](https://github.com/parse-community/parse-server/commit/8f5a8618cfa7ed9a2a239a095abffa8f3fd8d31a)) +* security upgrade follow-redirects from 1.14.7 to 1.14.8 ([#7801](https://github.com/parse-community/parse-server/issues/7801)) ([70088a9](https://github.com/parse-community/parse-server/commit/70088a95a78393da2a4ac68be81e63107747626a)) +* security vulnerability that allows remote code execution (GHSA-p6h4-93qp-jhcm) ([#7844](https://github.com/parse-community/parse-server/issues/7844)) ([e569f40](https://github.com/parse-community/parse-server/commit/e569f402b1fd8648fb0d1523b71b2a03273902a5)) +* server crash using GraphQL due to missing @apollo/client peer dependency ([#7787](https://github.com/parse-community/parse-server/issues/7787)) ([08089d6](https://github.com/parse-community/parse-server/commit/08089d6fcbb215412448ce7d92b21b9fe6c929f2)) +* unable to use objectId size higher than 19 on GraphQL API ([#7627](https://github.com/parse-community/parse-server/issues/7627)) ([ed86c80](https://github.com/parse-community/parse-server/commit/ed86c807721cc52a1a5a9dea0b768717eec269ed)) +* upgrade mime from 2.5.2 to 3.0.0 ([#7725](https://github.com/parse-community/parse-server/issues/7725)) ([f5ef98b](https://github.com/parse-community/parse-server/commit/f5ef98bde32083403c0e30a12162fcc1e52cac37)) +* upgrade parse from 3.3.1 to 3.4.0 ([#7723](https://github.com/parse-community/parse-server/issues/7723)) ([d4c1f47](https://github.com/parse-community/parse-server/commit/d4c1f473073764cb0570c633fc4a30669c2ce889)) +* upgrade winston from 3.5.0 to 3.5.1 ([#7820](https://github.com/parse-community/parse-server/issues/7820)) ([4af253d](https://github.com/parse-community/parse-server/commit/4af253d1f8654a6f57b5137ad310cdacadc922cc)) + +### Features + +* add Cloud Code context to `ParseObject.fetch` ([#7779](https://github.com/parse-community/parse-server/issues/7779)) ([315290d](https://github.com/parse-community/parse-server/commit/315290d16110110938f80a6b779cc2d1db58c552)) +* add Idempotency to Postgres ([#7750](https://github.com/parse-community/parse-server/issues/7750)) ([0c3feaa](https://github.com/parse-community/parse-server/commit/0c3feaaa1751964c0db89f25674935c3354b1538)) +* add support for Node 16 ([#7707](https://github.com/parse-community/parse-server/issues/7707)) ([45cc58c](https://github.com/parse-community/parse-server/commit/45cc58c7e5e640a46c5d508019a3aa81242964b1)) +* bump required node engine to >=12.22.10 ([#7846](https://github.com/parse-community/parse-server/issues/7846)) ([5ace99d](https://github.com/parse-community/parse-server/commit/5ace99d542a11e422af46d9fd6b1d3d2513b34cf)) +* support `postgresql` protocol in database URI ([#7757](https://github.com/parse-community/parse-server/issues/7757)) ([caf4a23](https://github.com/parse-community/parse-server/commit/caf4a2341f554b28e3918c53e7e897a3ca47bf8b)) +* support relativeTime query constraint on Postgres ([#7747](https://github.com/parse-community/parse-server/issues/7747)) ([16b1b2a](https://github.com/parse-community/parse-server/commit/16b1b2a19714535ca805f2dbb3b561d8f6a519a7)) +* upgrade to MongoDB Node.js driver 4.x for MongoDB 5.0 support ([#7794](https://github.com/parse-community/parse-server/issues/7794)) ([f88aa2a](https://github.com/parse-community/parse-server/commit/f88aa2a62a533e5344d1c13dd38c5a0b283a480a)) + +### Reverts + +* refactor: allow ES import for cloud string if package type is module ([b64640c](https://github.com/parse-community/parse-server/commit/b64640c5705f733798783e68d216e957044ef23c)) +* update node engine to 2.22.0 ([#7827](https://github.com/parse-community/parse-server/issues/7827)) ([f235412](https://github.com/parse-community/parse-server/commit/f235412c1b6c2b173b7531f285429ea7214b56a2)) + +### âš ī¸ NOTABLE CHANGES + +*The following changes would formally require a major version increment (Parse Server 6.0), but given their low relevance they are released as part of this minor version increment (Parse Server 5.1).* + +* The MongoDB GridStore adapter has been removed. By default, Parse Server already uses GridFS, so if you do not manually use the GridStore adapter, you can ignore this change. Parse Server uses the GridFSBucket adapter instead of GridStore adapter by default since 2018. ([f88aa2a](f88aa2a)) +* Removes official Node 15 support which has already reached it End-of-Life date. ([45cc58c](45cc58c)) + + +# [5.0.0](https://github.com/parse-community/parse-server/compare/4.10.7...5.0.0) (2022-03-14) + + +### BREAKING CHANGES +- Improved schema caching through database real-time hooks. Reduces DB queries, decreases Parse Query execution time and fixes a potential schema memory leak. If multiple Parse Server instances connect to the same DB (for example behind a load balancer), set the [Parse Server Option](https://parseplatform.org/parse-server/api/master/ParseServerOptions.html) `databaseOptions.enableSchemaHooks: true` to enable this feature and keep the schema in sync across all instances. Failing to do so will cause a schema change to not propagate to other instances and re-syncing will only happen when these instances restart. The options `enableSingleSchemaCache` and `schemaCacheTTL` have been removed. To use this feature with MongoDB, a replica set cluster with [change stream](https://docs.mongodb.com/manual/changeStreams/#availability) support is required. (Diamond Lewis, SebC) [#7214](https://github.com/parse-community/parse-server/issues/7214) +- Fix security vulnerability that allows remote code execution; as part of the fix a new security feature scans for sensitive keywords in request data to prevent JavaScript prototype pollution. If such a keyword is found, the request is rejected with HTTP response code `400` and Parse Error `105` (`INVALID_KEY_NAME`). By default these keywords are: `{_bsontype: "Code"}`, `constructor`, `__proto__`. If you are using any of these keywords in your request data, you can override the default keywords by setting the new Parse Server option `requestKeywordDenylist` to `[]` and specify your own keywords as needed. ([GHSA-p6h4-93qp-jhcm](https://github.com/advisories/GHSA-p6h4-93qp-jhcm)) ([#7843](https://github.com/parse-community/parse-server/issues/7843)) ([971adb5](https://github.com/parse-community/parse-server/commit/971adb54387b0ede31be05ca407d5f35b4575c83)) +- Added file upload restriction. File upload is now only allowed for authenticated users by default for improved security. To allow file upload also for Anonymous Users or Public, set the `fileUpload` parameter in the [Parse Server Options](https://parseplatform.org/parse-server/api/master/ParseServerOptions.html) (dblythy, Manuel Trezza) [#7071](https://github.com/parse-community/parse-server/pull/7071) +- Removed [parse-server-simple-mailgun-adapter](https://github.com/parse-community/parse-server-simple-mailgun-adapter) dependency; to continue using the adapter it has to be explicitly installed (Manuel Trezza) [#7321](https://github.com/parse-community/parse-server/pull/7321) +- Remove support for MongoDB 3.6 which has reached its End-of-Life date and PostgreSQL 10 (Manuel Trezza) [#7315](https://github.com/parse-community/parse-server/pull/7315) +- Remove support for Node 10 which has reached its End-of-Life date (Manuel Trezza) [#7314](https://github.com/parse-community/parse-server/pull/7314) +- Bump required Node engine to >=12.22.10 ([#7848](https://github.com/parse-community/parse-server/issues/7848)) ([23a3488](https://github.com/parse-community/parse-server/commit/23a3488f15511fafbe0e1d7ff0ef8355f9cb0215)) +- Remove S3 Files Adapter from Parse Server, instead install separately as `@parse/s3-files-adapter` (Manuel Trezza) [#7324](https://github.com/parse-community/parse-server/pull/7324) +- Remove Session field `restricted`; the field was a code artifact from a feature that never existed in Open Source Parse Server; if you have been using this field for custom purposes, consider that for new Parse Server installations the field does not exist anymore in the schema, and for existing installations the field default value `false` will not be set anymore when creating a new session (Manuel Trezza) [#7543](https://github.com/parse-community/parse-server/pull/7543) +- To delete a field via the GraphQL API, the field value has to be set to `null`. Previously, setting a field value to `null` would save a null value in the database, which was not according to the [GraphQL specs](https://spec.graphql.org/June2018/#sec-Null-Value). To delete a file field use `file: null`, the previous way of using `file: { file: null }` has become obsolete. ([626fad2](626fad2)) + +### Notable Changes +- Alphabetical ordered GraphQL API, improved GraphQL Schema cache system and fix GraphQL input reassign issue (Moumouls) [#7344](https://github.com/parse-community/parse-server/issues/7344) +- Added Parse Server Security Check to report weak security settings (Manuel Trezza, dblythy) [#7247](https://github.com/parse-community/parse-server/issues/7247) +- EXPERIMENTAL: Added new page router with placeholder rendering and localization of custom and feature pages such as password reset and email verification (Manuel Trezza) [#7128](https://github.com/parse-community/parse-server/pull/7128) +- EXPERIMENTAL: Added custom routes to easily customize flows for password reset, email verification or build entirely new flows (Manuel Trezza) [#7231](https://github.com/parse-community/parse-server/pull/7231) +- Added Deprecation Policy to govern the introduction of breaking changes in a phased pattern that is more predictable for developers (Manuel Trezza) [#7199](https://github.com/parse-community/parse-server/pull/7199) +- Add REST API endpoint `/loginAs` to create session of any user with master key; allows to impersonate another user. (GormanFletcher) [#7406](https://github.com/parse-community/parse-server/pull/7406) +- Add official support for MongoDB 5.0 (Manuel Trezza) [#7469](https://github.com/parse-community/parse-server/pull/7469) +- Added Parse Server Configuration `enforcePrivateUsers`, which will remove public access by default on new Parse.Users (dblythy) [#7319](https://github.com/parse-community/parse-server/pull/7319) +- add support for Postgres 14 ([#7644](https://github.com/parse-community/parse-server/issues/7644)) ([090350a](https://github.com/parse-community/parse-server/commit/090350a7a0fac945394ca1cb24b290316ef06aa7)) +- add user-defined schema and migrations ([#7418](https://github.com/parse-community/parse-server/issues/7418)) ([25d5c30](https://github.com/parse-community/parse-server/commit/25d5c30be2111be332eb779eb0697774a17da7af)) +- setting a field to null does not delete it via GraphQL API ([#7649](https://github.com/parse-community/parse-server/issues/7649)) ([626fad2](https://github.com/parse-community/parse-server/commit/626fad2e71017dcc62196c487de5f908fa43000b)) +- combined `and` query with relational query condition returns incorrect results ([#7593](https://github.com/parse-community/parse-server/issues/7593)) ([174886e](https://github.com/parse-community/parse-server/commit/174886e385e091c6bbd4a84891ef95f80b50d05c)) +- node engine range has no upper limit to exclude incompatible node versions ([#7693](https://github.com/parse-community/parse-server/issues/7693)) ([6a54dac](https://github.com/parse-community/parse-server/commit/6a54dac24d9fb63a44f311b8d414f4aa64140f32)) +- unable to use objectId size higher than 19 on GraphQL API ([#7722](https://github.com/parse-community/parse-server/issues/7722)) ([8ee0445](https://github.com/parse-community/parse-server/commit/8ee0445c0aeeb88dff2559b46ade408071d22143)) +- schema cache not cleared in some cases ([#7771](https://github.com/parse-community/parse-server/issues/7771)) ([3b92fa1](https://github.com/parse-community/parse-server/commit/3b92fa1ca9e8889127a32eba913d68309397ca2c)) + +### Other Changes +- Support native mongodb syntax in aggregation pipelines (Raschid JF Rafeally) [#7339](https://github.com/parse-community/parse-server/pull/7339) +- Fix error when a not yet inserted job is updated (Antonio Davi Macedo Coelho de Castro) [#7196](https://github.com/parse-community/parse-server/pull/7196) +- request.context for afterFind triggers (dblythy) [#7078](https://github.com/parse-community/parse-server/pull/7078) +- Winston Logger interpolating stdout to console (dplewis) [#7114](https://github.com/parse-community/parse-server/pull/7114) +- Added convenience method `Parse.Cloud.sendEmail(...)` to send email via email adapter in Cloud Code (dblythy) [#7089](https://github.com/parse-community/parse-server/pull/7089) +- LiveQuery support for $and, $nor, $containedBy, $geoWithin, $geoIntersects queries (dplewis) [#7113](https://github.com/parse-community/parse-server/pull/7113) +- Supporting patterns in LiveQuery server's config parameter `classNames` (Nes-si) [#7131](https://github.com/parse-community/parse-server/pull/7131) +- Added `requireAnyUserRoles` and `requireAllUserRoles` for Parse Cloud validator (dblythy) [#7097](https://github.com/parse-community/parse-server/pull/7097) +- Support Facebook Limited Login (miguel-s) [#7219](https://github.com/parse-community/parse-server/pull/7219) +- Removed Stage name check on aggregate pipelines (BRETT71) [#7237](https://github.com/parse-community/parse-server/pull/7237) +- Retry transactions on MongoDB when it fails due to transient error (Antonio Davi Macedo Coelho de Castro) [#7187](https://github.com/parse-community/parse-server/pull/7187) +- Bump tests to use Mongo 4.4.4 (Antonio Davi Macedo Coelho de Castro) [#7184](https://github.com/parse-community/parse-server/pull/7184) +- Added new account lockout policy option `accountLockout.unlockOnPasswordReset` to automatically unlock account on password reset (Manuel Trezza) [#7146](https://github.com/parse-community/parse-server/pull/7146) +- Test Parse Server continuously against all recent MongoDB versions that have not reached their end-of-life support date, added MongoDB compatibility table to Parse Server docs (Manuel Trezza) [#7161](https://github.com/parse-community/parse-server/pull/7161) +- Test Parse Server continuously against all recent Node.js versions that have not reached their end-of-life support date, added Node.js compatibility table to Parse Server docs (Manuel Trezza) [7161](https://github.com/parse-community/parse-server/pull/7177) +- Throw error on invalid Cloud Function validation configuration (dblythy) [#7154](https://github.com/parse-community/parse-server/pull/7154) +- Allow Cloud Validator `options` to be async (dblythy) [#7155](https://github.com/parse-community/parse-server/pull/7155) +- Optimize queries on classes with pointer permissions (Pedro Diaz) [#7061](https://github.com/parse-community/parse-server/pull/7061) +- Test Parse Server continuously against all relevant Postgres versions (minor versions), added Postgres compatibility table to Parse Server docs (Corey Baker) [#7176](https://github.com/parse-community/parse-server/pull/7176) +- Randomize test suite (Diamond Lewis) [#7265](https://github.com/parse-community/parse-server/pull/7265) +- LDAP: Properly unbind client on group search error (Diamond Lewis) [#7265](https://github.com/parse-community/parse-server/pull/7265) +- Improve data consistency in Push and Job Status update (Diamond Lewis) [#7267](https://github.com/parse-community/parse-server/pull/7267) +- Excluding keys that have trailing edges.node when performing GraphQL resolver (Chris Bland) [#7273](https://github.com/parse-community/parse-server/pull/7273) +- Added centralized feature deprecation with standardized warning logs (Manuel Trezza) [#7303](https://github.com/parse-community/parse-server/pull/7303) +- Use Node.js 15.13.0 in CI (Olle Jonsson) [#7312](https://github.com/parse-community/parse-server/pull/7312) +- Fix file upload issue for S3 compatible storage (Linode, DigitalOcean) by avoiding empty tags property when creating a file (Ali Oguzhan Yildiz) [#7300](https://github.com/parse-community/parse-server/pull/7300) +- Add building Docker image as CI check (Manuel Trezza) [#7332](https://github.com/parse-community/parse-server/pull/7332) +- Add NPM package-lock version check to CI (Manuel Trezza) [#7333](https://github.com/parse-community/parse-server/pull/7333) +- Fix incorrect LiveQuery events triggered for multiple subscriptions on the same class with different events [#7341](https://github.com/parse-community/parse-server/pull/7341) +- Fix select and excludeKey queries to properly accept JSON string arrays. Also allow nested fields in exclude (Corey Baker) [#7242](https://github.com/parse-community/parse-server/pull/7242) +- Fix LiveQuery server crash when using $all query operator on a missing object key (Jason Posthuma) [#7421](https://github.com/parse-community/parse-server/pull/7421) +- Added runtime deprecation warnings (Manuel Trezza) [#7451](https://github.com/parse-community/parse-server/pull/7451) +- Add ability to pass context of an object via a header, X-Parse-Cloud-Context, for Cloud Code triggers. The header addition allows client SDK's to add context without injecting _context in the body of JSON objects (Corey Baker) [#7437](https://github.com/parse-community/parse-server/pull/7437) +- Add CI check to add changelog entry (Manuel Trezza) [#7512](https://github.com/parse-community/parse-server/pull/7512) +- Refactor: uniform issue templates across repos (Manuel Trezza) [#7528](https://github.com/parse-community/parse-server/pull/7528) +- ci: bump ci environment (Manuel Trezza) [#7539](https://github.com/parse-community/parse-server/pull/7539) +- CI now pushes docker images to Docker Hub (Corey Baker) [#7548](https://github.com/parse-community/parse-server/pull/7548) +- Allow afterFind and afterLiveQueryEvent to set unsaved pointers and keys (dblythy) [#7310](https://github.com/parse-community/parse-server/pull/7310) +- Allow setting descending sort to full text queries (dblythy) [#7496](https://github.com/parse-community/parse-server/pull/7496) +- Allow cloud string for ES modules (Daniel Blyth) [#7560](https://github.com/parse-community/parse-server/pull/7560) +- docs: Introduce deprecation ID for reference in comments and online search (Manuel Trezza) [#7562](https://github.com/parse-community/parse-server/pull/7562) +- refactor: deprecate `Parse.Cloud.httpRequest`; it is recommended to use a HTTP library instead. (Daniel Blyth) [#7595](https://github.com/parse-community/parse-server/pull/7595) +- refactor: Modernize HTTPRequest tests (brandongregoryscott) [#7604](https://github.com/parse-community/parse-server/pull/7604) +- Allow liveQuery on Session class (Daniel Blyth) [#7554](https://github.com/parse-community/parse-server/pull/7554) +- security upgrade follow-redirects from 1.14.2 to 1.14.7 ([#7772](https://github.com/parse-community/parse-server/issues/7772)) ([4bd34b1](https://github.com/parse-community/parse-server/commit/4bd34b189bc9f5aa2e70b7e7c1a456e91b6de773)) +- security upgrade follow-redirects from 1.14.7 to 1.14.8 ([#7802](https://github.com/parse-community/parse-server/issues/7802)) ([7029b27](https://github.com/parse-community/parse-server/commit/7029b274ca87bc8058617f29865d683dc3b351a1)) +- Add node engine version check (Manuel Trezza) [#7574](https://github.com/parse-community/parse-server/pull/7574) + +## [4.10.7](https://github.com/parse-community/parse-server/compare/4.10.6...4.10.7) (2022-03-11) + + +### Bug Fixes + +* security vulnerability that allows remote code execution ([GHSA-p6h4-93qp-jhcm](https://github.com/parse-community/parse-server/security/advisories/GHSA-p6h4-93qp-jhcm)) ([#7841](https://github.com/parse-community/parse-server/issues/7841)) ([886bfd7](https://github.com/parse-community/parse-server/commit/886bfd7cac69496e3f73d4bb536f0eec3cba0e4d)) + + Note that as part of the fix a new security feature scans for sensitive keywords in request data to prevent JavaScript prototype pollution. If such a keyword is found, the request is rejected with HTTP response code `400` and Parse Error `105` (`INVALID_KEY_NAME`). By default these keywords are: `{_bsontype: "Code"}`, `constructor`, `__proto__`. If you are using any of these keywords in your request data, you can override the default keywords by setting the new Parse Server option `requestKeywordDenylist` to `[]` and specify your own keywords as needed. + +## [4.10.6](https://github.com/parse-community/parse-server/compare/4.10.5...4.10.6) (2022-02-12) + + +### Bug Fixes + +* update graphql dependencies to work with Parse Dashboard ([#7658](https://github.com/parse-community/parse-server/issues/7658)) ([350ecde](https://github.com/parse-community/parse-server/commit/350ecdee590f1b9d721895b2c79306c01622c3fc)) + +## [4.10.5](https://github.com/parse-community/parse-server/compare/4.10.4...4.10.5) (2022-02-12) + + +### Bug Fixes + +* security upgrade follow-redirects from 1.13.0 to 1.14.8 ([#7803](https://github.com/parse-community/parse-server/issues/7803)) ([611332e](https://github.com/parse-community/parse-server/commit/611332ea33831258efd3dd2f2c621c2e35fc95d3)) + +# [4.10.4](https://github.com/parse-community/parse-server/compare/4.10.3...4.10.4) + +### Security Fixes +- Strip out sessionToken when LiveQuery is used on Parse.User (Daniel Blyth) [GHSA-7pr3-p5fm-8r9x](https://github.com/parse-community/parse-server/security/advisories/GHSA-7pr3-p5fm-8r9x) + +# [4.10.3](https://github.com/parse-community/parse-server/compare/4.10.2...4.10.3) + +### Security Fixes +- Validate `explain` query parameter to avoid a server crash due to MongoDB bug [NODE-3463](https://jira.mongodb.org/browse/NODE-3463) (Kartal Kaan Bozdogan) [GHSA-xqp8-w826-hh6x](https://github.com/parse-community/parse-server/security/advisories/GHSA-xqp8-w826-hh6x) + +# [4.10.2](https://github.com/parse-community/parse-server/compare/4.10.1...4.10.2) + +### Other Changes +- Move graphql-tag from devDependencies to dependencies (Antonio Davi Macedo Coelho de Castro) [#7183](https://github.com/parse-community/parse-server/pull/7183) + +# [4.10.1](https://github.com/parse-community/parse-server/compare/4.10.0...4.10.1) + +### Security Fixes +- Updated to Parse JS SDK 3.3.0 and other security fixes (Manuel Trezza) [#7508](https://github.com/parse-community/parse-server/pull/7508) + +> âš ī¸ This includes a security fix of the Parse JS SDK where `logIn` will default to `POST` instead of `GET` method. This may require changes in your deployment before you upgrade to this release, see the Parse JS SDK 3.0.0 [release notes](https://github.com/parse-community/Parse-SDK-JS/releases/tag/3.0.0). + +# [4.10.0](https://github.com/parse-community/parse-server/compare/4.5.2...4.10.0) + +*Versions >4.5.2 and <4.10.0 are skipped.* + +> âš ī¸ A security incident caused a number of incorrect version tags to be pushed to the Parse Server repository. These version tags linked to a personal fork of a contributor who had write access to the repository. The code to which these tags linked has not been reviewed or approved by Parse Platform. Even though no releases were published with these incorrect versions, it was possible to define a Parse Server dependency that pointed to these version tags, for example if you defined this dependency: +> ```js +> "parse-server": "git@github.com:parse-community/parse-server.git#4.9.3" +> ``` +> +> We have since deleted the incorrect version tags, but they may still show up if your personal fork on GitHub or locally. We do not know when these tags have been pushed to the Parse Server repository, but we first became aware of this issue on July 21, 2021. We are not aware of any malicious code or concerns related to privacy, security or legality (e.g. proprietary code). However, it has been reported that some functionality does not work as expected and the introduction of security vulnerabilities cannot be ruled out. +> +> You may be also affected if you used the Bitnami image for Parse Server. Bitnami picked up the incorrect version tag `4.9.3` and published a new Bitnami image for Parse Server. +> +>**If you are using any of the affected versions, we urgently recommend to upgrade to version `4.10.0`.** + +# [4.5.2](https://github.com/parse-community/parse-server/compare/4.5.0...4.5.2) + +#### Security Fixes +- SECURITY FIX: Fixes incorrect session property `authProvider: password` of anonymous users. When signing up an anonymous user, the session field `createdWith` indicates incorrectly that the session has been created using username and password with `authProvider: password`, instead of an anonymous sign-up with `authProvider: anonymous`. This fixes the issue by setting the correct `authProvider: anonymous` for future sign-ups of anonymous users. This fix does not fix incorrect `authProvider: password` for existing sessions of anonymous users. Consider this if your app logic depends on the `authProvider` field. (Corey Baker) [GHSA-23r4-5mxp-c7g5](https://github.com/parse-community/parse-server/security/advisories/GHSA-23r4-5mxp-c7g5) + +# 4.5.1 +*This version was published by mistake and has been removed.* + +# [4.5.0](https://github.com/parse-community/parse-server/compare/4.4.0...4.5.0) +### Breaking Changes +- FIX: Consistent casing for afterLiveQueryEvent. The afterLiveQueryEvent was introduced in 4.4.0 with inconsistent casing for the event names, which was fixed in 4.5.0. [#7023](https://github.com/parse-community/parse-server/pull/7023). Thanks to [dblythy](https://github.com/dblythy). +### Other Changes +- FIX: Properly handle serverURL and publicServerUrl in Batch requests. [#7049](https://github.com/parse-community/parse-server/pull/7049). Thanks to [Zach Goldberg](https://github.com/ZachGoldberg). +- IMPROVE: Prevent invalid column names (className and length). [#7053](https://github.com/parse-community/parse-server/pull/7053). Thanks to [Diamond Lewis](https://github.com/dplewis). +- IMPROVE: GraphQL: Remove viewer from logout mutation. [#7029](https://github.com/parse-community/parse-server/pull/7029). Thanks to [Antoine Cormouls](https://github.com/Moumouls). +- IMPROVE: GraphQL: Optimize on Relation. [#7044](https://github.com/parse-community/parse-server/pull/7044). Thanks to [Antoine Cormouls](https://github.com/Moumouls). +- NEW: Include sessionToken in onLiveQueryEvent. [#7043](https://github.com/parse-community/parse-server/pull/7043). Thanks to [dblythy](https://github.com/dblythy). +- FIX: Definitions for accountLockout and passwordPolicy. [#7040](https://github.com/parse-community/parse-server/pull/7040). Thanks to [dblythy](https://github.com/dblythy). +- FIX: Fix typo in server definitions for emailVerifyTokenReuseIfValid. [#7037](https://github.com/parse-community/parse-server/pull/7037). Thanks to [dblythy](https://github.com/dblythy). +- SECURITY FIX: LDAP auth stores password in plain text. See [GHSA-4w46-w44m-3jq3](https://github.com/parse-community/parse-server/security/advisories/GHSA-4w46-w44m-3jq3) for more details about the vulnerability and [da905a3](https://github.com/parse-community/parse-server/commit/da905a357d062ab4fea727a21eac231acc2ed92a) for the fix. Thanks to [Fabian Strachanski](https://github.com/fastrde). +- NEW: Reuse tokens if they haven't expired. [#7017](https://github.com/parse-community/parse-server/pull/7017). Thanks to [dblythy](https://github.com/dblythy). +- NEW: Add LDAPS-support to LDAP-Authcontroller. [#7014](https://github.com/parse-community/parse-server/pull/7014). Thanks to [Fabian Strachanski](https://github.com/fastrde). +- FIX: (beforeSave/afterSave): Return value instead of Parse.Op for nested fields. [#7005](https://github.com/parse-community/parse-server/pull/7005). Thanks to [Diamond Lewis](https://github.com/dplewis). +- FIX: (beforeSave): Skip Sanitizing Database results. [#7003](https://github.com/parse-community/parse-server/pull/7003). Thanks to [Diamond Lewis](https://github.com/dplewis). +- FIX: Fix includeAll for querying a Pointer and Pointer array. [#7002](https://github.com/parse-community/parse-server/pull/7002). Thanks to [Corey Baker](https://github.com/cbaker6). +- FIX: Add encryptionKey to src/options/index.js. [#6999](https://github.com/parse-community/parse-server/pull/6999). Thanks to [dblythy](https://github.com/dblythy). +- IMPROVE: Update PostgresStorageAdapter.js. [#6989](https://github.com/parse-community/parse-server/pull/6989). Thanks to [Vitaly Tomilov](https://github.com/vitaly-t). + +# [4.4.0](https://github.com/parse-community/parse-server/compare/4.3.0...4.4.0) +- IMPROVE: Update PostgresStorageAdapter.js. [#6981](https://github.com/parse-community/parse-server/pull/6981). Thanks to [Vitaly Tomilov](https://github.com/vitaly-t) +- NEW: skipWithMasterKey on Built-In Validator. [#6972](https://github.com/parse-community/parse-server/issues/6972). Thanks to [dblythy](https://github.com/dblythy). +- NEW: Add fileKey rotation to GridFSBucketAdapter. [#6768](https://github.com/parse-community/parse-server/pull/6768). Thanks to [Corey Baker](https://github.com/cbaker6). +- IMPROVE: Remove unused parameter in Cloud Function. [#6969](https://github.com/parse-community/parse-server/issues/6969). Thanks to [Diamond Lewis](https://github.com/dplewis). +- IMPROVE: Validation Handler Update. [#6968](https://github.com/parse-community/parse-server/issues/6968). Thanks to [dblythy](https://github.com/dblythy). +- FIX: (directAccess): Properly handle response status. [#6966](https://github.com/parse-community/parse-server/issues/6966). Thanks to [Diamond Lewis](https://github.com/dplewis). +- FIX: Remove hostnameMaxLen for Mongo URL. [#6693](https://github.com/parse-community/parse-server/issues/6693). Thanks to [markhoward02](https://github.com/markhoward02). +- IMPROVE: Show a message if cloud functions are duplicated. [#6963](https://github.com/parse-community/parse-server/issues/6963). Thanks to [dblythy](https://github.com/dblythy). +- FIX: Pass request.query to afterFind. [#6960](https://github.com/parse-community/parse-server/issues/6960). Thanks to [dblythy](https://github.com/dblythy). +- SECURITY FIX: Patch session vulnerability over Live Query. See [GHSA-2xm2-xj2q-qgpj](https://github.com/parse-community/parse-server/security/advisories/GHSA-2xm2-xj2q-qgpj) for more details about the vulnerability and [78b59fb](https://github.com/parse-community/parse-server/commit/78b59fb26b1c36e3cdbd42ba9fec025003267f58) for the fix. Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo). +- IMPROVE: LiveQueryEvent Error Logging Improvements. [#6951](https://github.com/parse-community/parse-server/issues/6951). Thanks to [dblythy](https://github.com/dblythy). +- IMPROVE: Include stack in Cloud Code. [#6958](https://github.com/parse-community/parse-server/issues/6958). Thanks to [dblythy](https://github.com/dblythy). +- FIX: (jobs): Add Error Message to JobStatus Failure. [#6954](https://github.com/parse-community/parse-server/issues/6954). Thanks to [Diamond Lewis](https://github.com/dplewis). +- NEW: Create Cloud function afterLiveQueryEvent. [#6859](https://github.com/parse-community/parse-server/issues/6859). Thanks to [dblythy](https://github.com/dblythy). +- FIX: Update vkontakte API to the latest version. [#6944](https://github.com/parse-community/parse-server/issues/6944). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo). +- FIX: Use an empty object as default value of options for Google Sign in. [#6844](https://github.com/parse-community/parse-server/issues/6844). Thanks to [Kevin Kuang](https://github.com/kvnkuang). +- FIX: Postgres: prepend className to unique indexes. [#6741](https://github.com/parse-community/parse-server/pull/6741). Thanks to [Corey Baker](https://github.com/cbaker6). +- FIX: GraphQL: Transform input types also on user mutations. [#6934](https://github.com/parse-community/parse-server/pull/6934). Thanks to [Antoine Cormouls](https://github.com/Moumouls). +- FIX: Set objectId into query for Email Validation. [#6930](https://github.com/parse-community/parse-server/pull/6930). Thanks to [Danaru](https://github.com/Danaru87). +- FIX: GraphQL: Optimize queries, fixes some null returns (on object), fix stitched GraphQLUpload. [#6709](https://github.com/parse-community/parse-server/pull/6709). Thanks to [Antoine Cormouls](https://github.com/Moumouls). +- FIX: Do not throw error if user provide a pointer like index onMongo. [#6923](https://github.com/parse-community/parse-server/pull/6923). Thanks to [Antoine Cormouls](https://github.com/Moumouls). +- FIX: Hotfix instagram api. [#6922](https://github.com/parse-community/parse-server/issues/6922). Thanks to [Tim](https://github.com/timination). +- FIX: (directAccess/cloud-code): Pass installationId with LogIn. [#6903](https://github.com/parse-community/parse-server/issues/6903). Thanks to [Diamond Lewis](https://github.com/dplewis). +- FIX: Fix bcrypt binary incompatibility. [#6891](https://github.com/parse-community/parse-server/issues/6891). Thanks to [Manuel Trezza](https://github.com/mtrezza). +- NEW: Keycloak auth adapter. [#6376](https://github.com/parse-community/parse-server/issues/6376). Thanks to [Rhuan](https://github.com/rhuanbarreto). +- IMPROVE: Changed incorrect key name in apple auth adapter tests. [#6861](https://github.com/parse-community/parse-server/issues/6861). Thanks to [Manuel Trezza](https://github.com/mtrezza). +- FIX: Fix mutating beforeSubscribe Query. [#6868](https://github.com/parse-community/parse-server/issues/6868). Thanks to [dblythy](https://github.com/dblythy). +- FIX: Fix beforeLogin for users logging in with AuthData. [#6872](https://github.com/parse-community/parse-server/issues/6872). Thanks to [Kevin Kuang](https://github.com/kvnkuang). +- FIX: Remove Facebook AccountKit auth. [#6870](https://github.com/parse-community/parse-server/issues/6870). Thanks to [Diamond Lewis](https://github.com/dplewis). +- FIX: Updated TOKEN_ISSUER to 'accounts.google.com'. [#6836](https://github.com/parse-community/parse-server/issues/6836). Thanks to [Arjun Vedak](https://github.com/arjun3396). +- IMPROVE: Optimized deletion of class field from schema by using an index if available to do an index scan instead of a collection scan. [#6815](https://github.com/parse-community/parse-server/issues/6815). Thanks to [Manuel Trezza](https://github.com/mtrezza). +- IMPROVE: Enable MongoDB transaction test for MongoDB >= 4.0.4 [#6827](https://github.com/parse-community/parse-server/pull/6827). Thanks to [Manuel](https://github.com/mtrezza). + +# [4.3.0](https://github.com/parse-community/parse-server/compare/4.2.0...4.3.0) +- PERFORMANCE: Optimizing pointer CLP query decoration done by DatabaseController#addPointerPermissions [#6747](https://github.com/parse-community/parse-server/pull/6747). Thanks to [mess-lelouch](https://github.com/mess-lelouch). +- SECURITY: Fix security breach on GraphQL viewer [78239ac](https://github.com/parse-community/parse-server/commit/78239ac9071167fdf243c55ae4bc9a2c0b0d89aa), [security advisory](https://github.com/parse-community/parse-server/security/advisories/GHSA-236h-rqv8-8q73). Thanks to [Antoine Cormouls](https://github.com/Moumouls). +- FIX: Save context not present if direct access enabled [#6764](https://github.com/parse-community/parse-server/pull/6764). Thanks to [Omair Vaiyani](https://github.com/omairvaiyani). +- NEW: Before Connect + Before Subscribe [#6793](https://github.com/parse-community/parse-server/pull/6793). Thanks to [dblythy](https://github.com/dblythy). +- FIX: Add version to playground to fix CDN [#6804](https://github.com/parse-community/parse-server/pull/6804). Thanks to [Antoine Cormouls](https://github.com/Moumouls). +- 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.** [#6748](https://github.com/parse-community/parse-server/issues/6748). Thanks to [Manuel Trezza](https://github.com/mtrezza). +- FIX: Add production Google Auth Adapter instead of using the development url [#6734](https://github.com/parse-community/parse-server/pull/6734). Thanks to [SebC.](https://github.com/SebC99). +- IMPROVE: Run Prettier JS Again Without requiring () on arrow functions [#6796](https://github.com/parse-community/parse-server/pull/6796). Thanks to [Diamond Lewis](https://github.com/dplewis). +- IMPROVE: Run Prettier JS [#6795](https://github.com/parse-community/parse-server/pull/6795). Thanks to [Diamond Lewis](https://github.com/dplewis). +- IMPROVE: Replace bcrypt with @node-rs/bcrypt [#6794](https://github.com/parse-community/parse-server/pull/6794). Thanks to [LongYinan](https://github.com/Brooooooklyn). +- IMPROVE: Make clear description of anonymous user [#6655](https://github.com/parse-community/parse-server/pull/6655). Thanks to [Jerome De Leon](https://github.com/JeromeDeLeon). +- IMPROVE: Simplify GraphQL merge system to avoid js ref bugs [#6791](https://github.com/parse-community/parse-server/pull/6791). Thanks to [Antoine Cormouls](https://github.com/Moumouls). +- NEW: Pass context in beforeDelete, afterDelete, beforeFind and Parse.Cloud.run [#6666](https://github.com/parse-community/parse-server/pull/6666). Thanks to [yog27ray](https://github.com/yog27ray). +- NEW: Allow passing custom gql schema function to ParseServer#start options [#6762](https://github.com/parse-community/parse-server/pull/6762). Thanks to [Luca](https://github.com/lucatk). +- NEW: Allow custom cors origin header [#6772](https://github.com/parse-community/parse-server/pull/6772). Thanks to [Kevin Yao](https://github.com/kzmeyao). +- FIX: Fix context for cascade-saving and saving existing object [#6735](https://github.com/parse-community/parse-server/pull/6735). Thanks to [Manuel](https://github.com/mtrezza). +- NEW: Add file bucket encryption using fileKey [#6765](https://github.com/parse-community/parse-server/pull/6765). Thanks to [Corey Baker](https://github.com/cbaker6). +- FIX: Removed gaze from dev dependencies and removed not working dev script [#6745](https://github.com/parse-community/parse-server/pull/6745). Thanks to [Vincent Semrau](https://github.com/vince1995). +- IMPROVE: Upgrade graphql-tools to v6 [#6701](https://github.com/parse-community/parse-server/pull/6701). Thanks to [Yaacov Rydzinski](https://github.com/yaacovCR). +- NEW: Support Metadata in GridFSAdapter [#6660](https://github.com/parse-community/parse-server/pull/6660). Thanks to [Diamond Lewis](https://github.com/dplewis). +- NEW: Allow to unset file from graphql [#6651](https://github.com/parse-community/parse-server/pull/6651). Thanks to [Antoine Cormouls](https://github.com/Moumouls). +- NEW: Handle shutdown for RedisCacheAdapter [#6658](https://github.com/parse-community/parse-server/pull/6658). Thanks to [promisenxu](https://github.com/promisenxu). +- FIX: Fix explain on user class [#6650](https://github.com/parse-community/parse-server/pull/6650). Thanks to [Manuel](https://github.com/mtrezza). +- FIX: Fix read preference for aggregate [#6585](https://github.com/parse-community/parse-server/pull/6585). Thanks to [Manuel](https://github.com/mtrezza). +- NEW: Add context to Parse.Object.save [#6626](https://github.com/parse-community/parse-server/pull/6626). Thanks to [Manuel](https://github.com/mtrezza). +- NEW: Adding ssl config params to Postgres URI [#6580](https://github.com/parse-community/parse-server/pull/6580). Thanks to [Corey Baker](https://github.com/cbaker6). +- FIX: Travis postgres update: removing unnecessary start of mongo-runner [#6594](https://github.com/parse-community/parse-server/pull/6594). Thanks to [Corey Baker](https://github.com/cbaker6). +- FIX: ObjectId size for Pointer in Postgres [#6619](https://github.com/parse-community/parse-server/pull/6619). Thanks to [Corey Baker](https://github.com/cbaker6). +- IMPROVE: Improve a test case [#6629](https://github.com/parse-community/parse-server/pull/6629). Thanks to [Gordon Sun](https://github.com/sunshineo). +- NEW: Allow to resolve automatically Parse Type fields from Custom Schema [#6562](https://github.com/parse-community/parse-server/pull/6562). Thanks to [Antoine Cormouls](https://github.com/Moumouls). +- FIX: Remove wrong console log in test [#6627](https://github.com/parse-community/parse-server/pull/6627). Thanks to [Gordon Sun](https://github.com/sunshineo). +- IMPROVE: Graphql tools v5 [#6611](https://github.com/parse-community/parse-server/pull/6611). Thanks to [Yaacov Rydzinski](https://github.com/yaacovCR). +- FIX: Catch JSON.parse and return 403 properly [#6589](https://github.com/parse-community/parse-server/pull/6589). Thanks to [Gordon Sun](https://github.com/sunshineo). +- PERFORMANCE: Allow covering relation queries with minimal index [#6581](https://github.com/parse-community/parse-server/pull/6581). Thanks to [Noah Silas](https://github.com/noahsilas). +- FIX: Fix Postgres group aggregation [#6522](https://github.com/parse-community/parse-server/pull/6522). Thanks to [Siddharth Ramesh](https://github.com/srameshr). +- NEW: Allow set user mapped from JWT directly on request [#6411](https://github.com/parse-community/parse-server/pull/6411). Thanks to [Gordon Sun](https://github.com/sunshineo). + +# [4.2.0](https://github.com/parse-community/parse-server/compare/4.1.0...4.2.0) + +### Breaking Changes +- CHANGE: The Sign-In with Apple authentication adapter parameter `client_id` has been changed to `clientId`. If using the Apple authentication adapter, this change requires to update the Parse Server configuration accordingly. See [#6523](https://github.com/parse-community/parse-server/pull/6523) for details. +___ +- UPGRADE: Parse JS SDK to 2.12.0 [#6548](https://github.com/parse-community/parse-server/pull/6548) +- NEW: Support Group aggregation on multiple columns for Postgres [#6483](https://github.com/parse-community/parse-server/pull/6483). Thanks to [Siddharth Ramesh](https://github.com/srameshr). +- FIX: Improve test reliability by instructing Travis to only install one version of Postgres [#6490](https://github.com/parse-community/parse-server/pull/6490). Thanks to +[Corey Baker](https://github.com/cbaker6). +- FIX: Unknown type bug on overloaded types [#6494](https://github.com/parse-community/parse-server/pull/6494). Thanks to [Antoine Cormouls](https://github.com/Moumouls). +- FIX: Improve reliability of 'SignIn with AppleID' [#6416](https://github.com/parse-community/parse-server/pull/6416). Thanks to [Andy King](https://github.com/andrewking0207). +- FIX: Improve Travis reliability by separating Postgres & Mongo scripts [#6505](https://github.com/parse-community/parse-server/pull/6505). Thanks to +[Corey Baker](https://github.com/cbaker6). +- NEW: Apple SignIn support for multiple IDs [#6523](https://github.com/parse-community/parse-server/pull/6523). Thanks to [UnderratedDev](https://github.com/UnderratedDev). +- NEW: Add support for new Instagram API [#6398](https://github.com/parse-community/parse-server/pull/6398). Thanks to [Maravilho Singa](https://github.com/maravilhosinga). +- FIX: Updating Postgres/Postgis Call and Postgis to 3.0 [#6528](https://github.com/parse-community/parse-server/pull/6528). Thanks to +[Corey Baker](https://github.com/cbaker6). +- FIX: enableExpressErrorHandler logic [#6423](https://github.com/parse-community/parse-server/pull/6423). Thanks to [Nikolay Andryukhin](https://github.com/hybeats). +- FIX: Change Order Enum Strategy for GraphQL [#6515](https://github.com/parse-community/parse-server/pull/6515). Thanks to [Antoine Cormouls](https://github.com/Moumouls). +- FIX: Switch ACL to Relay Global Id for GraphQL [#6495](https://github.com/parse-community/parse-server/pull/6495). Thanks to [Antoine Cormouls](https://github.com/Moumouls). +- FIX: Handle keys for pointer fields properly for GraphQL [#6499](https://github.com/parse-community/parse-server/pull/6499). Thanks to [Antoine Cormouls](https://github.com/Moumouls). +- FIX: GraphQL file mutation [#6507](https://github.com/parse-community/parse-server/pull/6507). Thanks to [Antoine Cormouls](https://github.com/Moumouls). +- FIX: Aggregate geoNear with date query [#6540](https://github.com/parse-community/parse-server/pull/6540). Thanks to [Manuel](https://github.com/mtrezza). +- NEW: Add file triggers and file meta data [#6344](https://github.com/parse-community/parse-server/pull/6344). Thanks to [stevestencil](https://github.com/stevestencil). +- FIX: Improve local testing of postgres [#6531](https://github.com/parse-community/parse-server/pull/6531). Thanks to +[Corey Baker](https://github.com/cbaker6). +- NEW: Case insensitive username and email indexing and query planning for Postgres [#6506](https://github.com/parse-community/parse-server/issues/6441). Thanks to +[Corey Baker](https://github.com/cbaker6). + +# [4.1.0](https://github.com/parse-community/parse-server/compare/4.0.2...4.1.0) + +_SECURITY RELEASE_: see [advisory](https://github.com/parse-community/parse-server/security/advisories/GHSA-h4mf-75hf-67w4) for details +- SECURITY FIX: Patch Regex vulnerabilities. See [3a3a5ee](https://github.com/parse-community/parse-server/commit/3a3a5eee5ffa48da1352423312cb767de14de269). Special thanks to [W0lfw00d](https://github.com/W0lfw00d) for identifying and [responsibly reporting](https://github.com/parse-community/parse-server/blob/master/SECURITY.md) the vulnerability. Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) for the speedy fix. + +# [4.0.2](https://github.com/parse-community/parse-server/compare/4.0.1...4.0.2) + +### Breaking Changes +1. Remove Support for Mongo 3.2 & 3.4. The new minimum supported version is Mongo 3.6. +2. Change username and email validation to be case insensitive. This change should be transparent in most use cases. The validation behavior should now behave 'as expected'. See [#5634](https://github.com/parse-community/parse-server/pull/5634) for details. + +> __Special Note on Upgrading to Parse Server 4.0.0 and above__ +> +> In addition to the breaking changes noted above, [#5634](https://github.com/parse-community/parse-server/pull/5634) introduces a two new case insensitive indexes on the `User` collection. Special care should be taken when upgrading to this version to ensure that: +> +> 1. The new indexes can be successfully created (see issue [#6465](https://github.com/parse-community/parse-server/issues/6465) for details on a potential issue for your installation). +> +> 2. Care is taken ensure that there is adequate compute capacity to create the index in the background while still servicing requests. + +- FIX: attempt to get travis to deploy to npmjs again. See [#6475](https://github.com/parse-community/parse-server/pull/6457). Thanks to [Arthur Cinader](https://github.com/acinader). + +# [4.0.1](https://github.com/parse-community/parse-server/compare/4.0.0...4.0.1) +- FIX: correct 'new' travis config to properly deploy. See [#6452](https://github.com/parse-community/parse-server/pull/6452). Thanks to [Arthur Cinader](https://github.com/acinader). +- FIX: Better message on not allowed to protect default fields. See [#6439](https://github.com/parse-community/parse-server/pull/6439).Thanks to [Old Grandpa](https://github.com/BufferUnderflower) + +# [4.0.0](https://github.com/parse-community/parse-server/compare/3.10.0...4.0.0) + +> __Special Note on Upgrading to Parse Server 4.0.0 and above__ +> +> In addition to the breaking changes noted below, [#5634](https://github.com/parse-community/parse-server/pull/5634) introduces a two new case insensitive indexes on the `User` collection. Special care should be taken when upgrading to this version to ensure that: +> +> 1. The new indexes can be successfully created (see issue [#6465](https://github.com/parse-community/parse-server/issues/6465) for details on a potential issue for your installation). +> +> 2. Care is taken ensure that there is adequate compute capacity to create the index in the background while still servicing requests. + +- NEW: add hint option to Parse.Query [#6322](https://github.com/parse-community/parse-server/pull/6322). Thanks to [Steve Stencil](https://github.com/stevestencil) +- FIX: CLP objectId size validation fix [#6332](https://github.com/parse-community/parse-server/pull/6332). Thanks to [Old Grandpa](https://github.com/BufferUnderflower) +- FIX: Add volumes to Docker command [#6356](https://github.com/parse-community/parse-server/pull/6356). Thanks to [Kasra Bigdeli](https://github.com/githubsaturn) +- NEW: GraphQL 3rd Party LoginWith Support [#6371](https://github.com/parse-community/parse-server/pull/6371). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- FIX: GraphQL Geo Queries [#6363](https://github.com/parse-community/parse-server/pull/6363). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- NEW: GraphQL Nested File Upload [#6372](https://github.com/parse-community/parse-server/pull/6372). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- NEW: Granular CLP pointer permissions [#6352](https://github.com/parse-community/parse-server/pull/6352). Thanks to [Old Grandpa](https://github.com/BufferUnderflower) +- FIX: Add missing colon for customPages [#6393](https://github.com/parse-community/parse-server/pull/6393). Thanks to [Jerome De Leon](https://github.com/JeromeDeLeon) +- NEW: `afterLogin` cloud code hook [#6387](https://github.com/parse-community/parse-server/pull/6387). Thanks to [David Corona](https://github.com/davesters) +- FIX: __BREAKING CHANGE__ Prevent new usernames or emails that clash with existing users' email or username if it only differs by case. For example, don't allow a new user with the name 'Jane' if we already have a user 'jane'. [#5634](https://github.com/parse-community/parse-server/pull/5634). Thanks to [Arthur Cinader](https://github.com/acinader) +- FIX: Support Travis CI V2. [#6414](https://github.com/parse-community/parse-server/pull/6414). Thanks to [Diamond Lewis](https://github.com/dplewis) +- FIX: Prevent crashing on websocket error. [#6418](https://github.com/parse-community/parse-server/pull/6418). Thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: Allow protectedFields for Authenticated users and Public. [$6415](https://github.com/parse-community/parse-server/pull/6415). Thanks to [Old Grandpa](https://github.com/BufferUnderflower) +- FIX: Correct bug in determining GraphQL pointer errors when mutating. [#6413](https://github.com/parse-community/parse-server/pull/6431). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- NEW: Allow true GraphQL Schema Customization. [#6360](https://github.com/parse-community/parse-server/pull/6360). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- __BREAKING CHANGE__: Remove Support for Mongo version < 3.6 [#6445](https://github.com/parse-community/parse-server/pull/6445). Thanks to [Arthur Cinader](https://github.com/acinader) + +# [3.10.0](https://github.com/parse-community/parse-server/compare/3.9.0...3.10.0) +- FIX: correct and cover ordering queries in GraphQL [#6316](https://github.com/parse-community/parse-server/pull/6316). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: GraphQL support for reset password email [#6301](https://github.com/parse-community/parse-server/pull/6301). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- FIX: Add default limit to GraphQL fetch [#6304](https://github.com/parse-community/parse-server/pull/6304). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- DOCS: use bash syntax highlighting [#6302](https://github.com/parse-community/parse-server/pull/6302). Thanks to [Jerome De Leon](https://github.com/JeromeDeLeon) +- NEW: Add max log file option [#6296](https://github.com/parse-community/parse-server/pull/6296). Thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: support user supplied objectId [#6101](https://github.com/parse-community/parse-server/pull/6101). Thanks to [Ruhan](https://github.com/rhuanbarretos) +- FIX: Add missing encodeURIComponent on username [#6278](https://github.com/parse-community/parse-server/pull/6278). Thanks to [Christopher Brookes](https://github.com/Klaitos) +- NEW: update PostgresStorageAdapter.js to use async/await [#6275](https://github.com/parse-community/parse-server/pull/6275). Thanks to [Vitaly Tomilov](https://github.com/vitaly-t) +- NEW: Support required fields on output type for GraphQL [#6279](https://github.com/parse-community/parse-server/pull/6279). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- NEW: Support required fields for GraphQL [#6271](https://github.com/parse-community/parse-server/pull/6279). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- CHANGE: use mongodb 3.3.5 [#6263](https://github.com/parse-community/parse-server/pull/6263). Thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: GraphQL: DX Relational Where Query [#6255](https://github.com/parse-community/parse-server/pull/6255). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- CHANGE: test against Postgres 11 [#6260](https://github.com/parse-community/parse-server/pull/6260). Thanks to [Diamond Lewis](https://github.com/dplewis) +- CHANGE: test against Postgres 11 [#6260](https://github.com/parse-community/parse-server/pull/6260). Thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: GraphQL alias for mutations in classConfigs [#6258](https://github.com/parse-community/parse-server/pull/6258). Thanks to [Old Grandpa](https://github.com/BufferUnderflower) +- NEW: GraphQL classConfig query alias [#6257](https://github.com/parse-community/parse-server/pull/6257). Thanks to [Old Grandpa](https://github.com/BufferUnderflower) +- NEW: Allow validateFilename to return a string or Parse Error [#6246](https://github.com/parse-community/parse-server/pull/6246). Thanks to [Mike Patnode](https://github.com/mpatnode) +- NEW: Relay Spec [#6089](https://github.com/parse-community/parse-server/pull/6089). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- CHANGE: Set default ACL for GraphQL [#6249](https://github.com/parse-community/parse-server/pull/6249). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- NEW: LDAP auth Adapter [#6226](https://github.com/parse-community/parse-server/pull/6226). Thanks to [Julian Dax](https://github.com/brodo) +- FIX: improve beforeFind to include Query info [#6237](https://github.com/parse-community/parse-server/pull/6237). Thanks to [Diamond Lewis](https://github.com/dplewis) +- FIX: improve websocket error handling [#6230](https://github.com/parse-community/parse-server/pull/6230). Thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: addition of an afterLogout trigger [#6217](https://github.com/parse-community/parse-server/pull/6217). Thanks to [Diamond Lewis](https://github.com/dplewis) +- FIX: Initialize default logger [#6186](https://github.com/parse-community/parse-server/pull/6186). Thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: Add funding link [#6192](https://github.com/parse-community/parse-server/pull/6192 ). Thanks to [Tom Fox](https://github.com/TomWFox) +- FIX: installationId on LiveQuery connect [#6180](https://github.com/parse-community/parse-server/pull/6180). Thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: Add exposing port in docker container [#6165](https://github.com/parse-community/parse-server/pull/6165). Thanks to [Priyash Patil](https://github.com/priyashpatil) +- NEW: Support Google Play Games Service [#6147](https://github.com/parse-community/parse-server/pull/6147). Thanks to [Diamond Lewis](https://github.com/dplewis) +- DOC: Throw error when setting authData to null [#6154](https://github.com/parse-community/parse-server/pull/6154). Thanks to [Manuel](https://github.com/mtrezza) +- CHANGE: Move filename validation out of the Router and into the FilesAdaptor [#6157](https://github.com/parse-community/parse-server/pull/6157). Thanks to [Mike Patnode](https://github.com/mpatnode) +- NEW: Added warning for special URL sensitive characters for appId [#6159](https://github.com/parse-community/parse-server/pull/6159). Thanks to [Saimoom Safayet Akash](https://github.com/saimoomsafayet) +- NEW: Support Apple Game Center Auth [#6143](https://github.com/parse-community/parse-server/pull/6143). Thanks to [Diamond Lewis](https://github.com/dplewis) +- CHANGE: test with Node 12 [#6133](https://github.com/parse-community/parse-server/pull/6133). Thanks to [Arthur Cinader](https://github.com/acinader) +- FIX: prevent after find from firing when saving objects [#6127](https://github.com/parse-community/parse-server/pull/6127). Thanks to [Diamond Lewis](https://github.com/dplewis) +- FIX: GraphQL Mutations not returning updated information [6130](https://github.com/parse-community/parse-server/pull/6130). Thanks to [Omair Vaiyani](https://github.com/omairvaiyani) +- CHANGE: Cleanup Schema cache per request [#6216](https://github.com/parse-community/parse-server/pull/6216). Thanks to [Diamond Lewis](https://github.com/dplewis) +- DOC: Improve installation instructions [#6120](https://github.com/parse-community/parse-server/pull/6120). Thanks to [Andres Galante](https://github.com/andresgalante) +- DOC: add code formatting to contributing guidelines [#6119](https://github.com/parse-community/parse-server/pull/6119). Thanks to [Andres Galante](https://github.com/andresgalante) +- NEW: Add GraphQL ACL Type + Input [#5957](https://github.com/parse-community/parse-server/pull/5957). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- CHANGE: replace public key [#6099](https://github.com/parse-community/parse-server/pull/6099). Thanks to [Arthur Cinader](https://github.com/acinader) +- NEW: Support microsoft authentication in GraphQL [#6051](https://github.com/parse-community/parse-server/pull/6051). Thanks to [Alann Maulana](https://github.com/alann-maulana) +- NEW: Install parse-server 3.9.0 instead of 2.2 [#6069](https://github.com/parse-community/parse-server/pull/6069). Thanks to [Julian Dax](https://github.com/brodo) +- NEW: Use #!/bin/bash instead of #!/bin/sh [#6062](https://github.com/parse-community/parse-server/pull/6062). Thanks to [Julian Dax](https://github.com/brodo) +- DOC: Update GraphQL readme section [#6030](https://github.com/parse-community/parse-server/pull/6030). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) + +# [3.9.0](https://github.com/parse-community/parse-server/compare/3.8.0...3.9.0) +- NEW: Add allowHeaders to Options [#6044](https://github.com/parse-community/parse-server/pull/6044). Thanks to [Omair Vaiyani](https://github.com/omairvaiyani) +- CHANGE: Introduce ReadOptionsInput to GraphQL API [#6030](https://github.com/parse-community/parse-server/pull/6030). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: Stream video with GridFSBucketAdapter (implements byte-range requests) [#6028](https://github.com/parse-community/parse-server/pull/6028). Thanks to [Diamond Lewis](https://github.com/dplewis) +- FIX: Aggregate not matching null values [#6043](https://github.com/parse-community/parse-server/pull/6043). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- CHANGE: Improve callCloudCode mutation to receive a CloudCodeFunction enum instead of a String in the GraphQL API [#6029](https://github.com/parse-community/parse-server/pull/6029). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- TEST: Add more tests to transactions [#6022](https://github.com/parse-community/parse-server/pull/6022). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- CHANGE: Pointer constraint input type as ID in the GraphQL API [#6020](https://github.com/parse-community/parse-server/pull/6020). Thanks to [Douglas Muraoka](https://github.com/douglasmuraoka) +- CHANGE: Remove underline from operators of the GraphQL API [#6024](https://github.com/parse-community/parse-server/pull/6024). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- FIX: Make method async as expected in usage [#6025](https://github.com/parse-community/parse-server/pull/6025). Thanks to [Omair Vaiyani](https://github.com/omairvaiyani) +- DOC: Added breaking change note to 3.8 release [#6023](https://github.com/parse-community/parse-server/pull/6023). Thanks to [Manuel](https://github.com/mtrezza) +- NEW: Added support for line auth [#6007](https://github.com/parse-community/parse-server/pull/6007). Thanks to [Saimoom Safayet Akash](https://github.com/saimoomsafayet) +- FIX: Fix aggregate group id [#5994](https://github.com/parse-community/parse-server/pull/5994). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- CHANGE: Schema operations instead of generic operations in the GraphQL API [#5993](https://github.com/parse-community/parse-server/pull/5993). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- DOC: Fix changelog formatting[#6009](https://github.com/parse-community/parse-server/pull/6009). Thanks to [Tom Fox](https://github.com/TomWFox) +- CHANGE: Rename objectId to id in the GraphQL API [#5985](https://github.com/parse-community/parse-server/pull/5985). Thanks to [Douglas Muraoka](https://github.com/douglasmuraoka) +- FIX: Fix beforeLogin trigger when user has a file [#6001](https://github.com/parse-community/parse-server/pull/6001). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- DOC: Update GraphQL Docs with the latest changes [#5980](https://github.com/parse-community/parse-server/pull/5980). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) + +# [3.8.0](https://github.com/parse-community/parse-server/compare/3.7.2...3.8.0) +- NEW: Protected fields pointer-permissions support [#5951](https://github.com/parse-community/parse-server/pull/5951). Thanks to [Dobbias Nan](https://github.com/Dobbias) +- NEW: GraphQL DX: Relation/Pointer [#5946](https://github.com/parse-community/parse-server/pull/5946). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- NEW: Master Key Only Config Properties [#5953](https://github.com/parse-community/parse-server/pull/5954). Thanks to [Manuel](https://github.com/mtrezza) +- FIX: Better validation when creating a Relation fields [#5922](https://github.com/parse-community/parse-server/pull/5922). Thanks to [Lucas Alencar](https://github.com/alencarlucas) +- NEW: enable GraphQL file upload [#5944](https://github.com/parse-community/parse-server/pull/5944). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: Handle shutdown on grid adapters [#5943](https://github.com/parse-community/parse-server/pull/5943). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: Fix GraphQL max upload size [#5940](https://github.com/parse-community/parse-server/pull/5940). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- FIX: Remove Buffer() deprecation notice [#5942](https://github.com/parse-community/parse-server/pull/5942). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- FIX: Remove MongoDB unified topology deprecation notice from the grid adapter [#5941](https://github.com/parse-community/parse-server/pull/5941). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: add callback for serverCloseComplete [#5937](https://github.com/parse-community/parse-server/pull/5937). Thanks to [Diamond Lewis](https://github.com/dplewis) +- DOCS: Add Cloud Code guide to README [#5936](https://github.com/parse-community/parse-server/pull/5936). Thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: Remove nested operations from GraphQL API [#5931](https://github.com/parse-community/parse-server/pull/5931). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: Improve Live Query Monitoring [#5927](https://github.com/parse-community/parse-server/pull/5927). Thanks to [Diamond Lewis](https://github.com/dplewis) +- FIX: GraphQL: Fix undefined Array [#5296](https://github.com/parse-community/parse-server/pull/5926). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- NEW: Added array support for pointer-permissions [#5921](https://github.com/parse-community/parse-server/pull/5921). Thanks to [Dobbias Nan](https://github.com/Dobbias) +- GraphQL: Renaming Types/Inputs [#5921](https://github.com/parse-community/parse-server/pull/5921). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- FIX: Lint no-prototype-builtins [#5920](https://github.com/parse-community/parse-server/pull/5920). Thanks to [Diamond Lewis](https://github.com/dplewis) +- GraphQL: Inline Fragment on Array Fields [#5908](https://github.com/parse-community/parse-server/pull/5908). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- DOCS: Add instructions to launch a compatible Docker Postgres [](). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- Fix: Undefined dot notation in matchKeyInQuery [#5917](https://github.com/parse-community/parse-server/pull/5917). Thanks to [Diamond Lewis](https://github.com/dplewis) +- Fix: Logger print JSON and Numbers [#5916](https://github.com/parse-community/parse-server/pull/5916). Thanks to [Diamond Lewis](https://github.com/dplewis) +- GraphQL: Return specific Type on specific Mutation [#5893](https://github.com/parse-community/parse-server/pull/5893). Thanks to [Antoine Cormouls](https://github.com/Moumouls) +- FIX: Apple sign-in authAdapter [#5891](https://github.com/parse-community/parse-server/pull/5891). Thanks to [SebC](https://github.com/SebC99). +- DOCS: Add GraphQL beta notice [#5886](https://github.com/parse-community/parse-server/pull/5886). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- GraphQL: Remove "password" output field from _User class [#5889](https://github.com/parse-community/parse-server/pull/5889). Thanks to [Douglas Muraoka](https://github.com/douglasmuraoka) +- GraphQL: Object constraints [#5715](https://github.com/parse-community/parse-server/pull/5715). Thanks to [Douglas Muraoka](https://github.com/douglasmuraoka) +- DOCS: README top section overhaul + add sponsors [#5876](https://github.com/parse-community/parse-server/pull/5876). Thanks to [Tom Fox](https://github.com/TomWFox) +- FIX: Return a Promise from classUpdate method [#5877](https://github.com/parse-community/parse-server/pull/5877). Thanks to [Lucas Alencar](https://github.com/alencarlucas) +- FIX: Use UTC Month in aggregate tests [#5879](https://github.com/parse-community/parse-server/pull/5879). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- FIX: Transaction was aborting before all promises have either resolved or rejected [#5878](https://github.com/parse-community/parse-server/pull/5878). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: Use transactions for batch operation [#5849](https://github.com/parse-community/parse-server/pull/5849). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) + +#### Breaking Changes +- If you are running Parse Server on top of a MongoDB deployment which does not fit the [Retryable Writes Requirements](https://docs.mongodb.com/manual/core/retryable-writes/#prerequisites), you will have to add `retryWrites=false` to your connection string in order to upgrade to Parse Server 3.8. + +# [3.7.2](https://github.com/parse-community/parse-server/compare/3.7.1...3.7.2) + +- FIX: Live Query was failing on release 3.7.1 + +# [3.7.1](https://github.com/parse-community/parse-server/compare/3.7.0...3.7.1) + +- FIX: Missing APN module +- FIX: Set falsy values as default to schema fields [#5868](https://github.com/parse-community/parse-server/pull/5868), thanks to [Lucas Alencar](https://github.com/alencarlucas) +- NEW: Implement WebSocketServer Adapter [#5866](https://github.com/parse-community/parse-server/pull/5866), thanks to [Diamond Lewis](https://github.com/dplewis) + +# [3.7.0](https://github.com/parse-community/parse-server/compare/3.6.0...3.7.0) + +- FIX: Prevent linkWith sessionToken from generating new session [#5801](https://github.com/parse-community/parse-server/pull/5801), thanks to [Diamond Lewis](https://github.com/dplewis) +- GraphQL: Improve session token error messages [#5753](https://github.com/parse-community/parse-server/pull/5753), thanks to [Douglas Muraoka](https://github.com/douglasmuraoka) +- NEW: GraphQL { functions { call } } generic mutation [#5818](https://github.com/parse-community/parse-server/pull/5818), thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: GraphQL Custom Schema [#5821](https://github.com/parse-community/parse-server/pull/5821), thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: GraphQL custom schema on CLI [#5828](https://github.com/parse-community/parse-server/pull/5828), thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: GraphQL @mock directive [#5836](https://github.com/parse-community/parse-server/pull/5836), thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- FIX: GraphQL _or operator not working [#5840](https://github.com/parse-community/parse-server/pull/5840), thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: Add "count" to CLP initial value [#5841](https://github.com/parse-community/parse-server/pull/5841), thanks to [Douglas Muraoka](https://github.com/douglasmuraoka) +- NEW: Add ability to alter the response from the after save trigger [#5814](https://github.com/parse-community/parse-server/pull/5814), thanks to [BrunoMaurice](https://github.com/brunoMaurice) +- FIX: Cache apple public key for the case it fails to fetch again [#5848](https://github.com/parse-community/parse-server/pull/5848), thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: GraphQL Configuration Options [#5782](https://github.com/parse-community/parse-server/pull/5782), thanks to [Omair Vaiyani](https://github.com/omairvaiyani) +- NEW: Required fields and default values [#5835](https://github.com/parse-community/parse-server/pull/5835), thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- FIX: Postgres safely escape strings in nested objects [#5855](https://github.com/parse-community/parse-server/pull/5855), thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: Support PhantAuth authentication [#5850](https://github.com/parse-community/parse-server/pull/5850), thanks to [Ivan SZKIBA](https://github.com/szkiba) +- FIX: Remove uws package [#5860](https://github.com/parse-community/parse-server/pull/5860), thanks to [Zeal Murapa](https://github.com/GoGross) + +# [3.6.0](https://github.com/parse-community/parse-server/compare/3.5.0...3.6.0) + +- SECURITY FIX: Address [Security Advisory](https://github.com/parse-community/parse-server/security/advisories/GHSA-8w3j-g983-8jh5) of a potential [Enumeration Attack](https://www.owasp.org/index.php/Testing_for_User_Enumeration_and_Guessable_User_Account_(OWASP-AT-002)#Description_of_the_Issue) [73b0f9a](https://github.com/parse-community/parse-server/commit/73b0f9a339b81f5d757725dc557955a7b670a3ec), big thanks to [Fabian Strachanski](https://github.com/fastrde) for identifying the problem, creating a fix and following the [vulnerability disclosure guidelines](https://github.com/parse-community/parse-server/blob/master/SECURITY.md#parse-community-vulnerability-disclosure-program) +- NEW: Added rest option: excludeKeys [#5737](https://github.com/parse-community/parse-server/pull/5737), thanks to [Raschid J.F. Rafeally](https://github.com/RaschidJFR) +- FIX: LiveQuery create event with fields [#5790](https://github.com/parse-community/parse-server/pull/5790), thanks to [Diamond Lewis](https://github.com/dplewis) +- FIX: Generate sessionToken with linkWith [#5799](https://github.com/parse-community/parse-server/pull/5799), thanks to [Diamond Lewis](https://github.com/dplewis) + +# [3.5.0](https://github.com/parse-community/parse-server/compare/3.4.4...3.5.0) + +- NEW: GraphQL Support [#5674](https://github.com/parse-community/parse-server/pull/5674), thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) + +[GraphQL Guide](https://github.com/parse-community/parse-server#graphql) + +- NEW: Sign in with Apple [#5694](https://github.com/parse-community/parse-server/pull/5694), thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: AppSecret to Facebook Auth [#5695](https://github.com/parse-community/parse-server/pull/5695), thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: Postgres: Regex support foreign characters [#5598](https://github.com/parse-community/parse-server/pull/5598), thanks to [Jeff Gu Kang](https://github.com/JeffGuKang) +- FIX: Winston Logger string interpolation [#5729](https://github.com/parse-community/parse-server/pull/5729), thanks to [Diamond Lewis](https://github.com/dplewis) + +# [3.4.4](https://github.com/parse-community/parse-server/compare/3.4.3...3.4.4) + +Fix: Commit changes + +# [3.4.3](https://github.com/parse-community/parse-server/compare/3.4.2...3.4.3) + +Fix: Use changes in master to travis configuration to enable pushing to npm and gh_pages. See diff for details. + +# [3.4.2](https://github.com/parse-community/parse-server/compare/3.4.1...3.4.2) + +Fix: In my haste to get a [Security Fix](https://github.com/parse-community/parse-server/security/advisories/GHSA-2479-qvv7-47qq) out, I added [8709daf](https://github.com/parse-community/parse-server/commit/8709daf698ea69b59268cb66f0f7cee75b52daa5) to master instead of to 3.4.1. This commit fixes that. [Arthur Cinader](https://github.com/acinader) + +# [3.4.1](https://github.com/parse-community/parse-server/compare/3.4.0...3.4.1) + +Security Fix: see Advisory: [GHSA-2479-qvv7-47q](https://github.com/parse-community/parse-server/security/advisories/GHSA-2479-qvv7-47qq) for details [8709daf](https://github.com/parse-community/parse-server/commit/8709daf698ea69b59268cb66f0f7cee75b52daa5). Big thanks to: [Benjamin Simonsson](https://github.com/BenniPlejd) for identifying the issue and promptly bringing it to the Parse Community's attention and also big thanks to the indefatigable [Diamond Lewis](https://github.com/dplewis) for crafting a failing test and then a solution within an hour of the report. + +# [3.4.0](https://github.com/parse-community/parse-server/compare/3.3.0...3.4.0) +- NEW: Aggregate supports group by date fields [#5538](https://github.com/parse-community/parse-server/pull/5538) thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: API for Read Preferences [#3963](https://github.com/parse-community/parse-server/pull/3963) thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- NEW: Add Redis options for LiveQuery [#5584](https://github.com/parse-community/parse-server/pull/5584) thanks to [Diamond Lewis](https://github.com/dplewis) +- NEW: Add Direct Access option for Server Config [#5550](https://github.com/parse-community/parse-server/pull/5550) thanks to [Diamond Lewis](https://github.com/dplewis) +- FIX: updating mixed array in Postgres [#5552](https://github.com/parse-community/parse-server/pull/5552) thanks to [Diamond Lewis](https://github.com/dplewis) +- FIX: notEqualTo GeoPoint Query in Postgres [#5549](https://github.com/parse-community/parse-server/pull/5549), thanks to [Diamond Lewis](https://github.com/dplewis) +- FIX: put the timestamp back in logs that was lost after Winston upgrade [#5571](https://github.com/parse-community/parse-server/pull/5571), thanks to [Steven Rowe](https://github.com/mrowe009) and [Arthur Cinader](https://github.com/acinader) +- FIX: Validates permission before calling beforeSave [#5546](https://github.com/parse-community/parse-server/pull/5546), thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo) +- FIX: Remove userSensitiveFields default value. [#5588](https://github.com/parse-community/parse-server/pull/5588), thanks to [William George](https://github.com/awgeorge) +- FIX: Decode Date JSON value in LiveQuery. [#5540](https://github.com/parse-community/parse-server/pull/5540), thanks to [ananfang](https://github.com/ananfang) + + +# [3.3.0](https://github.com/parse-community/parse-server/compare/3.2.3...3.3.0) +- NEW: beforeLogin trigger with support for auth providers ([#5445](https://github.com/parse-community/parse-server/pull/5445)), thanks to [Omair Vaiyani](https://github.com/omairvaiyani) +- NEW: RFC 7662 compliant OAuth2 auth adapter ([#4910](https://github.com/parse-community/parse-server/pull/4910)), thanks to [MÃŧller Zsolt](https://github.com/zsmuller) +- FIX: cannot change password when maxPasswordHistory is 1 ([#5191](https://github.com/parse-community/parse-server/pull/5191)), thanks to [Tulsi Sapkota](https://github.com/Tolsee) +- FIX (Postgres): count being very slow on large Parse Classes' collections ([#5330](https://github.com/parse-community/parse-server/pull/5330)), thanks to [CoderickLamar](https://github.com/CoderickLamar) +- FIX: using per-key basis queue ([#5420](https://github.com/parse-community/parse-server/pull/5420)), thanks to [Georges Jamous](https://github.com/georgesjamous) +- FIX: issue on count with Geo constraints and mongo ([#5286](https://github.com/parse-community/parse-server/pull/5286)), thanks to [Julien QuÊrÊ](https://github.com/jlnquere) + +# [3.2.3](https://github.com/parse-community/parse-server/compare/3.2.2...3.2.3) +- Correct previous release with patch that is fully merged + +# [3.2.2](https://github.com/parse-community/parse-server/compare/3.2.1...3.2.2) +- Security fix to properly process userSensitiveFields when parse-server is started with + ../lib/cli/parse-server [#5463](https://github.com/parse-community/parse-server/pull/5463 + ) + +# [3.2.1](https://github.com/parse-community/parse-server/compare/3.2.0...3.2.1) +- Increment package.json version to match the deployment tag + +# [3.2.0](https://github.com/parse-community/parse-server/compare/3.1.3...3.2.0) +- NEW: Support accessing sensitive fields with an explicit ACL. Not documented yet, see [tests](https://github.com/parse-community/parse-server/blob/f2c332ea6a984808ad5b2e3ce34864a20724f72b/spec/UserPII.spec.js#L526) for examples +- Upgrade Parse SDK JS to 2.3.1 [#5457](https://github.com/parse-community/parse-server/pull/5457) +- Hides token contents in logStartupOptions if they arrive as a buffer [#6a9380](https://github.com/parse-community/parse-server/commit/6a93806c62205a56a8f4e3b8765848c552510337) +- Support custom message for password requirements [#5399](https://github.com/parse-community/parse-server/pull/5399) +- Support for Ajax password reset [#5332](https://github.com/parse-community/parse-server/pull/5332) +- Postgres: Refuse to build unsafe JSON lists for contains [#5337](https://github.com/parse-community/parse-server/pull/5337) +- Properly handle return values in beforeSave [#5228](https://github.com/parse-community/parse-server/pull/5228) +- Fixes issue when querying user roles [#5276](https://github.com/parse-community/parse-server/pull/5276) +- Fixes issue affecting update with CLP [#5269](https://github.com/parse-community/parse-server/pull/5269) + +# [3.1.3](https://github.com/parse-community/parse-server/compare/3.1.2...3.1.3) + +- Postgres: Fixes support for global configuration +- Postgres: Fixes support for numeric arrays +- Postgres: Fixes issue affecting queries on empty arrays +- LiveQuery: Adds support for transmitting the original object +- Queries: Use estimated count if query is empty +- Docker: Reduces the size of the docker image to 154Mb + + +# [3.1.2](https://github.com/parse-community/parse-server/compare/3.1.1...3.1.2) + +- Removes dev script, use TDD instead of server. +- Removes nodemon and problematic dependencies. +- Addressed event-stream security debacle. + +# [3.1.1](https://github.com/parse-community/parse-server/compare/3.1.0...3.1.1) + +### Improvements: +* Fixes issue that would prevent users with large number of roles to resolve all of them [Antoine Cormouls](https://github.com/Moumouls) (#5131, #5132) +* Fixes distinct query on special fields ([#5144](https://github.com/parse-community/parse-server/pull/5144)) + + +# [3.1.0](https://github.com/parse-community/parse-server/compare/3.0.0...3.1.0) + +#### Breaking Changes: +* Return success on sendPasswordResetEmail even if email not found. (#7fe4030) +### Security Fix: +* Expire password reset tokens on email change (#5104) +### Improvements: +* Live Query CLPs (#4387) +* Reduces number of calls to injectDefaultSchema (#5107) +* Remove runtime dependency on request (#5076) +### Bug fixes: +* Fixes issue with vkontatke authentication (#4977) +* Use the correct function when validating google auth tokens (#5018) +* fix unexpected 'delete' trigger issue on LiveQuery (#5031) +* Improves performance for roles and ACL's in live query server (#5126) + + +# [3.0.0](https://github.com/parse-community/parse-server/compare/2.8.4...3.0.0) + +`parse-server` 3.0.0 comes with brand new handlers for cloud code. It now fully supports promises and async / await. +For more informations, visit the v3.0.0 [migration guide](https://github.com/parse-community/parse-server/blob/master/3.0.0.md). + +#### Breaking Changes: +* Cloud Code handlers have a new interface based on promises. +* response.success / response.error are removed in Cloud Code +* Cloud Code runs with Parse-SDK 2.0 +* The aggregate now require aggregates to be passed in the form: `{"pipeline": [...]}` (REST Only) + +### Improvements: +* Adds Pipeline Operator to Aggregate Router. +* Adds documentations for parse-server's adapters, constructors and more. +* Adds ability to pass a context object between `beforeSave` and `afterSave` affecting the same object. + +### Bug Fixes: +* Fixes issue that would crash the server when mongo objects had undefined values [#4966](https://github.com/parse-community/parse-server/issues/4966) +* Fixes issue that prevented ACL's from being used with `select` (see [#571](https://github.com/parse-community/Parse-SDK-JS/issues/571)) + +### Dependency updates: +* [@parse/simple-mailgun-adapter@1.1.0](https://www.npmjs.com/package/@parse/simple-mailgun-adapter) +* [mongodb@3.1.3](https://www.npmjs.com/package/mongodb) +* [request@2.88.0](https://www.npmjs.com/package/request) + +### Development Dependencies Updates: +* [@parse/minami@1.0.0](https://www.npmjs.com/package/@parse/minami) +* [deep-diff@1.0.2](https://www.npmjs.com/package/deep-diff) +* [flow-bin@0.79.0](https://www.npmjs.com/package/flow-bin) +* [jsdoc@3.5.5](https://www.npmjs.com/package/jsdoc) +* [jsdoc-babel@0.4.0](https://www.npmjs.com/package/jsdoc-babel) + +# [2.8.4](https://github.com/parse-community/parse-server/compare/2.8.3...2.8.4) + +#### Improvements: +* Adds ability to forward errors to express handler (#4697) +* Adds ability to increment the push badge with an arbitrary value (#4889) +* Adds ability to preserve the file names when uploading (#4915) +* `_User` now follow regular ACL policy. Letting administrator lock user out. (#4860) and (#4898) +* Ensure dates are properly handled in aggregates (#4743) +* Aggregates: Improved support for stages sharing the same name +* Add includeAll option +* Added verify password to users router and tests. (#4747) +* Ensure read preference is never overriden, so DB config prevails (#4833) +* add support for geoWithin.centerSphere queries via withJSON (#4825) +* Allow sorting an object field (#4806) +* Postgres: Don't merge JSON fields after save() to keep same behaviour as MongoDB (#4808) (#4815) + +#### Dependency updates +* [commander@2.16.0](https://www.npmjs.com/package/commander) +* [mongodb@3.1.1](https://www.npmjs.com/package/mongodb) +* [pg-promise@8.4.5](https://www.npmjs.com/package/pg-promise) +* [ws@6.0.0](https://www.npmjs.com/package/ws) +* [bcrypt@3.0.0](https://www.npmjs.com/package/bcrypt) +* [uws@10.148.1](https://www.npmjs.com/package/uws) + +##### Development Dependencies Updates: +* [cross-env@5.2.0](https://www.npmjs.com/package/cross-env) +* [eslint@5.0.0](https://www.npmjs.com/package/eslint) +* [flow-bin@0.76.0](https://www.npmjs.com/package/flow-bin) +* [mongodb-runner@4.0.0](https://www.npmjs.com/package/mongodb-runner) +* [nodemon@1.18.1](https://www.npmjs.com/package/nodemon) +* [nyc@12.0.2](https://www.npmjs.com/package/nyc) +* [request-promise@4.2.2](https://www.npmjs.com/package/request-promise) +* [supports-color@5.4.0](https://www.npmjs.com/package/supports-color) + +# [2.8.3](https://github.com/parse-community/parse-server/compare/2.8.2...2.8.3) + +#### Improvements: + +* Adds support for JS SDK 2.0 job status header +* Removes npm-git scripts as npm supports using git repositories that build, thanks to [Florent Vilmart](https://github.com/flovilmart) + + +# [2.8.2](https://github.com/parse-community/parse-server/compare/2.8.1...2.8.2) + +##### Bug Fixes: +* Ensure legacy users without ACL's are not locked out, thanks to [Florent Vilmart](https://github.com/flovilmart) + +#### Improvements: +* Use common HTTP agent to increase webhooks performance, thanks to [Tyler Brock](https://github.com/TylerBrock) +* Adds withinPolygon support for Polygon objects, thanks to [Mads Bjerre](https://github.com/madsb) + +#### Dependency Updates: +* [ws@5.2.0](https://www.npmjs.com/package/ws) +* [commander@2.15.1](https://www.npmjs.com/package/commander) +* [nodemon@1.17.5](https://www.npmjs.com/package/nodemon) + +##### Development Dependencies Updates: +* [flow-bin@0.73.0](https://www.npmjs.com/package/flow-bin) +* [cross-env@5.1.6](https://www.npmjs.com/package/cross-env) +* [gaze@1.1.3](https://www.npmjs.com/package/gaze) +* [deepcopy@1.0.0](https://www.npmjs.com/package/deepcopy) +* [deep-diff@1.0.1](https://www.npmjs.com/package/deep-diff) + + +# [2.8.1](https://github.com/parse-community/parse-server/compare/2.8.1...2.8.0) + +Ensure all the files are properly exported to the final package. + +# [2.8.0](https://github.com/parse-community/parse-server/compare/2.8.0...2.7.4) + +#### New Features +* Adding Mongodb element to add `arrayMatches` the #4762 (#4766), thanks to [JÊrÊmy Piednoel](https://github.com/jeremypiednoel) +* Adds ability to Lockout users (#4749), thanks to [Florent Vilmart](https://github.com/flovilmart) + +#### Bug fixes: +* Fixes issue when using afterFind with relations (#4752), thanks to [Florent Vilmart](https://github.com/flovilmart) +* New query condition support to match all strings that starts with some other given strings (#3864), thanks to [Eduard Bosch Bertran](https://github.com/eduardbosch) +* Allow creation of indices on default fields (#4738), thanks to [Claire Neveu](https://github.com/ClaireNeveu) +* Purging empty class (#4676), thanks to [Diamond Lewis](https://github.com/dplewis) +* Postgres: Fixes issues comparing to zero or false (#4667), thanks to [Diamond Lewis](https://github.com/dplewis) +* Fix Aggregate Match Pointer (#4643), thanks to [Diamond Lewis](https://github.com/dplewis) + +#### Improvements: +* Allow Parse.Error when returning from Cloud Code (#4695), thanks to [Saulo Tauil](https://github.com/saulogt) +* Fix typo: "requrest" -> "request" (#4761), thanks to [Joseph Frazier](https://github.com/josephfrazier) +* Send version for Vkontakte API (#4725), thanks to [oleg](https://github.com/alekoleg) +* Ensure we respond with invalid password even if email is unverified (#4708), thanks to [dblythy](https://github.com/dblythy) +* Add _password_history to default sensitive data (#4699), thanks to [Jong Eun Lee](https://github.com/yomybaby) +* Check for node version in postinstall script (#4657), thanks to [Diamond Lewis](https://github.com/dplewis) +* Remove FB Graph API version from URL to use the oldest non deprecated version, thanks to [SebC](https://github.com/SebC99) + +#### Dependency Updates: +* [@parse/push-adapter@2.0.3](https://www.npmjs.com/package/@parse/push-adapter) +* [@parse/simple-mailgun-adapter@1.0.2](https://www.npmjs.com/package/@parse/simple-mailgun-adapter) +* [uws@10.148.0](https://www.npmjs.com/package/uws) +* [body-parser@1.18.3](https://www.npmjs.com/package/body-parser) +* [mime@2.3.1](https://www.npmjs.com/package/mime) +* [request@2.85.0](https://www.npmjs.com/package/request) +* [mongodb@3.0.7](https://www.npmjs.com/package/mongodb) +* [bcrypt@2.0.1](https://www.npmjs.com/package/bcrypt) +* [ws@5.1.1](https://www.npmjs.com/package/ws) + +##### Development Dependencies Updates: +* [cross-env@5.1.5](https://www.npmjs.com/package/cross-env) +* [flow-bin@0.71.0](https://www.npmjs.com/package/flow-bin) +* [deep-diff@1.0.0](https://www.npmjs.com/package/deep-diff) +* [nodemon@1.17.3](https://www.npmjs.com/package/nodemon) + + +# [2.7.4](https://github.com/parse-community/parse-server/compare/2.7.4...2.7.3) + +#### Bug Fixes: +* Fixes an issue affecting polygon queries, thanks to [Diamond Lewis](https://github.com/dplewis) + +#### Dependency Updates: +* [pg-promise@8.2.1](https://www.npmjs.com/package/pg-promise) + +##### Development Dependencies Updates: +* [nodemon@1.17.1](https://www.npmjs.com/package/nodemon) + +# [2.7.3](https://github.com/parse-community/parse-server/compare/2.7.3...2.7.2) + +#### Improvements: +* Improve documentation for LiveQuery options, thanks to [Arthur Cinader](https://github.com/acinader) +* Improve documentation for using cloud code with docker, thanks to [Stephen Tuso](https://github.com/stephentuso) +* Adds support for Facebook's AccountKit, thanks to [6thfdwp](https://github.com/6thfdwp) +* Disable afterFind routines when running aggregates, thanks to [Diamond Lewis](https://github.com/dplewis) +* Improve support for distinct aggregations of nulls, thanks to [Diamond Lewis](https://github.com/dplewis) +* Regenreate the email verification token when requesting a new email, thanks to [Benjamin Wilson Friedman](https://github.com/montymxb) + +#### Bug Fixes: +* Fix issue affecting readOnly masterKey and purge command, thanks to [AreyouHappy](https://github.com/AreyouHappy) +* Fixes Issue unsetting in beforeSave doesn't allow object creation, thanks to [Diamond Lewis](https://github.com/dplewis) +* Fixes issue crashing server on invalid live query payload, thanks to [fridays](https://github.com/fridays) +* Fixes issue affecting postgres storage adapter "undefined property '__op'", thanks to [Tyson Andre](https://github,com/TysonAndre) + +#### Dependency Updates: +* [winston@2.4.1](https://www.npmjs.com/package/winston) +* [pg-promise@8.2.0](https://www.npmjs.com/package/pg-promise) +* [commander@2.15.0](https://www.npmjs.com/package/commander) +* [lru-cache@4.1.2](https://www.npmjs.com/package/lru-cache) +* [parse@1.11.1](https://www.npmjs.com/package/parse) +* [ws@5.0.0](https://www.npmjs.com/package/ws) +* [mongodb@3.0.4](https://www.npmjs.com/package/mongodb) +* [lodash@4.17.5](https://www.npmjs.com/package/lodash) + +##### Development Dependencies Updates: +* [cross-env@5.1.4](https://www.npmjs.com/package/cross-env) +* [flow-bin@0.67.1](https://www.npmjs.com/package/flow-bin) +* [jasmine@3.1.0](https://www.npmjs.com/package/jasmine) +* [parse@1.11.1](https://www.npmjs.com/package/parse) +* [babel-eslint@8.2.2](https://www.npmjs.com/package/babel-eslint) +* [nodemon@1.15.0](https://www.npmjs.com/package/nodemon) + +# [2.7.2](https://github.com/parse-community/parse-server/compare/2.7.2...2.7.1) + +#### Improvements: +* Improved match aggregate +* Do not mark the empty push as failed +* Support pointer in aggregate query +* Introduces flow types for storage +* Postgres: Refactoring of Postgres Storage Adapter +* Postgres: Support for multiple projection in aggregate +* Postgres: performance optimizations +* Adds infos about vulnerability disclosures +* Adds ability to login with email when provided as username + +#### Bug Fixes +* Scrub Passwords with URL Encoded Characters +* Fixes issue affecting using sorting in beforeFind + +#### Dependency Updates: +* [commander@2.13.0](https://www.npmjs.com/package/commander) +* [semver@5.5.0](https://www.npmjs.com/package/semver) +* [pg-promise@7.4.0](https://www.npmjs.com/package/pg-promise) +* [ws@4.0.0](https://www.npmjs.com/package/ws) +* [mime@2.2.0](https://www.npmjs.com/package/mime) +* [parse@1.11.0](https://www.npmjs.com/package/parse) + +##### Development Dependencies Updates: +* [nodemon@1.14.11](https://www.npmjs.com/package/nodemon) +* [flow-bin@0.64.0](https://www.npmjs.com/package/flow-bin) +* [jasmine@2.9.0](https://www.npmjs.com/package/jasmine) +* [cross-env@5.1.3](https://www.npmjs.com/package/cross-env) + +# [2.7.1](https://github.com/parse-community/parse-server/compare/2.7.1...2.7.0) + +:warning: Fixes a security issue affecting Class Level Permissions + +* Adds support for dot notation when using matchesKeyInQuery, thanks to [Henrik](https://github.com/bohemima) and [Arthur Cinader](https://github.com/acinader) + +# [2.7.0](https://github.com/parse-community/parse-server/compare/2.7.0...2.6.5) + +:warning: This version contains an issue affecting Class Level Permissions on mongoDB. Please upgrade to 2.7.1. + +Starting parse-server 2.7.0, the minimun nodejs version is 6.11.4, please update your engines before updating parse-server + +#### New Features: +* Aggregation endpoints, thanks to [Diamond Lewis](https://github.com/dplewis) +* Adds indexation options onto Schema endpoints, thanks to [Diamond Lewis](https://github.com/dplewis) + +#### Bug fixes: +* Fixes sessionTokens being overridden in 'find' (#4332), thanks to [Benjamin Wilson Friedman](https://github.com/montymxb) +* Proper `handleShutdown()` feature to close database connections (#4361), thanks to [CHANG, TZU-YEN](https://github.com/trylovetom) +* Fixes issue affecting state of _PushStatus objects, thanks to [Benjamin Wilson Friedman](https://github.com/montymxb) +* Fixes issue affecting calling password reset password pages with wrong appid, thanks to [Bryan de Leon](https://github.com/bryandel) +* Fixes issue affecting duplicates _Sessions on successive logins, thanks to [Florent Vilmart](https://github.com/flovilmart) + +#### Improvements: +* Updates contributing guides, and improves windows support, thanks to [Addison Elliott](https://github.com/addisonelliott) +* Uses new official scoped packaged, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Improves health checks responses, thanks to [Benjamin Wilson Friedman](https://github.com/montymxb) +* Add password confirmation to choose_password, thanks to [Worathiti Manosroi](https://github.com/pungme) +* Improve performance of relation queries, thanks to [Florent Vilmart](https://github.com/flovilmart) + +#### Dependency Updates: +* [commander@2.12.1](https://www.npmjs.com/package/commander) +* [ws@3.3.2](https://www.npmjs.com/package/ws) +* [uws@9.14.0](https://www.npmjs.com/package/uws) +* [pg-promise@7.3.2](https://www.npmjs.com/package/pg-promise) +* [parse@1.10.2](https://www.npmjs.com/package/parse) +* [pg-promise@7.3.1](https://www.npmjs.com/package/pg-promise) + +##### Development Dependencies Updates: +* [cross-env@5.1.1](https://www.npmjs.com/package/cross-env) + + + +# [2.6.5](https://github.com/ParsePlatform/parse-server/compare/2.6.5...2.6.4) + +#### New Features: +* Adds support for read-only masterKey, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Adds support for relative time queries (mongodb only), thanks to [Marvel Mathew](https://github.com/marvelm) + +#### Improvements: +* Handle possible afterSave exception, thanks to [Benjamin Wilson Friedman](https://github.com/montymxb) +* Add support for expiration interval in Push, thanks to [Marvel Mathew](https://github.com/marvelm) + +#### Bug Fixes: +* The REST API key was improperly inferred from environment when using the CLI, thanks to [Florent Vilmart](https://github.com/flovilmart) + +# [2.6.4](https://github.com/ParsePlatform/parse-server/compare/2.6.4...2.6.3) + +#### Improvements: +* Improves management of configurations and default values, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Adds ability to start ParseServer with `ParseServer.start(options)`, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Adds request original IP to cloud code hooks, thanks to [Gustav Ahlberg](https://github.com/Gyran) +* Corrects some outdated links, thanks to [Benjamin Wilson Friedman](https://github.com/montymxb) +* Adds serverURL validation on startup, thanks to [Benjamin Wilson Friedman](https://github.com/montymxb) +* Adds ability to login with POST requests alongside GET, thanks to [Benjamin Wilson Friedman](https://github.com/montymxb) +* Adds ability to login with email, instead of username, thanks to [Florent Vilmart](https://github.com/flovilmart) + +#### Bug Fixes: +* Fixes issue affecting beforeSaves and increments, thanks to [Benjamin Wilson Friedman](https://github.com/montymxb) + +#### Dependency Updates: +* [parse-server-push-adapter@2.0.2](https://www.npmjs.com/package/parse-server-push-adapter) +* [semver@5.4.1](https://www.npmjs.com/package/semver) +* [pg-promise@7.0.3](https://www.npmjs.com/package/pg-promise) +* [mongodb@2.2.33](https://www.npmjs.com/package/mongodb) +* [parse@1.10.1](https://www.npmjs.com/package/parse) +* [express@4.16.0](https://www.npmjs.com/package/express) +* [mime@1.4.1](https://www.npmjs.com/package/mime) +* [parse-server-simple-mailgun-adapter@1.0.1](https://www.npmjs.com/package/parse-server-simple-mailgun-adapter) + +##### Development Dependencies Updates: +* [babel-preset-env@1.6.1](https://www.npmjs.com/package/babel-preset-env) +* [cross-env@5.1.0](https://www.npmjs.com/package/cross-env) +* [mongodb-runner@3.6.1](https://www.npmjs.com/package/mongodb-runner) +* [eslint-plugin-flowtype@2.39.1](https://www.npmjs.com/package/eslint-plugin-flowtype) +* [eslint@4.9.0](https://www.npmjs.com/package/eslint) + +# [2.6.3](https://github.com/ParsePlatform/parse-server/compare/2.6.2...2.6.3) + +#### Improvements: +* Queries on Pointer fields with `$in` and `$nin` now supports list of objectId's, thanks to [Florent Vilmart](https://github.com/flovilmart) +* LiveQueries on `$in` and `$nin` for pointer fields work as expected thanks to [Florent Vilmart](https://github.com/flovilmart) +* Also remove device token when APNS error is BadDeviceToken, thanks to [Mauricio Tollin](https://github.com/) +* LRU cache is not available on the ParseServer object, thanks to [Tyler Brock](https://github.com/tbrock) +* Error messages are more expressive, thanks to [Tyler Brock](https://github.com/tbrock) +* Postgres: Properly handle undefined field values, thanks to [Diamond Lewis](https://github.com/dlewis) +* Updating with two GeoPoints fails correctly, thanks to [Anthony Mosca](https://github.com/aontas) + +#### New Features: +* Adds ability to set a maxLimit on server configuration for queries, thanks to [Chris Norris](https://github.com/) + +#### Bug fixes: +* Fixes issue affecting reporting `_PushStatus` with misconfigured serverURL, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fixes issue affecting deletion of class that doesn't exist, thanks to [Diamond Lewis](https://github.com/dlewis) + +#### Dependency Updates: +* [winston@2.4.0](https://www.npmjs.com/package/winston) +* [pg-promise@6.10.2](https://www.npmjs.com/package/pg-promise) +* [winston-daily-rotate-file@1.6.0](https://www.npmjs.com/package/winston-daily-rotate-file) +* [request@2.83.0](https://www.npmjs.com/package/request) +* [body-parser@1.18.2](https://www.npmjs.com/package/body-parser) + +##### Development Dependencies Updates: +* [request-promise@4.2.2](https://www.npmjs.com/package/request-promise) +* [eslint@4.7.1](https://www.npmjs.com/package/eslint) + +# [2.6.2](https://github.com/ParsePlatform/parse-server/compare/2.6.1...2.6.2) + +#### Improvements: +* PushWorker/PushQueue channels are properly prefixed with the Parse applicationId, thanks to [Marvel Mathew](https://github.com/marvelm) +* You can use Parse.Cloud.afterSave hooks on _PushStatus +* You can use Parse.Cloud.onLiveQueryEvent to track the number of clients and subscriptions +* Adds support for more fields from the Audience class. + +#### New Features: +* Push: Adds ability to track sentPerUTC offset if your push scheduler supports it. +* Push: Adds support for cleaning up invalid deviceTokens from _Installation (PARSE_SERVER_CLEANUP_INVALID_INSTALLATIONS=1). + +#### Dependency Updates: +* [ws@3.2.0](https://www.npmjs.com/package/ws) +* [pg-promise@6.5.3](https://www.npmjs.com/package/pg-promise) +* [winston-daily-rotate-file@1.5.0](https://www.npmjs.com/package/winston-daily-rotate-file) +* [body-parser@1.18.1](https://www.npmjs.com/package/body-parser) + +##### Development Dependencies Updates: +* [nodemon@1.12.1](https://www.npmjs.com/package/nodemon) +* [mongodb-runner@3.6.0](https://www.npmjs.com/package/mongodb-runner) +* [babel-eslint@8.0.0](https://www.npmjs.com/package/babel-eslint) + +# [2.6.1](https://github.com/ParsePlatform/parse-server/compare/2.6.0...2.6.1) + +#### Improvements: +* Improves overall performance of the server, more particularly with large query results. +* Improves performance of InMemoryCacheAdapter by removing serialization. +* Improves logging performance by skipping necessary log calls. +* Refactors object routers to simplify logic. +* Adds automatic indexing on $text indexes, thanks to [Diamon Lewis](https://github.com/dplewis) + +#### New Features: +* Push: Adds ability to send localized pushes according to the _Installation localeIdentifier +* Push: proper support for scheduling push in user's locale time, thanks to [Marvel Mathew](https://github.com/marvelm) +* LiveQuery: Adds ability to use LiveQuery with a masterKey, thanks to [Jeremy May](https://github.com/kenishi) + +#### Bug Fixes: +* Fixes an issue that would duplicate Session objects per userId-installationId pair. +* Fixes an issue affecting pointer permissions introduced in this release. +* Fixes an issue that would prevent displaying audiences correctly in dashboard. +* Fixes an issue affecting preventLoginWithUnverifiedEmail upon signups. + +#### Dependency Updates: +* [pg-promise@6.3.2](https://www.npmjs.com/package/pg-promise) +* [body-parser@1.18.0](https://www.npmjs.com/package/body-parser) +* [nodemon@1.11.1](https://www.npmjs.com/package/nodemon) + +##### Development Dependencies Updates: +* [babel-cli@6.26.0](https://www.npmjs.com/package/babel-cli) + +# [2.6.0](https://github.com/ParsePlatform/parse-server/compare/2.5.3...2.6.0) + +##### Breaking Changes: +* [parse-server-s3-adapter@1.2.0](https://www.npmjs.com/package/parse-server-s3-adapter): A new deprecation notice is introduced with parse-server-s3-adapter's version 1.2.0. An upcoming release will remove passing key and password arguments. AWS credentials should be set using AWS best practices. See the [Deprecation Notice for AWS credentials]( https://github.com/parse-server-modules/parse-server-s3-adapter/blob/master/README.md#deprecation-notice----aws-credentials) section of the adapter's README. + +#### New Features +* Polygon is fully supported as a type, thanks to [Diamond Lewis](https://github.com/dplewis) +* Query supports PolygonContains, thanks to [Diamond Lewis](https://github.com/dplewis) + +#### Improvements +* Postgres: Adds support nested contains and containedIn, thanks to [Diamond Lewis](https://github.com/dplewis) +* Postgres: Adds support for `null` in containsAll queries, thanks to [Diamond Lewis](https://github.com/dplewis) +* Cloud Code: Request headers are passed to the cloud functions, thanks to [miguel-s](https://github.com/miguel-s) +* Push: All push queries now filter only where deviceToken exists + +#### Bug Fixes: +* Fixes issue affecting updates of _User objects when authData was passed. +* Push: Pushing to an empty audience should now properly report a failed _PushStatus +* Linking Users: Fixes issue affecting linking users with sessionToken only + +#### Dependency Updates: +* [ws@3.1.0](https://www.npmjs.com/package/ws) +* [mime@1.4.0](https://www.npmjs.com/package/mime) +* [semver@5.4.0](https://www.npmjs.com/package/semver) +* [uws@8.14.1](https://www.npmjs.com/package/uws) +* [bcrypt@1.0.3](https://www.npmjs.com/package/bcrypt) +* [mongodb@2.2.31](https://www.npmjs.com/package/mongodb) +* [redis@2.8.0](https://www.npmjs.com/package/redis) +* [pg-promise@6.3.1](https://www.npmjs.com/package/pg-promise) +* [commander@2.11.0](https://www.npmjs.com/package/commander) + +##### Development Dependencies Updates: +* [jasmine@2.8.0](https://www.npmjs.com/package/jasmine) +* [babel-register@6.26.0](https://www.npmjs.com/package/babel-register) +* [babel-core@6.26.0](https://www.npmjs.com/package/babel-core) +* [cross-env@5.0.2](https://www.npmjs.com/package/cross-env) + +# [2.5.3](https://github.com/ParsePlatform/parse-server/compare/2.5.2...2.5.3) + +#### New Features: +* badge property on android installations will now be set as on iOS (#3970), thanks to [Florent Vilmart](https://github.com/flovilmart) + +#### Bug Fixes: +* Fixes incorrect number parser for cache options + +# [2.5.2](https://github.com/ParsePlatform/parse-server/compare/2.5.1...2.5.2) + +#### Improvements: +* Restores ability to run on node >= 4.6 +* Adds ability to configure cache from CLI +* Removes runtime check for node >= 4.6 + +# [2.5.1](https://github.com/ParsePlatform/parse-server/compare/2.5.0...2.5.1) + +#### New Features: +* Adds ability to set default objectId size (#3950), thanks to [Steven Shipton](https://github.com/steven-supersolid) + +#### Improvements: +* Uses LRU cache instead of InMemoryCache by default (#3979), thanks to [Florent Vilmart](https://github.com/flovilmart) +* iOS pushes are now using HTTP/2.0 instead of binary API (#3983), thanks to [Florent Vilmart](https://github.com/flovilmart) + +#### Dependency Updates: +* [parse@1.10.0](https://www.npmjs.com/package/parse) +* [pg-promise@6.3.0](https://www.npmjs.com/package/pg-promise) +* [parse-server-s3-adapter@1.1.0](https://www.npmjs.com/package/parse-server-s3-adapter) +* [parse-server-push-adapter@2.0.0](https://www.npmjs.com/package/parse-server-push-adapter) + +# [2.5.0](https://github.com/ParsePlatform/parse-server/compare/2.4.2...2.5.0) + +#### New Features: +* Adds ability to run full text search (#3904), thanks to [Diamond Lewis](https://github.com/dplewis) +* Adds ability to run `$withinPolygon` queries (#3889), thanks to [Diamond Lewis](https://github.com/dplewis) +* Adds ability to pass read preference per query with mongodb (#3865), thanks to [davimacedo](https://github.com/davimacedo) +* beforeFind trigger now includes `isGet` for get queries (#3862), thanks to [davimacedo](https://github.com/davimacedo) +* Adds endpoints for dashboard's audience API (#3861), thanks to [davimacedo](https://github.com/davimacedo) +* Restores the job scheduling endpoints (#3927), thanks to [Florent Vilmart](https://github.com/flovilmart) + +#### Improvements: +* Removes unnecessary warning when using maxTimeMs with mongodb, thanks to [Tyler Brock](https://github.com/tbrock) +* Improves access control on system classes (#3916), thanks to [Worathiti Manosroi](https://github.com/pungme) +* Adds bytes support in postgres (#3894), thanks to [Diamond Lewis](https://github.com/dplewis) + +#### Bug Fixes: +* Fixes issue with vkontakte adapter that would hang the request, thanks to [Denis Trofimov](https://github.com/denistrofimov) +* Fixes issue affecting null relational data (#3924), thanks to [davimacedo](https://github.com/davimacedo) +* Fixes issue affecting session token deletion (#3937), thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fixes issue affecting the serverInfo endpoint (#3933), thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fixes issue affecting beforeSave with dot-noted sub-documents (#3912), thanks to [IlyaDiallo](https://github.com/IlyaDiallo) +* Fixes issue affecting emails being sent when using a 3rd party auth (#3882), thanks to [davimacedo](https://github.com/davimacedo) + +#### Dependency Updates: +* [commander@2.10.0](https://www.npmjs.com/package/commander) +* [pg-promise@5.9.7](https://www.npmjs.com/package/pg-promise) +* [lru-cache@4.1.0](https://www.npmjs.com/package/lru-cache) +* [mongodb@2.2.28](https://www.npmjs.com/package/mongodb) + +##### Development dependencies +* [babel-core@6.25.0](https://www.npmjs.com/package/babel-core) +* [cross-env@5.0.1](https://www.npmjs.com/package/cross-env) +* [nyc@11.0.2](https://www.npmjs.com/package/nyc) + +# [2.4.2](https://github.com/ParsePlatform/parse-server/compare/2.4.1...2.4.2) + +#### New Features: +* ParseQuery: Support for withinPolygon [#3866](https://github.com/parse-community/parse-server/pull/3866), thanks to [Diamond Lewis](https://github.com/dplewis) + +#### Improvements: +* Postgres: Use transactions when deleting a class, [#3869](https://github.com/parse-community/parse-server/pull/3836), thanks to [Vitaly Tomilov](https://github.com/vitaly-t) +* Postgres: Proper support for GeoPoint equality query, [#3874](https://github.com/parse-community/parse-server/pull/3836), thanks to [Diamond Lewis](https://github.com/dplewis) +* beforeSave and liveQuery will be correctly triggered on email verification [#3851](https://github.com/parse-community/parse-server/pull/3851), thanks to [Florent Vilmart](https://github.com/flovilmart) + +#### Bug fixes: +* Skip authData validation if it hasn't changed, on PUT requests [#3872](https://github.com/parse-community/parse-server/pull/3872), thanks to [Florent Vilmart](https://github.com/flovilmart) + +#### Dependency Updates: +* [mongodb@2.2.27](https://www.npmjs.com/package/mongodb) +* [pg-promise@5.7.2](https://www.npmjs.com/package/pg-promise) + + +# [2.4.1](https://github.com/ParsePlatform/parse-server/compare/2.4.0...2.4.1) + +#### Bug fixes: +* Fixes issue affecting relation updates ([#3835](https://github.com/parse-community/parse-server/pull/3835), [#3836](https://github.com/parse-community/parse-server/pull/3836)), thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fixes issue affecting sending push notifications, thanks to [Felipe Andrade](https://github.com/felipemobile) +* Session are always cleared when updating the passwords ([#3289](https://github.com/parse-community/parse-server/pull/3289), [#3821](https://github.com/parse-community/parse-server/pull/3821), thanks to [Florent Vilmart](https://github.com/flovilmart) + +#### Dependency Updates: +* [body-parser@1.17.2](https://www.npmjs.com/package/body-parser) +* [pg-promise@5.7.1](https://www.npmjs.com/package/pg-promise) +* [ws@3.0.0](https://www.npmjs.com/package/ws) + + +# [2.4.0](https://github.com/ParsePlatform/parse-server/compare/2.3.8...2.4.0) + +Starting 2.4.0, parse-server is tested against node 6.10 and 7.10, mongodb 3.2 and 3.4. +If you experience issues with older versions, please [open a issue](https://github.com/parse-community/parse-server/issues). + +#### New Features: +* Adds `count` Class Level Permission ([#3814](https://github.com/parse-community/parse-server/pull/3814)), thanks to [Florent Vilmart](https://github.com/flovilmart) +* Proper graceful shutdown support ([#3786](https://github.com/parse-community/parse-server/pull/3786)), thanks to [Florent Vilmart](https://github.com/flovilmart) +* Let parse-server store as `scheduled` Push Notifications with push_time (#3717, #3722), thanks to [Felipe Andrade](https://github.com/felipemobile) + +#### Improvements +* Parse-Server images are built through docker hub, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Skip authData validation if it hasn't changed, thanks to [Florent Vilmart](https://github.com/flovilmart) +* [postgres] Improve performance when adding many new fields to the Schema ([#3740](https://github.com/parse-community/parse-server/pull/3740)), thanks to [Paulo Vítor S Reis](https://github.com/paulovitin) +* Test maintenance, wordsmithing and nits ([#3744](https://github.com/parse-community/parse-server/pull/3744)), thanks to [Arthur Cinader](https://github.com/acinader) + +#### Bug Fixes: +* [postgres] Fixes issue affecting deleting multiple fields of a Schema ([#3734](https://github.com/parse-community/parse-server/pull/3734), [#3735](https://github.com/parse-community/parse-server/pull/3735)), thanks to [Paulo Vítor S Reis](https://github.com/paulovitin) +* Fix issue affecting _PushStatus state ([#3808](https://github.com/parse-community/parse-server/pull/3808)), thanks to [Florent Vilmart](https://github.com/flovilmart) +* requiresAuthentication Class Level Permission behaves correctly, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Email Verification related fields are not exposed ([#3681](https://github.com/parse-community/parse-server/pull/3681), [#3393](https://github.com/parse-community/parse-server/pull/3393), [#3432](https://github.com/parse-community/parse-server/pull/3432)), thanks to [Anthony Mosca](https://github.com/aontas) +* HTTP query parameters are properly obfuscated in logs ([#3793](https://github.com/parse-community/parse-server/pull/3793), [#3789](https://github.com/parse-community/parse-server/pull/3789)), thanks to [@youngerong](https://github.com/youngerong) +* Improve handling of `$near` operators in `$or` queries ([#3767](https://github.com/parse-community/parse-server/pull/3767), [#3798](https://github.com/parse-community/parse-server/pull/3798)), thanks to [Jack Wearden](https://github.com/NotBobTheBuilder) +* Fix issue affecting arrays of pointers ([#3169](https://github.com/parse-community/parse-server/pull/3169)), thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fix issue affecting overloaded query constraints ([#3723](https://github.com/parse-community/parse-server/pull/3723), [#3678](https://github.com/parse-community/parse-server/pull/3678)), thanks to [Florent Vilmart](https://github.com/flovilmart) +* Properly catch unhandled rejections in _Installation updates ([#3795](https://github.com/parse-community/parse-server/pull/3795)), thanks to [kahoona77](https://github.com/kahoona77) + +#### Dependency Updates: + +* [uws@0.14.5](https://www.npmjs.com/package/uws) +* [mime@1.3.6](https://www.npmjs.com/package/mime) +* [mongodb@2.2.26](https://www.npmjs.com/package/mongodb) +* [pg-promise@5.7.0](https://www.npmjs.com/package/pg-promise) +* [semver@5.3.0](https://www.npmjs.com/package/semver) + +##### Development dependencies +* [babel-cli@6.24.1](https://www.npmjs.com/package/babel-cli) +* [babel-core@6.24.1](https://www.npmjs.com/package/babel-core) +* [babel-preset-es2015@6.24.1](https://www.npmjs.com/package/babel-preset-es2015) +* [babel-preset-stage-0@6.24.1](https://www.npmjs.com/package/babel-preset-stage-0) +* [babel-register@6.24.1](https://www.npmjs.com/package/babel-register) +* [cross-env@5.0.0](https://www.npmjs.com/package/cross-env) +* [deep-diff@0.3.8](https://www.npmjs.com/package/deep-diff) +* [gaze@1.1.2](https://www.npmjs.com/package/gaze) +* [jasmine@2.6.0](https://www.npmjs.com/package/jasmine) +* [jasmine-spec-reporter@4.1.0](https://www.npmjs.com/package/jasmine-spec-reporter) +* [mongodb-runner@3.5.0](https://www.npmjs.com/package/mongodb-runner) +* [nyc@10.3.2](https://www.npmjs.com/package/nyc) +* [request-promise@4.2.1](https://www.npmjs.com/package/request-promise) + + +# [2.3.8](https://github.com/ParsePlatform/parse-server/compare/2.3.7...2.3.8) + +#### New Features +* Support for PG-Promise options, thanks to [ren dong](https://github.com/rendongsc) + +#### Improvements +* Improves support for graceful shutdown, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Improves configuration validation for Twitter Authentication, thanks to [Benjamin Wilson Friedman](https://github.com/montymxb) + +#### Bug Fixes +* Fixes issue affecting GeoPoint __type with Postgres, thanks to [zhoul-HS](https://github.com/zhoul-HS) +* Prevent user creation if username or password is empty, thanks to [Wissam Abirached](https://github.com/wabirached) + +#### Dependency Updates: +* [cross-env@4.0.0 ](https://www.npmjs.com/package/cross-env) +* [ws@2.2.3](https://www.npmjs.com/package/ws) +* [babel-core@6.24.0](https://www.npmjs.com/package/babel-core) +* [uws@0.14.0](https://www.npmjs.com/package/uws) +* [babel-preset-es2015@6.24.0](https://www.npmjs.com/package/babel-preset-es2015) +* [babel-plugin-syntax-flow@6.18.0](https://www.npmjs.com/package/babel-plugin-syntax-flow) +* [babel-cli@6.24.0](https://www.npmjs.com/package/babel-cli) +* [babel-register@6.24.0](https://www.npmjs.com/package/babel-register) +* [winston-daily-rotate-file@1.4.6](https://www.npmjs.com/package/winston-daily-rotate-file) +* [mongodb@2.2.25](https://www.npmjs.com/package/mongodb) +* [redis@2.7.0](https://www.npmjs.com/package/redis) +* [pg-promise@5.6.4](https://www.npmjs.com/package/pg-promise) +* [parse-server-push-adapter@1.3.0](https://www.npmjs.com/package/parse-server-push-adapter) + +# [2.3.7](https://github.com/ParsePlatform/parse-server/compare/2.3.6...2.3.7) + +#### New Features +* New endpoint to resend verification email, thanks to [Xy Ziemba](https://github.com/xyziemba) + +#### Improvements +* Add TTL option for Redis Cache Adapter, thanks to [Ryan Foster](https://github.com/f0ster) +* Update Postgres Storage Adapter, thanks to [Vitaly Tomilov](https://github.com/vitaly-t) + +#### Bug Fixes +* Add index on Role.name, fixes (#3579), thanks to [Natan Rolnik](https://github.com/natanrolnik) +* Fix default value of userSensitiveFields, fixes (#3593), thanks to [Arthur Cinader](https://github.com/acinader) + +#### Dependency Updates: +* [body-parser@1.17.1](https://www.npmjs.com/package/body-parser) +* [express@4.15.2](https://www.npmjs.com/package/express) +* [request@2.81.0](https://www.npmjs.com/package/request) +* [winston-daily-rotate-file@1.4.5](https://www.npmjs.com/package/winston-daily-rotate-file) +* [ws@2.2.0](https://www.npmjs.com/package/ws) + + +# [2.3.6](https://github.com/ParsePlatform/parse-server/compare/2.3.5...2.3.6) + +#### Improvements +* Adds support for injecting a middleware for instumentation in the CLI, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Alleviate mongodb bug with $or queries [SERVER-13732](https://jira.mongodb.org/browse/SERVER-13732), thanks to [Jack Wearden](https://github.com/NotBobTheBuilder) + +#### Bug Fixes +* Fix issue affecting password policy and empty passwords, thanks to [Bhaskar Reddy Yasa](https://github.com/bhaskaryasa) +* Fix issue when logging url in non string objects, thanks to [Paulo Vítor S Reis](https://github.com/paulovitin) + +#### Dependencies updates: +* [ws@2.1.0](https://npmjs.com/package/ws) +* [uws@0.13.0](https://npmjs.com/package/uws) +* [pg-promise@5.6.2](https://npmjs.com/package/pg-promise) + + +# [2.3.5](https://github.com/ParsePlatform/parse-server/compare/2.3.3...2.3.5) + +#### Bug Fixes +* Allow empty client key +(#3497), thanks to [Arthur Cinader](https://github.com/acinader) +* Fix LiveQuery unsafe user +(#3525), thanks to [David Starke](https://github.com/dstarke) +* Use `flushdb` instead of `flushall` in RedisCacheAdapter +(#3523), thanks to [Jeremy Louie](https://github.com/JeremyPlease) +* Fix saving GeoPoints and Files in `_GlobalConfig` (Make sure we don't treat +dot notation keys as topLevel atoms) +(#3531), thanks to [Florent Vilmart](https://github.com/flovilmart) + +# [2.3.3](https://github.com/ParsePlatform/parse-server/compare/2.3.2...2.3.3) + +##### Breaking Changes +* **Minimum Node engine bumped to 4.6** (#3480), thanks to [Florent Vilmart](https://github.com/flovilmart) + +#### Bug Fixes +* Add logging on failure to create file (#3424), thanks to [Arthur Cinader](https://github.com/acinader) +* Log Parse Errors so they are intelligible (#3431), thanks to [Arthur Cinader](https://github.com/acinader) +* MongoDB $or Queries avoid SERVER-13732 bug (#3476), thanks to [Jack Wearden](https://github.com/NotBobTheBuilder) +* Mongo object to Parse object date serialization - avoid re-serialization of iso of type Date (#3389), thanks to [nodechefMatt](https://github.com/nodechefMatt) + +#### Improvements +* Ground preparations for push scalability (#3080), thanks to [Florent Vilmart](https://github.com/flovilmart) +* Use uWS as optional dependency for ws server (#3231), thanks to [Florent Vilmart](https://github.com/flovilmart) + +# [2.3.2](https://github.com/ParsePlatform/parse-server/compare/2.3.1...2.3.2) + +#### New features +* Add parseFrameURL for masking user-facing pages (#3267), thanks to [Lenart Rudel](https://github.com/lenart) + +#### Bug fixes +* Fix Parse-Server to work with winston-daily-rotate-1.4.2 (#3335), thanks to [Arthur Cinader](https://github.com/acinader) + +#### Improvements +* Add support for regex string for password policy validatorPattern setting (#3331), thanks to [Bhaskar Reddy Yasa](https://github.com/bhaskaryasa) +* LiveQuery should match subobjects with dot notation (#3322), thanks to [David Starke](https://github.com/dstarke) +* Reduce time to process high number of installations for push (#3264), thanks to [jeacott1](https://github.com/jeacott1) +* Fix trivial typo in error message (#3238), thanks to [Arthur Cinader](https://github.com/acinader) + +# [2.3.1](https://github.com/ParsePlatform/parse-server/compare/2.3.0...2.3.1) + +A major issue was introduced when refactoring the authentication modules. +This release addresses only that issue. + +# [2.3.0](https://github.com/ParsePlatform/parse-server/compare/2.2.25...2.3.0) + +##### Breaking Changes +* Parse.Cloud.useMasterKey() is a no-op, please refer to (Cloud Code migration guide)[https://github.com/ParsePlatform/parse-server/wiki/Compatibility-with-Hosted-Parse#cloud-code] +* Authentication helpers are now proper adapters, deprecates oauth option in favor of auth. +* DEPRECATES: facebookAppIds, use `auth: { facebook: { appIds: ["AAAAAAAAA" ] } }` +* `email` field is not returned anymore for `Parse.User` queries. (Provided only on the user itself if provided). + +#### New Features +* Adds ability to restrict access through Class Level Permissions to only authenticated users [see docs](http://parseplatform.github.io/docs/ios/guide/#requires-authentication-permission-requires-parse-server---230) +* Adds ability to strip sensitive data from `_User` responses, strips emails by default, thanks to [Arthur Cinader](https://github.com/acinader) +* Adds password history support for password policies, thanks to [Bhaskar Reddy Yasa](https://github.com/bhaskaryasa) + +#### Improvements +* Bump parse-server-s3-adapter to 1.0.6, thanks to [Arthur Cinader](https://github.com/acinader) +* Using PARSE_SERVER_ENABLE_EXPERIMENTAL_DIRECT_ACCESS let you create user sessions when passing {installationId: "xxx-xxx"} on signup in cloud code, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Add CLI option to pass `host` parameter when creating parse-server from CLI, thanks to [Kulshekhar Kabra](https://github.com/kulshekhar) + +#### Bug fixes +* Ensure batch routes are only using posix paths, thanks to [Steven Shipton](https://github.com/steven-supersolid) +* Ensure falsy options from CLI are properly taken into account, thanks to [Steven Shipton](https://github.com/steven-supersolid) +* Fixes issues affecting calls to `matchesKeyInQuery` with pointers. +* Ensure that `select` keys can be changed in triggers (beforeFind...), thanks to [Arthur Cinader](https://github.com/acinader) + +#### Housekeeping +* Enables and enforces linting with eslint, thanks to [Arthur Cinader](https://github.com/acinader) + +### 2.2.25 + +Postgres support requires v9.5 + +#### New Features +* Dockerizing Parse Server, thanks to [Kirill Kravinsky](https://github.com/woyorus) +* Login with qq, wechat, weibo, thanks to [haifeizhang]() +* Password policy, validation and expiration, thanks to [Bhaskar Reddy Yasa](https://github.com/bhaskaryasa) +* Health check on /health, thanks to [Kirill Kravinsky](https://github.com/woyorus) +* Reuse SchemaCache across requests option, thanks to [Steven Shipton](https://github.com/steven-supersolid) + +#### Improvements +* Better support for CLI options, thanks to [Steven Shipton](https://github.com/steven-supersolid) +* Specity a database timeout with maxTimeMS, thanks to [Tyler Brock](https://github.com/TylerBrock) +* Adds the username to reset password success pages, thanks to [Halim Qarroum](https://github.com/HQarroum) +* Better support for Redis cache adapter, thanks to [Tyler Brock](https://github.com/TylerBrock) +* Better coverage of Postgres, thanks to [Kulshekhar Kabra](https://github.com/kulshekhar) + +#### Bug Fixes +* Fixes issue when sending push to multiple installations, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fixes issues with twitter authentication, thanks to [jonas-db](https://github.com/jonas-db) +* Ignore createdAt fields update, thanks to [Yuki Takeichi](https://github.com/yuki-takeichi) +* Improve support for array equality with LiveQuery, thanks to [David Poetzsch-Heffter](https://github.com/dpoetzsch) +* Improve support for batch endpoint when serverURL and publicServerURL have different paths, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Support saving relation objects, thanks to [Yuki Takeichi](https://github.com/yuki-takeichi) + +### 2.2.24 + +#### New Features +* LiveQuery: Bring your own adapter (#2902), thanks to [Florent Vilmart](https://github.com/flovilmart) +* LiveQuery: Adds "update" operator to update a query subscription (#2935), thanks to [Florent Vilmart](https://github.com/flovilmart) + +#### Improvements +* Better Postgres support, thanks to [Kulshekhar Kabra](https://github.com/kulshekhar) +* Logs the function name when failing (#2963), thanks to [Michael Helvey](https://github.com/michaelhelvey) +* CLI: forces closing the connections with SIGINT/SIGTERM (#2964), thanks to [Kulshekhar Kabra](https://github.com/kulshekhar) +* Reduce the number of calls to the `_SCHEMA` table (#2912), thanks to [Steven Shipton](https://github.com/steven-supersolid) +* LiveQuery: Support for Role ACL's, thanks to [Aaron Blondeau](https://github.com/aaron-blondeau-dose) + +#### Bug Fixes +* Better support for checking application and client keys, thanks to [Steven Shipton](https://github.com/steven-supersolid) +* Google OAuth, better support for android and web logins, thanks to [Florent Vilmart](https://github.com/flovilmart) + +### 2.2.23 + +* Run liveQuery server from CLI with a different port, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Support for Postgres databaseURI, thanks to [Kulshekhar Kabra](https://github.com/kulshekhar) +* Support for Postgres options, thanks to [Kulshekhar Kabra](https://github.com/kulshekhar) +* Improved support for google login (id_token and access_token), thanks to [Florent Vilmart](https://github.com/flovilmart) +* Improvements with VKontakte login, thanks to [Eugene Antropov](https://github.com/antigp) +* Improved support for `select` and `include`, thanks to [Florent Vilmart](https://github.com/flovilmart) + +#### Bug fixes + +* Fix error when updating installation with useMasterKey (#2888), thanks to [Jeremy Louie](https://github.com/JeremyPlease) +* Fix bug affecting usage of multiple `notEqualTo`, thanks to [Jeremy Louie](https://github.com/JeremyPlease) +* Improved support for null values in arrays, thanks to [Florent Vilmart](https://github.com/flovilmart) + +### 2.2.22 + +* Minimum nodejs engine is now 4.5 + +#### New Features +* New: CLI for parse-live-query-server, thanks to [Florent Vilmart](https://github.com/flovilmart) +* New: Start parse-live-query-server for parse-server CLI, thanks to [Florent Vilmart](https://github.com/flovilmart) + +#### Bug fixes +* Fix: Include with pointers are not conflicting with get CLP anymore, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fix: Removes dependency on babel-polyfill, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fix: Support nested select calls, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fix: Use native column selection instead of runtime, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fix: installationId header is properly used when updating `_Installation` objects, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fix: don't crash parse-server on improperly formatted live-query messages, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fix: Passwords are properly stripped out of logs, thanks to [Arthur Cinader](https://github.com/acinader) +* Fix: Lookup for email in username if email is not set, thanks to [Florent Vilmart](https://github.com/flovilmart) + +### 2.2.21 + +* Fix: Reverts removal of babel-polyfill + +### 2.2.20 + +* New: Adds CloudCode handler for `beforeFind`, thanks to [Florent Vilmart](https://github.com/flovilmart) +* New: RedisCacheAdapter for syncing schema, role and user caches across servers, thanks to [Florent Vilmart](https://github.com/flovilmart) +* New: Latest master build available at `ParsePlatform/parse-server#latest`, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fix: Better support for upgradeToRevocableSession with missing session token, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fix: Removes babel-polyfill runtime dependency, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fix: Cluster option now support a boolean value for automatically choosing the right number of processes, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fix: Filenames now appear correctly, thanks to [Lama Chandrasena](https://github.com/lama-buddy) +* Fix: `_acl` is properly updated, thanks to [Steven Shipton](https://github.com/steven-supersolid) + +Other fixes by [Mathias Rangel Wulff](https://github.com/mathiasrw) + +### 2.2.19 + +* New: support for upgrading to revocable sessions, thanks to [Florent Vilmart](https://github.com/flovilmart) +* New: NullCacheAdapter for disabling caching, thanks to [Yuki Takeichi](https://github.com/yuki-takeichi) +* New: Account lockout policy [#2601](https://github.com/ParsePlatform/parse-server/pull/2601), thanks to [Diwakar Cherukumilli](https://github.com/cherukumilli) +* New: Jobs endpoint for defining and run jobs (no scheduling), thanks to [Florent Vilmart](https://github.com/flovilmart) +* New: Add --cluster option to the CLI, thanks to [Florent Vilmart](https://github.com/flovilmart) +* New: Support for login with vk.com, thanks to [Nurdaulet Bolatov](https://github.com/nbolatov) +* New: experimental support for postgres databases, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fix: parse-server doesn't call next() after successful responses, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fix: Nested objects are properly includeed with Pointer Permissions on, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fix: null values in include calls are properly handled, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fix: Schema validations now runs after beforeSave hooks, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fix: usersname and passwords are properly type checked, thanks to [Bam Wang](https://github.com/bamwang) +* Fix: logging in info log would log also in error log, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fix: removes extaneous logging from ParseLiveQueryServer, thanks to [Flavio Torres](https://github.com/flavionegrao) +* Fix: support for Range requests for files, thanks to [Brage G. Staven](https://github.com/Bragegs) + +### 2.2.18 + +* Fix: Improve support for objects in push alert, thanks to [Antoine Lenoir](https://github.com/alenoir) +* Fix; Prevent pointed from getting clobbered when they are changed in a beforeSave, thanks to [sud](https://github.com/sud80) +* Fix: Improve support for "Bytes" type, thanks to [CongHoang](https://github.com/conghoang) +* Fix: Better logging compatability with Parse.com, thanks to [Arthur Cinader](https://github.com/acinader) +* New: Add Janrain Capture and Janrain Engage auth provider, thanks to [Andrew Lane](https://github.com/AndrewLane) +* Improved: Include content length header in files response, thanks to [Steven Van Bael](https://github.com/vbsteven) +* Improved: Support byte range header for files, thanks to [Brage G. Staven](https://github.com/Bragegs) +* Improved: Validations for LinkedIn access_tokens, thanks to [Felix Dumit](https://github.com/felix-dumit) +* Improved: Experimental postgres support, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Perf: Use native bcrypt implementation if available, thanks to [Florent Vilmart](https://github.com/flovilmart) + + +# [2.2.17](https://github.com/ParsePlatform/parse-server/compare/2.2.16...2.2.17) + +* Cloud code logs [\#2370](https://github.com/ParsePlatform/parse-server/pull/2370) ([flovilmart](https://github.com/flovilmart)) +* Make sure \_PushStatus operations are run in order [\#2367](https://github.com/ParsePlatform/parse-server/pull/2367) ([flovilmart](https://github.com/flovilmart)) +* Typo fix for error message when can't ensure uniqueness of user email addresses [\#2360](https://github.com/ParsePlatform/parse-server/pull/2360) ([AndrewLane](https://github.com/AndrewLane)) +* LiveQuery constrains matching fix [\#2357](https://github.com/ParsePlatform/parse-server/pull/2357) ([simonas-notcat](https://github.com/simonas-notcat)) +* Fix typo in logging for commander parseConfigFile [\#2352](https://github.com/ParsePlatform/parse-server/pull/2352) ([AndrewLane](https://github.com/AndrewLane)) +* Fix minor typos in test names [\#2351](https://github.com/ParsePlatform/parse-server/pull/2351) ([acinader](https://github.com/acinader)) +* Makes sure we don't strip authData or session token from users using masterKey [\#2348](https://github.com/ParsePlatform/parse-server/pull/2348) ([flovilmart](https://github.com/flovilmart)) +* Run coverage with istanbul [\#2340](https://github.com/ParsePlatform/parse-server/pull/2340) ([flovilmart](https://github.com/flovilmart)) +* Run next\(\) after successfully sending data to the client [\#2338](https://github.com/ParsePlatform/parse-server/pull/2338) ([blacha](https://github.com/blacha)) +* Cache all the mongodb/version folder [\#2336](https://github.com/ParsePlatform/parse-server/pull/2336) ([flovilmart](https://github.com/flovilmart)) +* updates usage of setting: emailVerifyTokenValidityDuration [\#2331](https://github.com/ParsePlatform/parse-server/pull/2331) ([cherukumilli](https://github.com/cherukumilli)) +* Update Mongodb client to 2.2.4 [\#2329](https://github.com/ParsePlatform/parse-server/pull/2329) ([flovilmart](https://github.com/flovilmart)) +* Allow usage of analytics adapter [\#2327](https://github.com/ParsePlatform/parse-server/pull/2327) ([deashay](https://github.com/deashay)) +* Fix flaky tests [\#2324](https://github.com/ParsePlatform/parse-server/pull/2324) ([flovilmart](https://github.com/flovilmart)) +* don't serve null authData values [\#2320](https://github.com/ParsePlatform/parse-server/pull/2320) ([yuzeh](https://github.com/yuzeh)) +* Fix null relation problem [\#2319](https://github.com/ParsePlatform/parse-server/pull/2319) ([flovilmart](https://github.com/flovilmart)) +* Clear the connectionPromise upon close or error [\#2314](https://github.com/ParsePlatform/parse-server/pull/2314) ([flovilmart](https://github.com/flovilmart)) +* Report validation errors with correct error code [\#2299](https://github.com/ParsePlatform/parse-server/pull/2299) ([flovilmart](https://github.com/flovilmart)) +* Parses correctly Parse.Files and Dates when sent to Cloud Code Functions [\#2297](https://github.com/ParsePlatform/parse-server/pull/2297) ([flovilmart](https://github.com/flovilmart)) +* Adding proper generic Not Implemented. [\#2292](https://github.com/ParsePlatform/parse-server/pull/2292) ([vitaly-t](https://github.com/vitaly-t)) +* Adds schema caching capabilities \(5s by default\) [\#2286](https://github.com/ParsePlatform/parse-server/pull/2286) ([flovilmart](https://github.com/flovilmart)) +* add digits oauth provider [\#2284](https://github.com/ParsePlatform/parse-server/pull/2284) ([ranhsd](https://github.com/ranhsd)) +* Improve installations query [\#2281](https://github.com/ParsePlatform/parse-server/pull/2281) ([flovilmart](https://github.com/flovilmart)) +* Adding request headers to cloud functions fixes \#1461 [\#2274](https://github.com/ParsePlatform/parse-server/pull/2274) ([blacha](https://github.com/blacha)) +* Creates a new sessionToken when updating password [\#2266](https://github.com/ParsePlatform/parse-server/pull/2266) ([flovilmart](https://github.com/flovilmart)) +* Add Gitter chat link to the README. [\#2264](https://github.com/ParsePlatform/parse-server/pull/2264) ([nlutsenko](https://github.com/nlutsenko)) +* Restores ability to include non pointer keys [\#2263](https://github.com/ParsePlatform/parse-server/pull/2263) ([flovilmart](https://github.com/flovilmart)) +* Allow next middleware handle error in handleParseErrors [\#2260](https://github.com/ParsePlatform/parse-server/pull/2260) ([mejcz](https://github.com/mejcz)) +* Exposes the ClientSDK infos if available [\#2259](https://github.com/ParsePlatform/parse-server/pull/2259) ([flovilmart](https://github.com/flovilmart)) +* Adds support for multiple twitter auths options [\#2256](https://github.com/ParsePlatform/parse-server/pull/2256) ([flovilmart](https://github.com/flovilmart)) +* validate\_purchase fix for SANDBOX requests [\#2253](https://github.com/ParsePlatform/parse-server/pull/2253) ([valeryvaskabovich](https://github.com/valeryvaskabovich)) + +### 2.2.16 + +* New: Expose InMemoryCacheAdapter publicly, thanks to [Steven Shipton](https://github.com/steven-supersolid) +* New: Add ability to prevent login with unverified email, thanks to [Diwakar Cherukumilli](https://github.com/cherukumilli) +* Improved: Better error message for incorrect type, thanks to [Andrew Lane](https://github.com/AndrewLane) +* Improved: Better error message for permission denied, thanks to [Blayne Chard](https://github.com/blacha) +* Improved: Update authData on login, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Improved: Ability to not check for old files on Parse.com, thanks to [OzgeAkin](https://github.com/OzgeAkin) +* Fix: Issues with email adapter validation, thanks to [Tyler Brock](https://github.com/TylerBrock) +* Fix: Issues with nested $or queries, thanks to [Florent Vilmart](https://github.com/flovilmart) + +### 2.2.15 + +* Fix: Type in description for Parse.Error.INVALID_QUERY, thanks to [Andrew Lane](https://github.com/AndrewLane) +* Improvement: Stop requiring verifyUserEmails for password reset functionality, thanks to [Tyler Brock](https://github.com/TylerBrock) +* Improvement: Kill without validation, thanks to [Drew Gross](https://github.com/drew-gross) +* Fix: Deleting a file does not delete from fs.files, thanks to [David Keita](https://github.com/maninga) +* Fix: Postgres stoage adapter fix, thanks to [Vitaly Tomilov](https://github.com/vitaly-t) +* Fix: Results invalid session when providing an invalid session token, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fix: issue creating an anonymous user, thanks to [Hussam Moqhim](https://github.com/hmoqhim) +* Fix: make http response serializable, thanks to [Florent Vilmart](https://github.com/flovilmart) +* New: Add postmark email adapter alternative [Glenn Reyes](https://github.com/glennreyes) + +### 2.2.14 + +* Hotfix: Fix Parse.Cloud.HTTPResponse serialization + +### 2.2.13 + +* Hotfix: Pin version of deepcopy + +### 2.2.12 + +* New: Custom error codes in cloud code response.error, thanks to [Jeremy Pease](https://github.com/JeremyPlease) +* Fix: Crash in beforeSave when response is not an object, thanks to [Tyler Brock](https://github.com/TylerBrock) +* Fix: Allow "get" on installations +* Fix: Fix overly restrictive Class Level Permissions, thanks to [Florent Vilmart](https://github.com/flovilmart) +* Fix: Fix nested date parsing in Cloud Code, thanks to [Marco Cheung](https://github.com/Marco129) +* Fix: Support very old file formats from Parse.com + +### 2.2.11 + +* Security: Censor user password in logs, thanks to [Marco Cheung](https://github.com/Marco129) +* New: Add PARSE_SERVER_LOGS_FOLDER env var for setting log folder, thanks to [KartikeyaRokde](https://github.com/KartikeyaRokde) +* New: Webhook key support, thanks to [Tyler Brock](https://github.com/TylerBrock) +* Perf: Add cache adapter and default caching of certain objects, thanks to [Blayne Chard](https://github.com/blacha) +* Improvement: Better error messages for schema type mismatches, thanks to [Jeremy Pease](https://github.com/JeremyPlease) +* Improvement: Better error messages for reset password emails +* Improvement: Webhook key support in CLI, thanks to [Tyler Brock](https://github.com/TylerBrock) +* Fix: Remove read only fields when using beforeSave, thanks to [Tyler Brock](https://github.com/TylerBrock) +* Fix: Use content type provided by JS SDK, thanks to [Blayne Chard](https://github.com/blacha) and [Florent Vilmart](https://github.com/flovilmart) +* Fix: Tell the dashboard the stored push data is available, thanks to [Jeremy Pease](https://github.com/JeremyPlease) +* Fix: Add support for HTTP Basic Auth, thanks to [Hussam Moqhim](https://github.com/hmoqhim) +* Fix: Support for MongoDB version 3.2.6, (note: do not use MongoDB 3.2 with migrated apps that still have traffic on Parse.com), thanks to [Tyler Brock](https://github.com/TylerBrock) +* Fix: Prevent `pm2` from crashing when push notifications fail, thanks to [benishak](https://github.com/benishak) +* Fix: Add full list of default _Installation fields, thanks to [Jeremy Pease](https://github.com/JeremyPlease) +* Fix: Strip objectId out of hooks responses, thanks to [Tyler Brock](https://github.com/TylerBrock) +* Fix: Fix external webhook response format, thanks to [Tyler Brock](https://github.com/TylerBrock) +* Fix: Fix beforeSave when object is passed to `success`, thanks to [Madhav Bhagat](https://github.com/codebreach) +* Fix: Remove use of deprecated APIs, thanks to [Emad Ehsan](https://github.com/emadehsan) +* Fix: Crash when multiple Parse Servers on the same machine try to write to the same logs folder, thanks to [Steven Shipton](https://github.com/steven-supersolid) +* Fix: Various issues with key names in `Parse.Object`s +* Fix: Treat Bytes type properly +* Fix: Caching bugs that caused writes by masterKey or other session token to not show up to users reading with a different session token +* Fix: Pin mongo driver version, preventing a regression in version 2.1.19 +* Fix: Various issues with pointer fields not being treated properly +* Fix: Issues with pointed getting un-fetched due to changes in beforeSave +* Fix: Fixed crash when deleting classes that have CLPs + +### 2.2.10 + +* Fix: Write legacy ACLs to Mongo so that clients that still go through Parse.com can read them, thanks to [Tyler Brock](https://github.com/TylerBrock) and [carmenlau](https://github.com/carmenlau) +* Fix: Querying installations with limit = 0 and count = 1 now works, thanks to [ssk7833](https://github.com/ssk7833) +* Fix: Return correct error when violating unique index, thanks to [Marco Cheung](https://github.com/Marco129) +* Fix: Allow unsetting user's email, thanks to [Marco Cheung](https://github.com/Marco129) +* New: Support for Node 6.1 + +### 2.2.9 + +* Fix: Fix a regression that caused Parse Server to crash when a null parameter is passed to a Cloud function + +### 2.2.8 + +* New: Support for Pointer Permissions +* New: Expose logger in Cloud Code +* New: Option to revoke sessions on password reset +* New: Option to expire inactive sessions +* Perf: Improvements in ACL checking query +* Fix: Issues when sending pushes to list of devices that contains invalid values +* Fix: Issues caused by using babel-polyfill outside of Parse Server, but in the same express app +* Fix: Remove creation of extra session tokens +* Fix: Return authData when querying with master key +* Fix: Bugs when deleting webhooks +* Fix: Ignore _RevocableSession header, which might be sent by the JS SDK +* Fix: Issues with querying via URL params +* Fix: Properly encode "Date" parameters to cloud code functions + + +### 2.2.7 + +* Adds support for --verbose and verbose option when running ParseServer [\#1414](https://github.com/ParsePlatform/parse-server/pull/1414) ([flovilmart](https://github.com/flovilmart)) +* Adds limit = 0 as a valid parameter for queries [\#1493](https://github.com/ParsePlatform/parse-server/pull/1493) ([seijiakiyama](https://github.com/seijiakiyama)) +* Makes sure we preserve Installations when updating a token \(\#1475\) [\#1486](https://github.com/ParsePlatform/parse-server/pull/1486) ([flovilmart](https://github.com/flovilmart)) +* Hotfix for tests [\#1503](https://github.com/ParsePlatform/parse-server/pull/1503) ([flovilmart](https://github.com/flovilmart)) +* Enable logs [\#1502](https://github.com/ParsePlatform/parse-server/pull/1502) ([drew-gross](https://github.com/drew-gross)) +* Do some triple equals for great justice [\#1499](https://github.com/ParsePlatform/parse-server/pull/1499) ([TylerBrock](https://github.com/TylerBrock)) +* Apply credential stripping to all untransforms for \_User [\#1498](https://github.com/ParsePlatform/parse-server/pull/1498) ([TylerBrock](https://github.com/TylerBrock)) +* Checking if object has defined key for Pointer constraints in liveQuery [\#1487](https://github.com/ParsePlatform/parse-server/pull/1487) ([simonas-notcat](https://github.com/simonas-notcat)) +* Remove collection prefix and default mongo URI [\#1479](https://github.com/ParsePlatform/parse-server/pull/1479) ([drew-gross](https://github.com/drew-gross)) +* Store collection prefix in mongo adapter, and clean up adapter interface [\#1472](https://github.com/ParsePlatform/parse-server/pull/1472) ([drew-gross](https://github.com/drew-gross)) +* Move field deletion logic into mongo adapter [\#1471](https://github.com/ParsePlatform/parse-server/pull/1471) ([drew-gross](https://github.com/drew-gross)) +* Adds support for Long and Double mongodb types \(fixes \#1316\) [\#1470](https://github.com/ParsePlatform/parse-server/pull/1470) ([flovilmart](https://github.com/flovilmart)) +* Schema.js database agnostic [\#1468](https://github.com/ParsePlatform/parse-server/pull/1468) ([flovilmart](https://github.com/flovilmart)) +* Remove console.log [\#1465](https://github.com/ParsePlatform/parse-server/pull/1465) ([drew-gross](https://github.com/drew-gross)) +* Push status nits [\#1462](https://github.com/ParsePlatform/parse-server/pull/1462) ([flovilmart](https://github.com/flovilmart)) +* Fixes \#1444 [\#1451](https://github.com/ParsePlatform/parse-server/pull/1451) ([flovilmart](https://github.com/flovilmart)) +* Removing sessionToken and authData from \_User objects included in a query [\#1450](https://github.com/ParsePlatform/parse-server/pull/1450) ([simonas-notcat](https://github.com/simonas-notcat)) +* Move mongo field type logic into mongoadapter [\#1432](https://github.com/ParsePlatform/parse-server/pull/1432) ([drew-gross](https://github.com/drew-gross)) +* Prevents \_User lock out when setting ACL on signup or afterwards [\#1429](https://github.com/ParsePlatform/parse-server/pull/1429) ([flovilmart](https://github.com/flovilmart)) +* Update .travis.yml [\#1428](https://github.com/ParsePlatform/parse-server/pull/1428) ([flovilmart](https://github.com/flovilmart)) +* Adds relation fields to objects [\#1424](https://github.com/ParsePlatform/parse-server/pull/1424) ([flovilmart](https://github.com/flovilmart)) +* Update .travis.yml [\#1423](https://github.com/ParsePlatform/parse-server/pull/1423) ([flovilmart](https://github.com/flovilmart)) +* Sets the defaultSchemas keys in the SchemaCollection [\#1421](https://github.com/ParsePlatform/parse-server/pull/1421) ([flovilmart](https://github.com/flovilmart)) +* Fixes \#1417 [\#1420](https://github.com/ParsePlatform/parse-server/pull/1420) ([drew-gross](https://github.com/drew-gross)) +* Untransform should treat Array's as nested objects [\#1416](https://github.com/ParsePlatform/parse-server/pull/1416) ([blacha](https://github.com/blacha)) +* Adds X-Parse-Push-Status-Id header [\#1412](https://github.com/ParsePlatform/parse-server/pull/1412) ([flovilmart](https://github.com/flovilmart)) +* Schema format cleanup [\#1407](https://github.com/ParsePlatform/parse-server/pull/1407) ([drew-gross](https://github.com/drew-gross)) +* Updates the publicServerURL option [\#1397](https://github.com/ParsePlatform/parse-server/pull/1397) ([flovilmart](https://github.com/flovilmart)) +* Fix exception with non-expiring session tokens. [\#1386](https://github.com/ParsePlatform/parse-server/pull/1386) ([0x18B2EE](https://github.com/0x18B2EE)) +* Move mongo schema format related logic into mongo adapter [\#1385](https://github.com/ParsePlatform/parse-server/pull/1385) ([drew-gross](https://github.com/drew-gross)) +* WIP: Huge performance improvement on roles queries [\#1383](https://github.com/ParsePlatform/parse-server/pull/1383) ([flovilmart](https://github.com/flovilmart)) +* Removes GCS Adapter from provided adapters [\#1339](https://github.com/ParsePlatform/parse-server/pull/1339) ([flovilmart](https://github.com/flovilmart)) +* DBController refactoring [\#1228](https://github.com/ParsePlatform/parse-server/pull/1228) ([flovilmart](https://github.com/flovilmart)) +* Spotify authentication [\#1226](https://github.com/ParsePlatform/parse-server/pull/1226) ([1nput0utput](https://github.com/1nput0utput)) +* Expose DatabaseAdapter to simplify application tests [\#1121](https://github.com/ParsePlatform/parse-server/pull/1121) ([steven-supersolid](https://github.com/steven-supersolid)) + +### 2.2.6 + +* Important Fix: Disables find on installation from clients [\#1374](https://github.com/ParsePlatform/parse-server/pull/1374) ([flovilmart](https://github.com/flovilmart)) +* Adds missing options to the CLI [\#1368](https://github.com/ParsePlatform/parse-server/pull/1368) ([flovilmart](https://github.com/flovilmart)) +* Removes only master on travis [\#1367](https://github.com/ParsePlatform/parse-server/pull/1367) ([flovilmart](https://github.com/flovilmart)) +* Auth.\_loadRoles should not query the same role twice. [\#1366](https://github.com/ParsePlatform/parse-server/pull/1366) ([blacha](https://github.com/blacha)) + +### 2.2.5 + +* Improves config loading and tests [\#1363](https://github.com/ParsePlatform/parse-server/pull/1363) ([flovilmart](https://github.com/flovilmart)) +* Adds travis configuration to deploy NPM on new version tags [\#1361](https://github.com/ParsePlatform/parse-server/pull/1361) ([gfosco](https://github.com/gfosco)) +* Inject the default schemas properties when loading it [\#1357](https://github.com/ParsePlatform/parse-server/pull/1357) ([flovilmart](https://github.com/flovilmart)) +* Adds console transport when testing with VERBOSE=1 [\#1351](https://github.com/ParsePlatform/parse-server/pull/1351) ([flovilmart](https://github.com/flovilmart)) +* Make notEqual work on relations [\#1350](https://github.com/ParsePlatform/parse-server/pull/1350) ([flovilmart](https://github.com/flovilmart)) +* Accept only bool for $exists in LiveQuery [\#1315](https://github.com/ParsePlatform/parse-server/pull/1315) ([drew-gross](https://github.com/drew-gross)) +* Adds more options when using CLI/config [\#1305](https://github.com/ParsePlatform/parse-server/pull/1305) ([flovilmart](https://github.com/flovilmart)) +* Update error message [\#1297](https://github.com/ParsePlatform/parse-server/pull/1297) ([drew-gross](https://github.com/drew-gross)) +* Properly let masterKey add fields [\#1291](https://github.com/ParsePlatform/parse-server/pull/1291) ([flovilmart](https://github.com/flovilmart)) +* Point to \#1271 as how to write a good issue report [\#1290](https://github.com/ParsePlatform/parse-server/pull/1290) ([drew-gross](https://github.com/drew-gross)) +* Adds ability to override mount with publicServerURL for production uses [\#1287](https://github.com/ParsePlatform/parse-server/pull/1287) ([flovilmart](https://github.com/flovilmart)) +* Single object queries to use include and keys [\#1280](https://github.com/ParsePlatform/parse-server/pull/1280) ([jeremyjackson89](https://github.com/jeremyjackson89)) +* Improves report for Push error in logs and \_PushStatus [\#1269](https://github.com/ParsePlatform/parse-server/pull/1269) ([flovilmart](https://github.com/flovilmart)) +* Removes all stdout/err logs while testing [\#1268](https://github.com/ParsePlatform/parse-server/pull/1268) ([flovilmart](https://github.com/flovilmart)) +* Matching queries with doesNotExist constraint [\#1250](https://github.com/ParsePlatform/parse-server/pull/1250) ([andrecardoso](https://github.com/andrecardoso)) +* Added session length option for session tokens to server configuration [\#997](https://github.com/ParsePlatform/parse-server/pull/997) ([Kenishi](https://github.com/Kenishi)) +* Regression test for \#1259 [\#1286](https://github.com/ParsePlatform/parse-server/pull/1286) ([drew-gross](https://github.com/drew-gross)) +* Regression test for \#871 [\#1283](https://github.com/ParsePlatform/parse-server/pull/1283) ([drew-gross](https://github.com/drew-gross)) +* Add a test to repro \#701 [\#1281](https://github.com/ParsePlatform/parse-server/pull/1281) ([drew-gross](https://github.com/drew-gross)) +* Fix for \#1334: using relative cloud code files broken [\#1353](https://github.com/ParsePlatform/parse-server/pull/1353) ([airdrummingfool](https://github.com/airdrummingfool)) +* Fix Issue/1288 [\#1346](https://github.com/ParsePlatform/parse-server/pull/1346) ([flovilmart](https://github.com/flovilmart)) +* Fixes \#1271 [\#1295](https://github.com/ParsePlatform/parse-server/pull/1295) ([drew-gross](https://github.com/drew-gross)) +* Fixes issue \#1302 [\#1314](https://github.com/ParsePlatform/parse-server/pull/1314) ([flovilmart](https://github.com/flovilmart)) +* Fixes bug related to include in queries [\#1312](https://github.com/ParsePlatform/parse-server/pull/1312) ([flovilmart](https://github.com/flovilmart)) + + +### 2.2.4 + +* Hotfix: fixed imports issue for S3Adapter, GCSAdapter, FileSystemAdapter [\#1263](https://github.com/ParsePlatform/parse-server/pull/1263) ([drew-gross](https://github.com/drew-gross) +* Fix: Clean null authData values on _User update [\#1199](https://github.com/ParsePlatform/parse-server/pull/1199) ([yuzeh](https://github.com/yuzeh)) + +### 2.2.3 + +* Fixed bug with invalid email verification link on email update. [\#1253](https://github.com/ParsePlatform/parse-server/pull/1253) ([kzielonka](https://github.com/kzielonka)) +* Badge update supports increment as well as Increment [\#1248](https://github.com/ParsePlatform/parse-server/pull/1248) ([flovilmart](https://github.com/flovilmart)) +* Config/Push Tested with the dashboard. [\#1235](https://github.com/ParsePlatform/parse-server/pull/1235) ([drew-gross](https://github.com/drew-gross)) +* Better logging with winston [\#1234](https://github.com/ParsePlatform/parse-server/pull/1234) ([flovilmart](https://github.com/flovilmart)) +* Make GlobalConfig work like parse.com [\#1210](https://github.com/ParsePlatform/parse-server/pull/1210) ([framp](https://github.com/framp)) +* Improve flattening of results from pushAdapter [\#1204](https://github.com/ParsePlatform/parse-server/pull/1204) ([flovilmart](https://github.com/flovilmart)) +* Push adapters are provided by external packages [\#1195](https://github.com/ParsePlatform/parse-server/pull/1195) ([flovilmart](https://github.com/flovilmart)) +* Fix flaky test [\#1188](https://github.com/ParsePlatform/parse-server/pull/1188) ([drew-gross](https://github.com/drew-gross)) +* Fixes problem affecting finding array pointers [\#1185](https://github.com/ParsePlatform/parse-server/pull/1185) ([flovilmart](https://github.com/flovilmart)) +* Moves Files adapters to external packages [\#1172](https://github.com/ParsePlatform/parse-server/pull/1172) ([flovilmart](https://github.com/flovilmart)) +* Mark push as enabled in serverInfo endpoint [\#1164](https://github.com/ParsePlatform/parse-server/pull/1164) ([drew-gross](https://github.com/drew-gross)) +* Document email adapter [\#1144](https://github.com/ParsePlatform/parse-server/pull/1144) ([drew-gross](https://github.com/drew-gross)) +* Reset password fix [\#1133](https://github.com/ParsePlatform/parse-server/pull/1133) ([carmenlau](https://github.com/carmenlau)) + +### 2.2.2 + +* Important Fix: Mounts createLiveQueryServer, fix babel induced problem [\#1153](https://github.com/ParsePlatform/parse-server/pull/1153) (flovilmart) +* Move ParseServer to it's own file [\#1166](https://github.com/ParsePlatform/parse-server/pull/1166) (flovilmart) +* Update README.md * remove deploy buttons * replace with community links [\#1139](https://github.com/ParsePlatform/parse-server/pull/1139) (drew-gross) +* Adds bootstrap.sh [\#1138](https://github.com/ParsePlatform/parse-server/pull/1138) (flovilmart) +* Fix: Do not override username [\#1142](https://github.com/ParsePlatform/parse-server/pull/1142) (flovilmart) +* Fix: Add pushId back to GCM payload [\#1168](https://github.com/ParsePlatform/parse-server/pull/1168) (wangmengyan95) + +### 2.2.1 + +* New: Add FileSystemAdapter file adapter [\#1098](https://github.com/ParsePlatform/parse-server/pull/1098) (dtsolis) +* New: Enabled CLP editing [\#1128](https://github.com/ParsePlatform/parse-server/pull/1128) (drew-gross) +* Improvement: Reduces the number of connections to mongo created [\#1111](https://github.com/ParsePlatform/parse-server/pull/1111) (flovilmart) +* Improvement: Make ParseServer a class [\#980](https://github.com/ParsePlatform/parse-server/pull/980) (flovilmart) +* Fix: Adds support for plain object in $add, $addUnique, $remove [\#1114](https://github.com/ParsePlatform/parse-server/pull/1114) (flovilmart) +* Fix: Generates default CLP, freezes objects [\#1132](https://github.com/ParsePlatform/parse-server/pull/1132) (flovilmart) +* Fix: Properly sets installationId on creating session with 3rd party auth [\#1110](https://github.com/ParsePlatform/parse-server/pull/1110) (flovilmart) + +### 2.2.0 + +* New Feature: Real-time functionality with Live Queries! [\#1092](https://github.com/ParsePlatform/parse-server/pull/1092) (wangmengyan95) +* Improvement: Push Status API [\#1004](https://github.com/ParsePlatform/parse-server/pull/1004) (flovilmart) +* Improvement: Allow client operations on Roles [\#1068](https://github.com/ParsePlatform/parse-server/pull/1068) (flovilmart) +* Improvement: Add URI encoding to mongo auth parameters [\#986](https://github.com/ParsePlatform/parse-server/pull/986) (bgw) +* Improvement: Adds support for apps key in config file, but only support single app for now [\#979](https://github.com/ParsePlatform/parse-server/pull/979) (flovilmart) +* Documentation: Getting Started and Configuring Parse Server [\#988](https://github.com/ParsePlatform/parse-server/pull/988) (hramos) +* Fix: Various edge cases with REST API [\#1066](https://github.com/ParsePlatform/parse-server/pull/1066) (flovilmart) +* Fix: Makes sure the location in results has the proper objectId [\#1065](https://github.com/ParsePlatform/parse-server/pull/1065) (flovilmart) +* Fix: Third-party auth is properly removed when unlinked [\#1081](https://github.com/ParsePlatform/parse-server/pull/1081) (flovilmart) +* Fix: Clear the session-user cache when changing \_User objects [\#1072](https://github.com/ParsePlatform/parse-server/pull/1072) (gfosco) +* Fix: Bug related to subqueries on unfetched objects [\#1046](https://github.com/ParsePlatform/parse-server/pull/1046) (flovilmart) +* Fix: Properly urlencode parameters for email validation and password reset [\#1001](https://github.com/ParsePlatform/parse-server/pull/1001) (flovilmart) +* Fix: Better sanitization/decoding of object data for afterSave triggers [\#992](https://github.com/ParsePlatform/parse-server/pull/992) (flovilmart) +* Fix: Changes default encoding for httpRequest [\#892](https://github.com/ParsePlatform/parse-server/pull/892) (flovilmart) + +### 2.1.6 + +* Improvement: Full query support for badge Increment \(\#931\) [\#983](https://github.com/ParsePlatform/parse-server/pull/983) (flovilmart) +* Improvement: Shutdown standalone parse server gracefully [\#958](https://github.com/ParsePlatform/parse-server/pull/958) (raulr) +* Improvement: Add database options to ParseServer constructor and pass to MongoStorageAdapter [\#956](https://github.com/ParsePlatform/parse-server/pull/956) (steven-supersolid) +* Improvement: AuthData logic refactor [\#952](https://github.com/ParsePlatform/parse-server/pull/952) (flovilmart) +* Improvement: Changed FileLoggerAdapterSpec to fail gracefully on Windows [\#946](https://github.com/ParsePlatform/parse-server/pull/946) (aneeshd16) +* Improvement: Add new schema collection type and replace all usages of direct mongo collection for schema operations. [\#943](https://github.com/ParsePlatform/parse-server/pull/943) (nlutsenko) +* Improvement: Adds CLP API to Schema router [\#898](https://github.com/ParsePlatform/parse-server/pull/898) (flovilmart) +* Fix: Cleans up authData null keys on login for android crash [\#978](https://github.com/ParsePlatform/parse-server/pull/978) (flovilmart) +* Fix: Do master query for before/afterSaveHook [\#959](https://github.com/ParsePlatform/parse-server/pull/959) (wangmengyan95) +* Fix: re-add shebang [\#944](https://github.com/ParsePlatform/parse-server/pull/944) (flovilmart) +* Fix: Added test command for Windows support [\#886](https://github.com/ParsePlatform/parse-server/pull/886) (aneeshd16) + +### 2.1.5 + +* New: FileAdapter for Google Cloud Storage [\#708](https://github.com/ParsePlatform/parse-server/pull/708) (mcdonamp) +* Improvement: Minimize extra schema queries in some scenarios. [\#919](https://github.com/ParsePlatform/parse-server/pull/919) (Marco129) +* Improvement: Move DatabaseController and Schema fully to adaptive mongo collection. [\#909](https://github.com/ParsePlatform/parse-server/pull/909) (nlutsenko) +* Improvement: Cleanup PushController/PushRouter, remove raw mongo collection access. [\#903](https://github.com/ParsePlatform/parse-server/pull/903) (nlutsenko) +* Improvement: Increment badge the right way [\#902](https://github.com/ParsePlatform/parse-server/pull/902) (flovilmart) +* Improvement: Migrate ParseGlobalConfig to new database storage API. [\#901](https://github.com/ParsePlatform/parse-server/pull/901) (nlutsenko) +* Improvement: Improve delete flow for non-existent \_Join collection [\#881](https://github.com/ParsePlatform/parse-server/pull/881) (Marco129) +* Improvement: Adding a role scenario test for issue 827 [\#878](https://github.com/ParsePlatform/parse-server/pull/878) (gfosco) +* Improvement: Test empty authData block on login for \#413 [\#863](https://github.com/ParsePlatform/parse-server/pull/863) (gfosco) +* Improvement: Modified the npm dev script to support Windows [\#846](https://github.com/ParsePlatform/parse-server/pull/846) (aneeshd16) +* Improvement: Move HooksController to use MongoCollection instead of direct Mongo access. [\#844](https://github.com/ParsePlatform/parse-server/pull/844) (nlutsenko) +* Improvement: Adds public\_html and views for packaging [\#839](https://github.com/ParsePlatform/parse-server/pull/839) (flovilmart) +* Improvement: Better support for windows builds [\#831](https://github.com/ParsePlatform/parse-server/pull/831) (flovilmart) +* Improvement: Convert Schema.js to ES6 class. [\#826](https://github.com/ParsePlatform/parse-server/pull/826) (nlutsenko) +* Improvement: Remove duplicated instructions [\#816](https://github.com/ParsePlatform/parse-server/pull/816) (hramos) +* Improvement: Completely migrate SchemasRouter to new MongoCollection API. [\#794](https://github.com/ParsePlatform/parse-server/pull/794) (nlutsenko) +* Fix: Do not require where clause in $dontSelect condition on queries. [\#925](https://github.com/ParsePlatform/parse-server/pull/925) (nlutsenko) +* Fix: Make sure that ACLs propagate to before/after save hooks. [\#924](https://github.com/ParsePlatform/parse-server/pull/924) (nlutsenko) +* Fix: Support params option in Parse.Cloud.httpRequest. [\#912](https://github.com/ParsePlatform/parse-server/pull/912) (carmenlau) +* Fix: Fix flaky Parse.GeoPoint test. [\#908](https://github.com/ParsePlatform/parse-server/pull/908) (nlutsenko) +* Fix: Handle legacy \_client\_permissions key in \_SCHEMA. [\#900](https://github.com/ParsePlatform/parse-server/pull/900) (drew-gross) +* Fix: Fixes bug when querying equalTo on objectId and relation [\#887](https://github.com/ParsePlatform/parse-server/pull/887) (flovilmart) +* Fix: Allow crossdomain on filesRouter [\#876](https://github.com/ParsePlatform/parse-server/pull/876) (flovilmart) +* Fix: Remove limit when counting results. [\#867](https://github.com/ParsePlatform/parse-server/pull/867) (gfosco) +* Fix: beforeSave changes should propagate to the response [\#865](https://github.com/ParsePlatform/parse-server/pull/865) (gfosco) +* Fix: Delete relation field when \_Join collection not exist [\#864](https://github.com/ParsePlatform/parse-server/pull/864) (Marco129) +* Fix: Related query on non-existing column [\#861](https://github.com/ParsePlatform/parse-server/pull/861) (gfosco) +* Fix: Update markdown in .github/ISSUE\_TEMPLATE.md [\#859](https://github.com/ParsePlatform/parse-server/pull/859) (igorshubovych) +* Fix: Issue with creating wrong \_Session for Facebook login [\#857](https://github.com/ParsePlatform/parse-server/pull/857) (tobernguyen) +* Fix: Leak warnings in tests, use mongodb-runner from node\_modules [\#843](https://github.com/ParsePlatform/parse-server/pull/843) (drew-gross) +* Fix: Reversed roles lookup [\#841](https://github.com/ParsePlatform/parse-server/pull/841) (flovilmart) +* Fix: Improves loading of Push Adapter, fix loading of S3Adapter [\#833](https://github.com/ParsePlatform/parse-server/pull/833) (flovilmart) +* Fix: Add field to system schema [\#828](https://github.com/ParsePlatform/parse-server/pull/828) (Marco129) + +### 2.1.4 + +* New: serverInfo endpoint that returns server version and info about the server's features +* Improvement: Add support for badges on iOS +* Improvement: Improve failure handling in cloud code http requests +* Improvement: Add support for queries on pointers and relations +* Improvement: Add support for multiple $in clauses in a query +* Improvement: Add allowClientClassCreation config option +* Improvement: Allow atomically setting subdocument keys +* Improvement: Allow arbitrarily deeply nested roles +* Improvement: Set proper content-type in S3 File Adapter +* Improvement: S3 adapter auto-creates buckets +* Improvement: Better error messages for many errors +* Performance: Improved algorithm for validating client keys +* Experimental: Parse Hooks and Hooks API +* Experimental: Email verification and password reset emails +* Experimental: Improve compatability of logs feature with Parse.com +* Fix: Fix for attempting to delete missing classes via schemas API +* Fix: Allow creation of system classes via schemas API +* Fix: Allow missing where cause in $select +* Fix: Improve handling of invalid object ids +* Fix: Replace query overwriting existing query +* Fix: Propagate installationId in cloud code triggers +* Fix: Session expiresAt is now a Date instead of a string +* Fix: Fix count queries +* Fix: Disallow _Role objects without names or without ACL +* Fix: Better handling of invalid types submitted +* Fix: beforeSave will not be triggered for attempts to save with invalid authData +* Fix: Fix duplicate device token issues on Android +* Fix: Allow empty authData on signup +* Fix: Allow Master Key Headers (CORS) +* Fix: Fix bugs if JavaScript key was not provided in server configuration +* Fix: Parse Files on objects can now be stored without URLs +* Fix: allow both objectId or installationId when modifying installation +* Fix: Command line works better when not given options + +### 2.1.3 + +* Feature: Add initial support for in-app purchases +* Feature: Better error messages when attempting to run the server on a port that is already in use or without a server URL +* Feature: Allow customization of max file size +* Performance: Faster saves if not using beforeSave triggers +* Fix: Send session token in response to current user endpoint +* Fix: Remove triggers for _Session collection +* Fix: Improve compatability of cloud code beforeSave hook for newly created object +* Fix: ACL creation for master key only objects +* Fix: Allow uploading files without Content-Type +* Fix: Add features to http request to match Parse.com +* Fix: Bugs in development script when running from locations other than project root +* Fix: Can pass query constraints in URL +* Fix: Objects with legacy "_tombstone" key now don't cause issues. +* Fix: Allow nested keys in objects to begin with underscores +* Fix: Allow correct headers for CORS + +### 2.1.2 + +* Change: The S3 file adapter constructor requires a bucket name +* Fix: Parse Query should throw if improperly encoded +* Fix: Issue where roles were not used in some requests +* Fix: serverURL will no longer default to api.parse.com/1 + +### 2.1.1 + +* Experimental: Schemas API support for DELETE operations +* Fix: Session token issue fetching Users +* Fix: Facebook auth validation +* Fix: Invalid error when deleting missing session + +### 2.1.0 + +* Feature: Support for additional OAuth providers +* Feature: Ability to implement custom OAuth providers +* Feature: Support for deleting Parse Files +* Feature: Allow querying roles +* Feature: Support for logs, extensible via Log Adapter +* Feature: New Push Adapter for sending push notifications through OneSignal +* Feature: Tighter default security for Users +* Feature: Pass parameters to cloud code in query string +* Feature: Disable anonymous users via configuration. +* Experimental: Schemas API support for PUT operations +* Fix: Prevent installation ID from being added to User +* Fix: Becoming a user works properly with sessions +* Fix: Including multiple object when some object are unavailable will get all the objects that are available +* Fix: Invalid URL for Parse Files +* Fix: Making a query without a limit now returns 100 results +* Fix: Expose installation id in cloud code +* Fix: Correct username for Anonymous users +* Fix: Session token issue after fetching user +* Fix: Issues during install process +* Fix: Issue with Unity SDK sending _noBody + +### 2.0.8 + +* Add: support for Android and iOS push notifications +* Experimental: cloud code validation hooks (can mark as non-experimental after we have docs) +* Experimental: support for schemas API (GET and POST only) +* Experimental: support for Parse Config (GET and POST only) +* Fix: Querying objects with equality constraint on array column +* Fix: User logout will remove session token +* Fix: Various files related bugs +* Fix: Force minimum node version 4.3 due to security issues in earlier version +* Performance Improvement: Improved caching diff --git a/ci/CiVersionCheck.js b/ci/CiVersionCheck.js new file mode 100644 index 0000000000..20986a0b15 --- /dev/null +++ b/ci/CiVersionCheck.js @@ -0,0 +1,292 @@ +const semver = require('semver'); +const yaml = require('yaml'); +const fs = require('fs').promises; + +/** + * This checks the CI version of an environment variable in a YAML file + * against a list of released versions of a package. + */ +class CiVersionCheck { + + /** + * The constructor. + * @param {Object} config The config. + * @param {String} config.packageName The package name to check. + * @param {String} config.packageSupportUrl The URL to the package website + * that shows the End-of-Life support dates. + * @param {String} config.yamlFilePath The path to the GitHub workflow YAML + * file that contains the tests. + * @param {String} config.ciEnvironmentsKeyPath The key path in the CI YAML + * file to the environment specifications. + * @param {String} config.ciVersionKey The key in the CI YAML file to + * determine the package version. + * @param {Array} config.releasedVersions The released versions of + * the package to check against. + * @param {Array} config.ignoreReleasedVersions The versions to + * ignore when checking whether the CI tests against the latest versions. + * This can be used in case there is a package release for which Parse + * Server compatibility is not required. + * @param {String} [config.latestComponent='patch'] The version component + * (`major`, `minor`, `patch`) that must be the latest released version. + * Default is `patch`. + * + * For example: + * - Released versions: 1.0.0, 1.2.0, 1.2.1, 1.3.0, 1.3.1, 2.0.0 + * - Tested version: 1.2.0 + * + * If the latest version component is `patch`, then the check would + * fail and recommend an upgrade to version 1.2.1 and to add additional + * tests against 1.3.1 and 2.0.0. + * If the latest version component is `minor` then the check would + * fail and recommend an upgrade to version 1.3.0 and to add an additional + * test against 2.0.0. + * If the latest version component is `major` then the check would + * fail and recommend an upgrade to version 2.0.0. + */ + constructor(config) { + const { + packageName, + packageSupportUrl, + yamlFilePath, + ciEnvironmentsKeyPath, + ciVersionKey, + releasedVersions, + ignoreReleasedVersions = [], + latestComponent = CiVersionCheck.versionComponents.patch, + } = config; + + // Ensure required params are set + if ([ + packageName, + packageSupportUrl, + yamlFilePath, + ciEnvironmentsKeyPath, + ciVersionKey, + releasedVersions, + ].includes(undefined)) { + throw 'invalid configuration'; + } + + if (!Object.keys(CiVersionCheck.versionComponents).includes(latestComponent)) { + throw 'invalid configuration for latestComponent'; + } + + this.packageName = packageName; + this.packageSupportUrl = packageSupportUrl; + this.yamlFilePath = yamlFilePath; + this.ciEnvironmentsKeyPath = ciEnvironmentsKeyPath; + this.ciVersionKey = ciVersionKey; + this.releasedVersions = releasedVersions; + this.ignoreReleasedVersions = ignoreReleasedVersions; + this.latestComponent = latestComponent; + } + + /** + * The definition of version components. + */ + static get versionComponents() { + return Object.freeze({ + major: 'major', + minor: 'minor', + patch: 'patch', + }); + } + + /** + * Returns the test environments as specified in the YAML file. + */ + async getTests() { + try { + // Get CI workflow + const ciYaml = await fs.readFile(this.yamlFilePath, 'utf-8'); + const ci = yaml.parse(ciYaml); + + // Extract package versions + let versions = this.ciEnvironmentsKeyPath.split('.').reduce((o,k) => o !== undefined ? o[k] : undefined, ci); + versions = Object.entries(versions) + .map(entry => entry[1]) + .filter(entry => entry[this.ciVersionKey]); + + return versions; + } catch (e) { + throw `Failed to determine ${this.packageName} versions from CI YAML file with error: ${e}`; + } + } + + /** + * Returns the package versions which are missing in the CI environment. + * @param {Array} releasedVersions The released versions; need to + * be sorted descending. + * @param {Array} testedVersions The tested versions. + * @param {String} versionComponent The latest version component. + * @returns {Array} The untested versions. + */ + getUntestedVersions(releasedVersions, testedVersions, versionComponent) { + // Use these example values for debugging the version range logic below + // versionComponent = CiVersionCheck.versionComponents.patch; + // this.ignoreReleasedVersions = ['<4.4.0', '~4.7.0']; + // testedVersions = ['4.4.3']; + // releasedVersions = [ + // '5.0.0-rc0', + // '5.0.0', + // '4.9.1', + // '4.9.0', + // '4.8.1', + // '4.8.0', + // '4.7.1', + // '4.7.0', + // '4.4.3', + // '4.4.2', + // '4.4.0', + // '4.1.0', + // '3.5.0', + // ]; + + // Determine operator for range comparison + const operator = versionComponent == CiVersionCheck.versionComponents.major + ? '>=' + : versionComponent == CiVersionCheck.versionComponents.minor + ? '^' + : '~' + + // Get all untested versions + const untestedVersions = releasedVersions.reduce((m, v) => { + // If the version should be ignored, skip it + if (this.ignoreReleasedVersions.length > 0 && semver.satisfies(v, this.ignoreReleasedVersions.join(' || '))) { + return m; + } + // If the version is a pre-release, skip it + if ((semver.prerelease(v) || []).length > 0) { + return m; + } + // If a satisfying version has already been added to untested, skip it + if (semver.maxSatisfying(m, `${operator}${v}`)) { + return m; + } + // If a satisfying version is already tested, skip it + if (semver.maxSatisfying(testedVersions, `${operator}${v}`)) { + return m; + } + // Add version + m.push(v); + return m; + }, []); + + return untestedVersions; + } + + /** + * Returns the latest version for a given version and component. + * @param {Array} versions The versions in which to search. + * @param {String} version The version for which a newer version + * should be searched. + * @param {String} versionComponent The version component up to + * which the latest version should be checked. + * @returns {String|undefined} The newer version. + */ + getNewerVersion(versions, version, versionComponent) { + // Determine operator for range comparison + const operator = versionComponent == CiVersionCheck.versionComponents.major + ? '>=' + : versionComponent == CiVersionCheck.versionComponents.minor + ? '^' + : '~' + const latest = semver.maxSatisfying(versions, `${operator}${version}`); + + // If the version should be ignored, skip it + if (this.ignoreReleasedVersions.length > 0 && semver.satisfies(latest, this.ignoreReleasedVersions.join(' || '))) { + return undefined; + } + + // Return the latest version if it is newer than any currently used version + return semver.gt(latest, version) ? latest : undefined; + } + + /** + * This validates that the given versions strictly follow semver + * syntax. + * @param {Array} versions The versions to check. + */ + _validateVersionSyntax(versions) { + for (const version of versions) { + if (!semver.valid(version)) { + throw version; + } + } + } + + /** + * Runs the check. + */ + async check() { + const core = await import('@actions/core'); + /* eslint-disable no-console */ + try { + console.log(`\nChecking ${this.packageName} versions in CI environments...`); + + // Validate released versions syntax + try { + this._validateVersionSyntax(this.releasedVersions); + } catch (e) { + core.setFailed(`Failed to check ${this.packageName} versions because released version '${e}' does not follow semver syntax (x.y.z).`); + return; + } + + // Sort versions descending + semver.sort(this.releasedVersions).reverse() + + // Get tested package versions from CI + const tests = await this.getTests(); + + // Is true if any of the checks failed + let failed = false; + + // Check whether each tested version is the latest patch + for (const test of tests) { + const version = test[this.ciVersionKey]; + + // Validate version syntax + try { + this._validateVersionSyntax([version]); + } catch (e) { + core.setFailed(`Failed to check ${this.packageName} versions because environment version '${e}' does not follow semver syntax (x.y.z).`); + return; + } + + const newer = this.getNewerVersion(this.releasedVersions, version, this.latestComponent); + if (newer) { + console.log(`❌ CI environment '${test.name}' uses an old ${this.packageName} ${this.latestComponent} version ${version} instead of ${newer}.`); + failed = true; + } else { + console.log(`✅ CI environment '${test.name}' uses the latest ${this.packageName} ${this.latestComponent} version ${version}.`); + } + } + + // Check whether there is a newer component version available that is not tested + const testedVersions = tests.map(test => test[this.ciVersionKey]); + const untested = this.getUntestedVersions(this.releasedVersions, testedVersions, this.latestComponent); + if (untested.length > 0) { + console.log(`❌ CI does not have environments using the following versions of ${this.packageName}: ${untested.join(', ')}.`); + failed = true; + } else { + console.log(`✅ CI has environments using all recent versions of ${this.packageName}.`); + } + + if (failed) { + core.setFailed( + `CI environments are not up-to-date with the latest ${this.packageName} versions.` + + `\n\nCheck the error messages above and update the ${this.packageName} versions in the CI YAML ` + + `file.\n\nâ„šī¸ Additionally, there may be versions of ${this.packageName} that have reached their official end-of-life ` + + `support date and should be removed from the CI, see ${this.packageSupportUrl}.` + ); + } + + } catch (e) { + const msg = `Failed to check ${this.packageName} versions with error: ${e}`; + core.setFailed(msg); + } + /* eslint-enable no-console */ + } +} + +module.exports = CiVersionCheck; diff --git a/ci/ciCheck.js b/ci/ciCheck.js new file mode 100644 index 0000000000..8ee58cbdfd --- /dev/null +++ b/ci/ciCheck.js @@ -0,0 +1,69 @@ +'use strict'; + +const CiVersionCheck = require('./CiVersionCheck'); +const { exec } = require('child_process'); + +async function check() { + // Run checks + await checkMongoDbVersions(); + await checkNodeVersions(); +} + +/** + * Check the MongoDB versions used in test environments. + */ +async function checkMongoDbVersions() { + let latestStableVersions = await new Promise((resolve, reject) => { + exec('m ls', (error, stdout) => { + if (error) { + reject(error); + return; + } + resolve(stdout.trim()); + }); + }); + latestStableVersions = latestStableVersions.split('\n').map(version => version.trim()); + + await new CiVersionCheck({ + packageName: 'MongoDB', + packageSupportUrl: 'https://www.mongodb.com/support-policy', + yamlFilePath: './.github/workflows/ci.yml', + ciEnvironmentsKeyPath: 'jobs.check-mongo.strategy.matrix.include', + ciVersionKey: 'MONGODB_VERSION', + releasedVersions: latestStableVersions, + latestComponent: CiVersionCheck.versionComponents.patch, + ignoreReleasedVersions: [ + '<4.2.0', // These versions have reached their end-of-life support date + '>=4.3.0 <5.0.0', // Unsupported rapid release versions + '>=5.1.0 <6.0.0', // Unsupported rapid release versions + '>=6.1.0 <7.0.0', // Unsupported rapid release versions + '>=7.1.0 <8.0.0', // Unsupported rapid release versions + ], + }).check(); +} + +/** + * Check the Nodejs versions used in test environments. + */ +async function checkNodeVersions() { + const allVersions = (await import('all-node-versions')).default; + const { versions } = await allVersions(); + const nodeVersions = versions.map(version => version.node); + + await new CiVersionCheck({ + packageName: 'Node.js', + packageSupportUrl: 'https://github.com/nodejs/node/blob/master/CHANGELOG.md', + yamlFilePath: './.github/workflows/ci.yml', + ciEnvironmentsKeyPath: 'jobs.check-mongo.strategy.matrix.include', + ciVersionKey: 'NODE_VERSION', + releasedVersions: nodeVersions, + latestComponent: CiVersionCheck.versionComponents.minor, + ignoreReleasedVersions: [ + '<18.0.0', // These versions have reached their end-of-life support date + '>=19.0.0 <20.0.0', // These versions have reached their end-of-life support date + '>=21.0.0', // These versions are not officially supported yet + ], + }).check(); +} + +check(); diff --git a/ci/definitionsCheck.js b/ci/definitionsCheck.js new file mode 100644 index 0000000000..b4b9e88d0a --- /dev/null +++ b/ci/definitionsCheck.js @@ -0,0 +1,27 @@ +const fs = require('fs').promises; +const { exec } = require('child_process'); +const util = require('util'); +(async () => { + const core = await import('@actions/core'); + const [currentDefinitions, currentDocs] = await Promise.all([ + fs.readFile('./src/Options/Definitions.js', 'utf8'), + fs.readFile('./src/Options/docs.js', 'utf8'), + ]); + const execute = util.promisify(exec); + await execute('npm run definitions'); + const [newDefinitions, newDocs] = await Promise.all([ + fs.readFile('./src/Options/Definitions.js', 'utf8'), + fs.readFile('./src/Options/docs.js', 'utf8'), + ]); + if (currentDefinitions !== newDefinitions || currentDocs !== newDocs) { + // eslint-disable-next-line no-console + console.error( + '\x1b[31m%s\x1b[0m', + 'Definitions files cannot be updated manually. Please update src/Options/index.js then run `npm run definitions` to generate definitions.' + ); + core.error('Definitions files cannot be updated manually. Please update src/Options/index.js then run `npm run definitions` to generate definitions.'); + process.exit(1); + } else { + process.exit(0); + } +})(); diff --git a/ci/nodeEngineCheck.js b/ci/nodeEngineCheck.js new file mode 100644 index 0000000000..e2c4553604 --- /dev/null +++ b/ci/nodeEngineCheck.js @@ -0,0 +1,198 @@ +const semver = require('semver'); +const fs = require('fs').promises; +const path = require('path'); +let core; + +/** + * This checks whether any package dependency requires a minimum node engine + * version higher than the host package. + */ +class NodeEngineCheck { + + /** + * The constructor. + * @param {Object} config The config. + * @param {String} config.nodeModulesPath The path to the node_modules directory. + * @param {String} config.packageJsonPath The path to the parent package.json file. + */ + constructor(config) { + const { + nodeModulesPath, + packageJsonPath, + } = config; + + // Ensure required params are set + if ([ + nodeModulesPath, + packageJsonPath, + ].includes(undefined)) { + throw 'invalid configuration'; + } + + this.nodeModulesPath = nodeModulesPath; + this.packageJsonPath = packageJsonPath; + } + + /** + * Returns an array of `package.json` files under the given path and subdirectories. + * @param {String} [basePath] The base path for recursive directory search. + */ + async getPackageFiles(basePath = this.nodeModulesPath) { + try { + // Declare file list + const files = [] + + // Get files + const dirents = await fs.readdir(basePath, { withFileTypes: true }); + const validFiles = dirents.filter(d => d.name.toLowerCase() == 'package.json').map(d => path.join(basePath, d.name)); + files.push(...validFiles); + + // For each directory entry + for (const dirent of dirents) { + if (dirent.isDirectory()) { + const subFiles = await this.getPackageFiles(path.join(basePath, dirent.name)); + files.push(...subFiles); + } + } + return files; + } catch (e) { + throw `Failed to get package.json files in ${this.nodeModulesPath} with error: ${e}`; + } + } + + /** + * Extracts and returns the node engine versions of the given package.json + * files. + * @param {String[]} files The package.json files. + * @param {Boolean} clean Is true if packages with undefined node versions + * should be removed from the results. + * @returns {Object[]} A list of results. + */ + async getNodeVersion({ files, clean = false }) { + + // Declare response + let response = []; + + // For each file + for (const file of files) { + // Get node version + const contentString = await fs.readFile(file, 'utf-8'); + try { + const contentJson = JSON.parse(contentString); + const version = ((contentJson || {}).engines || {}).node; + + // Add response + response.push({ + file: file, + nodeVersion: version + }); + } catch { + // eslint-disable-next-line no-console + console.log(`Ignoring file because it is not valid JSON: ${file}`); + core.warning(`Ignoring file because it is not valid JSON: ${file}`); + } + } + + // If results should be cleaned by removing undefined node versions + if (clean) { + response = response.filter(r => r.nodeVersion !== undefined); + } + return response; + } + + /** + * Returns the highest semver definition that satisfies all versions + * in the given list. + * @param {String[]} versions The list of semver version ranges. + * @param {String} baseVersion The base version of which higher versions should be + * determined; as a version (1.2.3), not a range (>=1.2.3). + * @returns {String} The highest semver version. + */ + getHigherVersions({ versions, baseVersion }) { + // Add min satisfying node versions + const minVersions = versions.map(v => { + v.nodeMinVersion = semver.minVersion(v.nodeVersion) + return v; + }); + + // Sort by min version + const sortedMinVersions = minVersions.sort((v1, v2) => semver.compare(v1.nodeMinVersion, v2.nodeMinVersion)); + + // Filter by higher versions + const higherVersions = sortedMinVersions.filter(v => semver.gt(v.nodeMinVersion, baseVersion)); + // console.log(`getHigherVersions: ${JSON.stringify(higherVersions)}`); + return higherVersions; + } + + /** + * Returns the node version of the parent package. + * @return {Object} The parent package info. + */ + async getParentVersion() { + // Get parent package.json version + const version = await this.getNodeVersion({ files: [ this.packageJsonPath ], clean: true }); + // console.log(`getParentVersion: ${JSON.stringify(version)}`); + return version[0]; + } +} + +async function check() { + core = await import('@actions/core'); + // Define paths + const nodeModulesPath = path.join(__dirname, '../node_modules'); + const packageJsonPath = path.join(__dirname, '../package.json'); + + // Create check + const check = new NodeEngineCheck({ + nodeModulesPath, + packageJsonPath, + }); + + // Get package node version of parent package + const parentVersion = await check.getParentVersion(); + + // If parent node version could not be determined + if (parentVersion === undefined) { + core.setFailed(`Failed to determine node engine version of parent package at ${this.packageJsonPath}`); + return; + } + + // Determine parent min version + const parentMinVersion = semver.minVersion(parentVersion.nodeVersion); + + // Get package.json files + const files = await check.getPackageFiles(); + core.info(`Checking the minimum node version requirement of ${files.length} dependencies`); + + // Get node versions + const versions = await check.getNodeVersion({ files, clean: true }); + + // Get are dependencies that require a higher node version than the parent package + const higherVersions = check.getHigherVersions({ versions, baseVersion: parentMinVersion }); + + // Get highest version + const highestVersion = higherVersions.map(v => v.nodeMinVersion).pop(); + + /* eslint-disable no-console */ + // If there are higher versions + if (higherVersions.length > 0) { + console.log(`\nThere are ${higherVersions.length} dependencies that require a higher node engine version than the parent package (${parentVersion.nodeVersion}):`); + + // For each dependency + for (const higherVersion of higherVersions) { + + // Get package name + const _package = higherVersion.file.split('node_modules/').pop().replace('/package.json', ''); + console.log(`- ${_package} requires at least node ${higherVersion.nodeMinVersion} (${higherVersion.nodeVersion})`); + } + console.log(''); + core.setFailed(`❌ Upgrade the node engine version in package.json to at least '${highestVersion}' to satisfy the dependencies.`); + console.log(''); + return; + } + + console.log(`✅ All dependencies satisfy the node version requirement of the parent package (${parentVersion.nodeVersion}).`); + /* eslint-enable no-console */ +} + +check(); diff --git a/ci/uninstallDevDeps.sh b/ci/uninstallDevDeps.sh new file mode 100755 index 0000000000..633860223d --- /dev/null +++ b/ci/uninstallDevDeps.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# Read package exclusion list from arguments +exclusionList=("$@") + +# Convert exclusion list to grep pattern +exclusionPattern=$(printf "|%s" "${exclusionList[@]}") +exclusionPattern=${exclusionPattern:1} + +# Get list of all dev dependencies +devDeps=$(jq -r '.devDependencies | keys | .[]' package.json) + +# Filter out exclusion list +depsToUninstall=$(echo "$devDeps" | grep -Ev "$exclusionPattern") + +# If there are dependencies to uninstall then uninstall them +if [ -n "$depsToUninstall" ]; then + echo "Uninstalling dev dependencies: $depsToUninstall" + npm uninstall $depsToUninstall +else + echo "No dev dependencies to uninstall" +fi diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000000..f9e0977938 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,86 @@ +const js = require("@eslint/js"); +const babelParser = require("@babel/eslint-parser"); +const globals = require("globals"); +const unusedImports = require("eslint-plugin-unused-imports"); + +module.exports = [ + { + ignores: ["**/lib/**", "**/coverage/**", "**/out/**", "**/types/**"], + }, + js.configs.recommended, + { + languageOptions: { + parser: babelParser, + ecmaVersion: 6, + sourceType: "module", + globals: { + Parse: "readonly", + ...globals.node, + }, + parserOptions: { + requireConfigFile: false, + }, + }, + plugins: { + "unused-imports": unusedImports, + }, + rules: { + indent: ["error", 2, { SwitchCase: 1 }], + "unused-imports/no-unused-imports": "error", + "unused-imports/no-unused-vars": "error", + "linebreak-style": ["error", "unix"], + "no-trailing-spaces": "error", + "eol-last": "error", + "space-in-parens": ["error", "never"], + "no-multiple-empty-lines": "warn", + "prefer-const": "error", + "space-infix-ops": "error", + "no-useless-escape": "off", + "require-atomic-updates": "off", + "object-curly-spacing": ["error", "always"], + curly: ["error", "all"], + "block-spacing": ["error", "always"], + "no-unused-vars": "off", + "no-console": "warn", + "no-restricted-syntax": [ + "error", + { + selector: "BinaryExpression[operator='instanceof'][right.name='Date']", + message: "Use Utils.isDate() instead of instanceof Date (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='RegExp']", + message: "Use Utils.isRegExp() instead of instanceof RegExp (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Error']", + message: "Use Utils.isNativeError() instead of instanceof Error (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Promise']", + message: "Use Utils.isPromise() instead of instanceof Promise (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Map']", + message: "Use Utils.isMap() instead of instanceof Map (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Object']", + message: "Use Utils.isObject() instead of instanceof Object (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Set']", + message: "Use Utils.isSet() instead of instanceof Set (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Buffer']", + message: "Use Buffer.isBuffer() instead of instanceof Buffer (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Array']", + message: "Use Array.isArray() instead of instanceof Array (cross-realm safe).", + }, + ] + }, + }, +]; diff --git a/jsdoc-conf.json b/jsdoc-conf.json new file mode 100644 index 0000000000..e90f82556b --- /dev/null +++ b/jsdoc-conf.json @@ -0,0 +1,40 @@ +{ + "plugins": ["node_modules/jsdoc-babel", "plugins/markdown"], + "babel": { + "plugins": ["@babel/plugin-transform-flow-strip-types"] + }, + "source": { + "include": [ + "README.md", + "./lib/cloud-code", + "./lib/Options/docs.js", + "./lib/ParseServer.js", + "./lib/Adapters" + ], + "excludePattern": "(^|\\/|\\\\)_" + }, + "templates": { + "default": { + "outputSourceFiles": false, + "showInheritedInNav": false, + "useLongnameInNav": true + }, + "cleverLinks": true, + "monospaceLinks": false + }, + "opts": { + "encoding": "utf8", + "readme": "./README.md", + "recurse": true, + "template": "./node_modules/clean-jsdoc-theme", + "theme_opts": { + "default_theme": "dark", + "title": "", + "create_style": "header, .sidebar-section-title, .sidebar-title { color: #139cee !important } .logo { margin-left : 40px; margin-right: 40px; height: auto; max-width: 100%; object-fit: contain; }" + } + }, + "markdown": { + "hardwrap": false, + "idInHeadings": true + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..4696594f09 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,45262 @@ +{ + "name": "parse-server", + "version": "9.9.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "parse-server", + "version": "9.9.0", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@apollo/server": "5.5.0", + "@as-integrations/express5": "1.1.2", + "@fastify/busboy": "3.2.0", + "@graphql-tools/merge": "9.1.7", + "@graphql-tools/schema": "10.0.31", + "@graphql-tools/utils": "11.0.0", + "@parse/fs-files-adapter": "3.0.0", + "@parse/push-adapter": "8.4.0", + "bcryptjs": "3.0.3", + "commander": "14.0.3", + "cors": "2.8.6", + "express": "5.2.1", + "express-rate-limit": "8.3.1", + "follow-redirects": "1.15.11", + "graphql": "16.13.2", + "graphql-list-fields": "2.0.4", + "graphql-relay": "0.10.2", + "graphql-upload": "15.0.2", + "intersect": "1.0.1", + "jsonwebtoken": "9.0.3", + "jwks-rsa": "3.2.0", + "ldapjs": "3.0.7", + "lodash": "4.18.1", + "lru-cache": "11.2.7", + "mime": "4.1.0", + "mongodb": "7.1.0", + "mustache": "4.2.0", + "otpauth": "9.5.0", + "parse": "8.6.0", + "path-to-regexp": "8.4.2", + "pg-monitor": "3.1.0", + "pg-promise": "12.6.0", + "pluralize": "8.0.0", + "punycode": "2.3.1", + "rate-limit-redis": "4.3.1", + "redis": "5.11.0", + "semver": "7.7.4", + "tv4": "1.3.0", + "winston": "3.19.0", + "winston-daily-rotate-file": "5.0.0", + "ws": "8.20.0" + }, + "bin": { + "parse-server": "bin/parse-server" + }, + "devDependencies": { + "@actions/core": "3.0.0", + "@apollo/client": "3.13.8", + "@babel/cli": "7.28.6", + "@babel/core": "7.29.0", + "@babel/eslint-parser": "7.28.6", + "@babel/plugin-proposal-object-rest-spread": "7.20.7", + "@babel/plugin-transform-flow-strip-types": "7.27.1", + "@babel/preset-env": "7.29.2", + "@babel/preset-typescript": "7.27.1", + "@saithodev/semantic-release-backmerge": "4.0.1", + "@semantic-release/changelog": "6.0.3", + "@semantic-release/commit-analyzer": "13.0.1", + "@semantic-release/git": "10.0.1", + "@semantic-release/github": "12.0.6", + "@semantic-release/npm": "13.0.0", + "@semantic-release/release-notes-generator": "14.1.0", + "all-node-versions": "13.0.1", + "apollo-upload-client": "18.0.1", + "clean-jsdoc-theme": "4.3.0", + "cross-env": "7.0.3", + "deep-diff": "1.0.2", + "eslint": "9.27.0", + "eslint-plugin-expect-type": "0.6.2", + "eslint-plugin-unused-imports": "4.4.1", + "form-data": "4.0.5", + "globals": "17.3.0", + "graphql-tag": "2.12.6", + "jasmine": "6.1.0", + "jasmine-spec-reporter": "7.0.0", + "jsdoc": "4.0.5", + "jsdoc-babel": "0.5.0", + "lint-staged": "16.4.0", + "m": "1.10.0", + "madge": "8.0.0", + "mock-files-adapter": "file:spec/dependencies/mock-files-adapter", + "mock-mail-adapter": "file:spec/dependencies/mock-mail-adapter", + "mongodb-runner": "5.9.3", + "node-abort-controller": "3.1.1", + "node-fetch": "3.3.2", + "nyc": "17.1.0", + "prettier": "3.8.1", + "semantic-release": "25.0.3", + "typescript": "5.9.3", + "typescript-eslint": "8.58.0", + "yaml": "2.8.3" + }, + "engines": { + "node": ">=20.19.0 <21.0.0 || >=22.13.0 <23.0.0 || >=24.11.0 <25.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parse-server" + }, + "optionalDependencies": { + "@node-rs/bcrypt": "1.10.7" + } + }, + "node_modules/@actions/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.0.tgz", + "integrity": "sha512-zYt6cz+ivnTmiT/ksRVriMBOiuoUpDCJJlZ5KPl2/FRdvwU3f7MPh9qftvbkXJThragzUZieit2nyHUyw53Seg==", + "dev": true, + "dependencies": { + "@actions/exec": "^3.0.0", + "@actions/http-client": "^4.0.0" + } + }, + "node_modules/@actions/exec": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz", + "integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==", + "dev": true, + "dependencies": { + "@actions/io": "^3.0.2" + } + }, + "node_modules/@actions/http-client": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.0.tgz", + "integrity": "sha512-QuwPsgVMsD6qaPD57GLZi9sqzAZCtiJT8kVBCDpLtxhL5MydQ4gS+DrejtZZPdIYyB1e95uCK9Luyds7ybHI3g==", + "dev": true, + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^6.23.0" + } + }, + "node_modules/@actions/io": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz", + "integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==", + "dev": true + }, + "node_modules/@apollo/cache-control-types": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@apollo/cache-control-types/-/cache-control-types-1.0.3.tgz", + "integrity": "sha512-F17/vCp7QVwom9eG7ToauIKdAxpSoadsJnqIfyryLFSkLSOEqu+eC5Z3N8OXcUVStuOMcNHlyraRsA6rRICu4g==", + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/client": { + "version": "3.13.8", + "resolved": "https://registry.npmjs.org/@apollo/client/-/client-3.13.8.tgz", + "integrity": "sha512-YM9lQpm0VfVco4DSyKooHS/fDTiKQcCHfxr7i3iL6a0kP/jNO5+4NFK6vtRDxaYisd5BrwOZHLJpPBnvRVpKPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@wry/caches": "^1.0.0", + "@wry/equality": "^0.5.6", + "@wry/trie": "^0.5.0", + "graphql-tag": "^2.12.6", + "hoist-non-react-statics": "^3.3.2", + "optimism": "^0.18.0", + "prop-types": "^15.7.2", + "rehackt": "^0.1.0", + "symbol-observable": "^4.0.0", + "ts-invariant": "^0.10.3", + "tslib": "^2.3.0", + "zen-observable-ts": "^1.2.5" + }, + "peerDependencies": { + "graphql": "^15.0.0 || ^16.0.0", + "graphql-ws": "^5.5.5 || ^6.0.3", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc", + "subscriptions-transport-ws": "^0.9.0 || ^0.11.0" + }, + "peerDependenciesMeta": { + "graphql-ws": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "subscriptions-transport-ws": { + "optional": true + } + } + }, + "node_modules/@apollo/protobufjs": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@apollo/protobufjs/-/protobufjs-1.2.7.tgz", + "integrity": "sha512-Lahx5zntHPZia35myYDBRuF58tlwPskwHc5CWBZC/4bMKB6siTBWwtMrkqXcsNwQiFSzSx5hKdRPUmemrEp3Gg==", + "hasInstallScript": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.0", + "long": "^4.0.0" + }, + "bin": { + "apollo-pbjs": "bin/pbjs", + "apollo-pbts": "bin/pbts" + } + }, + "node_modules/@apollo/server": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@apollo/server/-/server-5.5.0.tgz", + "integrity": "sha512-vWtodBOK/SZwBTJzItECOmLfL8E8pn/IdvP7pnxN5g2tny9iW4+9sxdajE798wV1H2+PYp/rRcl/soSHIBKMPw==", + "license": "MIT", + "dependencies": { + "@apollo/cache-control-types": "^1.0.3", + "@apollo/server-gateway-interface": "^2.0.0", + "@apollo/usage-reporting-protobuf": "^4.1.1", + "@apollo/utils.createhash": "^3.0.0", + "@apollo/utils.fetcher": "^3.0.0", + "@apollo/utils.isnodelike": "^3.0.0", + "@apollo/utils.keyvaluecache": "^4.0.0", + "@apollo/utils.logger": "^3.0.0", + "@apollo/utils.usagereporting": "^2.1.0", + "@apollo/utils.withrequired": "^3.0.0", + "@graphql-tools/schema": "^10.0.0", + "async-retry": "^1.2.1", + "body-parser": "^2.2.2", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "finalhandler": "^2.1.0", + "loglevel": "^1.6.8", + "lru-cache": "^11.1.0", + "negotiator": "^1.0.0", + "uuid": "^11.1.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "graphql": "^16.11.0" + } + }, + "node_modules/@apollo/server-gateway-interface": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@apollo/server-gateway-interface/-/server-gateway-interface-2.0.0.tgz", + "integrity": "sha512-3HEMD6fSantG2My3jWkb9dvfkF9vJ4BDLRjMgsnD790VINtuPaEp+h3Hg9HOHiWkML6QsOhnaRqZ+gvhp3y8Nw==", + "license": "MIT", + "dependencies": { + "@apollo/usage-reporting-protobuf": "^4.1.1", + "@apollo/utils.fetcher": "^3.0.0", + "@apollo/utils.keyvaluecache": "^4.0.0", + "@apollo/utils.logger": "^3.0.0" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/server/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/@apollo/usage-reporting-protobuf": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@apollo/usage-reporting-protobuf/-/usage-reporting-protobuf-4.1.1.tgz", + "integrity": "sha512-u40dIUePHaSKVshcedO7Wp+mPiZsaU6xjv9J+VyxpoU/zL6Jle+9zWeG98tr/+SZ0nZ4OXhrbb8SNr0rAPpIDA==", + "dependencies": { + "@apollo/protobufjs": "1.2.7" + } + }, + "node_modules/@apollo/utils.createhash": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.createhash/-/utils.createhash-3.0.1.tgz", + "integrity": "sha512-CKrlySj4eQYftBE5MJ8IzKwIibQnftDT7yGfsJy5KSEEnLlPASX0UTpbKqkjlVEwPPd4mEwI7WOM7XNxEuO05A==", + "license": "MIT", + "dependencies": { + "@apollo/utils.isnodelike": "^3.0.0", + "sha.js": "^2.4.11" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@apollo/utils.dropunuseddefinitions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.dropunuseddefinitions/-/utils.dropunuseddefinitions-2.0.1.tgz", + "integrity": "sha512-EsPIBqsSt2BwDsv8Wu76LK5R1KtsVkNoO4b0M5aK0hx+dGg9xJXuqlr7Fo34Dl+y83jmzn+UvEW+t1/GP2melA==", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.fetcher": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.fetcher/-/utils.fetcher-3.1.0.tgz", + "integrity": "sha512-Z3QAyrsQkvrdTuHAFwWDNd+0l50guwoQUoaDQssLOjkmnmVuvXlJykqlEJolio+4rFwBnWdoY1ByFdKaQEcm7A==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@apollo/utils.isnodelike": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.isnodelike/-/utils.isnodelike-3.0.0.tgz", + "integrity": "sha512-xrjyjfkzunZ0DeF6xkHaK5IKR8F1FBq6qV+uZ+h9worIF/2YSzA0uoBxGv6tbTeo9QoIQnRW4PVFzGix5E7n/g==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@apollo/utils.keyvaluecache": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.keyvaluecache/-/utils.keyvaluecache-4.0.0.tgz", + "integrity": "sha512-mKw1myRUkQsGPNB+9bglAuhviodJ2L2MRYLTafCMw5BIo7nbvCPNCkLnIHjZ1NOzH7SnMAr5c9LmXiqsgYqLZw==", + "license": "MIT", + "dependencies": { + "@apollo/utils.logger": "^3.0.0", + "lru-cache": "^11.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@apollo/utils.logger": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.logger/-/utils.logger-3.0.0.tgz", + "integrity": "sha512-M8V8JOTH0F2qEi+ktPfw4RL7MvUycDfKp7aEap2eWXfL5SqWHN6jTLbj5f5fj1cceHpyaUSOZlvlaaryaxZAmg==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@apollo/utils.printwithreducedwhitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.printwithreducedwhitespace/-/utils.printwithreducedwhitespace-2.0.1.tgz", + "integrity": "sha512-9M4LUXV/fQBh8vZWlLvb/HyyhjJ77/I5ZKu+NBWV/BmYGyRmoEP9EVAy7LCVoY3t8BDcyCAGfxJaLFCSuQkPUg==", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.removealiases": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.removealiases/-/utils.removealiases-2.0.1.tgz", + "integrity": "sha512-0joRc2HBO4u594Op1nev+mUF6yRnxoUH64xw8x3bX7n8QBDYdeYgY4tF0vJReTy+zdn2xv6fMsquATSgC722FA==", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.sortast": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.sortast/-/utils.sortast-2.0.1.tgz", + "integrity": "sha512-eciIavsWpJ09za1pn37wpsCGrQNXUhM0TktnZmHwO+Zy9O4fu/WdB4+5BvVhFiZYOXvfjzJUcc+hsIV8RUOtMw==", + "dependencies": { + "lodash.sortby": "^4.7.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.stripsensitiveliterals": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.stripsensitiveliterals/-/utils.stripsensitiveliterals-2.0.1.tgz", + "integrity": "sha512-QJs7HtzXS/JIPMKWimFnUMK7VjkGQTzqD9bKD1h3iuPAqLsxd0mUNVbkYOPTsDhUKgcvUOfOqOJWYohAKMvcSA==", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.usagereporting": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.usagereporting/-/utils.usagereporting-2.1.0.tgz", + "integrity": "sha512-LPSlBrn+S17oBy5eWkrRSGb98sWmnEzo3DPTZgp8IQc8sJe0prDgDuppGq4NeQlpoqEHz0hQeYHAOA0Z3aQsxQ==", + "dependencies": { + "@apollo/usage-reporting-protobuf": "^4.1.0", + "@apollo/utils.dropunuseddefinitions": "^2.0.1", + "@apollo/utils.printwithreducedwhitespace": "^2.0.1", + "@apollo/utils.removealiases": "2.0.1", + "@apollo/utils.sortast": "^2.0.1", + "@apollo/utils.stripsensitiveliterals": "^2.0.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "graphql": "14.x || 15.x || 16.x" + } + }, + "node_modules/@apollo/utils.withrequired": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.withrequired/-/utils.withrequired-3.0.0.tgz", + "integrity": "sha512-aaxeavfJ+RHboh7c2ofO5HHtQobGX4AgUujXP4CXpREHp9fQ9jPi6K9T1jrAKe7HIipoP0OJ1gd6JamSkFIpvA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@as-integrations/express5": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@as-integrations/express5/-/express5-1.1.2.tgz", + "integrity": "sha512-BxfwtcWNf2CELDkuPQxi5Zl3WqY/dQVJYafeCBOGoFQjv5M0fjhxmAFZ9vKx/5YKKNeok4UY6PkFbHzmQrdxIA==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@apollo/server": "^4.0.0 || ^5.0.0", + "express": "^5.0.0" + } + }, + "node_modules/@babel/cli": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.28.6.tgz", + "integrity": "sha512-6EUNcuBbNkj08Oj4gAZ+BUU8yLCgKzgVX4gaTh09Ya2C8ICM4P+G30g4m3akRxSYAp3A/gnWchrNst7px4/nUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.28", + "commander": "^6.2.0", + "convert-source-map": "^2.0.0", + "fs-readdir-recursive": "^1.1.0", + "glob": "^7.2.0", + "make-dir": "^2.1.0", + "slash": "^2.0.0" + }, + "bin": { + "babel": "bin/babel.js", + "babel-external-helpers": "bin/babel-external-helpers.js" + }, + "engines": { + "node": ">=6.9.0" + }, + "optionalDependencies": { + "@nicolo-ribaudo/chokidar-2": "2.1.8-no-fsevents.3", + "chokidar": "^3.6.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/cli/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@babel/cli/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/eslint-parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.6.tgz", + "integrity": "sha512-QGmsKi2PBO/MHSQk+AAgA9R6OHQr+VqnniFE0eMWZcVcfBZoA2dKn2hUsl3Csg/Plt9opRUWdY7//VXsrIlEiA==", + "dev": true, + "dependencies": { + "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", + "eslint-visitor-keys": "^2.1.0", + "semver": "^6.3.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || >=14.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0", + "eslint": "^7.5.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/@babel/eslint-parser/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "dev": true, + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", + "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", + "dev": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", + "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", + "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-object-rest-spread": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.20.7.tgz", + "integrity": "sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-object-rest-spread instead.", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.20.5", + "@babel/helper-compilation-targets": "^7.20.7", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.20.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz", + "integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", + "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", + "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", + "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", + "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", + "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", + "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", + "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", + "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/template": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", + "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", + "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", + "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.27.1.tgz", + "integrity": "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-flow": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", + "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", + "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", + "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", + "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", + "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", + "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", + "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", + "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", + "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", + "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", + "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", + "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", + "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.27.1.tgz", + "integrity": "sha512-Q5sT5+O4QUebHdbwKedFBEwRLb02zJ7r4A5Gg2hUoLuU3FjdMcyqcywqUrLCaDsFCxzokf7u9kuy7qz51YUuAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", + "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", + "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.2.tgz", + "integrity": "sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.28.6", + "@babel/plugin-syntax-import-attributes": "^7.28.6", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.29.0", + "@babel/plugin-transform-async-to-generator": "^7.28.6", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.6", + "@babel/plugin-transform-class-properties": "^7.28.6", + "@babel/plugin-transform-class-static-block": "^7.28.6", + "@babel/plugin-transform-classes": "^7.28.6", + "@babel/plugin-transform-computed-properties": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-dotall-regex": "^7.28.6", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.6", + "@babel/plugin-transform-exponentiation-operator": "^7.28.6", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.28.6", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.28.6", + "@babel/plugin-transform-modules-systemjs": "^7.29.0", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", + "@babel/plugin-transform-numeric-separator": "^7.28.6", + "@babel/plugin-transform-object-rest-spread": "^7.28.6", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.28.6", + "@babel/plugin-transform-optional-chaining": "^7.28.6", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.28.6", + "@babel/plugin-transform-private-property-in-object": "^7.28.6", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.29.0", + "@babel/plugin-transform-regexp-modifiers": "^7.28.6", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.28.6", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.28.6", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", + "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime-corejs3": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.2.tgz", + "integrity": "sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw==", + "license": "MIT", + "dependencies": { + "core-js-pure": "^3.48.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@dependents/detective-less": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@dependents/detective-less/-/detective-less-5.0.0.tgz", + "integrity": "sha512-D/9dozteKcutI5OdxJd8rU+fL6XgaaRg60sPPJWkT33OCiRfkCu5wO5B/yXTaaL2e6EB0lcCBGe5E0XscZCvvQ==", + "dev": true, + "dependencies": { + "gonzales-pe": "^4.3.0", + "node-source-walk": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.3.1.tgz", + "integrity": "sha512-pVGjBIt1Y6gg3EJN8jTcfpP/+uuRksIo055oE/OBkDNcjZqVbfkWCksG1Jp4yZnj3iKWyWX8fdG/j6UDYPbFog==", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", + "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.1.tgz", + "integrity": "sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", + "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz", + "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.27.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.27.0.tgz", + "integrity": "sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", + "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.15.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/component": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.2.tgz", + "integrity": "sha512-iyVDGc6Vjx7Rm0cAdccLH/NG6fADsgJak/XW9IA2lPf8AjIlsemOpFGKczYyPHxm4rnKdR8z6sK4+KEC7NwmEg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.2.tgz", + "integrity": "sha512-lP96CMjMPy/+d1d9qaaHjHHdzdwvEOuyyLq9ehX89e2XMKwS1jHNzYBO+42bdSumuj5ukPbmnFtViZu8YOMT+w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.2.tgz", + "integrity": "sha512-j4A6IhVZbgxAzT6gJJC2PfOxYCK9SrDrUO7nTM4EscTYtKkAkzsbKoCnDdjFapQfnsncvPWjqVTr/0PffUwg3g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.2", + "@firebase/database": "1.1.2", + "@firebase/database-types": "1.0.18", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.18", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.18.tgz", + "integrity": "sha512-yOY8IC2go9lfbVDMiy2ATun4EB2AFwocPaQADwMN/RHRUAZSM4rlAV7PGbWPSG/YhkJ2A9xQAiAENgSua9G5Fg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.15.0" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", + "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/util": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.15.0.tgz", + "integrity": "sha512-AmWf3cHAOMbrCPG4xdPKQaj5iHnyYfyLKZxwz+Xf55bqKbpAmcYifB4jQinT2W9XhDRHISOoPyBOariJpCG6FA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@google-cloud/firestore": { + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz", + "integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@opentelemetry/api": "^1.3.0", + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^4.3.3", + "protobufjs": "^7.2.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", + "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0", + "uuid": "^8.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@google-cloud/storage/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@graphql-tools/merge": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.7.tgz", + "integrity": "sha512-Y5E1vTbTabvcXbkakdFUt4zUIzB1fyaEnVmIWN0l0GMed2gdD01TpZWLUm4RNAxpturvolrb24oGLQrBbPLSoQ==", + "license": "MIT", + "dependencies": { + "@graphql-tools/utils": "^11.0.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/schema": { + "version": "10.0.31", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.31.tgz", + "integrity": "sha512-ZewRgWhXef6weZ0WiP7/MV47HXiuFbFpiDUVLQl6mgXsWSsGELKFxQsyUCBos60Qqy1JEFAIu3Ns6GGYjGkqkQ==", + "license": "MIT", + "dependencies": { + "@graphql-tools/merge": "^9.1.7", + "@graphql-tools/utils": "^11.0.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-tools/utils": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.0.0.tgz", + "integrity": "sha512-bM1HeZdXA2C3LSIeLOnH/bcqSgbQgKEDrjxODjqi3y58xai2TkNrtYcQSoWzGbt9VMN1dORGjR7Vem8SPnUFQA==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.1.1.tgz", + "integrity": "sha512-NQ17ii0rK1b34VZonlmT2QMJFI70m0TRwbknO/ihlbatXyaktDhN/98vBiUU6kNBPljqGqyIrl2T4nY2RpFANg==", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/grpc-js/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@grpc/grpc-js/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@grpc/grpc-js/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT", + "optional": true + }, + "node_modules/@grpc/grpc-js/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/@grpc/grpc-js/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@grpc/grpc-js/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@grpc/grpc-js/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "optional": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@grpc/proto-loader/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@grpc/proto-loader/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@grpc/proto-loader/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT", + "optional": true + }, + "node_modules/@grpc/proto-loader/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/@grpc/proto-loader/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@grpc/proto-loader/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@grpc/proto-loader/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "optional": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", + "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "devOptional": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "devOptional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "devOptional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "devOptional": true + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "devOptional": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "devOptional": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "devOptional": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jasminejs/reporters": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jasminejs/reporters/-/reporters-1.0.0.tgz", + "integrity": "sha512-rM3GG4vx2H1Gp5kYCTr9aKlOEJFd43pzpiMAiy5b1+FUc2ub4e6bS6yCi/WQNDzAa5MVp9++dwcoEtcIfoEnhA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", + "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", + "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@jsdoc/salty": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.5.tgz", + "integrity": "sha512-TfRP53RqunNe2HBobVBJ0VLhK1HbfvBYeTC1ahnN64PWvyYyGebmMiPkuwvD9fpw2ZbkoPb8Q7mwy0aR8Z9rvw==", + "dev": true, + "dependencies": { + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v12.0.0" + } + }, + "node_modules/@ldapjs/asn1": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/asn1/-/asn1-2.0.0.tgz", + "integrity": "sha512-G9+DkEOirNgdPmD0I8nu57ygQJKOOgFEMKknEuQvIHbGLwP3ny1mY+OTUYLCbCaGJP4sox5eYgBJRuSUpnAddA==" + }, + "node_modules/@ldapjs/attribute": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/attribute/-/attribute-1.0.0.tgz", + "integrity": "sha512-ptMl2d/5xJ0q+RgmnqOi3Zgwk/TMJYG7dYMC0Keko+yZU6n+oFM59MjQOUht5pxJeS4FWrImhu/LebX24vJNRQ==", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/change": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/change/-/change-1.0.0.tgz", + "integrity": "sha512-EOQNFH1RIku3M1s0OAJOzGfAohuFYXFY4s73wOhRm4KFGhmQQ7MChOh2YtYu9Kwgvuq1B0xKciXVzHCGkB5V+Q==", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/attribute": "1.0.0" + } + }, + "node_modules/@ldapjs/controls": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@ldapjs/controls/-/controls-2.1.0.tgz", + "integrity": "sha512-2pFdD1yRC9V9hXfAWvCCO2RRWK9OdIEcJIos/9cCVP9O4k72BY1bLDQQ4KpUoJnl4y/JoD4iFgM+YWT3IfITWw==", + "dependencies": { + "@ldapjs/asn1": "^1.2.0", + "@ldapjs/protocol": "^1.2.1" + } + }, + "node_modules/@ldapjs/controls/node_modules/@ldapjs/asn1": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ldapjs/asn1/-/asn1-1.2.0.tgz", + "integrity": "sha512-KX/qQJ2xxzvO2/WOvr1UdQ+8P5dVvuOLk/C9b1bIkXxZss8BaR28njXdPgFCpj5aHaf1t8PmuVnea+N9YG9YMw==" + }, + "node_modules/@ldapjs/dn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ldapjs/dn/-/dn-1.1.0.tgz", + "integrity": "sha512-R72zH5ZeBj/Fujf/yBu78YzpJjJXG46YHFo5E4W1EqfNpo1UsVPqdLrRMXeKIsJT3x9dJVIfR6OpzgINlKpi0A==", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/filter": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@ldapjs/filter/-/filter-2.1.1.tgz", + "integrity": "sha512-TwPK5eEgNdUO1ABPBUQabcZ+h9heDORE4V9WNZqCtYLKc06+6+UAJ3IAbr0L0bYTnkkWC/JEQD2F+zAFsuikNw==", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/messages": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ldapjs/messages/-/messages-1.3.0.tgz", + "integrity": "sha512-K7xZpXJ21bj92jS35wtRbdcNrwmxAtPwy4myeh9duy/eR3xQKvikVycbdWVzkYEAVE5Ce520VXNOwCHjomjCZw==", + "dependencies": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.2.0" + } + }, + "node_modules/@ldapjs/protocol": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@ldapjs/protocol/-/protocol-1.2.1.tgz", + "integrity": "sha512-O89xFDLW2gBoZWNXuXpBSM32/KealKCTb3JGtJdtUQc7RjAk8XzrRgyz02cPAwGKwKPxy0ivuC7UP9bmN87egQ==" + }, + "node_modules/@mongodb-js/mongodb-downloader": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@mongodb-js/mongodb-downloader/-/mongodb-downloader-0.4.2.tgz", + "integrity": "sha512-uCd6nDtKuM2J12jgqPkApEvGQWfgZOq6yUitagvXYIqg6ofdqAnmMJO3e3wIph+Vi++dnLoMv0ME9geBzHYwDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.0", + "decompress": "^4.2.1", + "mongodb-download-url": "^1.6.2", + "node-fetch": "^2.7.0", + "tar": "^6.1.15" + } + }, + "node_modules/@mongodb-js/mongodb-downloader/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/@mongodb-js/mongodb-downloader/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@mongodb-js/mongodb-downloader/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/@mongodb-js/mongodb-downloader/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.0.tgz", + "integrity": "sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.5.tgz", + "integrity": "sha512-kwUxR7J9WLutBbulqg1dfOrMTwhMdXLdcGUhcbCcGwnPLt3gz19uHVdwH1syKVDbE022ZS2vZxOWflFLS0YTjw==", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.1.0", + "@emnapi/runtime": "^1.1.0", + "@tybys/wasm-util": "^0.9.0" + } + }, + "node_modules/@nicolo-ribaudo/chokidar-2": { + "version": "2.1.8-no-fsevents.3", + "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz", + "integrity": "sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ==", + "dev": true, + "optional": true + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { + "version": "5.1.1-v1", + "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", + "integrity": "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==", + "dev": true, + "dependencies": { + "eslint-scope": "5.1.1" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@node-rs/bcrypt": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt/-/bcrypt-1.10.7.tgz", + "integrity": "sha512-1wk0gHsUQC/ap0j6SJa2K34qNhomxXRcEe3T8cI5s+g6fgHBgLTN7U9LzWTG/HE6G4+2tWWLeCabk1wiYGEQSA==", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@node-rs/bcrypt-android-arm-eabi": "1.10.7", + "@node-rs/bcrypt-android-arm64": "1.10.7", + "@node-rs/bcrypt-darwin-arm64": "1.10.7", + "@node-rs/bcrypt-darwin-x64": "1.10.7", + "@node-rs/bcrypt-freebsd-x64": "1.10.7", + "@node-rs/bcrypt-linux-arm-gnueabihf": "1.10.7", + "@node-rs/bcrypt-linux-arm64-gnu": "1.10.7", + "@node-rs/bcrypt-linux-arm64-musl": "1.10.7", + "@node-rs/bcrypt-linux-x64-gnu": "1.10.7", + "@node-rs/bcrypt-linux-x64-musl": "1.10.7", + "@node-rs/bcrypt-wasm32-wasi": "1.10.7", + "@node-rs/bcrypt-win32-arm64-msvc": "1.10.7", + "@node-rs/bcrypt-win32-ia32-msvc": "1.10.7", + "@node-rs/bcrypt-win32-x64-msvc": "1.10.7" + } + }, + "node_modules/@node-rs/bcrypt-android-arm-eabi": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm-eabi/-/bcrypt-android-arm-eabi-1.10.7.tgz", + "integrity": "sha512-8dO6/PcbeMZXS3VXGEtct9pDYdShp2WBOWlDvSbcRwVqyB580aCBh0BEFmKYtXLzLvUK8Wf+CG3U6sCdILW1lA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-android-arm64": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm64/-/bcrypt-android-arm64-1.10.7.tgz", + "integrity": "sha512-UASFBS/CucEMHiCtL/2YYsAY01ZqVR1N7vSb94EOvG5iwW7BQO06kXXCTgj+Xbek9azxixrCUmo3WJnkJZ0hTQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-darwin-arm64": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-arm64/-/bcrypt-darwin-arm64-1.10.7.tgz", + "integrity": "sha512-DgzFdAt455KTuiJ/zYIyJcKFobjNDR/hnf9OS7pK5NRS13Nq4gLcSIIyzsgHwZHxsJWbLpHmFc1H23Y7IQoQBw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-darwin-x64": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-x64/-/bcrypt-darwin-x64-1.10.7.tgz", + "integrity": "sha512-SPWVfQ6sxSokoUWAKWD0EJauvPHqOGQTd7CxmYatcsUgJ/bruvEHxZ4bIwX1iDceC3FkOtmeHO0cPwR480n/xA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-freebsd-x64": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-freebsd-x64/-/bcrypt-freebsd-x64-1.10.7.tgz", + "integrity": "sha512-gpa+Ixs6GwEx6U6ehBpsQetzUpuAGuAFbOiuLB2oo4N58yU4AZz1VIcWyWAHrSWRs92O0SHtmo2YPrMrwfBbSw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-arm-gnueabihf": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm-gnueabihf/-/bcrypt-linux-arm-gnueabihf-1.10.7.tgz", + "integrity": "sha512-kYgJnTnpxrzl9sxYqzflobvMp90qoAlaX1oDL7nhNTj8OYJVDIk0jQgblj0bIkjmoPbBed53OJY/iu4uTS+wig==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-arm64-gnu": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-gnu/-/bcrypt-linux-arm64-gnu-1.10.7.tgz", + "integrity": "sha512-7cEkK2RA+gBCj2tCVEI1rDSJV40oLbSq7bQ+PNMHNI6jCoXGmj9Uzo7mg7ZRbNZ7piIyNH5zlJqutjo8hh/tmA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-arm64-musl": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-musl/-/bcrypt-linux-arm64-musl-1.10.7.tgz", + "integrity": "sha512-X7DRVjshhwxUqzdUKDlF55cwzh+wqWJ2E/tILvZPboO3xaNO07Um568Vf+8cmKcz+tiZCGP7CBmKbBqjvKN/Pw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-x64-gnu": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-gnu/-/bcrypt-linux-x64-gnu-1.10.7.tgz", + "integrity": "sha512-LXRZsvG65NggPD12hn6YxVgH0W3VR5fsE/o1/o2D5X0nxKcNQGeLWnRzs5cP8KpoFOuk1ilctXQJn8/wq+Gn/Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-x64-musl": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-musl/-/bcrypt-linux-x64-musl-1.10.7.tgz", + "integrity": "sha512-tCjHmct79OfcO3g5q21ME7CNzLzpw1MAsUXCLHLGWH+V6pp/xTvMbIcLwzkDj6TI3mxK6kehTn40SEjBkZ3Rog==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-wasm32-wasi": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-wasm32-wasi/-/bcrypt-wasm32-wasi-1.10.7.tgz", + "integrity": "sha512-4qXSihIKeVXYglfXZEq/QPtYtBUvR8d3S85k15Lilv3z5B6NSGQ9mYiNleZ7QHVLN2gEc5gmi7jM353DMH9GkA==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@node-rs/bcrypt-win32-arm64-msvc": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-arm64-msvc/-/bcrypt-win32-arm64-msvc-1.10.7.tgz", + "integrity": "sha512-FdfUQrqmDfvC5jFhntMBkk8EI+fCJTx/I1v7Rj+Ezlr9rez1j1FmuUnywbBj2Cg15/0BDhwYdbyZ5GCMFli2aQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-win32-ia32-msvc": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-ia32-msvc/-/bcrypt-win32-ia32-msvc-1.10.7.tgz", + "integrity": "sha512-lZLf4Cx+bShIhU071p5BZft4OvP4PGhyp542EEsb3zk34U5GLsGIyCjOafcF/2DGewZL6u8/aqoxbSuROkgFXg==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-win32-x64-msvc": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-x64-msvc/-/bcrypt-win32-x64-msvc-1.10.7.tgz", + "integrity": "sha512-hdw7tGmN1DxVAMTzICLdaHpXjy+4rxaxnBMgI8seG1JL5e3VcRGsd1/1vVDogVp2cbsmgq+6d6yAY+D9lW/DCg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.2.tgz", + "integrity": "sha512-ODsoD39Lq6vR6aBgvjTnA3nZGliknKboc9Gtxr7E4WDNqY24MxANKcuDQSF0jzapvGb3KWOEDrKfve4HoWGK+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.1", + "@octokit/request": "^10.0.2", + "@octokit/request-error": "^7.0.0", + "@octokit/types": "^14.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core/node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/core/node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.0.tgz", + "integrity": "sha512-hoYicJZaqISMAI3JfaDr1qMNi48OctWuOih1m80bkYow/ayPw6Jj52tqWJ6GEoFTk1gBqfanSoI1iY99Z5+ekQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint/node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/endpoint/node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.1.tgz", + "integrity": "sha512-j1nQNU1ZxNFx2ZtKmL4sMrs4egy5h65OMDmSbVyuCzjOcwsHq6EaYjOTGXPQxgfiN8dJ4CriYHk6zF050WEULg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.2", + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql/node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/graphql/node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "22.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", + "integrity": "sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==", + "dev": true + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@octokit/plugin-retry": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-8.0.1.tgz", + "integrity": "sha512-KUoYR77BjF5O3zcwDQHRRZsUvJwepobeqiSSdCJ8lWt27FZExzb0GgVxrhhfuyF6z2B2zpO0hN5pteni1sqWiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request-error": "^7.0.0", + "@octokit/types": "^14.0.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=7" + } + }, + "node_modules/@octokit/plugin-retry/node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-retry/node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, + "node_modules/@octokit/plugin-throttling": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-11.0.1.tgz", + "integrity": "sha512-S+EVhy52D/272L7up58dr3FNSMXWuNZolkL4zMJBNIfIxyZuUcczsQAU4b5w6dewJXnKYVgSHSV5wxitMSW1kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": "^7.0.0" + } + }, + "node_modules/@octokit/plugin-throttling/node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-throttling/node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.2.tgz", + "integrity": "sha512-iYj4SJG/2bbhh+iIpFmG5u49DtJ4lipQ+aPakjL9OKpsGY93wM8w06gvFbEQxcMsZcCvk5th5KkIm2m8o14aWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.0", + "@octokit/request-error": "^7.0.0", + "@octokit/types": "^14.0.0", + "fast-content-type-parse": "^3.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.0.0.tgz", + "integrity": "sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error/node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/request-error/node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, + "node_modules/@octokit/request/node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/request/node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, + "node_modules/@octokit/types": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.5.0.tgz", + "integrity": "sha512-HdqWTf5Z3qwDVlzCrP8UJquMwunpDiMPt5er+QjGzL4hqr/vBVY/MauQgS1xWxCDT1oMx1EULyqxncdCY/NVSQ==", + "dev": true, + "dependencies": { + "@octokit/openapi-types": "^22.2.0" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@parse/fs-files-adapter": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@parse/fs-files-adapter/-/fs-files-adapter-3.0.0.tgz", + "integrity": "sha512-Bb+qLtXQ/1SA2Ck6JLVhfD9JQf6cCwgeDZZJjcIdHzUtdPTFu1hj51xdD7tUCL47Ed2i3aAx6K/M6AjLWYVs3A==" + }, + "node_modules/@parse/node-apn": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@parse/node-apn/-/node-apn-8.0.0.tgz", + "integrity": "sha512-blvU/V0FL3j7u2lstso1aInMw7yYrKg/6Ctr3Kc/7kleFatAfZswhzHk9d5lI4DUQBsUBun8nidgZHCY6sft+Q==", + "license": "MIT", + "dependencies": { + "debug": "4.4.3", + "jsonwebtoken": "9.0.3", + "node-forge": "1.4.0", + "verror": "1.10.1" + }, + "engines": { + "node": "20 || 22 || 24" + } + }, + "node_modules/@parse/push-adapter": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@parse/push-adapter/-/push-adapter-8.4.0.tgz", + "integrity": "sha512-sWinUJZvbWIH6cJfIRuwUCcsjvi6IkoJ3zp2JoCP/mLzItt6NPNk+j73RE9UJzIKlwt3NciWXeSHoxprPnNH/A==", + "license": "MIT", + "dependencies": { + "@parse/node-apn": "8.0.0", + "expo-server-sdk": "6.1.0", + "firebase-admin": "13.7.0", + "npmlog": "7.0.1", + "parse": "8.5.0", + "web-push": "3.6.7" + }, + "engines": { + "node": "20 || 22 || 24" + } + }, + "node_modules/@parse/push-adapter/node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@parse/push-adapter/node_modules/@babel/runtime-corejs3": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.0.tgz", + "integrity": "sha512-TgUkdp71C9pIbBcHudc+gXZnihEDOjUAmXO1VO4HHGES7QLZcShR0stfKIxLSNIYx2fqhmJChOjm/wkF8wv4gA==", + "license": "MIT", + "dependencies": { + "core-js-pure": "^3.48.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@parse/push-adapter/node_modules/parse": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/parse/-/parse-8.5.0.tgz", + "integrity": "sha512-X9gI4Yjbi9LPMPnCtKL4h0Nxe1aSCFMPWcB1zbu11qU/Be3eVSB5I5IMBunTuWlVz6Wchu3dtM5jl/1aBZ9wiQ==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "7.28.6", + "@babel/runtime-corejs3": "7.29.0", + "crypto-js": "4.2.0", + "idb-keyval": "6.2.2", + "react-native-crypto-js": "1.0.0", + "ws": "8.19.0" + }, + "engines": { + "node": ">=20.19.0 <21 || >=22.12.0 <23 || >=24.1.0 <25" + } + }, + "node_modules/@parse/push-adapter/node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pnpm/config.env-replace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", + "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", + "dev": true, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", + "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", + "dev": true, + "dependencies": { + "graceful-fs": "4.2.10" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/npm-conf": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-2.2.2.tgz", + "integrity": "sha512-UA91GwWPhFExt3IizW6bOeY/pQ0BkuNwKjk9iQW9KqxluGCrg4VenZ0/L+2Y0+ZOtme72EVvg6v0zo3AMQRCeA==", + "dev": true, + "dependencies": { + "@pnpm/config.env-replace": "^1.1.0", + "@pnpm/network.ca-file": "^1.0.1", + "config-chain": "^1.1.11" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" + }, + "node_modules/@redis/bloom": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.11.0.tgz", + "integrity": "sha512-KYiVilAhAFN3057afUb/tfYJpsEyTkQB+tQcn5gVVA7DgcNOAj8lLxe4j8ov8BF6I9C1Fe/kwlbuAICcTMX8Lw==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.11.0" + } + }, + "node_modules/@redis/client": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.11.0.tgz", + "integrity": "sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.11.0.tgz", + "integrity": "sha512-1iAy9kAtcD0quB21RbPTbUqqy+T2Uu2JxucwE+B4A+VaDbIRvpZR6DMqV8Iqaws2YxJYB3GC5JVNzPYio2ErUg==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.11.0" + } + }, + "node_modules/@redis/search": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.11.0.tgz", + "integrity": "sha512-g1l7f3Rnyk/xI99oGHIgWHSKFl45Re5YTIcO8j/JE8olz389yUFyz2+A6nqVy/Zi031VgPDWscbbgOk8hlhZ3g==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.11.0" + } + }, + "node_modules/@redis/time-series": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.11.0.tgz", + "integrity": "sha512-TWFeOcU4xkj0DkndnOyhtxvX1KWD+78UHT3XX3x3XRBUGWeQrKo3jqzDsZwxbggUgf9yLJr/akFHXru66X5UQA==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.11.0" + } + }, + "node_modules/@saithodev/semantic-release-backmerge": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@saithodev/semantic-release-backmerge/-/semantic-release-backmerge-4.0.1.tgz", + "integrity": "sha512-WDsU28YrXSLx0xny7FgFlEk8DCKGcj6OOhA+4Q9k3te1jJD1GZuqY8sbIkVQaw9cqJ7CT+fCZUN6QDad8JW4Dg==", + "dev": true, + "dependencies": { + "@semantic-release/error": "^3.0.0", + "aggregate-error": "^3.1.0", + "debug": "^4.3.4", + "execa": "^5.1.1", + "lodash": "^4.17.21", + "semantic-release": "^22.0.7" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/auth-token": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", + "integrity": "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==", + "dev": true, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/core": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.0.tgz", + "integrity": "sha512-1LFfa/qnMQvEOAdzlQymH0ulepxbxnCYAKJZfMci/5XJyIHWgEYnDmgnKakbTh7CH2tFQ5O60oYDvns4i9RAIg==", + "dev": true, + "dependencies": { + "@octokit/auth-token": "^4.0.0", + "@octokit/graphql": "^7.1.0", + "@octokit/request": "^8.3.1", + "@octokit/request-error": "^5.1.0", + "@octokit/types": "^13.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/endpoint": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.5.tgz", + "integrity": "sha512-ekqR4/+PCLkEBF6qgj8WqJfvDq65RH85OAgrtnVp1mSxaXF03u2xW/hUdweGS5654IlC0wkNYC18Z50tSYTAFw==", + "dev": true, + "dependencies": { + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/graphql": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.0.tgz", + "integrity": "sha512-r+oZUH7aMFui1ypZnAvZmn0KSqAUgE1/tUXIWaqUCa1758ts/Jio84GZuzsvUkme98kv0WFY8//n0J1Z+vsIsQ==", + "dev": true, + "dependencies": { + "@octokit/request": "^8.3.0", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/openapi-types": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-20.0.0.tgz", + "integrity": "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==", + "dev": true + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/plugin-paginate-rest": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-9.2.1.tgz", + "integrity": "sha512-wfGhE/TAkXZRLjksFXuDZdmGnJQHvtU/joFQdweXUgzo1XwvBCD4o4+75NtFfjfLK5IwLf9vHTfSiU3sLRYpRw==", + "dev": true, + "dependencies": { + "@octokit/types": "^12.6.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.6.0.tgz", + "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", + "dev": true, + "dependencies": { + "@octokit/openapi-types": "^20.0.0" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/plugin-retry": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-6.0.1.tgz", + "integrity": "sha512-SKs+Tz9oj0g4p28qkZwl/topGcb0k0qPNX/i7vBKmDsjoeqnVfFUquqrE/O9oJY7+oLzdCtkiWSXLpLjvl6uog==", + "dev": true, + "dependencies": { + "@octokit/request-error": "^5.0.0", + "@octokit/types": "^12.0.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=5" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/plugin-retry/node_modules/@octokit/types": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.6.0.tgz", + "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", + "dev": true, + "dependencies": { + "@octokit/openapi-types": "^20.0.0" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/plugin-throttling": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-8.2.0.tgz", + "integrity": "sha512-nOpWtLayKFpgqmgD0y3GqXafMFuKcA4tRPZIfu7BArd2lEZeb1988nhWhwx4aZWmjDmUfdgVf7W+Tt4AmvRmMQ==", + "dev": true, + "dependencies": { + "@octokit/types": "^12.2.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "^5.0.0" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/plugin-throttling/node_modules/@octokit/types": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.6.0.tgz", + "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", + "dev": true, + "dependencies": { + "@octokit/openapi-types": "^20.0.0" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/request": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.4.0.tgz", + "integrity": "sha512-9Bb014e+m2TgBeEJGEbdplMVWwPmL1FPtggHQRkV+WVsMggPtEkLKPlcVYm/o8xKLkpJ7B+6N8WfQMtDLX2Dpw==", + "dev": true, + "dependencies": { + "@octokit/endpoint": "^9.0.1", + "@octokit/request-error": "^5.1.0", + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@octokit/request-error": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.1.0.tgz", + "integrity": "sha512-GETXfE05J0+7H2STzekpKObFe765O5dlAKUTLNGeH+x47z7JjXHfsHKo5z21D/o/IOZTUEI6nyWyR+bZVP/n5Q==", + "dev": true, + "dependencies": { + "@octokit/types": "^13.1.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@semantic-release/commit-analyzer": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@semantic-release/commit-analyzer/-/commit-analyzer-11.1.0.tgz", + "integrity": "sha512-cXNTbv3nXR2hlzHjAMgbuiQVtvWHTlwwISt60B+4NZv01y/QRY7p2HcJm8Eh2StzcTJoNnflvKjHH/cjFS7d5g==", + "dev": true, + "dependencies": { + "conventional-changelog-angular": "^7.0.0", + "conventional-commits-filter": "^4.0.0", + "conventional-commits-parser": "^5.0.0", + "debug": "^4.0.0", + "import-from-esm": "^1.0.3", + "lodash-es": "^4.17.21", + "micromatch": "^4.0.2" + }, + "engines": { + "node": "^18.17 || >=20.6.1" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@semantic-release/github": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-9.2.6.tgz", + "integrity": "sha512-shi+Lrf6exeNZF+sBhK+P011LSbhmIAoUEgEY6SsxF8irJ+J2stwI5jkyDQ+4gzYyDImzV6LCKdYB9FXnQRWKA==", + "dev": true, + "dependencies": { + "@octokit/core": "^5.0.0", + "@octokit/plugin-paginate-rest": "^9.0.0", + "@octokit/plugin-retry": "^6.0.0", + "@octokit/plugin-throttling": "^8.0.0", + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "debug": "^4.3.4", + "dir-glob": "^3.0.1", + "globby": "^14.0.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "issue-parser": "^6.0.0", + "lodash-es": "^4.17.21", + "mime": "^4.0.0", + "p-filter": "^4.0.0", + "url-join": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@semantic-release/github/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@semantic-release/github/node_modules/aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "dependencies": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@semantic-release/npm": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-11.0.3.tgz", + "integrity": "sha512-KUsozQGhRBAnoVg4UMZj9ep436VEGwT536/jwSqB7vcEfA6oncCUU7UIYTRdLx7GvTtqn0kBjnkfLVkcnBa2YQ==", + "dev": true, + "dependencies": { + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "execa": "^8.0.0", + "fs-extra": "^11.0.0", + "lodash-es": "^4.17.21", + "nerf-dart": "^1.0.0", + "normalize-url": "^8.0.0", + "npm": "^10.5.0", + "rc": "^1.2.8", + "read-pkg": "^9.0.0", + "registry-auth-token": "^5.0.0", + "semver": "^7.1.2", + "tempy": "^3.0.0" + }, + "engines": { + "node": "^18.17 || >=20" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@semantic-release/npm/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@semantic-release/npm/node_modules/aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "dependencies": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@semantic-release/npm/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@semantic-release/npm/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@semantic-release/release-notes-generator": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-12.1.0.tgz", + "integrity": "sha512-g6M9AjUKAZUZnxaJZnouNBeDNTCUrJ5Ltj+VJ60gJeDaRRahcHsry9HW8yKrnKkKNkx5lbWiEP1FPMqVNQz8Kg==", + "dev": true, + "dependencies": { + "conventional-changelog-angular": "^7.0.0", + "conventional-changelog-writer": "^7.0.0", + "conventional-commits-filter": "^4.0.0", + "conventional-commits-parser": "^5.0.0", + "debug": "^4.0.0", + "get-stream": "^7.0.0", + "import-from-esm": "^1.0.3", + "into-stream": "^7.0.0", + "lodash-es": "^4.17.21", + "read-pkg-up": "^11.0.0" + }, + "engines": { + "node": "^18.17 || >=20.6.1" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/@semantic-release/release-notes-generator/node_modules/get-stream": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-7.0.1.tgz", + "integrity": "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/ansi-escapes": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.1.tgz", + "integrity": "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", + "dev": true + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "dev": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/clean-stack": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.2.0.tgz", + "integrity": "sha512-TyUIUJgdFnCISzG5zu3291TAsE77ddchd0bepon1VVQrKLGKFED4iXFEDQ24mIPdPBbyE16PK3F8MYE1CmcBEQ==", + "dev": true, + "dependencies": { + "escape-string-regexp": "5.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/conventional-changelog-angular": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", + "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==", + "dev": true, + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/conventional-changelog-writer": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-7.0.1.tgz", + "integrity": "sha512-Uo+R9neH3r/foIvQ0MKcsXkX642hdm9odUp7TqgFS7BsalTcjzRlIfWZrZR1gbxOozKucaKt5KAbjW8J8xRSmA==", + "dev": true, + "dependencies": { + "conventional-commits-filter": "^4.0.0", + "handlebars": "^4.7.7", + "json-stringify-safe": "^5.0.1", + "meow": "^12.0.1", + "semver": "^7.5.2", + "split2": "^4.0.0" + }, + "bin": { + "conventional-changelog-writer": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/conventional-commits-filter": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-4.0.0.tgz", + "integrity": "sha512-rnpnibcSOdFcdclpFwWa+pPlZJhXE7l+XK04zxhbWrhgpR96h33QLz8hITTXbcYICxVr3HZFtbtUAQ+4LdBo9A==", + "dev": true, + "engines": { + "node": ">=16" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/conventional-commits-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", + "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", + "dev": true, + "dependencies": { + "is-text-path": "^2.0.0", + "JSONStream": "^1.3.5", + "meow": "^12.0.1", + "split2": "^4.0.0" + }, + "bin": { + "conventional-commits-parser": "cli.mjs" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/env-ci": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-10.0.0.tgz", + "integrity": "sha512-U4xcd/utDYFgMh0yWj07R1H6L5fwhVbmxBCpnL0DbVSDZVnsC82HONw0wxtxNkIAcua3KtbomQvIk5xFZGAQJw==", + "dev": true, + "dependencies": { + "execa": "^8.0.0", + "java-properties": "^1.0.2" + }, + "engines": { + "node": "^18.17 || >=20.6.1" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/env-ci/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/env-ci/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/find-versions": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-5.1.0.tgz", + "integrity": "sha512-+iwzCJ7C5v5KgcBuueqVoNiHVoQpwiUK5XFLjf0affFTep+Wcw93tPvmb8tqujDNmzhBDPddnWV/qgWSXgq+Hg==", + "dev": true, + "dependencies": { + "semver-regex": "^4.0.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/globby": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.2.tgz", + "integrity": "sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==", + "dev": true, + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.2", + "ignore": "^5.2.4", + "path-type": "^5.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/globby/node_modules/path-type": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz", + "integrity": "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/issue-parser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-6.0.0.tgz", + "integrity": "sha512-zKa/Dxq2lGsBIXQ7CUZWTHfvxPC2ej0KfO7fIPqLlHB9J2hJ7rGhZ5rilhuufylr4RXYPzJUeFjKxz305OsNlA==", + "dev": true, + "dependencies": { + "lodash.capitalize": "^4.2.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.uniqby": "^4.7.0" + }, + "engines": { + "node": ">=10.13" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/marked": { + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz", + "integrity": "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==", + "dev": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/marked-terminal": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/marked-terminal/-/marked-terminal-6.2.0.tgz", + "integrity": "sha512-ubWhwcBFHnXsjYNsu+Wndpg0zhY4CahSpPlA70PlO0rR9r2sZpkyU+rkCsOWH+KMEkx847UpALON+HWgxowFtw==", + "dev": true, + "dependencies": { + "ansi-escapes": "^6.2.0", + "cardinal": "^2.1.1", + "chalk": "^5.3.0", + "cli-table3": "^0.6.3", + "node-emoji": "^2.1.3", + "supports-hyperlinks": "^3.0.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "marked": ">=1 <12" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/meow": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", + "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", + "dev": true, + "engines": { + "node": ">=16.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/p-reduce": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-3.0.0.tgz", + "integrity": "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/semantic-release": { + "version": "22.0.12", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-22.0.12.tgz", + "integrity": "sha512-0mhiCR/4sZb00RVFJIUlMuiBkW3NMpVIW2Gse7noqEMoFGkvfPPAImEQbkBV8xga4KOPP4FdTRYuLLy32R1fPw==", + "dev": true, + "dependencies": { + "@semantic-release/commit-analyzer": "^11.0.0", + "@semantic-release/error": "^4.0.0", + "@semantic-release/github": "^9.0.0", + "@semantic-release/npm": "^11.0.0", + "@semantic-release/release-notes-generator": "^12.0.0", + "aggregate-error": "^5.0.0", + "cosmiconfig": "^8.0.0", + "debug": "^4.0.0", + "env-ci": "^10.0.0", + "execa": "^8.0.0", + "figures": "^6.0.0", + "find-versions": "^5.1.0", + "get-stream": "^6.0.0", + "git-log-parser": "^1.2.0", + "hook-std": "^3.0.0", + "hosted-git-info": "^7.0.0", + "import-from-esm": "^1.3.1", + "lodash-es": "^4.17.21", + "marked": "^9.0.0", + "marked-terminal": "^6.0.0", + "micromatch": "^4.0.2", + "p-each-series": "^3.0.0", + "p-reduce": "^3.0.0", + "read-pkg-up": "^11.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.3.2", + "semver-diff": "^4.0.0", + "signale": "^1.2.1", + "yargs": "^17.5.1" + }, + "bin": { + "semantic-release": "bin/semantic-release.js" + }, + "engines": { + "node": "^18.17 || >=20.6.1" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/semantic-release/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/semantic-release/node_modules/aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "dependencies": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/semantic-release/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/semantic-release/node_modules/execa/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", + "dev": true + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@saithodev/semantic-release-backmerge/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true + }, + "node_modules/@semantic-release/changelog": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@semantic-release/changelog/-/changelog-6.0.3.tgz", + "integrity": "sha512-dZuR5qByyfe3Y03TpmCvAxCyTnp7r5XwtHRf/8vD9EAn4ZWbavUX8adMtXYzE86EVh0gyLA7lm5yW4IV30XUag==", + "dev": true, + "dependencies": { + "@semantic-release/error": "^3.0.0", + "aggregate-error": "^3.0.0", + "fs-extra": "^11.0.0", + "lodash": "^4.17.4" + }, + "engines": { + "node": ">=14.17" + }, + "peerDependencies": { + "semantic-release": ">=18.0.0" + } + }, + "node_modules/@semantic-release/commit-analyzer": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/@semantic-release/commit-analyzer/-/commit-analyzer-13.0.1.tgz", + "integrity": "sha512-wdnBPHKkr9HhNhXOhZD5a2LNl91+hs8CC2vsAVYxtZH3y0dV3wKn+uZSN61rdJQZ8EGxzWB3inWocBHV9+u/CQ==", + "dev": true, + "dependencies": { + "conventional-changelog-angular": "^8.0.0", + "conventional-changelog-writer": "^8.0.0", + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.0.0", + "debug": "^4.0.0", + "import-from-esm": "^2.0.0", + "lodash-es": "^4.17.21", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=20.8.1" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@semantic-release/commit-analyzer/node_modules/import-from-esm": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-2.0.0.tgz", + "integrity": "sha512-YVt14UZCgsX1vZQ3gKjkWVdBdHQ6eu3MPU1TBgL1H5orXe2+jWD006WCPPtOuwlQm10NuzOW5WawiF1Q9veW8g==", + "dev": true, + "dependencies": { + "debug": "^4.3.4", + "import-meta-resolve": "^4.0.0" + }, + "engines": { + "node": ">=18.20" + } + }, + "node_modules/@semantic-release/error": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-3.0.0.tgz", + "integrity": "sha512-5hiM4Un+tpl4cKw3lV4UgzJj+SmfNIDCLLw0TepzQxz9ZGV5ixnqkzIVF+3tp0ZHgcMKE+VNGHJjEeyFG2dcSw==", + "dev": true, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@semantic-release/git": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@semantic-release/git/-/git-10.0.1.tgz", + "integrity": "sha512-eWrx5KguUcU2wUPaO6sfvZI0wPafUKAMNC18aXY4EnNcrZL86dEmpNVnC9uMpGZkmZJ9EfCVJBQx4pV4EMGT1w==", + "dev": true, + "dependencies": { + "@semantic-release/error": "^3.0.0", + "aggregate-error": "^3.0.0", + "debug": "^4.0.0", + "dir-glob": "^3.0.0", + "execa": "^5.0.0", + "lodash": "^4.17.4", + "micromatch": "^4.0.0", + "p-reduce": "^2.0.0" + }, + "engines": { + "node": ">=14.17" + }, + "peerDependencies": { + "semantic-release": ">=18.0.0" + } + }, + "node_modules/@semantic-release/github": { + "version": "12.0.6", + "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.6.tgz", + "integrity": "sha512-aYYFkwHW3c6YtHwQF0t0+lAjlU+87NFOZuH2CvWFD0Ylivc7MwhZMiHOJ0FMpIgPpCVib/VUAcOwvrW0KnxQtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.0", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-retry": "^8.0.0", + "@octokit/plugin-throttling": "^11.0.0", + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "debug": "^4.3.4", + "dir-glob": "^3.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "issue-parser": "^7.0.0", + "lodash-es": "^4.17.21", + "mime": "^4.0.0", + "p-filter": "^4.0.0", + "tinyglobby": "^0.2.14", + "undici": "^7.0.0", + "url-join": "^5.0.0" + }, + "engines": { + "node": "^22.14.0 || >= 24.10.0" + }, + "peerDependencies": { + "semantic-release": ">=24.1.0" + } + }, + "node_modules/@semantic-release/github/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/github/node_modules/aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "dependencies": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/github/node_modules/clean-stack": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.2.0.tgz", + "integrity": "sha512-TyUIUJgdFnCISzG5zu3291TAsE77ddchd0bepon1VVQrKLGKFED4iXFEDQ24mIPdPBbyE16PK3F8MYE1CmcBEQ==", + "dev": true, + "dependencies": { + "escape-string-regexp": "5.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/github/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/github/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/github/node_modules/undici": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", + "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/@semantic-release/npm": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-13.0.0.tgz", + "integrity": "sha512-7RIx9nUdUekYbIZ0dG7k7G/iSvUCZb03LmmBPFqAQEhPVC+BnHfhFxj5ewSNP6zMUsYaEQSckcOhKD8AuS/EzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "execa": "^9.0.0", + "fs-extra": "^11.0.0", + "lodash-es": "^4.17.21", + "nerf-dart": "^1.0.0", + "normalize-url": "^8.0.0", + "npm": "^11.6.2", + "rc": "^1.2.8", + "read-pkg": "^9.0.0", + "registry-auth-token": "^5.0.0", + "semver": "^7.1.2", + "tempy": "^3.0.0" + }, + "engines": { + "node": "^22.14.0 || >= 24.10.0" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/npm/node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "dependencies": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/clean-stack": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.2.0.tgz", + "integrity": "sha512-TyUIUJgdFnCISzG5zu3291TAsE77ddchd0bepon1VVQrKLGKFED4iXFEDQ24mIPdPBbyE16PK3F8MYE1CmcBEQ==", + "dev": true, + "dependencies": { + "escape-string-regexp": "5.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/execa": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.3.0.tgz", + "integrity": "sha512-l6JFbqnHEadBoVAVpN5dl2yCyfX28WoBAGaoQcNmLLSedOxTxcn2Qa83s8I/PA5i56vWru2OHOtrwF7Om2vqlg==", + "dev": true, + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.3", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^7.0.0", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^5.2.0", + "pretty-ms": "^9.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@semantic-release/npm/node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/human-signals": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-7.0.0.tgz", + "integrity": "sha512-74kytxOUSvNbjrT9KisAbaTZ/eJwD/LrbM/kh5j0IhPuJzwuA19dWvniFGwBzN9rVjg+O/e+F310PjObDXS+9Q==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.12.0.tgz", + "integrity": "sha512-xPhOap4ZbJWyd7DAOukP564WFwNSGu/2FeTRFHhiiKthcauxhH/NpkJAQm24xD+cAn8av5tQ00phi98DqtfLsg==", + "bundleDependencies": [ + "@isaacs/string-locale-compare", + "@npmcli/arborist", + "@npmcli/config", + "@npmcli/fs", + "@npmcli/map-workspaces", + "@npmcli/metavuln-calculator", + "@npmcli/package-json", + "@npmcli/promise-spawn", + "@npmcli/redact", + "@npmcli/run-script", + "@sigstore/tuf", + "abbrev", + "archy", + "cacache", + "chalk", + "ci-info", + "fastest-levenshtein", + "fs-minipass", + "glob", + "graceful-fs", + "hosted-git-info", + "ini", + "init-package-json", + "is-cidr", + "json-parse-even-better-errors", + "libnpmaccess", + "libnpmdiff", + "libnpmexec", + "libnpmfund", + "libnpmorg", + "libnpmpack", + "libnpmpublish", + "libnpmsearch", + "libnpmteam", + "libnpmversion", + "make-fetch-happen", + "minimatch", + "minipass", + "minipass-pipeline", + "ms", + "node-gyp", + "nopt", + "npm-audit-report", + "npm-install-checks", + "npm-package-arg", + "npm-pick-manifest", + "npm-profile", + "npm-registry-fetch", + "npm-user-validate", + "p-map", + "pacote", + "parse-conflict-json", + "proc-log", + "qrcode-terminal", + "read", + "semver", + "spdx-expression-parse", + "ssri", + "supports-color", + "tar", + "text-table", + "tiny-relative-date", + "treeverse", + "validate-npm-package-name", + "which" + ], + "dev": true, + "license": "Artistic-2.0", + "workspaces": [ + "docs", + "smoke-tests", + "mock-globals", + "mock-registry", + "workspaces/*" + ], + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/arborist": "^9.4.2", + "@npmcli/config": "^10.8.0", + "@npmcli/fs": "^5.0.0", + "@npmcli/map-workspaces": "^5.0.3", + "@npmcli/metavuln-calculator": "^9.0.3", + "@npmcli/package-json": "^7.0.5", + "@npmcli/promise-spawn": "^9.0.1", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.4", + "@sigstore/tuf": "^4.0.2", + "abbrev": "^4.0.0", + "archy": "~1.0.0", + "cacache": "^20.0.4", + "chalk": "^5.6.2", + "ci-info": "^4.4.0", + "fastest-levenshtein": "^1.0.16", + "fs-minipass": "^3.0.3", + "glob": "^13.0.6", + "graceful-fs": "^4.2.11", + "hosted-git-info": "^9.0.2", + "ini": "^6.0.0", + "init-package-json": "^8.2.5", + "is-cidr": "^6.0.3", + "json-parse-even-better-errors": "^5.0.0", + "libnpmaccess": "^10.0.3", + "libnpmdiff": "^8.1.5", + "libnpmexec": "^10.2.5", + "libnpmfund": "^7.0.19", + "libnpmorg": "^8.0.1", + "libnpmpack": "^9.1.5", + "libnpmpublish": "^11.1.3", + "libnpmsearch": "^9.0.1", + "libnpmteam": "^8.0.2", + "libnpmversion": "^8.0.3", + "make-fetch-happen": "^15.0.5", + "minimatch": "^10.2.4", + "minipass": "^7.1.3", + "minipass-pipeline": "^1.2.4", + "ms": "^2.1.2", + "node-gyp": "^12.2.0", + "nopt": "^9.0.0", + "npm-audit-report": "^7.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.2", + "npm-pick-manifest": "^11.0.3", + "npm-profile": "^12.0.1", + "npm-registry-fetch": "^19.1.1", + "npm-user-validate": "^4.0.0", + "p-map": "^7.0.4", + "pacote": "^21.5.0", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.1.0", + "qrcode-terminal": "^0.12.0", + "read": "^5.0.1", + "semver": "^7.7.4", + "spdx-expression-parse": "^4.0.0", + "ssri": "^13.0.1", + "supports-color": "^10.2.2", + "tar": "^7.5.11", + "text-table": "~0.2.0", + "tiny-relative-date": "^2.0.2", + "treeverse": "^3.0.0", + "validate-npm-package-name": "^7.0.2", + "which": "^6.0.1" + }, + "bin": { + "npm": "bin/npm-cli.js", + "npx": "bin/npx-cli.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@gar/promise-retry": { + "version": "1.0.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/agent": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/arborist": { + "version": "9.4.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^5.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/metavuln-calculator": "^9.0.2", + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/query": "^5.0.0", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.0", + "bin-links": "^6.0.0", + "cacache": "^20.0.1", + "common-ancestor-path": "^2.0.0", + "hosted-git-info": "^9.0.0", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^11.2.1", + "minimatch": "^10.0.3", + "nopt": "^9.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.0", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "pacote": "^21.0.2", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.0.0", + "proggy": "^4.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "semver": "^7.3.7", + "ssri": "^13.0.0", + "treeverse": "^3.0.0", + "walk-up-path": "^4.0.0" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/config": { + "version": "10.8.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "ci-info": "^4.0.0", + "ini": "^6.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "walk-up-path": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/fs": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/git": { + "version": "7.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "ini": "^6.0.0", + "lru-cache": "^11.2.1", + "npm-pick-manifest": "^11.0.1", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/installed-package-contents": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^5.0.0", + "npm-normalize-package-bin": "^5.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/map-workspaces": { + "version": "5.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "glob": "^13.0.0", + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/metavuln-calculator": { + "version": "9.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cacache": "^20.0.0", + "json-parse-even-better-errors": "^5.0.0", + "pacote": "^21.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/name-from-folder": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/node-gyp": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/package-json": { + "version": "7.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^7.0.0", + "glob": "^13.0.0", + "hosted-git-info": "^9.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.5.3", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/promise-spawn": { + "version": "9.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/query": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/redact": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@npmcli/run-script": { + "version": "10.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "node-gyp": "^12.1.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@sigstore/bundle": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@sigstore/core": { + "version": "3.2.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@sigstore/protobuf-specs": { + "version": "0.5.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@sigstore/sign": { + "version": "4.1.1", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@gar/promise-retry": "^1.0.2", + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.0", + "@sigstore/protobuf-specs": "^0.5.0", + "make-fetch-happen": "^15.0.4", + "proc-log": "^6.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@sigstore/tuf": { + "version": "4.0.2", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^4.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@sigstore/verify": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/@tufjs/models": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^10.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/abbrev": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/agent-base": { + "version": "7.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/aproba": { + "version": "2.1.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/archy": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/bin-links": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "proc-log": "^6.0.0", + "read-cmd-shim": "^6.0.0", + "write-file-atomic": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/binary-extensions": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/brace-expansion": { + "version": "5.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/cacache": { + "version": "20.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/chalk": { + "version": "5.6.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/chownr": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/ci-info": { + "version": "4.4.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/cidr-regex": { + "version": "5.0.3", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/cmd-shim": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/common-ancestor-path": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/cssesc": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/debug": { + "version": "4.4.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/diff": { + "version": "8.0.3", + "dev": true, + "inBundle": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/env-paths": { + "version": "2.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/exponential-backoff": { + "version": "3.1.3", + "dev": true, + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/fastest-levenshtein": { + "version": "1.0.16", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/fs-minipass": { + "version": "3.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/glob": { + "version": "13.0.6", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/hosted-git-info": { + "version": "9.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/http-cache-semantics": { + "version": "4.2.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/http-proxy-agent": { + "version": "7.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/https-proxy-agent": { + "version": "7.0.6", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/iconv-lite": { + "version": "0.7.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/ignore-walk": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/ini": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/init-package-json": { + "version": "8.2.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^7.0.0", + "npm-package-arg": "^13.0.0", + "promzard": "^3.0.1", + "read": "^5.0.1", + "semver": "^7.7.2", + "validate-npm-package-name": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/ip-address": { + "version": "10.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/is-cidr": { + "version": "6.0.3", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "cidr-regex": "^5.0.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/isexe": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/json-parse-even-better-errors": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/json-stringify-nice": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/jsonparse": { + "version": "1.3.1", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "inBundle": true, + "license": "MIT" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/just-diff": { + "version": "6.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/just-diff-apply": { + "version": "5.5.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/libnpmaccess": { + "version": "10.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-package-arg": "^13.0.0", + "npm-registry-fetch": "^19.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/libnpmdiff": { + "version": "8.1.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.4.2", + "@npmcli/installed-package-contents": "^4.0.0", + "binary-extensions": "^3.0.0", + "diff": "^8.0.2", + "minimatch": "^10.0.3", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2", + "tar": "^7.5.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/libnpmexec": { + "version": "10.2.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/arborist": "^9.4.2", + "@npmcli/package-json": "^7.0.0", + "@npmcli/run-script": "^10.0.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2", + "proc-log": "^6.0.0", + "read": "^5.0.1", + "semver": "^7.3.7", + "signal-exit": "^4.1.0", + "walk-up-path": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/libnpmfund": { + "version": "7.0.19", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.4.2" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/libnpmorg": { + "version": "8.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^19.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/libnpmpack": { + "version": "9.1.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.4.2", + "@npmcli/run-script": "^10.0.0", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/libnpmpublish": { + "version": "11.1.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^7.0.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^13.0.0", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.7", + "sigstore": "^4.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/libnpmsearch": { + "version": "9.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^19.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/libnpmteam": { + "version": "8.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^19.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/libnpmversion": { + "version": "8.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^7.0.0", + "@npmcli/run-script": "^10.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.7" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/lru-cache": { + "version": "11.2.7", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/make-fetch-happen": { + "version": "15.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/minimatch": { + "version": "10.2.4", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/minipass": { + "version": "7.1.3", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/minipass-collect": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/minipass-fetch": { + "version": "5.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.7.2" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/minipass-flush": { + "version": "1.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/minipass-pipeline": { + "version": "1.2.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/minipass-sized": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/minizlib": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/ms": { + "version": "2.1.3", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/mute-stream": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/negotiator": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/node-gyp": { + "version": "12.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^15.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/nopt": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/npm-audit-report": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/npm-bundled": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/npm-install-checks": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/npm-normalize-package-bin": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/npm-package-arg": { + "version": "13.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/npm-packlist": { + "version": "10.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^8.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/npm-pick-manifest": { + "version": "11.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "npm-package-arg": "^13.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/npm-profile": { + "version": "12.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/npm-registry-fetch": { + "version": "19.1.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^4.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^15.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^13.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/npm-user-validate": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/p-map": { + "version": "7.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/pacote": { + "version": "21.5.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/git": "^7.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "@npmcli/run-script": "^10.0.0", + "cacache": "^20.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^13.0.0", + "npm-packlist": "^10.0.1", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "sigstore": "^4.0.0", + "ssri": "^13.0.0", + "tar": "^7.4.3" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/parse-conflict-json": { + "version": "5.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^5.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/path-scurry": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/proc-log": { + "version": "6.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/proggy": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/promise-all-reject-late": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/promise-call-limit": { + "version": "3.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/promzard": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "read": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/qrcode-terminal": { + "version": "0.12.0", + "dev": true, + "inBundle": true, + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/read": { + "version": "5.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "mute-stream": "^3.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/read-cmd-shim": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/safer-buffer": { + "version": "2.1.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/sigstore": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^4.1.0", + "@sigstore/tuf": "^4.0.1", + "@sigstore/verify": "^3.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/smart-buffer": { + "version": "4.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/socks": { + "version": "2.8.7", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/socks-proxy-agent": { + "version": "8.0.5", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/spdx-exceptions": { + "version": "2.5.0", + "dev": true, + "inBundle": true, + "license": "CC-BY-3.0" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/spdx-expression-parse": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/spdx-license-ids": { + "version": "3.0.23", + "dev": true, + "inBundle": true, + "license": "CC0-1.0" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/ssri": { + "version": "13.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/supports-color": { + "version": "10.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/tar": { + "version": "7.5.11", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/text-table": { + "version": "0.2.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/tiny-relative-date": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/tinyglobby": { + "version": "0.2.15", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/treeverse": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/tuf-js": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/util-deprecate": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/validate-npm-package-name": { + "version": "7.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/walk-up-path": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/which": { + "version": "6.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/write-file-atomic": { + "version": "7.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@semantic-release/npm/node_modules/npm/node_modules/yallist": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@semantic-release/npm/node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/pretty-ms": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.0.0.tgz", + "integrity": "sha512-E9e9HJ9R9NasGOgPaPE8VMeiPKAyWR5jcFpNnwIejslIhWqdqOrb2wShBsncMPUb+BcCd2OPYfh7p2W6oemTng==", + "dev": true, + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/npm/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@semantic-release/npm/node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/release-notes-generator": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.0.tgz", + "integrity": "sha512-CcyDRk7xq+ON/20YNR+1I/jP7BYKICr1uKd1HHpROSnnTdGqOTburi4jcRiTYz0cpfhxSloQO3cGhnoot7IEkA==", + "dev": true, + "dependencies": { + "conventional-changelog-angular": "^8.0.0", + "conventional-changelog-writer": "^8.0.0", + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.0.0", + "debug": "^4.0.0", + "get-stream": "^7.0.0", + "import-from-esm": "^2.0.0", + "into-stream": "^7.0.0", + "lodash-es": "^4.17.21", + "read-package-up": "^11.0.0" + }, + "engines": { + "node": ">=20.8.1" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@semantic-release/release-notes-generator/node_modules/get-stream": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-7.0.1.tgz", + "integrity": "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/release-notes-generator/node_modules/import-from-esm": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-2.0.0.tgz", + "integrity": "sha512-YVt14UZCgsX1vZQ3gKjkWVdBdHQ6eu3MPU1TBgL1H5orXe2+jWD006WCPPtOuwlQm10NuzOW5WawiF1Q9veW8g==", + "dev": true, + "dependencies": { + "debug": "^4.3.4", + "import-meta-resolve": "^4.0.0" + }, + "engines": { + "node": ">=18.20" + } + }, + "node_modules/@sindresorhus/is": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", + "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", + "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.1" + }, + "engines": { + "node": ">=14.16" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ts-graphviz/adapter": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@ts-graphviz/adapter/-/adapter-2.0.5.tgz", + "integrity": "sha512-K/xd2SJskbSLcUz9uYW9IDy26I3Oyutj/LREjJgcuLMxT3um4sZfy9LiUhGErHjxLRaNcaDVGSsmWeiNuhidXg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ts-graphviz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ts-graphviz" + } + ], + "dependencies": { + "@ts-graphviz/common": "^2.1.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ts-graphviz/ast": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@ts-graphviz/ast/-/ast-2.0.5.tgz", + "integrity": "sha512-HVT+Bn/smDzmKNJFccwgrpJaEUMPzXQ8d84JcNugzTHNUVgxAIe2Vbf4ug351YJpowivQp6/N7XCluQMjtgi5w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ts-graphviz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ts-graphviz" + } + ], + "dependencies": { + "@ts-graphviz/common": "^2.1.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ts-graphviz/common": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@ts-graphviz/common/-/common-2.1.4.tgz", + "integrity": "sha512-PNEzOgE4vgvorp/a4Ev26jVNtiX200yODoyPa8r6GfpPZbxWKW6bdXF6xWqzMkQoO1CnJOYJx2VANDbGqCqCCw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ts-graphviz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ts-graphviz" + } + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@ts-graphviz/core": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@ts-graphviz/core/-/core-2.0.5.tgz", + "integrity": "sha512-YwaCGAG3Hs0nhxl+2lVuwuTTAK3GO2XHqOGvGIwXQB16nV858rrR5w2YmWCw9nhd11uLTStxLsCAhI9koWBqDA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ts-graphviz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ts-graphviz" + } + ], + "dependencies": { + "@ts-graphviz/ast": "^2.0.5", + "@ts-graphviz/common": "^2.1.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", + "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/busboy": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.3.tgz", + "integrity": "sha512-YMBLFN/xBD8bnqywIlGyYqsNFXu6bsiY7h3Ae0kO17qEuTjsqeyYMRPSUDacIKIquws2Y6KjmxAyNx8xB3xQbw==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.17.43", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.43.tgz", + "integrity": "sha512-oaYtiBirUOPQGSWNGPWnzyAFJ0BP3cwvN4oWZQY+zUBwpVIGsKUkpBpSztp74drYcjavs7SKFZ4DX1V2QeN8rg==", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", + "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-4NpsnpYl2Gt1ljyBGrKMxFYAYvpqbnnkgP/i/g+NLpjEUa3obn1XJCur9YbEXKDAkaXqsR1LbDnGEJ0MmKFxfg==", + "dev": true, + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" + }, + "node_modules/@types/node": { + "version": "22.9.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", + "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==", + "dependencies": { + "undici-types": "~6.19.8" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true + }, + "node_modules/@types/object-path": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/@types/object-path/-/object-path-0.11.4.tgz", + "integrity": "sha512-4tgJ1Z3elF/tOMpA8JLVuR9spt9Ynsf7+JjqsQ2IqtiPJtcLoHoXcT6qU4E10cPFqyXX5HDm9QwIzZhBSkLxsw==" + }, + "node_modules/@types/qs": { + "version": "6.9.11", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.11.tgz", + "integrity": "sha512-oGk0gmhnEJK4Yyk+oI7EfXsLayXatCWPHary1MtcmbAifkobT9cM9yutG/hZKIseOU0MqbIwQ/u2nn/Gb+ltuQ==" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" + }, + "node_modules/@types/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + } + }, + "node_modules/@types/request/node_modules/form-data": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/@types/semver": { + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", + "dev": true + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.5.tgz", + "integrity": "sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==", + "dependencies": { + "@types/http-errors": "*", + "@types/mime": "*", + "@types/node": "*" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==" + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "dev": true, + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", + "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/type-utils": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", + "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", + "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/scope-manager/node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/scope-manager/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/scope-manager/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", + "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.11", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.11.tgz", + "integrity": "sha512-PwAdxs7/9Hc3ieBO12tXzmTD+Ln4qhT/56S+8DvrrZ4kLDn4Z/AMUr8tXJD0axiJBS0RKIoNaR0yMuQB9v9Udg==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/shared": "3.5.11", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.11", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.11.tgz", + "integrity": "sha512-pyGf8zdbDDRkBrEzf8p7BQlMKNNF5Fk/Cf/fQ6PiUz9at4OaUfyXW0dGJTo2Vl1f5U9jSLCNf0EZJEogLXoeew==", + "dev": true, + "dependencies": { + "@vue/compiler-core": "3.5.11", + "@vue/shared": "3.5.11" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.11", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.11.tgz", + "integrity": "sha512-gsbBtT4N9ANXXepprle+X9YLg2htQk1sqH/qGJ/EApl+dgpUBdTv3yP7YlR535uHZY3n6XaR0/bKo0BgwwDniw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/compiler-core": "3.5.11", + "@vue/compiler-dom": "3.5.11", + "@vue/compiler-ssr": "3.5.11", + "@vue/shared": "3.5.11", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.11", + "postcss": "^8.4.47", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.11", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.11.tgz", + "integrity": "sha512-P4+GPjOuC2aFTk1Z4WANvEhyOykcvEd5bIj2KVNGKGfM745LaXGr++5njpdBTzVz5pZifdlR1kpYSJJpIlSePA==", + "dev": true, + "dependencies": { + "@vue/compiler-dom": "3.5.11", + "@vue/shared": "3.5.11" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.11", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.11.tgz", + "integrity": "sha512-W8GgysJVnFo81FthhzurdRAWP/byq3q2qIw70e0JWblzVhjgOMiC2GyovXrZTFQJnFVryYaKGP3Tc9vYzYm6PQ==", + "dev": true + }, + "node_modules/@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@wry/caches": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz", + "integrity": "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==", + "dev": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/context": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.4.tgz", + "integrity": "sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==", + "dev": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/equality": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.7.tgz", + "integrity": "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==", + "dev": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/trie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.5.0.tgz", + "integrity": "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==", + "dev": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "optional": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", + "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz", + "integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==", + "dependencies": { + "mime-db": "^1.53.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/all-node-versions": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/all-node-versions/-/all-node-versions-13.0.1.tgz", + "integrity": "sha512-5pG14FNgn5ClyGv8diB7uTcsmi2NWk9rDH+cGbVsqHjeqptegK0UfCsBA/vNUOZPNOPnYNzk31EM9OjJktld/g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "fetch-node-website": "^9.0.1", + "filter-obj": "^6.1.0", + "global-cache-dir": "^6.0.1", + "is-plain-obj": "^4.1.0", + "path-exists": "^5.0.0", + "semver": "^7.7.1", + "write-file-atomic": "^6.0.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/all-node-versions/node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/all-node-versions/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/all-node-versions/node_modules/write-file-atomic": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-6.0.0.tgz", + "integrity": "sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/ansi-escapes": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", + "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", + "dev": true, + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ansicolors": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/ansicolors/-/ansicolors-0.3.2.tgz", + "integrity": "sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==", + "dev": true + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "optional": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/apollo-upload-client": { + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/apollo-upload-client/-/apollo-upload-client-18.0.1.tgz", + "integrity": "sha512-OQvZg1rK05VNI79D658FUmMdoI2oB/KJKb6QGMa2Si25QXOaAvLMBFUEwJct7wf+19U8vk9ILhidBOU1ZWv6QA==", + "dev": true, + "dependencies": { + "extract-files": "^13.0.0" + }, + "engines": { + "node": "^18.15.0 || >=20.4.0" + }, + "funding": { + "url": "https://github.com/sponsors/jaydenseric" + }, + "peerDependencies": { + "@apollo/client": "^3.8.0", + "graphql": "14 - 16" + } + }, + "node_modules/app-module-path": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/app-module-path/-/app-module-path-2.2.0.tgz", + "integrity": "sha512-gkco+qxENJV+8vFcDiiFhuoSvRXb2a/QPqpSoWhVz829VNJfOTnELbBmPmNKFxf3xdNnw4DWCkzkDaavcX/1YQ==", + "dev": true + }, + "node_modules/append-transform": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", + "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", + "dev": true, + "dependencies": { + "default-require-extensions": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" + }, + "node_modules/archy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", + "dev": true + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/argv-formatter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/argv-formatter/-/argv-formatter-1.0.0.tgz", + "integrity": "sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw==", + "dev": true + }, + "node_modules/array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "dev": true + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/assert-options": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/assert-options/-/assert-options-0.8.3.tgz", + "integrity": "sha512-s6v4HnA+vYSGO4eZX+F+I3gvF74wPk+m6Z1Q3w1Dsg4Pnv/R24vhKAasoMVZGvDpOOfTg1Qz4ptZnEbuy95XsQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/ast-module-types": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ast-module-types/-/ast-module-types-6.0.0.tgz", + "integrity": "sha512-LFRg7178Fw5R4FAEwZxVqiRI8IxSM+Ay2UBrHoCerXNme+kMMMfz7T3xDGV/c2fer87hcrtgJGsnSOfUrPK6ng==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/async": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "devOptional": true + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", + "dev": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "dev": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/backo2": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", + "integrity": "sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA==", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/backoff": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz", + "integrity": "sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==", + "dependencies": { + "precond": "0.2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "devOptional": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", + "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true + }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==" + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bottleneck": { + "version": "2.19.5", + "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", + "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bson": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.1.1.tgz", + "integrity": "sha512-TtJgBB+QyOlWjrbM+8bRgH84VM/xrDjyBFgSgGrfZF4xvt6gbEDtcswm27Tn9F9TWsjQybxT8b8VpCP/oJK4Dw==", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-alloc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", + "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-alloc-unsafe": "^1.1.0", + "buffer-fill": "^1.0.0" + } + }, + "node_modules/buffer-alloc-unsafe": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", + "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-fill": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", + "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacheable-lookup": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/cacheable-request": { + "version": "10.2.14", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz", + "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "^4.0.2", + "get-stream": "^6.0.1", + "http-cache-semantics": "^4.1.1", + "keyv": "^4.5.3", + "mimic-response": "^4.0.0", + "normalize-url": "^8.0.0", + "responselike": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + } + }, + "node_modules/cachedir": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", + "integrity": "sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caching-transform": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", + "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", + "dev": true, + "dependencies": { + "hasha": "^5.0.0", + "make-dir": "^3.0.0", + "package-hash": "^4.0.0", + "write-file-atomic": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/caching-transform/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caching-transform/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "dev": true, + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001782", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", + "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/cardinal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/cardinal/-/cardinal-2.1.1.tgz", + "integrity": "sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==", + "dev": true, + "dependencies": { + "ansicolors": "~0.3.2", + "redeyed": "~2.1.0" + }, + "bin": { + "cdl": "bin/cdl.js" + } + }, + "node_modules/catharsis": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/catharsis/-/catharsis-0.9.0.tgz", + "integrity": "sha512-prMTQVpcns/tzFgFVkVp6ak6RykZyWb3gu8ckUpd6YkTlacOd3DXGJjIpD4Q6zJirizvaiAjSSHlOsA+6sNh2A==", + "dev": true, + "dependencies": { + "lodash": "^4.17.15" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "optional": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/clean-css": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", + "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==", + "dev": true, + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 10.0" + } + }, + "node_modules/clean-jsdoc-theme": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/clean-jsdoc-theme/-/clean-jsdoc-theme-4.3.0.tgz", + "integrity": "sha512-QMrBdZ2KdPt6V2Ytg7dIt0/q32U4COpxvR0UDhPjRRKRL0o0MvRCR5YpY37/4rPF1SI1AYEKAWyof7ndCb/dzA==", + "dev": true, + "dependencies": { + "@jsdoc/salty": "^0.2.4", + "fs-extra": "^10.1.0", + "html-minifier-terser": "^7.2.0", + "klaw-sync": "^6.0.0", + "lodash": "^4.17.21", + "showdown": "^2.1.0" + }, + "peerDependencies": { + "jsdoc": ">=3.x <=4.x" + } + }, + "node_modules/clean-jsdoc-theme/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-highlight": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", + "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", + "dev": true, + "license": "ISC", + "dependencies": { + "chalk": "^4.0.0", + "highlight.js": "^10.7.1", + "mz": "^2.4.0", + "parse5": "^5.1.1", + "parse5-htmlparser2-tree-adapter": "^6.0.0", + "yargs": "^16.0.0" + }, + "bin": { + "highlight": "bin/highlight" + }, + "engines": { + "node": ">=8.0.0", + "npm": ">=5.0.0" + } + }, + "node_modules/cli-highlight/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cli-highlight/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-highlight/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/cli-highlight/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/cli-highlight/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-highlight/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-highlight/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-highlight/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/cli-highlight/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/cli-highlight/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cli-highlight/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.3" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-spinners": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.7.0.tgz", + "integrity": "sha512-qu3pN8Y3qHNgE2AFweciB1IfMnmZ/fsNTEE+NOFjmGB2F/7rLhnhzppvpCnN4FovtP26k8lHyy9ptEbNwWFLzw==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-string/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/colors-option": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/colors-option/-/colors-option-6.0.1.tgz", + "integrity": "sha512-FsAlu5KTTN+W6Xc4NpxNAhl8iCKwVBzjL7Y2ZK6G9zMv50AfMDlU7Mi16lzaDK8Iwpoq/GfAXX+WrYx38gfSHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.4.1", + "is-plain-obj": "^4.1.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/colors-option/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "devOptional": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "engines": { + "node": ">=20" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, + "node_modules/compare-func": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", + "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "dev": true, + "dependencies": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/conventional-changelog-angular": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.0.0.tgz", + "integrity": "sha512-CLf+zr6St0wIxos4bmaKHRXWAcsCXrJU6F4VdNDrGRK3B8LDLKoX3zuMV5GhtbGkVR/LohZ6MT6im43vZLSjmA==", + "dev": true, + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/conventional-changelog-writer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-8.0.0.tgz", + "integrity": "sha512-TQcoYGRatlAnT2qEWDON/XSfnVG38JzA7E0wcGScu7RElQBkg9WWgZd1peCWFcWDh1xfb2CfsrcvOn1bbSzztA==", + "dev": true, + "dependencies": { + "@types/semver": "^7.5.5", + "conventional-commits-filter": "^5.0.0", + "handlebars": "^4.7.7", + "meow": "^13.0.0", + "semver": "^7.5.2" + }, + "bin": { + "conventional-changelog-writer": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/conventional-commits-filter": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz", + "integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/conventional-commits-parser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.0.0.tgz", + "integrity": "sha512-TbsINLp48XeMXR8EvGjTnKGsZqBemisPoyWESlpRyR8lif0lcwzqz+NMtYSj1ooF/WYjSuu7wX0CtdeeMEQAmA==", + "dev": true, + "dependencies": { + "meow": "^13.0.0" + }, + "bin": { + "conventional-commits-parser": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-hrtime": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-5.0.0.tgz", + "integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "dev": true, + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-js-pure": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.49.0.tgz", + "integrity": "sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dev": true, + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-inspect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz", + "integrity": "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "devOptional": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, + "node_modules/crypto-random-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", + "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", + "dev": true, + "dependencies": { + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/crypto-random-string/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz", + "integrity": "sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA==", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress/-/decompress-4.2.1.tgz", + "integrity": "sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "decompress-tar": "^4.0.0", + "decompress-tarbz2": "^4.0.0", + "decompress-targz": "^4.0.0", + "decompress-unzip": "^4.0.1", + "graceful-fs": "^4.1.10", + "make-dir": "^1.0.0", + "pify": "^2.3.0", + "strip-dirs": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-tar": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tar/-/decompress-tar-4.1.1.tgz", + "integrity": "sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-type": "^5.2.0", + "is-stream": "^1.1.0", + "tar-stream": "^1.5.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tar/node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress-tarbz2": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz", + "integrity": "sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "decompress-tar": "^4.1.0", + "file-type": "^6.1.0", + "is-stream": "^1.1.0", + "seek-bzip": "^1.0.5", + "unbzip2-stream": "^1.0.9" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tarbz2/node_modules/file-type": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-6.2.0.tgz", + "integrity": "sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tarbz2/node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress-targz": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-targz/-/decompress-targz-4.1.1.tgz", + "integrity": "sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "decompress-tar": "^4.1.1", + "file-type": "^5.2.0", + "is-stream": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-targz/node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress-unzip": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/decompress-unzip/-/decompress-unzip-4.0.1.tgz", + "integrity": "sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-type": "^3.8.0", + "get-stream": "^2.2.0", + "pify": "^2.3.0", + "yauzl": "^2.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-unzip/node_modules/file-type": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", + "integrity": "sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress-unzip/node_modules/get-stream": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", + "integrity": "sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4.0.1", + "pinkie-promise": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress-unzip/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress/node_modules/make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress/node_modules/make-dir/node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress/node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deep-diff": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz", + "integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==", + "dev": true + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/default-require-extensions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz", + "integrity": "sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==", + "dev": true, + "dependencies": { + "strip-bom": "^4.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "devOptional": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dependency-tree": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/dependency-tree/-/dependency-tree-11.0.1.tgz", + "integrity": "sha512-eCt7HSKIC9NxgIykG2DRq3Aewn9UhVS14MB3rEn6l/AsEI1FBg6ZGSlCU0SZ6Tjm2kkhj6/8c2pViinuyKELhg==", + "dev": true, + "dependencies": { + "commander": "^12.0.0", + "filing-cabinet": "^5.0.1", + "precinct": "^12.0.2", + "typescript": "^5.4.5" + }, + "bin": { + "dependency-tree": "bin/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/dependency-tree/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", + "dev": true + }, + "node_modules/detective-amd": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/detective-amd/-/detective-amd-6.0.0.tgz", + "integrity": "sha512-NTqfYfwNsW7AQltKSEaWR66hGkTeD52Kz3eRQ+nfkA9ZFZt3iifRCWh+yZ/m6t3H42JFwVFTrml/D64R2PAIOA==", + "dev": true, + "dependencies": { + "ast-module-types": "^6.0.0", + "escodegen": "^2.1.0", + "get-amd-module-type": "^6.0.0", + "node-source-walk": "^7.0.0" + }, + "bin": { + "detective-amd": "bin/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/detective-cjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/detective-cjs/-/detective-cjs-6.0.0.tgz", + "integrity": "sha512-R55jTS6Kkmy6ukdrbzY4x+I7KkXiuDPpFzUViFV/tm2PBGtTCjkh9ZmTuJc1SaziMHJOe636dtiZLEuzBL9drg==", + "dev": true, + "dependencies": { + "ast-module-types": "^6.0.0", + "node-source-walk": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/detective-es6": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/detective-es6/-/detective-es6-5.0.0.tgz", + "integrity": "sha512-NGTnzjvgeMW1khUSEXCzPDoraLenWbUjCFjwxReH+Ir+P6LGjYtaBbAvITWn2H0VSC+eM7/9LFOTAkrta6hNYg==", + "dev": true, + "dependencies": { + "node-source-walk": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/detective-postcss": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/detective-postcss/-/detective-postcss-7.0.0.tgz", + "integrity": "sha512-pSXA6dyqmBPBuERpoOKKTUUjQCZwZPLRbd1VdsTbt6W+m/+6ROl4BbE87yQBUtLoK7yX8pvXHdKyM/xNIW9F7A==", + "dev": true, + "dependencies": { + "is-url": "^1.2.4", + "postcss-values-parser": "^6.0.2" + }, + "engines": { + "node": "^14.0.0 || >=16.0.0" + }, + "peerDependencies": { + "postcss": "^8.4.38" + } + }, + "node_modules/detective-sass": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/detective-sass/-/detective-sass-6.0.0.tgz", + "integrity": "sha512-h5GCfFMkPm4ZUUfGHVPKNHKT8jV7cSmgK+s4dgQH4/dIUNh9/huR1fjEQrblOQNDalSU7k7g+tiW9LJ+nVEUhg==", + "dev": true, + "dependencies": { + "gonzales-pe": "^4.3.0", + "node-source-walk": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/detective-scss": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/detective-scss/-/detective-scss-5.0.0.tgz", + "integrity": "sha512-Y64HyMqntdsCh1qAH7ci95dk0nnpA29g319w/5d/oYcHolcGUVJbIhOirOFjfN1KnMAXAFm5FIkZ4l2EKFGgxg==", + "dev": true, + "dependencies": { + "gonzales-pe": "^4.3.0", + "node-source-walk": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/detective-stylus": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/detective-stylus/-/detective-stylus-5.0.0.tgz", + "integrity": "sha512-KMHOsPY6aq3196WteVhkY5FF+6Nnc/r7q741E+Gq+Ax9mhE2iwj8Hlw8pl+749hPDRDBHZ2WlgOjP+twIG61vQ==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/detective-typescript": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/detective-typescript/-/detective-typescript-13.0.0.tgz", + "integrity": "sha512-tcMYfiFWoUejSbvSblw90NDt76/4mNftYCX0SMnVRYzSXv8Fvo06hi4JOPdNvVNxRtCAKg3MJ3cBJh+ygEMH+A==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "^7.6.0", + "ast-module-types": "^6.0.0", + "node-source-walk": "^7.0.0" + }, + "engines": { + "node": "^14.14.0 || >=16.0.0" + }, + "peerDependencies": { + "typescript": "^5.4.4" + } + }, + "node_modules/detective-vue2": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detective-vue2/-/detective-vue2-2.0.3.tgz", + "integrity": "sha512-AgWdSfVnft8uPGnUkdvE1EDadEENDCzoSRMt2xZfpxsjqVO617zGWXbB8TGIxHaqHz/nHa6lOSgAB8/dt0yEug==", + "dev": true, + "dependencies": { + "@vue/compiler-sfc": "^3.4.27", + "detective-es6": "^5.0.0", + "detective-sass": "^6.0.0", + "detective-scss": "^5.0.0", + "detective-stylus": "^5.0.0", + "detective-typescript": "^13.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "typescript": "^5.4.4" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/duplexify/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "devOptional": true + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.328", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz", + "integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==", + "dev": true + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/emojilib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", + "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", + "dev": true + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "devOptional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-ci": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-11.2.0.tgz", + "integrity": "sha512-D5kWfzkmaOQDioPmiviWAVtKmpPT4/iJmMVQxWxMPJTFyTkdc5JQUfc5iXEeWxcOdsYTKSAiA/Age4NUOqKsRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^8.0.0", + "java-properties": "^1.0.2" + }, + "engines": { + "node": "^18.17 || >=20.6.1" + } + }, + "node_modules/env-ci/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/env-ci/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/env-ci/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-ci/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/env-ci/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "devOptional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/eslint": { + "version": "9.27.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.27.0.tgz", + "integrity": "sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.20.0", + "@eslint/config-helpers": "^0.2.1", + "@eslint/core": "^0.14.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.27.0", + "@eslint/plugin-kit": "^0.3.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.3.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-expect-type": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-expect-type/-/eslint-plugin-expect-type-0.6.2.tgz", + "integrity": "sha512-XWgtpplzr6GlpPUFG9ZApnSTv7QJXAPNN6hNmrlleVVCkAK23f/3E2BiCoA3Xtb0rIKfVKh7TLe+D1tcGt8/1w==", + "dev": true, + "dependencies": { + "@typescript-eslint/utils": "^6.10.0 || ^7.0.1 || ^8", + "fs-extra": "^11.1.1", + "get-tsconfig": "^4.8.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@typescript-eslint/parser": ">=6", + "eslint": ">=7", + "typescript": ">=4" + } + }, + "node_modules/eslint-plugin-unused-imports": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz", + "integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==", + "dev": true, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", + "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/eslint/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/eslint-scope": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", + "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/espree": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.14.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esquery/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/expo-server-sdk": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/expo-server-sdk/-/expo-server-sdk-6.1.0.tgz", + "integrity": "sha512-ISuax1AQ7cpM5RAqcu8gVcoLL0ZKskJ5OLoMWmdITBe9nYjTucjdGyBq817YkIvTcj1pAUwx+9toUT7l/V7thA==", + "license": "MIT", + "dependencies": { + "promise-limit": "^2.7.0", + "promise-retry": "^2.0.1", + "undici": "^7.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/expo-server-sdk/node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", + "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz", + "integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==", + "dependencies": { + "mime-db": "^1.53.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/extract-files": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/extract-files/-/extract-files-13.0.0.tgz", + "integrity": "sha512-FXD+2Tsr8Iqtm3QZy1Zmwscca7Jx3mMC5Crr+sEP1I303Jy1CYMuYCm7hRTplFNg3XdUavErkxnTzpaqdSoi6g==", + "dev": true, + "dependencies": { + "is-plain-obj": "^4.1.0" + }, + "engines": { + "node": "^14.17.0 || ^16.0.0 || >= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/jaydenseric" + } + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ] + }, + "node_modules/farmhash-modern": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", + "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.9", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz", + "integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastq": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.14.0.tgz", + "integrity": "sha512-eR2D+V9/ExcbF9ls441yIuN6TI2ED1Y2ZcA5BmMtJsOkWOFRJQ0Jt0g1UwqXJJVAb+V+umH5Dfr8oh4EVP7VVg==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/fetch-node-website": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/fetch-node-website/-/fetch-node-website-9.0.1.tgz", + "integrity": "sha512-htQY+YRRFdMAxmQG8EpnVy32lQyXBjgFAvyfaaq7VCn53Py1gorggPMYAt1Zmp0AlNS1X/YnGt641RAkUbsETw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "cli-progress": "^3.12.0", + "colors-option": "^6.0.1", + "figures": "^6.0.1", + "got": "^13.0.0", + "is-plain-obj": "^4.1.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/figures/node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-stream-rotator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz", + "integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==", + "dependencies": { + "moment": "^2.29.1" + } + }, + "node_modules/file-type": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", + "integrity": "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/filing-cabinet": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/filing-cabinet/-/filing-cabinet-5.0.2.tgz", + "integrity": "sha512-RZlFj8lzyu6jqtFBeXNqUjjNG6xm+gwXue3T70pRxw1W40kJwlgq0PSWAmh0nAnn5DHuBIecLXk9+1VKS9ICXA==", + "dev": true, + "dependencies": { + "app-module-path": "^2.2.0", + "commander": "^12.0.0", + "enhanced-resolve": "^5.16.0", + "module-definition": "^6.0.0", + "module-lookup-amd": "^9.0.1", + "resolve": "^1.22.8", + "resolve-dependency-path": "^4.0.0", + "sass-lookup": "^6.0.1", + "stylus-lookup": "^6.0.0", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.4.4" + }, + "bin": { + "filing-cabinet": "bin/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/filing-cabinet/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/filter-obj": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-6.1.0.tgz", + "integrity": "sha512-xdMtCAODmPloU9qtmPcdBV9Kd27NtMse+4ayThxqIHUES5Z2S6bGpap5PpdmNM56ub7y3i1eyr+vJJIIgWGKmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-cache-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-cache-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-cache-dir/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-cache-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-cache-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-cache-dir/node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-cache-dir/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-versions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-6.0.0.tgz", + "integrity": "sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==", + "dev": true, + "dependencies": { + "semver-regex": "^4.0.5", + "super-regex": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/firebase-admin": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.7.0.tgz", + "integrity": "sha512-o3qS8zCJbApe7aKzkO2Pa380t9cHISqeSd3blqYTtOuUUUua3qZTLwNWgGUOss3td6wbzrZhiHIj3c8+fC046Q==", + "license": "Apache-2.0", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "^2.0.0", + "@firebase/database-types": "^1.0.6", + "farmhash-modern": "^1.1.0", + "fast-deep-equal": "^3.1.1", + "google-auth-library": "^10.6.1", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.1.0", + "node-forge": "^1.3.1", + "uuid": "^11.0.2" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@google-cloud/firestore": "^7.11.0", + "@google-cloud/storage": "^7.19.0" + } + }, + "node_modules/firebase-admin/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", + "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", + "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.17" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/from2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", + "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", + "dev": true, + "dependencies": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.0" + } + }, + "node_modules/fromentries": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz", + "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/fs-capacitor": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/fs-capacitor/-/fs-capacitor-6.2.0.tgz", + "integrity": "sha512-nKcE1UduoSKX27NSZlg879LdQc94OtbOsEmKMN2MBNudXREvijRKx2GEBsTMTfws+BrbkJoEuynbGSVRSpauvw==", + "engines": { + "node": ">=10" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-readdir-recursive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz", + "integrity": "sha512-GNanXlVr2pf02+sPN40XN8HG+ePaNcvM0q5mZBd668Obwb0yD5GiUbZOFgwn8kGMY6I3mdyDJzieUy3PTYyTRA==", + "dev": true + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-timeout": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-1.0.2.tgz", + "integrity": "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "license": "MIT", + "optional": true + }, + "node_modules/gauge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-5.0.1.tgz", + "integrity": "sha512-CmykPMJGuNan/3S4kZOpvvPYSNqSHANiWnh9XcMU2pSjtBfF0XzZ2p1bFAxTbnFxyBuPxQYHhzwaoOmUdqzvxQ==", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^4.0.1", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/gauge/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/gaxios/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true + }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gaxios/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/gaxios/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/gcp-metadata": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-7.0.1.tgz", + "integrity": "sha512-UcO3kefx6dCcZkgcTGgVOTFb7b1LlQ02hY1omMjjrrBzkajRMCFgYOjs7J71WqnuG1k2b+9ppGL7FsOfhZMQKQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/gcp-metadata/node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gcp-metadata/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gcp-metadata/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/gcp-metadata/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gcp-metadata/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "optional": true, + "peer": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-amd-module-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/get-amd-module-type/-/get-amd-module-type-6.0.0.tgz", + "integrity": "sha512-hFM7oivtlgJ3d6XWD6G47l8Wyh/C6vFw5G24Kk1Tbq85yh5gcM8Fne5/lFhiuxB+RT6+SI7I1ThB9lG4FBh3jw==", + "dev": true, + "dependencies": { + "ast-module-types": "^6.0.0", + "node-source-walk": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "devOptional": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-own-enumerable-property-symbols": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", + "dev": true + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.0.tgz", + "integrity": "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==", + "dev": true, + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/git-log-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/git-log-parser/-/git-log-parser-1.2.0.tgz", + "integrity": "sha512-rnCVNfkTL8tdNryFuaY0fYiBWEBcgF748O6ZI61rslBvr2o7U65c2/6npCRqH40vuAhtgtDiqLTJjBVdrejCzA==", + "dev": true, + "dependencies": { + "argv-formatter": "~1.0.0", + "spawn-error-forwarder": "~1.0.0", + "split2": "~1.0.0", + "stream-combiner2": "~1.1.1", + "through2": "~2.0.0", + "traverse": "~0.6.6" + } + }, + "node_modules/git-log-parser/node_modules/split2": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-1.0.0.tgz", + "integrity": "sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg==", + "dev": true, + "dependencies": { + "through2": "~2.0.0" + } + }, + "node_modules/git-log-parser/node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/global-cache-dir": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/global-cache-dir/-/global-cache-dir-6.0.1.tgz", + "integrity": "sha512-HOOgvCW8le14HM0sTTvyYkTMRot7hq5ERIzNTUcDyZ4Vr9qF/IHUZeIcz4+v6vpwTFMqZ8QHKJYpXYRy/DSb6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cachedir": "^2.4.0", + "path-exists": "^5.0.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/global-cache-dir/node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/globals": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz", + "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/gonzales-pe": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/gonzales-pe/-/gonzales-pe-4.3.0.tgz", + "integrity": "sha512-otgSPpUmdWJ43VXyiNgEYE4luzHCL2pz4wQ0OnDluC6Eg4Ko3Vexy/SrSynglw/eR+OhkzmqFCZa/OFa/RgAOQ==", + "dev": true, + "dependencies": { + "minimist": "^1.2.5" + }, + "bin": { + "gonzales": "bin/gonzales.js" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", + "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/google-gax/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true + }, + "node_modules/google-gax/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/google-gax/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/google-gax/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/got/-/got-13.0.0.tgz", + "integrity": "sha512-XfBk1CxOOScDcMr9O1yKkNaQyy865NbYs+F7dr4H0LZMVgCj2Le59k6PqbNHoL5ToeaEQUYh6c6yMfVcc6SJxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^5.2.0", + "@szmarczak/http-timer": "^5.0.1", + "cacheable-lookup": "^7.0.0", + "cacheable-request": "^10.2.8", + "decompress-response": "^6.0.0", + "form-data-encoder": "^2.1.2", + "get-stream": "^6.0.1", + "http2-wrapper": "^2.1.10", + "lowercase-keys": "^3.0.0", + "p-cancelable": "^3.0.0", + "responselike": "^3.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "dev": true + }, + "node_modules/graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-list-fields": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/graphql-list-fields/-/graphql-list-fields-2.0.4.tgz", + "integrity": "sha512-q3prnhAL/dBsD+vaGr83B8DzkBijg+Yh+lbt7qp2dW1fpuO+q/upzDXvFJstVsSAA8m11MHGkSxxyxXeLou4MA==" + }, + "node_modules/graphql-relay": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/graphql-relay/-/graphql-relay-0.10.2.tgz", + "integrity": "sha512-abybva1hmlNt7Y9pMpAzHuFnM2Mme/a2Usd8S4X27fNteLGRAECMYfhmsrpZFvGn3BhmBZugMXYW/Mesv3P1Kw==", + "engines": { + "node": "^12.20.0 || ^14.15.0 || >= 15.9.0" + }, + "peerDependencies": { + "graphql": "^16.2.0" + } + }, + "node_modules/graphql-tag": { + "version": "2.12.6", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", + "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/graphql-upload": { + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/graphql-upload/-/graphql-upload-15.0.2.tgz", + "integrity": "sha512-ufJAkZJBKWRDD/4wJR3VZMy9QWTwqIYIciPtCEF5fCNgWF+V1p7uIgz+bP2YYLiS4OJBhCKR8rnqE/Wg3XPUiw==", + "dependencies": { + "@types/busboy": "^1.5.0", + "@types/node": "*", + "@types/object-path": "^0.11.1", + "busboy": "^1.6.0", + "fs-capacitor": "^6.2.0", + "http-errors": "^2.0.0", + "object-path": "^0.11.8" + }, + "engines": { + "node": "^14.17.0 || ^16.0.0 || >= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/jaydenseric" + }, + "peerDependencies": { + "@types/express": "^4.0.29", + "@types/koa": "^2.11.4", + "graphql": "^16.3.0" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + }, + "@types/koa": { + "optional": true + } + } + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "optional": true, + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" + }, + "node_modules/hasha": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", + "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, + "dependencies": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hasha/node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dev": true, + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hook-std": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hook-std/-/hook-std-3.0.0.tgz", + "integrity": "sha512-jHRQzjSDzMtFy34AGj1DN+vq54WVuhSvKgrHf0OMiFQTwDD4L/qqofVEWjLOBMTn5+lCD3fPg32W9yOfnEJTTw==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "node_modules/html-minifier-terser": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz", + "integrity": "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==", + "dev": true, + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "~5.3.2", + "commander": "^10.0.0", + "entities": "^4.4.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.15.1" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/html-minifier-terser/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "engines": { + "node": ">=16" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", + "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/idb-keyval": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz", + "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==", + "license": "Apache-2.0" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-from-esm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-1.3.4.tgz", + "integrity": "sha512-7EyUlPFC0HOlBDpUFGfYstsU7XHxZJKAAMzCT8wZ0hMW7b+hG51LIKTDcsgtz8Pu6YC0HqRVbX+rVUtsGMUKvg==", + "dev": true, + "dependencies": { + "debug": "^4.3.4", + "import-meta-resolve": "^4.0.0" + }, + "engines": { + "node": ">=16.20" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", + "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/index-to-position": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-0.1.2.tgz", + "integrity": "sha512-MWDKS3AS1bGCHLBA2VLImJz42f7bJh8wQsTGCzI3j519/CASStoDONUBVz2I/VID0MpiX3SGSnbOD2xUalbE5g==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true + }, + "node_modules/intersect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/intersect/-/intersect-1.0.1.tgz", + "integrity": "sha512-qsc720yevCO+4NydrJWgEWKccAQwTOvj2m73O/VBA6iUL2HGZJ9XqBiyraNrBXX/W1IAjdpXdRZk24sq8TzBRg==" + }, + "node_modules/into-stream": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-7.0.0.tgz", + "integrity": "sha512-2dYz766i9HprMBasCMvHMuazJ7u4WzhJwo5kb3iPSiW/iRYV6uPari3zHoqZlnuaR7V1bEiNMxikhp37rdBXbw==", + "dev": true, + "dependencies": { + "from2": "^2.3.0", + "p-is-promise": "^3.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "optional": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-natural-number": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz", + "integrity": "sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-text-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz", + "integrity": "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==", + "dev": true, + "dependencies": { + "text-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "dev": true + }, + "node_modules/is-url-superb": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-url-superb/-/is-url-superb-4.0.0.tgz", + "integrity": "sha512-GI+WjezhPPcbM+tqE9LnmsY5qqjwHzTvjJ36wxYX5ujNXefSUJ/T17r5bqDV8yLhcgB59KTPNOc9O9cmHTPWsA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "devOptional": true + }, + "node_modules/issue-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.1.tgz", + "integrity": "sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg==", + "dev": true, + "dependencies": { + "lodash.capitalize": "^4.2.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.uniqby": "^4.7.0" + }, + "engines": { + "node": "^18.17 || >=20.6.1" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", + "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-hook": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", + "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", + "dev": true, + "dependencies": { + "append-transform": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-processinfo": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz", + "integrity": "sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==", + "dev": true, + "dependencies": { + "archy": "^1.0.0", + "cross-spawn": "^7.0.3", + "istanbul-lib-coverage": "^3.2.0", + "p-map": "^3.0.0", + "rimraf": "^3.0.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^3.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-report/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.5.tgz", + "integrity": "sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterall": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/iterall/-/iterall-1.3.0.tgz", + "integrity": "sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg==", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "devOptional": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jasmine": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-6.1.0.tgz", + "integrity": "sha512-WPphPqEMY0uBRMjuhRHoVoxQNvJuxIMqz0yIcJ3k3oYxBedeGoH60/NXNgasxnx2FvfXrq5/r+2wssJ7WE8ABw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jasminejs/reporters": "^1.0.0", + "glob": "^10.2.2 || ^11.0.3 || ^12.0.0 || ^13.0.0", + "jasmine-core": "~6.1.0" + }, + "bin": { + "jasmine": "bin/jasmine.js" + } + }, + "node_modules/jasmine-core": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-6.1.0.tgz", + "integrity": "sha512-p/tjBw58O6vxKIWMlrU+yys8lqR3+l3UrqwNTT7wpj+dQ7N4etQekFM8joI+cWzPDYqZf54kN+hLC1+s5TvZvg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jasmine-spec-reporter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/jasmine-spec-reporter/-/jasmine-spec-reporter-7.0.0.tgz", + "integrity": "sha512-OtC7JRasiTcjsaCBPtMO0Tl8glCejM4J4/dNuOJdA8lBjz4PmWjYQ6pzb0uzpBNAWJMDudYuj9OdXJWqM2QTJg==", + "dev": true, + "dependencies": { + "colors": "1.4.0" + } + }, + "node_modules/jasmine/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/jasmine/node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jasmine/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jasmine/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jasmine/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/jasmine/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/java-properties": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz", + "integrity": "sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==", + "dev": true, + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/jose": { + "version": "4.15.5", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", + "integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/js2xmlparser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-4.0.2.tgz", + "integrity": "sha512-6n4D8gLlLf1n5mNLQPRfViYzu9RATblzPEtm1SthMX1Pjao0r9YI9nw7ZIfRxQMERS87mcswrg+r/OYrPRX6jA==", + "dev": true, + "dependencies": { + "xmlcreate": "^2.0.4" + } + }, + "node_modules/jsdoc": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.5.tgz", + "integrity": "sha512-P4C6MWP9yIlMiK8nwoZvxN84vb6MsnXcHuy7XzVOvQoCizWX5JFCBsWIIWKXBltpoRZXddUOVQmCTOZt9yDj9g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/parser": "^7.20.15", + "@jsdoc/salty": "^0.2.1", + "@types/markdown-it": "^14.1.1", + "bluebird": "^3.7.2", + "catharsis": "^0.9.0", + "escape-string-regexp": "^2.0.0", + "js2xmlparser": "^4.0.2", + "klaw": "^3.0.0", + "markdown-it": "^14.1.0", + "markdown-it-anchor": "^8.6.7", + "marked": "^4.0.10", + "mkdirp": "^1.0.4", + "requizzle": "^0.2.3", + "strip-json-comments": "^3.1.0", + "underscore": "~1.13.2" + }, + "bin": { + "jsdoc": "jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/jsdoc-babel": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsdoc-babel/-/jsdoc-babel-0.5.0.tgz", + "integrity": "sha512-PYfTbc3LNTeR8TpZs2M94NLDWqARq0r9gx3SvuziJfmJS7/AeMKvtj0xjzOX0R/4MOVA7/FqQQK7d6U0iEoztQ==", + "dev": true, + "dependencies": { + "jsdoc-regex": "^1.0.1", + "lodash": "^4.17.10" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/jsdoc-regex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/jsdoc-regex/-/jsdoc-regex-1.0.1.tgz", + "integrity": "sha512-CMFgT3K8GbmChWEfLWe6jlv9x33E8wLPzBjxIlh/eHLMcnDF+TF3CL265ZGBe029o1QdFepwVrQu0WuqqNPncg==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/jsdoc/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ] + }, + "node_modules/JSONStream": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "dev": true, + "dependencies": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + }, + "bin": { + "JSONStream": "bin.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz", + "integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==", + "license": "MIT", + "dependencies": { + "@types/express": "^4.17.20", + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/klaw": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", + "integrity": "sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.9" + } + }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" + }, + "node_modules/ldapjs": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-3.0.7.tgz", + "integrity": "sha512-1ky+WrN+4CFMuoekUOv7Y1037XWdjKpu0xAPwSP+9KdvmV9PG+qOKlssDV6a+U32apwxdD3is/BZcWOYzN30cg==", + "dependencies": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/messages": "^1.3.0", + "@ldapjs/protocol": "^1.2.1", + "abstract-logging": "^2.0.1", + "assert-plus": "^1.0.0", + "backoff": "^2.5.0", + "once": "^1.4.0", + "vasync": "^2.2.1", + "verror": "^1.10.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/lint-staged": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", + "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", + "dev": true, + "dependencies": { + "commander": "^14.0.3", + "listr2": "^9.0.5", + "picomatch": "^4.0.3", + "string-argv": "^0.3.2", + "tinyexec": "^1.0.4", + "yaml": "^2.8.2" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/load-json-file/node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/load-json-file/node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/load-json-file/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT", + "optional": true + }, + "node_modules/lodash.capitalize": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz", + "integrity": "sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==", + "dev": true + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "dev": true + }, + "node_modules/lodash.flattendeep": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", + "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", + "dev": true + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==" + }, + "node_modules/lodash.uniqby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", + "integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==", + "dev": true + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/log-symbols/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/log-symbols/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/logform/node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/loglevel": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz", + "integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lowercase-keys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lru-memoizer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.2.0.tgz", + "integrity": "sha512-QfOZ6jNkxCcM/BkIPnFsqDhtrazLRsghi9mBwFAzol5GCvj4EkFT899Za3+QwikCg5sRX8JstioBDwOxEyzaNw==", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "~4.0.0" + } + }, + "node_modules/lru-memoizer/node_modules/lru-cache": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.0.2.tgz", + "integrity": "sha512-uQw9OqphAGiZhkuPlpFGmdTU2tEuhxTourM/19qGJrxBPHAr/f8BT1a0i/lOclESnGatdJG/UCkP9kZB/Lh1iw==", + "dependencies": { + "pseudomap": "^1.0.1", + "yallist": "^2.0.0" + } + }, + "node_modules/lru-memoizer/node_modules/yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==" + }, + "node_modules/m": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/m/-/m-1.10.0.tgz", + "integrity": "sha512-+vXing+uUyCeZZlY2RIteWHSPHgVcFyBoeWrBU5F3ibDt847sVPGHK41GriFP05uMvfHZkhlaAMYEHoQkfksvA==", + "dev": true, + "bin": { + "m": "bin/m" + } + }, + "node_modules/madge": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/madge/-/madge-8.0.0.tgz", + "integrity": "sha512-9sSsi3TBPhmkTCIpVQF0SPiChj1L7Rq9kU2KDG1o6v2XH9cCw086MopjVCD+vuoL5v8S77DTbVopTO8OUiQpIw==", + "dev": true, + "dependencies": { + "chalk": "^4.1.2", + "commander": "^7.2.0", + "commondir": "^1.0.1", + "debug": "^4.3.4", + "dependency-tree": "^11.0.0", + "ora": "^5.4.1", + "pluralize": "^8.0.0", + "pretty-ms": "^7.0.1", + "rc": "^1.2.8", + "stream-to-array": "^2.3.0", + "ts-graphviz": "^2.1.2", + "walkdir": "^0.4.1" + }, + "bin": { + "madge": "bin/cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://www.paypal.me/pahen" + }, + "peerDependencies": { + "typescript": "^5.4.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/madge/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/madge/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/madge/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/madge/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/madge/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/madge/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/madge/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/magic-string": { + "version": "0.30.11", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", + "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "dependencies": { + "pify": "^4.0.1", + "semver": "^5.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it-anchor": { + "version": "8.6.7", + "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.7.tgz", + "integrity": "sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==", + "dev": true, + "peerDependencies": { + "@types/markdown-it": "*", + "markdown-it": "*" + } + }, + "node_modules/marked": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", + "dev": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/marked-terminal": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/marked-terminal/-/marked-terminal-7.3.0.tgz", + "integrity": "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "ansi-regex": "^6.1.0", + "chalk": "^5.4.1", + "cli-highlight": "^2.1.11", + "cli-table3": "^0.6.5", + "node-emoji": "^2.2.0", + "supports-hyperlinks": "^3.1.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "marked": ">=1 <16" + } + }, + "node_modules/marked-terminal/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/marked-terminal/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" + }, + "node_modules/meow": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "devOptional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "devOptional": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-response": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", + "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "devOptional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mock-files-adapter": { + "resolved": "spec/dependencies/mock-files-adapter", + "link": true + }, + "node_modules/mock-mail-adapter": { + "resolved": "spec/dependencies/mock-mail-adapter", + "link": true + }, + "node_modules/module-definition": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/module-definition/-/module-definition-6.0.0.tgz", + "integrity": "sha512-sEGP5nKEXU7fGSZUML/coJbrO+yQtxcppDAYWRE9ovWsTbFoUHB2qDUx564WUzDaBHXsD46JBbIK5WVTwCyu3w==", + "dev": true, + "dependencies": { + "ast-module-types": "^6.0.0", + "node-source-walk": "^7.0.0" + }, + "bin": { + "module-definition": "bin/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/module-lookup-amd": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/module-lookup-amd/-/module-lookup-amd-9.0.2.tgz", + "integrity": "sha512-p7PzSVEWiW9fHRX9oM+V4aV5B2nCVddVNv4DZ/JB6t9GsXY4E+ZVhPpnwUX7bbJyGeeVZqhS8q/JZ/H77IqPFA==", + "dev": true, + "dependencies": { + "commander": "^12.1.0", + "glob": "^7.2.3", + "requirejs": "^2.3.7", + "requirejs-config-file": "^4.0.0" + }, + "bin": { + "lookup-amd": "bin/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/module-lookup-amd/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "engines": { + "node": "*" + } + }, + "node_modules/mongodb": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.0.tgz", + "integrity": "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^7.1.1", + "mongodb-connection-string-url": "^7.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": ">=7.0.0 <7.1.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "dev": true, + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/mongodb-download-url": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/mongodb-download-url/-/mongodb-download-url-1.6.2.tgz", + "integrity": "sha512-89g7A+ktFQ6L3fcjV1ClCj5ftlMSuVy22q76N6vhuzxBdYcD2O0Wxt+i16SQ7BAD1QtOPsGpSQVL4bUtLvY6+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.0", + "minimist": "^1.2.8", + "node-fetch": "^2.7.0", + "semver": "^7.7.1" + }, + "bin": { + "mongodb-download-url": "bin/mongodb-download-url.js" + } + }, + "node_modules/mongodb-download-url/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/mongodb-download-url/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/mongodb-download-url/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/mongodb-download-url/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/mongodb-runner": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/mongodb-runner/-/mongodb-runner-5.9.3.tgz", + "integrity": "sha512-2n2fCyUITi0UrAs0eg/zLSehSVOoWWUsgJleEBh6p1otHaiqMSAMURS6W7PLJvvGxFlnO3tjiDB6T11gjqAkUQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/mongodb-downloader": "^0.4.2", + "@mongodb-js/saslprep": "^1.3.0", + "debug": "^4.4.0", + "mongodb": "^6.9.0", + "mongodb-connection-string-url": "^3.0.0", + "yargs": "^17.7.2" + }, + "bin": { + "mongodb-runner": "bin/runner.js" + } + }, + "node_modules/mongodb-runner/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/mongodb-runner/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/mongodb-runner/node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "dev": true, + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/mongodb-runner/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/mongodb-runner/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/mongodb-runner/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/mongodb-runner/node_modules/gaxios": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", + "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/mongodb-runner/node_modules/gcp-metadata": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", + "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "gaxios": "^5.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/mongodb-runner/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/mongodb-runner/node_modules/mongodb": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz", + "integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==", + "dev": true, + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.3.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-runner/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/mongodb-runner/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/mongodb-runner/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true + }, + "node_modules/mongodb-runner/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/mongodb-runner/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/mongodb-runner/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/mongodb-runner/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/mongodb/node_modules/@types/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/mongodb/node_modules/mongodb-connection-string-url": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", + "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "node_modules/nerf-dart": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/nerf-dart/-/nerf-dart-1.0.0.tgz", + "integrity": "sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==", + "dev": true + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "dev": true + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-emoji": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", + "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.6.0", + "char-regex": "^1.0.2", + "emojilib": "^2.4.0", + "skin-tone": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/node-emoji/node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-preload": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", + "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", + "dev": true, + "dependencies": { + "process-on-spawn": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true + }, + "node_modules/node-source-walk": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/node-source-walk/-/node-source-walk-7.0.0.tgz", + "integrity": "sha512-1uiY543L+N7Og4yswvlm5NCKgPKDEXd9AUR9Jh3gen6oOeBsesr6LqhXom1er3eRzSUcVRWXzhv8tSNrIfGHKw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.24.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.1.tgz", + "integrity": "sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm": { + "version": "10.8.1", + "resolved": "https://registry.npmjs.org/npm/-/npm-10.8.1.tgz", + "integrity": "sha512-Dp1C6SvSMYQI7YHq/y2l94uvI+59Eqbu1EpuKQHQ8p16txXRuRit5gH3Lnaagk2aXDIjg/Iru9pd05bnneKgdw==", + "bundleDependencies": [ + "@isaacs/string-locale-compare", + "@npmcli/arborist", + "@npmcli/config", + "@npmcli/fs", + "@npmcli/map-workspaces", + "@npmcli/package-json", + "@npmcli/promise-spawn", + "@npmcli/redact", + "@npmcli/run-script", + "@sigstore/tuf", + "abbrev", + "archy", + "cacache", + "chalk", + "ci-info", + "cli-columns", + "fastest-levenshtein", + "fs-minipass", + "glob", + "graceful-fs", + "hosted-git-info", + "ini", + "init-package-json", + "is-cidr", + "json-parse-even-better-errors", + "libnpmaccess", + "libnpmdiff", + "libnpmexec", + "libnpmfund", + "libnpmhook", + "libnpmorg", + "libnpmpack", + "libnpmpublish", + "libnpmsearch", + "libnpmteam", + "libnpmversion", + "make-fetch-happen", + "minimatch", + "minipass", + "minipass-pipeline", + "ms", + "node-gyp", + "nopt", + "normalize-package-data", + "npm-audit-report", + "npm-install-checks", + "npm-package-arg", + "npm-pick-manifest", + "npm-profile", + "npm-registry-fetch", + "npm-user-validate", + "p-map", + "pacote", + "parse-conflict-json", + "proc-log", + "qrcode-terminal", + "read", + "semver", + "spdx-expression-parse", + "ssri", + "supports-color", + "tar", + "text-table", + "tiny-relative-date", + "treeverse", + "validate-npm-package-name", + "which", + "write-file-atomic" + ], + "dev": true, + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/arborist": "^7.5.3", + "@npmcli/config": "^8.3.3", + "@npmcli/fs": "^3.1.1", + "@npmcli/map-workspaces": "^3.0.6", + "@npmcli/package-json": "^5.1.1", + "@npmcli/promise-spawn": "^7.0.2", + "@npmcli/redact": "^2.0.0", + "@npmcli/run-script": "^8.1.0", + "@sigstore/tuf": "^2.3.4", + "abbrev": "^2.0.0", + "archy": "~1.0.0", + "cacache": "^18.0.3", + "chalk": "^5.3.0", + "ci-info": "^4.0.0", + "cli-columns": "^4.0.0", + "fastest-levenshtein": "^1.0.16", + "fs-minipass": "^3.0.3", + "glob": "^10.4.1", + "graceful-fs": "^4.2.11", + "hosted-git-info": "^7.0.2", + "ini": "^4.1.3", + "init-package-json": "^6.0.3", + "is-cidr": "^5.1.0", + "json-parse-even-better-errors": "^3.0.2", + "libnpmaccess": "^8.0.6", + "libnpmdiff": "^6.1.3", + "libnpmexec": "^8.1.2", + "libnpmfund": "^5.0.11", + "libnpmhook": "^10.0.5", + "libnpmorg": "^6.0.6", + "libnpmpack": "^7.0.3", + "libnpmpublish": "^9.0.9", + "libnpmsearch": "^7.0.6", + "libnpmteam": "^6.0.5", + "libnpmversion": "^6.0.3", + "make-fetch-happen": "^13.0.1", + "minimatch": "^9.0.4", + "minipass": "^7.1.1", + "minipass-pipeline": "^1.2.4", + "ms": "^2.1.2", + "node-gyp": "^10.1.0", + "nopt": "^7.2.1", + "normalize-package-data": "^6.0.1", + "npm-audit-report": "^5.0.0", + "npm-install-checks": "^6.3.0", + "npm-package-arg": "^11.0.2", + "npm-pick-manifest": "^9.0.1", + "npm-profile": "^10.0.0", + "npm-registry-fetch": "^17.0.1", + "npm-user-validate": "^2.0.1", + "p-map": "^4.0.0", + "pacote": "^18.0.6", + "parse-conflict-json": "^3.0.1", + "proc-log": "^4.2.0", + "qrcode-terminal": "^0.12.0", + "read": "^3.0.1", + "semver": "^7.6.2", + "spdx-expression-parse": "^4.0.0", + "ssri": "^10.0.6", + "supports-color": "^9.4.0", + "tar": "^6.2.1", + "text-table": "~0.2.0", + "tiny-relative-date": "^1.3.0", + "treeverse": "^3.0.0", + "validate-npm-package-name": "^5.0.1", + "which": "^4.0.0", + "write-file-atomic": "^5.0.1" + }, + "bin": { + "npm": "bin/npm-cli.js", + "npx": "bin/npx-cli.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui": { + "version": "8.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/@npmcli/agent": { + "version": "2.2.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/arborist": { + "version": "7.5.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^3.1.1", + "@npmcli/installed-package-contents": "^2.1.0", + "@npmcli/map-workspaces": "^3.0.2", + "@npmcli/metavuln-calculator": "^7.1.1", + "@npmcli/name-from-folder": "^2.0.0", + "@npmcli/node-gyp": "^3.0.0", + "@npmcli/package-json": "^5.1.0", + "@npmcli/query": "^3.1.0", + "@npmcli/redact": "^2.0.0", + "@npmcli/run-script": "^8.1.0", + "bin-links": "^4.0.4", + "cacache": "^18.0.3", + "common-ancestor-path": "^1.0.1", + "hosted-git-info": "^7.0.2", + "json-parse-even-better-errors": "^3.0.2", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^10.2.2", + "minimatch": "^9.0.4", + "nopt": "^7.2.1", + "npm-install-checks": "^6.2.0", + "npm-package-arg": "^11.0.2", + "npm-pick-manifest": "^9.0.1", + "npm-registry-fetch": "^17.0.1", + "pacote": "^18.0.6", + "parse-conflict-json": "^3.0.0", + "proc-log": "^4.2.0", + "proggy": "^2.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "read-package-json-fast": "^3.0.2", + "semver": "^7.3.7", + "ssri": "^10.0.6", + "treeverse": "^3.0.0", + "walk-up-path": "^3.0.1" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/config": { + "version": "8.3.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/map-workspaces": "^3.0.2", + "ci-info": "^4.0.0", + "ini": "^4.1.2", + "nopt": "^7.2.1", + "proc-log": "^4.2.0", + "read-package-json-fast": "^3.0.2", + "semver": "^7.3.5", + "walk-up-path": "^3.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/fs": { + "version": "3.1.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/git": { + "version": "5.0.7", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/promise-spawn": "^7.0.0", + "lru-cache": "^10.0.1", + "npm-pick-manifest": "^9.0.0", + "proc-log": "^4.0.0", + "promise-inflight": "^1.0.1", + "promise-retry": "^2.0.1", + "semver": "^7.3.5", + "which": "^4.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/installed-package-contents": { + "version": "2.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^3.0.0", + "npm-normalize-package-bin": "^3.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/map-workspaces": { + "version": "3.0.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^2.0.0", + "glob": "^10.2.2", + "minimatch": "^9.0.0", + "read-package-json-fast": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { + "version": "7.1.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cacache": "^18.0.0", + "json-parse-even-better-errors": "^3.0.0", + "pacote": "^18.0.0", + "proc-log": "^4.1.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/name-from-folder": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/node-gyp": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/package-json": { + "version": "5.1.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^5.0.0", + "glob": "^10.2.2", + "hosted-git-info": "^7.0.0", + "json-parse-even-better-errors": "^3.0.0", + "normalize-package-data": "^6.0.0", + "proc-log": "^4.0.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/promise-spawn": { + "version": "7.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "which": "^4.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/query": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/redact": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@npmcli/run-script": { + "version": "8.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^3.0.0", + "@npmcli/package-json": "^5.0.0", + "@npmcli/promise-spawn": "^7.0.0", + "node-gyp": "^10.0.0", + "proc-log": "^4.0.0", + "which": "^4.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/npm/node_modules/@sigstore/bundle": { + "version": "2.3.2", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.3.2" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@sigstore/core": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@sigstore/protobuf-specs": { + "version": "0.3.2", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@sigstore/sign": { + "version": "2.3.2", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^2.3.2", + "@sigstore/core": "^1.0.0", + "@sigstore/protobuf-specs": "^0.3.2", + "make-fetch-happen": "^13.0.1", + "proc-log": "^4.2.0", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@sigstore/tuf": { + "version": "2.3.4", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.3.2", + "tuf-js": "^2.2.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@sigstore/verify": { + "version": "1.2.1", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^2.3.2", + "@sigstore/core": "^1.1.0", + "@sigstore/protobuf-specs": "^0.3.2" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@tufjs/models": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^9.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/abbrev": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/agent-base": { + "version": "7.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/aggregate-error": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/ansi-styles": { + "version": "6.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/npm/node_modules/aproba": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/archy": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/bin-links": { + "version": "4.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^6.0.0", + "npm-normalize-package-bin": "^3.0.0", + "read-cmd-shim": "^4.0.0", + "write-file-atomic": "^5.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/binary-extensions": { + "version": "2.3.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/brace-expansion": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/npm/node_modules/cacache": { + "version": "18.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^3.1.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^4.0.0", + "ssri": "^10.0.0", + "tar": "^6.1.11", + "unique-filename": "^3.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/chalk": { + "version": "5.3.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/npm/node_modules/chownr": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/ci-info": { + "version": "4.0.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/cidr-regex": { + "version": "4.1.1", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "ip-regex": "^5.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/npm/node_modules/clean-stack": { + "version": "2.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/npm/node_modules/cli-columns": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/npm/node_modules/cmd-shim": { + "version": "6.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/npm/node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/common-ancestor-path": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/cross-spawn": { + "version": "7.0.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/cssesc": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/debug": { + "version": "4.3.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/npm/node_modules/debug/node_modules/ms": { + "version": "2.1.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/diff": { + "version": "5.2.0", + "dev": true, + "inBundle": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/npm/node_modules/eastasianwidth": { + "version": "0.2.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/encoding": { + "version": "0.1.13", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/npm/node_modules/env-paths": { + "version": "2.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/npm/node_modules/err-code": { + "version": "2.0.3", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/exponential-backoff": { + "version": "3.1.1", + "dev": true, + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/npm/node_modules/fastest-levenshtein": { + "version": "1.0.16", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/npm/node_modules/foreground-child": { + "version": "3.1.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/fs-minipass": { + "version": "3.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/function-bind": { + "version": "1.1.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/npm/node_modules/glob": { + "version": "10.4.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/hasown": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/npm/node_modules/hosted-git-info": { + "version": "7.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/http-cache-semantics": { + "version": "4.1.1", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause" + }, + "node_modules/npm/node_modules/http-proxy-agent": { + "version": "7.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/https-proxy-agent": { + "version": "7.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/iconv-lite": { + "version": "0.6.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm/node_modules/ignore-walk": { + "version": "6.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/npm/node_modules/indent-string": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/ini": { + "version": "4.1.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/init-package-json": { + "version": "6.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^5.0.0", + "npm-package-arg": "^11.0.0", + "promzard": "^1.0.0", + "read": "^3.0.1", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/ip-address": { + "version": "9.0.5", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/npm/node_modules/ip-regex": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/is-cidr": { + "version": "5.1.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "cidr-regex": "^4.1.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/npm/node_modules/is-core-module": { + "version": "2.13.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/npm/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/is-lambda": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/jackspeak": { + "version": "3.1.2", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/npm/node_modules/jsbn": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/json-parse-even-better-errors": { + "version": "3.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/json-stringify-nice": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/jsonparse": { + "version": "1.3.1", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff": { + "version": "6.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff-apply": { + "version": "5.5.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/libnpmaccess": { + "version": "8.0.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-package-arg": "^11.0.2", + "npm-registry-fetch": "^17.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/libnpmdiff": { + "version": "6.1.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^7.5.3", + "@npmcli/installed-package-contents": "^2.1.0", + "binary-extensions": "^2.3.0", + "diff": "^5.1.0", + "minimatch": "^9.0.4", + "npm-package-arg": "^11.0.2", + "pacote": "^18.0.6", + "tar": "^6.2.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/libnpmexec": { + "version": "8.1.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^7.5.3", + "@npmcli/run-script": "^8.1.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^11.0.2", + "pacote": "^18.0.6", + "proc-log": "^4.2.0", + "read": "^3.0.1", + "read-package-json-fast": "^3.0.2", + "semver": "^7.3.7", + "walk-up-path": "^3.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/libnpmfund": { + "version": "5.0.11", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^7.5.3" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/libnpmhook": { + "version": "10.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^17.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/libnpmorg": { + "version": "6.0.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^17.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/libnpmpack": { + "version": "7.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^7.5.3", + "@npmcli/run-script": "^8.1.0", + "npm-package-arg": "^11.0.2", + "pacote": "^18.0.6" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/libnpmpublish": { + "version": "9.0.9", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "ci-info": "^4.0.0", + "normalize-package-data": "^6.0.1", + "npm-package-arg": "^11.0.2", + "npm-registry-fetch": "^17.0.1", + "proc-log": "^4.2.0", + "semver": "^7.3.7", + "sigstore": "^2.2.0", + "ssri": "^10.0.6" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/libnpmsearch": { + "version": "7.0.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^17.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/libnpmteam": { + "version": "6.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^17.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/libnpmversion": { + "version": "6.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^5.0.7", + "@npmcli/run-script": "^8.1.0", + "json-parse-even-better-errors": "^3.0.2", + "proc-log": "^4.2.0", + "semver": "^7.3.7" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/lru-cache": { + "version": "10.2.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "14 || >=16.14" + } + }, + "node_modules/npm/node_modules/make-fetch-happen": { + "version": "13.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/agent": "^2.0.0", + "cacache": "^18.0.0", + "http-cache-semantics": "^4.1.1", + "is-lambda": "^1.0.1", + "minipass": "^7.0.2", + "minipass-fetch": "^3.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "proc-log": "^4.2.0", + "promise-retry": "^2.0.1", + "ssri": "^10.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/minimatch": { + "version": "9.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/minipass": { + "version": "7.1.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-collect": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-fetch": { + "version": "3.0.5", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/npm/node_modules/minipass-flush": { + "version": "1.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-json-stream": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "jsonparse": "^1.3.1", + "minipass": "^3.0.0" + } + }, + "node_modules/npm/node_modules/minipass-json-stream/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-pipeline": { + "version": "1.2.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-sized": { + "version": "1.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minizlib": { + "version": "2.1.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/mkdirp": { + "version": "1.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/ms": { + "version": "2.1.3", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/mute-stream": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/negotiator": { + "version": "0.6.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/npm/node_modules/node-gyp": { + "version": "10.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^13.0.0", + "nopt": "^7.0.0", + "proc-log": "^3.0.0", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^4.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/proc-log": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/nopt": { + "version": "7.2.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/normalize-package-data": { + "version": "6.0.1", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "is-core-module": "^2.8.1", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/npm-audit-report": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/npm-bundled": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/npm-install-checks": { + "version": "6.3.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/npm-normalize-package-bin": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/npm-package-arg": { + "version": "11.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^7.0.0", + "proc-log": "^4.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/npm-packlist": { + "version": "8.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^6.0.4" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/npm-pick-manifest": { + "version": "9.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^6.0.0", + "npm-normalize-package-bin": "^3.0.0", + "npm-package-arg": "^11.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/npm-profile": { + "version": "10.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^17.0.1", + "proc-log": "^4.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/npm/node_modules/npm-registry-fetch": { + "version": "17.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^2.0.0", + "make-fetch-happen": "^13.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^3.0.0", + "minipass-json-stream": "^1.0.1", + "minizlib": "^2.1.2", + "npm-package-arg": "^11.0.0", + "proc-log": "^4.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/npm-user-validate": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/p-map": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/pacote": { + "version": "18.0.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^5.0.0", + "@npmcli/installed-package-contents": "^2.0.1", + "@npmcli/package-json": "^5.1.0", + "@npmcli/promise-spawn": "^7.0.0", + "@npmcli/run-script": "^8.0.0", + "cacache": "^18.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^11.0.0", + "npm-packlist": "^8.0.0", + "npm-pick-manifest": "^9.0.0", + "npm-registry-fetch": "^17.0.0", + "proc-log": "^4.0.0", + "promise-retry": "^2.0.1", + "sigstore": "^2.2.0", + "ssri": "^10.0.0", + "tar": "^6.1.11" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/parse-conflict-json": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^3.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/path-scurry": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/postcss-selector-parser": { + "version": "6.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/proc-log": { + "version": "4.2.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/proggy": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/promise-all-reject-late": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promise-call-limit": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promise-inflight": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/promise-retry": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/promzard": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "read": "^3.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/qrcode-terminal": { + "version": "0.12.0", + "dev": true, + "inBundle": true, + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "node_modules/npm/node_modules/read": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "mute-stream": "^1.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/read-cmd-shim": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/read-package-json-fast": { + "version": "3.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^3.0.0", + "npm-normalize-package-bin": "^3.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/retry": { + "version": "0.12.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/npm/node_modules/safer-buffer": { + "version": "2.1.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true + }, + "node_modules/npm/node_modules/semver": { + "version": "7.6.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/sigstore": { + "version": "2.3.1", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^2.3.2", + "@sigstore/core": "^1.0.0", + "@sigstore/protobuf-specs": "^0.3.2", + "@sigstore/sign": "^2.3.2", + "@sigstore/tuf": "^2.3.4", + "@sigstore/verify": "^1.2.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/smart-buffer": { + "version": "4.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks": { + "version": "2.8.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks-proxy-agent": { + "version": "8.0.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.1", + "debug": "^4.3.4", + "socks": "^2.7.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/spdx-correct": { + "version": "3.2.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-correct/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-exceptions": { + "version": "2.5.0", + "dev": true, + "inBundle": true, + "license": "CC-BY-3.0" + }, + "node_modules/npm/node_modules/spdx-expression-parse": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-license-ids": { + "version": "3.0.18", + "dev": true, + "inBundle": true, + "license": "CC0-1.0" + }, + "node_modules/npm/node_modules/sprintf-js": { + "version": "1.1.3", + "dev": true, + "inBundle": true, + "license": "BSD-3-Clause" + }, + "node_modules/npm/node_modules/ssri": { + "version": "10.0.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/supports-color": { + "version": "9.4.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/npm/node_modules/tar": { + "version": "6.2.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/text-table": { + "version": "0.2.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/tiny-relative-date": { + "version": "1.3.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/treeverse": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/tuf-js": { + "version": "2.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "2.0.1", + "debug": "^4.3.4", + "make-fetch-happen": "^13.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/unique-filename": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^4.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/unique-slug": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/util-deprecate": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/validate-npm-package-license": { + "version": "3.0.4", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/npm/node_modules/validate-npm-package-license/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/validate-npm-package-name": { + "version": "5.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/walk-up-path": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/which": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/which/node_modules/isexe": { + "version": "3.1.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/npm/node_modules/wrap-ansi": { + "version": "8.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "9.2.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/string-width": { + "version": "5.1.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/write-file-atomic": { + "version": "5.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/npmlog": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-7.0.1.tgz", + "integrity": "sha512-uJ0YFk/mCQpLBt+bxN88AKd+gyqZvZDbtiNxk6Waqcj2aPRyfVx8ITawkyQynxUagInjdYT1+qj4NfA5KJJUxg==", + "dependencies": { + "are-we-there-yet": "^4.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^5.0.0", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npmlog/node_modules/are-we-there-yet": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-4.0.2.tgz", + "integrity": "sha512-ncSWAawFhKMJDTdoAeOV+jyW1VCMj5QIAwULIBV0SSR7B/RLPPEQiknKcg/RIIZlUQrxELpsxMiTUoAQ4sIUyg==", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/nyc": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-17.1.0.tgz", + "integrity": "sha512-U42vQ4czpKa0QdI1hu950XuNhYqgoM+ZF1HT+VuUHL9hPfDPVvNQyltmMqdE9bUHMVa+8yNbc3QKTj8zQhlVxQ==", + "dev": true, + "dependencies": { + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "caching-transform": "^4.0.0", + "convert-source-map": "^1.7.0", + "decamelize": "^1.2.0", + "find-cache-dir": "^3.2.0", + "find-up": "^4.1.0", + "foreground-child": "^3.3.0", + "get-package-type": "^0.1.0", + "glob": "^7.1.6", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-hook": "^3.0.0", + "istanbul-lib-instrument": "^6.0.2", + "istanbul-lib-processinfo": "^2.0.2", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.0.2", + "make-dir": "^3.0.0", + "node-preload": "^0.2.1", + "p-map": "^3.0.0", + "process-on-spawn": "^1.0.0", + "resolve-from": "^5.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "spawn-wrap": "^2.0.0", + "test-exclude": "^6.0.0", + "yargs": "^15.0.2" + }, + "bin": { + "nyc": "bin/nyc.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nyc/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nyc/node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nyc/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nyc/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nyc/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/nyc/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-path": { + "version": "0.11.8", + "resolved": "https://registry.npmjs.org/object-path/-/object-path-0.11.8.tgz", + "integrity": "sha512-YJjNZrlXJFM42wTBn6zgOJVar9KFJvzx6sTWDte8sWZF//cnjl0BxHNpfZx+ZffXX63A9q0b1zsFiBX4g4X5KA==", + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optimism": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.0.tgz", + "integrity": "sha512-tGn8+REwLRNFnb9WmcY5IfpOqeX2kpaYJ1s6Ae3mn12AeydLkR3j+jSCmVQFoXqU8D41PAJ1RG1rCRNWmNZVmQ==", + "dev": true, + "dependencies": { + "@wry/caches": "^1.0.0", + "@wry/context": "^0.7.0", + "@wry/trie": "^0.4.3", + "tslib": "^2.3.0" + } + }, + "node_modules/optimism/node_modules/@wry/trie": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.4.3.tgz", + "integrity": "sha512-I6bHwH0fSf6RqQcnnXLJKhkSXG45MFral3GxPaY4uAl0LYDZM+YDVDAiU9bYwjTuysy1S0IeecWtmq1SZA3M1w==", + "dev": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/ora/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/ora/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ora/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/otpauth": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.0.tgz", + "integrity": "sha512-Ldhc6UYl4baR5toGr8nfKC+L/b8/RgHKoIixAebgoNGzUUCET02g04rMEZ2ZsPfeVQhMHcuaOgb28nwMr81zCA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=1" + } + }, + "node_modules/p-cancelable": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", + "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/p-each-series": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-3.0.0.tgz", + "integrity": "sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-filter": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-4.1.0.tgz", + "integrity": "sha512-37/tPdZ3oJwHaS3gNJdenCDB3Tz26i9sjhnguBtvN0vYlRIiDNnvTWkuh+0hETV9rLPdJ3rlL3yVOYPIAnM8rw==", + "dev": true, + "dependencies": { + "p-map": "^7.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-filter/node_modules/p-map": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.2.tgz", + "integrity": "sha512-z4cYYMMdKHzw4O5UkWJImbZynVIo0lSGTXc7bzB1e/rrDqkgGUNysK/o4bTr+0+xKvvLoTyGqYC4Fgljy9qe1Q==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-is-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", + "integrity": "sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "devOptional": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-reduce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-2.1.0.tgz", + "integrity": "sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/package-hash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", + "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.15", + "hasha": "^5.0.0", + "lodash.flattendeep": "^4.4.0", + "release-zalgo": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "devOptional": true + }, + "node_modules/param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "dev": true, + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/parse/-/parse-8.6.0.tgz", + "integrity": "sha512-AZjc8yGo8/iTZFpCXWw/r1qNusiUGWtq9i92/u0jNd+Iupg3EJUSV/OOyTrCeav8NDyo92wVS5O3iKAYPlhlsA==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "7.29.2", + "@babel/runtime-corejs3": "7.29.2", + "crypto-js": "4.2.0", + "idb-keyval": "6.2.2", + "react-native-crypto-js": "1.0.0", + "ws": "8.20.0" + }, + "engines": { + "node": ">=20.19.0 <21 || >=22.13.0 <23 || >=24.1.0 <25" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz", + "integrity": "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^6.0.1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "dev": true, + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "devOptional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "devOptional": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "license": "MIT" + }, + "node_modules/pg-cursor": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.17.0.tgz", + "integrity": "sha512-2Uio3Xfl5ldwJfls+RgGL+YbPcKQncWACWjYQFqlamvHZ4HJFjZhhZBbqd7jQ2LIkZYSvU90bm2dNW0rno+QFQ==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "pg": "^8" + } + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-minify": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/pg-minify/-/pg-minify-1.8.0.tgz", + "integrity": "sha512-jO/oJOununpx8DzKgvSsWm61P8JjwXlaxSlbbfTBo1nvSWoo/+I6qZYaSN96jm/KDwa5d+JMQwPGgcP6HXDRow==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/pg-monitor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pg-monitor/-/pg-monitor-3.1.0.tgz", + "integrity": "sha512-giK0h52AOO/v8iu6hZCdZ/X9W8oAM9Dm1VReQQtki532X8g4z1LVIm4Z/3cGvDcETWW+Ty0FrtU8iTrGFYIZfA==", + "dependencies": { + "picocolors": "1.1.1" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/pg-pool": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-promise": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/pg-promise/-/pg-promise-12.6.0.tgz", + "integrity": "sha512-ZnfNn7c0U2p1OWYqoENcke8eSTe+yCGOpuMurExTuot/pe3POodbakE9Sj5MWUsyPpyreARUUPwe4j/5Dfs9Dw==", + "license": "MIT", + "dependencies": { + "assert-options": "0.8.3", + "pg": "8.18.0", + "pg-minify": "1.8.0", + "spex": "4.1.0" + }, + "engines": { + "node": ">=16.0" + }, + "peerDependencies": { + "pg-query-stream": "4.12.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "license": "MIT" + }, + "node_modules/pg-query-stream": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/pg-query-stream/-/pg-query-stream-4.12.0.tgz", + "integrity": "sha512-H97oiVPQ0+eRqIFOeYMUnjDcv9od7vHHMjiVDAhg2SEzAUr3M/dT83UEV1B+fm+tcVnymI8j2LSp57/+yjF6Fg==", + "license": "MIT", + "peer": true, + "dependencies": { + "pg-cursor": "^2.17.0" + }, + "peerDependencies": { + "pg": "^8" + } + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pinkie": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pkg-conf": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-2.1.0.tgz", + "integrity": "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==", + "dev": true, + "dependencies": { + "find-up": "^2.0.0", + "load-json-file": "^4.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-conf/node_modules/find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==", + "dev": true, + "dependencies": { + "locate-path": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-conf/node_modules/locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==", + "dev": true, + "dependencies": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-conf/node_modules/p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "dependencies": { + "p-try": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-conf/node_modules/p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==", + "dev": true, + "dependencies": { + "p-limit": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-conf/node_modules/p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/pkg-conf/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-values-parser": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-values-parser/-/postcss-values-parser-6.0.2.tgz", + "integrity": "sha512-YLJpK0N1brcNJrs9WatuJFtHaV9q5aAOj+S4DI5S7jgHlRfm0PIbDCAFRYMQD5SHq7Fy6xsDhyutgS0QOAs0qw==", + "dev": true, + "dependencies": { + "color-name": "^1.1.4", + "is-url-superb": "^4.0.0", + "quote-unquote": "^1.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "postcss": "^8.2.9" + } + }, + "node_modules/postcss-values-parser/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/precinct": { + "version": "12.1.2", + "resolved": "https://registry.npmjs.org/precinct/-/precinct-12.1.2.tgz", + "integrity": "sha512-x2qVN3oSOp3D05ihCd8XdkIPuEQsyte7PSxzLqiRgktu79S5Dr1I75/S+zAup8/0cwjoiJTQztE9h0/sWp9bJQ==", + "dev": true, + "dependencies": { + "@dependents/detective-less": "^5.0.0", + "commander": "^12.1.0", + "detective-amd": "^6.0.0", + "detective-cjs": "^6.0.0", + "detective-es6": "^5.0.0", + "detective-postcss": "^7.0.0", + "detective-sass": "^6.0.0", + "detective-scss": "^5.0.0", + "detective-stylus": "^5.0.0", + "detective-typescript": "^13.0.0", + "detective-vue2": "^2.0.3", + "module-definition": "^6.0.0", + "node-source-walk": "^7.0.0", + "postcss": "^8.4.40", + "typescript": "^5.5.4" + }, + "bin": { + "precinct": "bin/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/precinct/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/precond": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz", + "integrity": "sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-ms": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-7.0.1.tgz", + "integrity": "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==", + "dev": true, + "dependencies": { + "parse-ms": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "node_modules/process-on-spawn": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", + "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==", + "dev": true, + "dependencies": { + "fromentries": "^1.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/process-warning": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-2.3.2.tgz", + "integrity": "sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==" + }, + "node_modules/promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", + "license": "ISC" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/promise-retry/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true + }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/protobufjs/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/quote-unquote": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/quote-unquote/-/quote-unquote-1.0.0.tgz", + "integrity": "sha512-twwRO/ilhlG/FIgYeKGFqyHhoEhqgnKVkcmqMKi2r524gz3ZbDTcyFt38E9xjJI2vT+KbRNHVbnJ/e0I25Azwg==", + "dev": true + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rate-limit-redis": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/rate-limit-redis/-/rate-limit-redis-4.3.1.tgz", + "integrity": "sha512-+a1zU8+D7L8siDK9jb14refQXz60vq427VuiplgnaLk9B2LnvGe/APLTfhwb4uNIL7eWVknh8GnRp/unCj+lMA==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "peerDependencies": { + "express-rate-limit": ">= 6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true + }, + "node_modules/react-native-crypto-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/react-native-crypto-js/-/react-native-crypto-js-1.0.0.tgz", + "integrity": "sha512-FNbLuG/HAdapQoybeZSoes1PWdOj0w242gb+e1R0hicf3Gyj/Mf8M9NaED2AnXVOX01b2FXomwUiw1xP1K+8sA==" + }, + "node_modules/read-package-up": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", + "integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==", + "dev": true, + "dependencies": { + "find-up-simple": "^1.0.0", + "read-pkg": "^9.0.0", + "type-fest": "^4.6.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "dev": true, + "dependencies": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-11.0.0.tgz", + "integrity": "sha512-LOVbvF1Q0SZdjClSefZ0Nz5z8u+tIE7mV5NibzmE9VYmDe9CaBbAVtz1veOSZbofrdsilxuDAYnFenukZVp8/Q==", + "deprecated": "Renamed to read-package-up", + "dev": true, + "dependencies": { + "find-up-simple": "^1.0.0", + "read-pkg": "^9.0.0", + "type-fest": "^4.6.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/parse-json": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.1.0.tgz", + "integrity": "sha512-rum1bPifK5SSar35Z6EKZuYPJx85pkNaFrxBK3mwdfSJ1/WKbYrjoW/zTPSjRRamfmVX1ACBIdFAO0VRErW/EA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.22.13", + "index-to-position": "^0.1.2", + "type-fest": "^4.7.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "optional": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redeyed": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/redeyed/-/redeyed-2.1.1.tgz", + "integrity": "sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ==", + "dev": true, + "dependencies": { + "esprima": "~4.0.0" + } + }, + "node_modules/redis": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.11.0.tgz", + "integrity": "sha512-YwXjATVDT+AuxcyfOwZn046aml9jMlQPvU1VXIlLDVAExe0u93aTfPYSeRgG4p9Q/Jlkj+LXJ1XEoFV+j2JKcQ==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "5.11.0", + "@redis/client": "5.11.0", + "@redis/json": "5.11.0", + "@redis/search": "5.11.0", + "@redis/time-series": "5.11.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/registry-auth-token": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.0.2.tgz", + "integrity": "sha512-o/3ikDxtXaA59BmZuZrJZDJv8NMDGSj+6j6XaeBmHw8eY1i1qd9+6H+LjVvQXx3HN6aRCGa1cUdJ9RaJZUugnQ==", + "dev": true, + "dependencies": { + "@pnpm/npm-conf": "^2.1.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true + }, + "node_modules/regjsparser": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", + "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "dev": true, + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/rehackt": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/rehackt/-/rehackt-0.1.0.tgz", + "integrity": "sha512-7kRDOuLHB87D/JESKxQoRwv4DzbIdwkAGQ7p6QKGdVlY1IZheUnVhlk/4UZlNUVxdAXpyxikE3URsG067ybVzw==", + "dev": true, + "peerDependencies": { + "@types/react": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/release-zalgo": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", + "integrity": "sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==", + "dev": true, + "dependencies": { + "es6-error": "^4.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "devOptional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "node_modules/requirejs": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/requirejs/-/requirejs-2.3.7.tgz", + "integrity": "sha512-DouTG8T1WanGok6Qjg2SXuCMzszOo0eHeH9hDZ5Y4x8Je+9JB38HdTLT4/VA8OaUhBa0JPVHJ0pyBkM1z+pDsw==", + "dev": true, + "bin": { + "r_js": "bin/r.js", + "r.js": "bin/r.js" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/requirejs-config-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/requirejs-config-file/-/requirejs-config-file-4.0.0.tgz", + "integrity": "sha512-jnIre8cbWOyvr8a5F2KuqBnY+SDA4NXr/hzEZJG79Mxm2WiFQz2dzhC8ibtPJS7zkmBEl1mxSwp5HhC1W4qpxw==", + "dev": true, + "dependencies": { + "esprima": "^4.0.0", + "stringify-object": "^3.2.1" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/requizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/requizzle/-/requizzle-0.2.4.tgz", + "integrity": "sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==", + "dev": true, + "dependencies": { + "lodash": "^4.17.21" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve-dependency-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-dependency-path/-/resolve-dependency-path-4.0.0.tgz", + "integrity": "sha512-hlY1SybBGm5aYN3PC4rp15MzsJLM1w+MEA/4KU3UBPfz4S0lL3FL6mgv7JgaA8a+ZTeEQAiF1a1BuN2nkqiIlg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/responselike": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", + "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safe-stable-stringify": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.1.tgz", + "integrity": "sha512-dVHE6bMtS/bnL2mwualjc6IxEv1F+OCUpA46pKUj6F8uDbUM0jCCulPqRNPSnWwGNKx5etqMjZYdXtrm5KJZGA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/sass-lookup": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/sass-lookup/-/sass-lookup-6.0.1.tgz", + "integrity": "sha512-nl9Wxbj9RjEJA5SSV0hSDoU2zYGtE+ANaDS4OFUR7nYrquvBFvPKZZtQHe3lvnxCcylEDV00KUijjdMTUElcVQ==", + "dev": true, + "dependencies": { + "commander": "^12.0.0" + }, + "bin": { + "sass-lookup": "bin/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/sass-lookup/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/seek-bzip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.6.tgz", + "integrity": "sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^2.8.1" + }, + "bin": { + "seek-bunzip": "bin/seek-bunzip", + "seek-table": "bin/seek-bzip-table" + } + }, + "node_modules/seek-bzip/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/semantic-release": { + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.3.tgz", + "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@semantic-release/commit-analyzer": "^13.0.1", + "@semantic-release/error": "^4.0.0", + "@semantic-release/github": "^12.0.0", + "@semantic-release/npm": "^13.1.1", + "@semantic-release/release-notes-generator": "^14.1.0", + "aggregate-error": "^5.0.0", + "cosmiconfig": "^9.0.0", + "debug": "^4.0.0", + "env-ci": "^11.0.0", + "execa": "^9.0.0", + "figures": "^6.0.0", + "find-versions": "^6.0.0", + "get-stream": "^6.0.0", + "git-log-parser": "^1.2.0", + "hook-std": "^4.0.0", + "hosted-git-info": "^9.0.0", + "import-from-esm": "^2.0.0", + "lodash-es": "^4.17.21", + "marked": "^15.0.0", + "marked-terminal": "^7.3.0", + "micromatch": "^4.0.2", + "p-each-series": "^3.0.0", + "p-reduce": "^3.0.0", + "read-package-up": "^12.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.3.2", + "signale": "^1.2.1", + "yargs": "^18.0.0" + }, + "bin": { + "semantic-release": "bin/semantic-release.js" + }, + "engines": { + "node": "^22.14.0 || >= 24.10.0" + } + }, + "node_modules/semantic-release/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/semantic-release/node_modules/@semantic-release/npm": { + "version": "13.1.5", + "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-13.1.5.tgz", + "integrity": "sha512-Hq5UxzoatN3LHiq2rTsWS54nCdqJHlsssGERCo8WlvdfFA9LoN0vO+OuKVSjtNapIc/S8C2LBj206wKLHg62mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/core": "^3.0.0", + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "env-ci": "^11.2.0", + "execa": "^9.0.0", + "fs-extra": "^11.0.0", + "lodash-es": "^4.17.21", + "nerf-dart": "^1.0.0", + "normalize-url": "^9.0.0", + "npm": "^11.6.2", + "rc": "^1.2.8", + "read-pkg": "^10.0.0", + "registry-auth-token": "^5.0.0", + "semver": "^7.1.2", + "tempy": "^3.0.0" + }, + "engines": { + "node": "^22.14.0 || >= 24.10.0" + }, + "peerDependencies": { + "semantic-release": ">=20.1.0" + } + }, + "node_modules/semantic-release/node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "dependencies": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/semantic-release/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/semantic-release/node_modules/clean-stack": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.2.0.tgz", + "integrity": "sha512-TyUIUJgdFnCISzG5zu3291TAsE77ddchd0bepon1VVQrKLGKFED4iXFEDQ24mIPdPBbyE16PK3F8MYE1CmcBEQ==", + "dev": true, + "dependencies": { + "escape-string-regexp": "5.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/semantic-release/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/semantic-release/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/execa": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.3.0.tgz", + "integrity": "sha512-l6JFbqnHEadBoVAVpN5dl2yCyfX28WoBAGaoQcNmLLSedOxTxcn2Qa83s8I/PA5i56vWru2OHOtrwF7Om2vqlg==", + "dev": true, + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.3", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^7.0.0", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^5.2.0", + "pretty-ms": "^9.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/semantic-release/node_modules/execa/node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/hook-std": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hook-std/-/hook-std-4.0.0.tgz", + "integrity": "sha512-IHI4bEVOt3vRUDJ+bFA9VUJlo7SzvFARPNLw75pqSmAOP2HmTWfFJtPvLBrDrlgjEYXY9zs7SFdHPQaJShkSCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/hosted-git-info": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", + "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/human-signals": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-7.0.0.tgz", + "integrity": "sha512-74kytxOUSvNbjrT9KisAbaTZ/eJwD/LrbM/kh5j0IhPuJzwuA19dWvniFGwBzN9rVjg+O/e+F310PjObDXS+9Q==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/semantic-release/node_modules/import-from-esm": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-2.0.0.tgz", + "integrity": "sha512-YVt14UZCgsX1vZQ3gKjkWVdBdHQ6eu3MPU1TBgL1H5orXe2+jWD006WCPPtOuwlQm10NuzOW5WawiF1Q9veW8g==", + "dev": true, + "dependencies": { + "debug": "^4.3.4", + "import-meta-resolve": "^4.0.0" + }, + "engines": { + "node": ">=18.20" + } + }, + "node_modules/semantic-release/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/semantic-release/node_modules/normalize-package-data": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", + "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^9.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/normalize-url": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.0.tgz", + "integrity": "sha512-z9nC87iaZXXySbWWtTHfCFJyFvKaUAW6lODhikG7ILSbVgmwuFjUqkgnheHvAUcGedO29e2QGBRXMUD64aurqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/npm": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.12.0.tgz", + "integrity": "sha512-xPhOap4ZbJWyd7DAOukP564WFwNSGu/2FeTRFHhiiKthcauxhH/NpkJAQm24xD+cAn8av5tQ00phi98DqtfLsg==", + "bundleDependencies": [ + "@isaacs/string-locale-compare", + "@npmcli/arborist", + "@npmcli/config", + "@npmcli/fs", + "@npmcli/map-workspaces", + "@npmcli/metavuln-calculator", + "@npmcli/package-json", + "@npmcli/promise-spawn", + "@npmcli/redact", + "@npmcli/run-script", + "@sigstore/tuf", + "abbrev", + "archy", + "cacache", + "chalk", + "ci-info", + "fastest-levenshtein", + "fs-minipass", + "glob", + "graceful-fs", + "hosted-git-info", + "ini", + "init-package-json", + "is-cidr", + "json-parse-even-better-errors", + "libnpmaccess", + "libnpmdiff", + "libnpmexec", + "libnpmfund", + "libnpmorg", + "libnpmpack", + "libnpmpublish", + "libnpmsearch", + "libnpmteam", + "libnpmversion", + "make-fetch-happen", + "minimatch", + "minipass", + "minipass-pipeline", + "ms", + "node-gyp", + "nopt", + "npm-audit-report", + "npm-install-checks", + "npm-package-arg", + "npm-pick-manifest", + "npm-profile", + "npm-registry-fetch", + "npm-user-validate", + "p-map", + "pacote", + "parse-conflict-json", + "proc-log", + "qrcode-terminal", + "read", + "semver", + "spdx-expression-parse", + "ssri", + "supports-color", + "tar", + "text-table", + "tiny-relative-date", + "treeverse", + "validate-npm-package-name", + "which" + ], + "dev": true, + "license": "Artistic-2.0", + "workspaces": [ + "docs", + "smoke-tests", + "mock-globals", + "mock-registry", + "workspaces/*" + ], + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/arborist": "^9.4.2", + "@npmcli/config": "^10.8.0", + "@npmcli/fs": "^5.0.0", + "@npmcli/map-workspaces": "^5.0.3", + "@npmcli/metavuln-calculator": "^9.0.3", + "@npmcli/package-json": "^7.0.5", + "@npmcli/promise-spawn": "^9.0.1", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.4", + "@sigstore/tuf": "^4.0.2", + "abbrev": "^4.0.0", + "archy": "~1.0.0", + "cacache": "^20.0.4", + "chalk": "^5.6.2", + "ci-info": "^4.4.0", + "fastest-levenshtein": "^1.0.16", + "fs-minipass": "^3.0.3", + "glob": "^13.0.6", + "graceful-fs": "^4.2.11", + "hosted-git-info": "^9.0.2", + "ini": "^6.0.0", + "init-package-json": "^8.2.5", + "is-cidr": "^6.0.3", + "json-parse-even-better-errors": "^5.0.0", + "libnpmaccess": "^10.0.3", + "libnpmdiff": "^8.1.5", + "libnpmexec": "^10.2.5", + "libnpmfund": "^7.0.19", + "libnpmorg": "^8.0.1", + "libnpmpack": "^9.1.5", + "libnpmpublish": "^11.1.3", + "libnpmsearch": "^9.0.1", + "libnpmteam": "^8.0.2", + "libnpmversion": "^8.0.3", + "make-fetch-happen": "^15.0.5", + "minimatch": "^10.2.4", + "minipass": "^7.1.3", + "minipass-pipeline": "^1.2.4", + "ms": "^2.1.2", + "node-gyp": "^12.2.0", + "nopt": "^9.0.0", + "npm-audit-report": "^7.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.2", + "npm-pick-manifest": "^11.0.3", + "npm-profile": "^12.0.1", + "npm-registry-fetch": "^19.1.1", + "npm-user-validate": "^4.0.0", + "p-map": "^7.0.4", + "pacote": "^21.5.0", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.1.0", + "qrcode-terminal": "^0.12.0", + "read": "^5.0.1", + "semver": "^7.7.4", + "spdx-expression-parse": "^4.0.0", + "ssri": "^13.0.1", + "supports-color": "^10.2.2", + "tar": "^7.5.11", + "text-table": "~0.2.0", + "tiny-relative-date": "^2.0.2", + "treeverse": "^3.0.0", + "validate-npm-package-name": "^7.0.2", + "which": "^6.0.1" + }, + "bin": { + "npm": "bin/npm-cli.js", + "npx": "bin/npx-cli.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@gar/promise-retry": { + "version": "1.0.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/agent": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/arborist": { + "version": "9.4.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^5.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/metavuln-calculator": "^9.0.2", + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/query": "^5.0.0", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.0", + "bin-links": "^6.0.0", + "cacache": "^20.0.1", + "common-ancestor-path": "^2.0.0", + "hosted-git-info": "^9.0.0", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^11.2.1", + "minimatch": "^10.0.3", + "nopt": "^9.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.0", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "pacote": "^21.0.2", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.0.0", + "proggy": "^4.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "semver": "^7.3.7", + "ssri": "^13.0.0", + "treeverse": "^3.0.0", + "walk-up-path": "^4.0.0" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/config": { + "version": "10.8.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "ci-info": "^4.0.0", + "ini": "^6.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "walk-up-path": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/fs": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/git": { + "version": "7.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "ini": "^6.0.0", + "lru-cache": "^11.2.1", + "npm-pick-manifest": "^11.0.1", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/installed-package-contents": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^5.0.0", + "npm-normalize-package-bin": "^5.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/map-workspaces": { + "version": "5.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "glob": "^13.0.0", + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/metavuln-calculator": { + "version": "9.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cacache": "^20.0.0", + "json-parse-even-better-errors": "^5.0.0", + "pacote": "^21.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/name-from-folder": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/node-gyp": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/package-json": { + "version": "7.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^7.0.0", + "glob": "^13.0.0", + "hosted-git-info": "^9.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.5.3", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/promise-spawn": { + "version": "9.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/query": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/redact": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@npmcli/run-script": { + "version": "10.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "node-gyp": "^12.1.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@sigstore/bundle": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@sigstore/core": { + "version": "3.2.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@sigstore/protobuf-specs": { + "version": "0.5.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@sigstore/sign": { + "version": "4.1.1", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@gar/promise-retry": "^1.0.2", + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.0", + "@sigstore/protobuf-specs": "^0.5.0", + "make-fetch-happen": "^15.0.4", + "proc-log": "^6.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@sigstore/tuf": { + "version": "4.0.2", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^4.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@sigstore/verify": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/@tufjs/models": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^10.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/abbrev": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/agent-base": { + "version": "7.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/aproba": { + "version": "2.1.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/semantic-release/node_modules/npm/node_modules/archy": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/semantic-release/node_modules/npm/node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/bin-links": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "proc-log": "^6.0.0", + "read-cmd-shim": "^6.0.0", + "write-file-atomic": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/binary-extensions": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/brace-expansion": { + "version": "5.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/cacache": { + "version": "20.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/chalk": { + "version": "5.6.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/chownr": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/ci-info": { + "version": "4.4.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/cidr-regex": { + "version": "5.0.3", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/cmd-shim": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/common-ancestor-path": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/cssesc": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/debug": { + "version": "4.4.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/diff": { + "version": "8.0.3", + "dev": true, + "inBundle": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/env-paths": { + "version": "2.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/exponential-backoff": { + "version": "3.1.3", + "dev": true, + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/semantic-release/node_modules/npm/node_modules/fastest-levenshtein": { + "version": "1.0.16", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/fs-minipass": { + "version": "3.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/glob": { + "version": "13.0.6", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/semantic-release/node_modules/npm/node_modules/hosted-git-info": { + "version": "9.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/http-cache-semantics": { + "version": "4.2.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause" + }, + "node_modules/semantic-release/node_modules/npm/node_modules/http-proxy-agent": { + "version": "7.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/https-proxy-agent": { + "version": "7.0.6", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/iconv-lite": { + "version": "0.7.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/ignore-walk": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/ini": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/init-package-json": { + "version": "8.2.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^7.0.0", + "npm-package-arg": "^13.0.0", + "promzard": "^3.0.1", + "read": "^5.0.1", + "semver": "^7.7.2", + "validate-npm-package-name": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/ip-address": { + "version": "10.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/is-cidr": { + "version": "6.0.3", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "cidr-regex": "^5.0.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/isexe": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/json-parse-even-better-errors": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/json-stringify-nice": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/jsonparse": { + "version": "1.3.1", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "inBundle": true, + "license": "MIT" + }, + "node_modules/semantic-release/node_modules/npm/node_modules/just-diff": { + "version": "6.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/semantic-release/node_modules/npm/node_modules/just-diff-apply": { + "version": "5.5.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/semantic-release/node_modules/npm/node_modules/libnpmaccess": { + "version": "10.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-package-arg": "^13.0.0", + "npm-registry-fetch": "^19.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/libnpmdiff": { + "version": "8.1.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.4.2", + "@npmcli/installed-package-contents": "^4.0.0", + "binary-extensions": "^3.0.0", + "diff": "^8.0.2", + "minimatch": "^10.0.3", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2", + "tar": "^7.5.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/libnpmexec": { + "version": "10.2.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/arborist": "^9.4.2", + "@npmcli/package-json": "^7.0.0", + "@npmcli/run-script": "^10.0.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2", + "proc-log": "^6.0.0", + "read": "^5.0.1", + "semver": "^7.3.7", + "signal-exit": "^4.1.0", + "walk-up-path": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/libnpmfund": { + "version": "7.0.19", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.4.2" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/libnpmorg": { + "version": "8.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^19.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/libnpmpack": { + "version": "9.1.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.4.2", + "@npmcli/run-script": "^10.0.0", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/libnpmpublish": { + "version": "11.1.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^7.0.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^13.0.0", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.7", + "sigstore": "^4.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/libnpmsearch": { + "version": "9.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^19.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/libnpmteam": { + "version": "8.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^19.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/libnpmversion": { + "version": "8.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^7.0.0", + "@npmcli/run-script": "^10.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.7" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/lru-cache": { + "version": "11.2.7", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/make-fetch-happen": { + "version": "15.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/minimatch": { + "version": "10.2.4", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/minipass": { + "version": "7.1.3", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/minipass-collect": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/minipass-fetch": { + "version": "5.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.7.2" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/minipass-flush": { + "version": "1.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/semantic-release/node_modules/npm/node_modules/minipass-pipeline": { + "version": "1.2.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "node_modules/semantic-release/node_modules/npm/node_modules/minipass-sized": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/minizlib": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/ms": { + "version": "2.1.3", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/semantic-release/node_modules/npm/node_modules/mute-stream": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/negotiator": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/node-gyp": { + "version": "12.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^15.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/nopt": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/npm-audit-report": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/npm-bundled": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/npm-install-checks": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/npm-normalize-package-bin": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/npm-package-arg": { + "version": "13.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/npm-packlist": { + "version": "10.0.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^8.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/npm-pick-manifest": { + "version": "11.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "npm-package-arg": "^13.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/npm-profile": { + "version": "12.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/npm-registry-fetch": { + "version": "19.1.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^4.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^15.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^13.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/npm-user-validate": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/p-map": { + "version": "7.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/pacote": { + "version": "21.5.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/git": "^7.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "@npmcli/run-script": "^10.0.0", + "cacache": "^20.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^13.0.0", + "npm-packlist": "^10.0.1", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "sigstore": "^4.0.0", + "ssri": "^13.0.0", + "tar": "^7.4.3" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/parse-conflict-json": { + "version": "5.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^5.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/path-scurry": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/proc-log": { + "version": "6.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/proggy": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/promise-all-reject-late": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/promise-call-limit": { + "version": "3.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/promzard": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "read": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/qrcode-terminal": { + "version": "0.12.0", + "dev": true, + "inBundle": true, + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/read": { + "version": "5.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "mute-stream": "^3.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/read-cmd-shim": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/safer-buffer": { + "version": "2.1.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true + }, + "node_modules/semantic-release/node_modules/npm/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/sigstore": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^4.1.0", + "@sigstore/tuf": "^4.0.1", + "@sigstore/verify": "^3.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/smart-buffer": { + "version": "4.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/socks": { + "version": "2.8.7", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/socks-proxy-agent": { + "version": "8.0.5", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/spdx-exceptions": { + "version": "2.5.0", + "dev": true, + "inBundle": true, + "license": "CC-BY-3.0" + }, + "node_modules/semantic-release/node_modules/npm/node_modules/spdx-expression-parse": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/spdx-license-ids": { + "version": "3.0.23", + "dev": true, + "inBundle": true, + "license": "CC0-1.0" + }, + "node_modules/semantic-release/node_modules/npm/node_modules/ssri": { + "version": "13.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/supports-color": { + "version": "10.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/tar": { + "version": "7.5.11", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/text-table": { + "version": "0.2.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/semantic-release/node_modules/npm/node_modules/tiny-relative-date": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/semantic-release/node_modules/npm/node_modules/tinyglobby": { + "version": "0.2.15", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/treeverse": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/tuf-js": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/util-deprecate": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "node_modules/semantic-release/node_modules/npm/node_modules/validate-npm-package-name": { + "version": "7.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/walk-up-path": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/which": { + "version": "6.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/write-file-atomic": { + "version": "7.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/semantic-release/node_modules/npm/node_modules/yallist": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/semantic-release/node_modules/p-reduce": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-3.0.0.tgz", + "integrity": "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/parse-json/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/pretty-ms": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.0.0.tgz", + "integrity": "sha512-E9e9HJ9R9NasGOgPaPE8VMeiPKAyWR5jcFpNnwIejslIhWqdqOrb2wShBsncMPUb+BcCd2OPYfh7p2W6oemTng==", + "dev": true, + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/read-package-up": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-12.0.0.tgz", + "integrity": "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.1", + "read-pkg": "^10.0.0", + "type-fest": "^5.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/read-pkg": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.1.0.tgz", + "integrity": "sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.4", + "normalize-package-data": "^8.0.0", + "parse-json": "^8.3.0", + "type-fest": "^5.4.4", + "unicorn-magic": "^0.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/semantic-release/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/semantic-release/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/semantic-release/node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/type-fest": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/unicorn-magic": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semantic-release/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/semantic-release/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/semantic-release/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/semantic-release/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-4.0.0.tgz", + "integrity": "sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==", + "dev": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semver-regex": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-4.0.5.tgz", + "integrity": "sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "devOptional": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "devOptional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/showdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz", + "integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==", + "dev": true, + "dependencies": { + "commander": "^9.0.0" + }, + "bin": { + "showdown": "bin/showdown.js" + }, + "funding": { + "type": "individual", + "url": "https://www.paypal.me/tiviesantos" + } + }, + "node_modules/showdown/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/signale": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/signale/-/signale-1.4.0.tgz", + "integrity": "sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w==", + "dev": true, + "dependencies": { + "chalk": "^2.3.2", + "figures": "^2.0.0", + "pkg-conf": "^2.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/signale/node_modules/figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/skin-tone": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", + "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", + "dev": true, + "dependencies": { + "unicode-emoji-modifier-base": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/spawn-error-forwarder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/spawn-error-forwarder/-/spawn-error-forwarder-1.0.0.tgz", + "integrity": "sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g==", + "dev": true + }, + "node_modules/spawn-wrap": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", + "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", + "dev": true, + "dependencies": { + "foreground-child": "^2.0.0", + "is-windows": "^1.0.2", + "make-dir": "^3.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "which": "^2.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/spawn-wrap/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/spawn-wrap/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.18.tgz", + "integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==", + "dev": true + }, + "node_modules/spex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/spex/-/spex-4.1.0.tgz", + "integrity": "sha512-ktgNAQ1X9x1A3IMChM6XBDeVjhGPbLgPQ8aEzGOaUIhZTnLeJSBApvi3gXT789hee6h73N3jOeWkXDwoPbYT/A==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "engines": { + "node": "*" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-combiner2": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", + "integrity": "sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==", + "dev": true, + "dependencies": { + "duplexer2": "~0.1.0", + "readable-stream": "^2.0.2" + } + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "optional": true, + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT", + "optional": true + }, + "node_modules/stream-to-array": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/stream-to-array/-/stream-to-array-2.3.0.tgz", + "integrity": "sha512-UsZtOYEn4tWU2RGLOXr/o/xjRBftZRlG3dEWoaHr8j4GuypJ3isitGbVyjQKAuMu+xbiop8q224TjiZWc4XTZA==", + "dev": true, + "dependencies": { + "any-promise": "^1.1.0" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "devOptional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dev": true, + "dependencies": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/stringify-object/node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "devOptional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-dirs": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/strip-dirs/-/strip-dirs-2.1.0.tgz", + "integrity": "sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-natural-number": "^4.0.1" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT", + "optional": true + }, + "node_modules/stylus-lookup": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/stylus-lookup/-/stylus-lookup-6.0.0.tgz", + "integrity": "sha512-RaWKxAvPnIXrdby+UWCr1WRfa+lrPMSJPySte4Q6a+rWyjeJyFOLJxr5GrAVfcMCsfVlCuzTAJ/ysYT8p8do7Q==", + "dev": true, + "dependencies": { + "commander": "^12.0.0" + }, + "bin": { + "stylus-lookup": "bin/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/stylus-lookup/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/subscriptions-transport-ws": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.11.0.tgz", + "integrity": "sha512-8D4C6DIH5tGiAIpp5I0wD/xRlNiZAPGHygzCe7VzyzUoxHtawzjNAY9SUTXU05/EY2NMY9/9GF0ycizkXr1CWQ==", + "deprecated": "The `subscriptions-transport-ws` package is no longer maintained. We recommend you use `graphql-ws` instead. For help migrating Apollo software to `graphql-ws`, see https://www.apollographql.com/docs/apollo-server/data/subscriptions/#switching-from-subscriptions-transport-ws For general help using `graphql-ws`, see https://github.com/enisdenjo/graphql-ws/blob/master/README.md", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "backo2": "^1.0.2", + "eventemitter3": "^3.1.0", + "iterall": "^1.2.1", + "symbol-observable": "^1.0.4", + "ws": "^5.2.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependencies": { + "graphql": "^15.7.2 || ^16.0.0" + } + }, + "node_modules/subscriptions-transport-ws/node_modules/symbol-observable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/subscriptions-transport-ws/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/super-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.0.0.tgz", + "integrity": "sha512-CY8u7DtbvucKuquCmOFEKhr9Besln7n9uN8eFbwcoGYWXOMW07u2o8njWaiXt11ylS3qoGF55pILjRmPlbodyg==", + "dev": true, + "dependencies": { + "function-timeout": "^1.0.1", + "time-span": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/supports-hyperlinks/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-observable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", + "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", + "dev": true, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz", + "integrity": "sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^1.0.0", + "buffer-alloc": "^1.2.0", + "end-of-stream": "^1.0.0", + "fs-constants": "^1.0.0", + "readable-stream": "^2.3.0", + "to-buffer": "^1.1.1", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/tar-stream/node_modules/bl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", + "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/teeny-request/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true + }, + "node_modules/teeny-request/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/teeny-request/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/teeny-request/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/temp-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", + "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", + "dev": true, + "engines": { + "node": ">=14.16" + } + }, + "node_modules/tempy": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.1.0.tgz", + "integrity": "sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g==", + "dev": true, + "dependencies": { + "is-stream": "^3.0.0", + "temp-dir": "^3.0.0", + "type-fest": "^2.12.2", + "unique-string": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.30.0.tgz", + "integrity": "sha512-Y/SblUl5kEyEFzhMAQdsxVHh+utAxd4IuRNJzKywY/4uzSogh3G219jqbDDxYu4MXO9CzY3tSEqmZvW6AoEDJw==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-extensions": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz", + "integrity": "sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true + }, + "node_modules/time-span": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz", + "integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==", + "dev": true, + "dependencies": { + "convert-hrtime": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-buffer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.1.tgz", + "integrity": "sha512-tB82LpAIWjhLYbqjx3X4zEeHN6M8CiuOEy2JY8SEQVdYRe3CCHOFaqrBW1doLDrfpWhplcW7BL+bO3/6S3pcDQ==", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-buffer/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/traverse": { + "version": "0.6.7", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.7.tgz", + "integrity": "sha512-/y956gpUo9ZNCb99YjxG7OaslxZWHfCHAUUfshwqOXmxUIvqLjVO581BT+gM59+QV9tFe6/CGG53tsA1Y7RSdg==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-graphviz": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/ts-graphviz/-/ts-graphviz-2.1.4.tgz", + "integrity": "sha512-0g465/ES70H0h5rcLUqaenKqNYekQaR9W0m0xUGy3FxueGujpGr+0GN2YWlgLIYSE2Xg0W7Uq1Qqnn7Cg+Af2w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ts-graphviz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ts-graphviz" + } + ], + "dependencies": { + "@ts-graphviz/adapter": "^2.0.5", + "@ts-graphviz/ast": "^2.0.5", + "@ts-graphviz/common": "^2.1.4", + "@ts-graphviz/core": "^2.0.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-invariant": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.10.3.tgz", + "integrity": "sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/tv4": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz", + "integrity": "sha512-afizzfpJgvPr+eDkREK4MxJ/+r8nEEHcmitwgnPUqpaP+FpwQyadnxNoSACbgc/b1LsZYtODGoPiFxQrgJgjvw==", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.21.0.tgz", + "integrity": "sha512-ADn2w7hVPcK6w1I0uWnM//y1rLXZhzB9mr0a3OirzclKF1Wp6VzevUmzz/NRAWunOT6E8HrnpGY7xOfc6K57fA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.0.tgz", + "integrity": "sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.0", + "@typescript-eslint/parser": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/typescript-eslint/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/typescript-eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/typescript-eslint/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typescript-eslint/node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true + }, + "node_modules/uglify-js": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.18.0.tgz", + "integrity": "sha512-SyVVbcNBCk0dzr9XL/R/ySrmYf0s372K6/hFklzgcp2lBFyXtw4I7BOdDjlLhE1aVqaI/SHWXWmYdlZxuyF38A==", + "dev": true, + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, + "node_modules/underscore": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz", + "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", + "dev": true + }, + "node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "dev": true, + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-emoji-modifier-base": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", + "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unique-string": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", + "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", + "dev": true, + "dependencies": { + "crypto-random-string": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-join": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", + "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vasync": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/vasync/-/vasync-2.2.1.tgz", + "integrity": "sha512-Hq72JaTpcTFdWiNA4Y22Amej2GH3BFmBaKPPlDZ4/oC8HNn2ISHLkFrJU4Ds8R3jcUi7oo5Y9jcMHKjES+N9wQ==", + "engines": [ + "node >=0.6.0" + ], + "dependencies": { + "verror": "1.10.0" + } + }, + "node_modules/vasync/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" + }, + "node_modules/vasync/node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/verror/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" + }, + "node_modules/walkdir": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/walkdir/-/walkdir-0.4.1.tgz", + "integrity": "sha512-3eBwRyEln6E1MSzcxcVpQIhRG8Q1jLvEqRmCZqS3dsfXEDR/AhOF4d+jHg1qvDCpYaVRZjENPQyrVxAkQqxPgQ==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", + "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "engines": { + "node": ">=12" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "devOptional": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==", + "dev": true + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/winston": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-daily-rotate-file": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz", + "integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==", + "dependencies": { + "file-stream-rotator": "^0.6.1", + "object-hash": "^3.0.0", + "triple-beam": "^1.4.1", + "winston-transport": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "winston": "^3" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/winston/node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/winston/node_modules/readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "devOptional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "devOptional": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "devOptional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "devOptional": true + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlcreate": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.4.tgz", + "integrity": "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==", + "dev": true + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dev": true, + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "devOptional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "devOptional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.1.tgz", + "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zen-observable": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz", + "integrity": "sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==", + "dev": true + }, + "node_modules/zen-observable-ts": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-1.2.5.tgz", + "integrity": "sha512-QZWQekv6iB72Naeake9hS1KxHlotfRpe+WGNbNx5/ta+R3DNjVO2bswf63gXlWDcs+EMd7XY8HfVQyP1X6T4Zg==", + "dev": true, + "dependencies": { + "zen-observable": "0.8.15" + } + }, + "spec/dependencies/mock-files-adapter": { + "version": "1.0.0", + "dev": true + }, + "spec/dependencies/mock-mail-adapter": { + "version": "1.0.0", + "dev": true + } + }, + "dependencies": { + "@actions/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.0.tgz", + "integrity": "sha512-zYt6cz+ivnTmiT/ksRVriMBOiuoUpDCJJlZ5KPl2/FRdvwU3f7MPh9qftvbkXJThragzUZieit2nyHUyw53Seg==", + "dev": true, + "requires": { + "@actions/exec": "^3.0.0", + "@actions/http-client": "^4.0.0" + } + }, + "@actions/exec": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz", + "integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==", + "dev": true, + "requires": { + "@actions/io": "^3.0.2" + } + }, + "@actions/http-client": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.0.tgz", + "integrity": "sha512-QuwPsgVMsD6qaPD57GLZi9sqzAZCtiJT8kVBCDpLtxhL5MydQ4gS+DrejtZZPdIYyB1e95uCK9Luyds7ybHI3g==", + "dev": true, + "requires": { + "tunnel": "^0.0.6", + "undici": "^6.23.0" + } + }, + "@actions/io": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz", + "integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==", + "dev": true + }, + "@apollo/cache-control-types": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@apollo/cache-control-types/-/cache-control-types-1.0.3.tgz", + "integrity": "sha512-F17/vCp7QVwom9eG7ToauIKdAxpSoadsJnqIfyryLFSkLSOEqu+eC5Z3N8OXcUVStuOMcNHlyraRsA6rRICu4g==", + "requires": {} + }, + "@apollo/client": { + "version": "3.13.8", + "resolved": "https://registry.npmjs.org/@apollo/client/-/client-3.13.8.tgz", + "integrity": "sha512-YM9lQpm0VfVco4DSyKooHS/fDTiKQcCHfxr7i3iL6a0kP/jNO5+4NFK6vtRDxaYisd5BrwOZHLJpPBnvRVpKPg==", + "dev": true, + "requires": { + "@graphql-typed-document-node/core": "^3.1.1", + "@wry/caches": "^1.0.0", + "@wry/equality": "^0.5.6", + "@wry/trie": "^0.5.0", + "graphql-tag": "^2.12.6", + "hoist-non-react-statics": "^3.3.2", + "optimism": "^0.18.0", + "prop-types": "^15.7.2", + "rehackt": "^0.1.0", + "symbol-observable": "^4.0.0", + "ts-invariant": "^0.10.3", + "tslib": "^2.3.0", + "zen-observable-ts": "^1.2.5" + } + }, + "@apollo/protobufjs": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@apollo/protobufjs/-/protobufjs-1.2.7.tgz", + "integrity": "sha512-Lahx5zntHPZia35myYDBRuF58tlwPskwHc5CWBZC/4bMKB6siTBWwtMrkqXcsNwQiFSzSx5hKdRPUmemrEp3Gg==", + "requires": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.0", + "long": "^4.0.0" + } + }, + "@apollo/server": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@apollo/server/-/server-5.5.0.tgz", + "integrity": "sha512-vWtodBOK/SZwBTJzItECOmLfL8E8pn/IdvP7pnxN5g2tny9iW4+9sxdajE798wV1H2+PYp/rRcl/soSHIBKMPw==", + "requires": { + "@apollo/cache-control-types": "^1.0.3", + "@apollo/server-gateway-interface": "^2.0.0", + "@apollo/usage-reporting-protobuf": "^4.1.1", + "@apollo/utils.createhash": "^3.0.0", + "@apollo/utils.fetcher": "^3.0.0", + "@apollo/utils.isnodelike": "^3.0.0", + "@apollo/utils.keyvaluecache": "^4.0.0", + "@apollo/utils.logger": "^3.0.0", + "@apollo/utils.usagereporting": "^2.1.0", + "@apollo/utils.withrequired": "^3.0.0", + "@graphql-tools/schema": "^10.0.0", + "async-retry": "^1.2.1", + "body-parser": "^2.2.2", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "finalhandler": "^2.1.0", + "loglevel": "^1.6.8", + "lru-cache": "^11.1.0", + "negotiator": "^1.0.0", + "uuid": "^11.1.0", + "whatwg-mimetype": "^4.0.0" + }, + "dependencies": { + "uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==" + } + } + }, + "@apollo/server-gateway-interface": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@apollo/server-gateway-interface/-/server-gateway-interface-2.0.0.tgz", + "integrity": "sha512-3HEMD6fSantG2My3jWkb9dvfkF9vJ4BDLRjMgsnD790VINtuPaEp+h3Hg9HOHiWkML6QsOhnaRqZ+gvhp3y8Nw==", + "requires": { + "@apollo/usage-reporting-protobuf": "^4.1.1", + "@apollo/utils.fetcher": "^3.0.0", + "@apollo/utils.keyvaluecache": "^4.0.0", + "@apollo/utils.logger": "^3.0.0" + } + }, + "@apollo/usage-reporting-protobuf": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@apollo/usage-reporting-protobuf/-/usage-reporting-protobuf-4.1.1.tgz", + "integrity": "sha512-u40dIUePHaSKVshcedO7Wp+mPiZsaU6xjv9J+VyxpoU/zL6Jle+9zWeG98tr/+SZ0nZ4OXhrbb8SNr0rAPpIDA==", + "requires": { + "@apollo/protobufjs": "1.2.7" + } + }, + "@apollo/utils.createhash": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.createhash/-/utils.createhash-3.0.1.tgz", + "integrity": "sha512-CKrlySj4eQYftBE5MJ8IzKwIibQnftDT7yGfsJy5KSEEnLlPASX0UTpbKqkjlVEwPPd4mEwI7WOM7XNxEuO05A==", + "requires": { + "@apollo/utils.isnodelike": "^3.0.0", + "sha.js": "^2.4.11" + } + }, + "@apollo/utils.dropunuseddefinitions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.dropunuseddefinitions/-/utils.dropunuseddefinitions-2.0.1.tgz", + "integrity": "sha512-EsPIBqsSt2BwDsv8Wu76LK5R1KtsVkNoO4b0M5aK0hx+dGg9xJXuqlr7Fo34Dl+y83jmzn+UvEW+t1/GP2melA==", + "requires": {} + }, + "@apollo/utils.fetcher": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.fetcher/-/utils.fetcher-3.1.0.tgz", + "integrity": "sha512-Z3QAyrsQkvrdTuHAFwWDNd+0l50guwoQUoaDQssLOjkmnmVuvXlJykqlEJolio+4rFwBnWdoY1ByFdKaQEcm7A==" + }, + "@apollo/utils.isnodelike": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.isnodelike/-/utils.isnodelike-3.0.0.tgz", + "integrity": "sha512-xrjyjfkzunZ0DeF6xkHaK5IKR8F1FBq6qV+uZ+h9worIF/2YSzA0uoBxGv6tbTeo9QoIQnRW4PVFzGix5E7n/g==" + }, + "@apollo/utils.keyvaluecache": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.keyvaluecache/-/utils.keyvaluecache-4.0.0.tgz", + "integrity": "sha512-mKw1myRUkQsGPNB+9bglAuhviodJ2L2MRYLTafCMw5BIo7nbvCPNCkLnIHjZ1NOzH7SnMAr5c9LmXiqsgYqLZw==", + "requires": { + "@apollo/utils.logger": "^3.0.0", + "lru-cache": "^11.0.0" + } + }, + "@apollo/utils.logger": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.logger/-/utils.logger-3.0.0.tgz", + "integrity": "sha512-M8V8JOTH0F2qEi+ktPfw4RL7MvUycDfKp7aEap2eWXfL5SqWHN6jTLbj5f5fj1cceHpyaUSOZlvlaaryaxZAmg==" + }, + "@apollo/utils.printwithreducedwhitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.printwithreducedwhitespace/-/utils.printwithreducedwhitespace-2.0.1.tgz", + "integrity": "sha512-9M4LUXV/fQBh8vZWlLvb/HyyhjJ77/I5ZKu+NBWV/BmYGyRmoEP9EVAy7LCVoY3t8BDcyCAGfxJaLFCSuQkPUg==", + "requires": {} + }, + "@apollo/utils.removealiases": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.removealiases/-/utils.removealiases-2.0.1.tgz", + "integrity": "sha512-0joRc2HBO4u594Op1nev+mUF6yRnxoUH64xw8x3bX7n8QBDYdeYgY4tF0vJReTy+zdn2xv6fMsquATSgC722FA==", + "requires": {} + }, + "@apollo/utils.sortast": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.sortast/-/utils.sortast-2.0.1.tgz", + "integrity": "sha512-eciIavsWpJ09za1pn37wpsCGrQNXUhM0TktnZmHwO+Zy9O4fu/WdB4+5BvVhFiZYOXvfjzJUcc+hsIV8RUOtMw==", + "requires": { + "lodash.sortby": "^4.7.0" + } + }, + "@apollo/utils.stripsensitiveliterals": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@apollo/utils.stripsensitiveliterals/-/utils.stripsensitiveliterals-2.0.1.tgz", + "integrity": "sha512-QJs7HtzXS/JIPMKWimFnUMK7VjkGQTzqD9bKD1h3iuPAqLsxd0mUNVbkYOPTsDhUKgcvUOfOqOJWYohAKMvcSA==", + "requires": {} + }, + "@apollo/utils.usagereporting": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.usagereporting/-/utils.usagereporting-2.1.0.tgz", + "integrity": "sha512-LPSlBrn+S17oBy5eWkrRSGb98sWmnEzo3DPTZgp8IQc8sJe0prDgDuppGq4NeQlpoqEHz0hQeYHAOA0Z3aQsxQ==", + "requires": { + "@apollo/usage-reporting-protobuf": "^4.1.0", + "@apollo/utils.dropunuseddefinitions": "^2.0.1", + "@apollo/utils.printwithreducedwhitespace": "^2.0.1", + "@apollo/utils.removealiases": "2.0.1", + "@apollo/utils.sortast": "^2.0.1", + "@apollo/utils.stripsensitiveliterals": "^2.0.1" + } + }, + "@apollo/utils.withrequired": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@apollo/utils.withrequired/-/utils.withrequired-3.0.0.tgz", + "integrity": "sha512-aaxeavfJ+RHboh7c2ofO5HHtQobGX4AgUujXP4CXpREHp9fQ9jPi6K9T1jrAKe7HIipoP0OJ1gd6JamSkFIpvA==" + }, + "@as-integrations/express5": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@as-integrations/express5/-/express5-1.1.2.tgz", + "integrity": "sha512-BxfwtcWNf2CELDkuPQxi5Zl3WqY/dQVJYafeCBOGoFQjv5M0fjhxmAFZ9vKx/5YKKNeok4UY6PkFbHzmQrdxIA==", + "requires": {} + }, + "@babel/cli": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.28.6.tgz", + "integrity": "sha512-6EUNcuBbNkj08Oj4gAZ+BUU8yLCgKzgVX4gaTh09Ya2C8ICM4P+G30g4m3akRxSYAp3A/gnWchrNst7px4/nUQ==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "^0.3.28", + "@nicolo-ribaudo/chokidar-2": "2.1.8-no-fsevents.3", + "chokidar": "^3.6.0", + "commander": "^6.2.0", + "convert-source-map": "^2.0.0", + "fs-readdir-recursive": "^1.1.0", + "glob": "^7.2.0", + "make-dir": "^2.1.0", + "slash": "^2.0.0" + }, + "dependencies": { + "commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true + }, + "convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + } + } + }, + "@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + } + }, + "@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true + }, + "@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "dependencies": { + "convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + } + } + }, + "@babel/eslint-parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.6.tgz", + "integrity": "sha512-QGmsKi2PBO/MHSQk+AAgA9R6OHQr+VqnniFE0eMWZcVcfBZoA2dKn2hUsl3Csg/Plt9opRUWdY7//VXsrIlEiA==", + "dev": true, + "requires": { + "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", + "eslint-visitor-keys": "^2.1.0", + "semver": "^6.3.1" + }, + "dependencies": { + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + } + } + }, + "@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "requires": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + } + }, + "@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "requires": { + "@babel/types": "^7.27.3" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "dependencies": { + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } + }, + "@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "dependencies": { + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + } + } + }, + "@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "dependencies": { + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + } + } + }, + "@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "dev": true, + "requires": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + } + }, + "@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "dev": true, + "requires": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + } + }, + "@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "requires": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + } + }, + "@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "requires": { + "@babel/types": "^7.27.1" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true + }, + "@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + } + }, + "@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "dev": true, + "requires": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + } + }, + "@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "requires": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + } + }, + "@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true + }, + "@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true + }, + "@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true + }, + "@babel/helper-wrap-function": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", + "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", + "dev": true, + "requires": { + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + } + }, + "@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "requires": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + } + }, + "@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "requires": { + "@babel/types": "^7.29.0" + } + }, + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", + "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + } + }, + "@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" + } + }, + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", + "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/traverse": "^7.28.6" + } + }, + "@babel/plugin-proposal-object-rest-spread": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.20.7.tgz", + "integrity": "sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.20.5", + "@babel/helper-compilation-targets": "^7.20.7", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.20.7" + } + }, + "@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "requires": {} + }, + "@babel/plugin-syntax-flow": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz", + "integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/plugin-syntax-import-assertions": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", + "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-async-generator-functions": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", + "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.29.0" + } + }, + "@babel/plugin-transform-async-to-generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", + "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1" + } + }, + "@babel/plugin-transform-block-scoped-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-block-scoping": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", + "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/plugin-transform-class-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", + "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/plugin-transform-class-static-block": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", + "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/plugin-transform-classes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", + "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/traverse": "^7.28.6" + } + }, + "@babel/plugin-transform-computed-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", + "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/template": "^7.28.6" + } + }, + "@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + } + }, + "@babel/plugin-transform-dotall-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", + "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/plugin-transform-duplicate-keys": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/plugin-transform-dynamic-import": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", + "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5" + } + }, + "@babel/plugin-transform-exponentiation-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", + "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-flow-strip-types": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.27.1.tgz", + "integrity": "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-flow": "^7.27.1" + } + }, + "@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + } + }, + "@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "dev": true, + "requires": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + } + }, + "@babel/plugin-transform-json-strings": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", + "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", + "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/plugin-transform-member-expression-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-modules-amd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/plugin-transform-modules-systemjs": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", + "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.29.0" + } + }, + "@babel/plugin-transform-modules-umd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/plugin-transform-new-target": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", + "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/plugin-transform-numeric-separator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", + "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/plugin-transform-object-rest-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", + "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "dev": true, + "requires": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.6" + } + }, + "@babel/plugin-transform-object-super": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" + } + }, + "@babel/plugin-transform-optional-catch-binding": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", + "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/plugin-transform-optional-chaining": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", + "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + } + }, + "@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-private-methods": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", + "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/plugin-transform-private-property-in-object": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", + "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/plugin-transform-property-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-regenerator": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", + "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/plugin-transform-regexp-modifiers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", + "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", + "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + } + }, + "@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-typeof-symbol": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.27.1.tgz", + "integrity": "sha512-Q5sT5+O4QUebHdbwKedFBEwRLb02zJ7r4A5Gg2hUoLuU3FjdMcyqcywqUrLCaDsFCxzokf7u9kuy7qz51YUuAg==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1" + } + }, + "@babel/plugin-transform-unicode-escapes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-unicode-property-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", + "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-unicode-sets-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", + "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/preset-env": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.2.tgz", + "integrity": "sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.28.6", + "@babel/plugin-syntax-import-attributes": "^7.28.6", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.29.0", + "@babel/plugin-transform-async-to-generator": "^7.28.6", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.6", + "@babel/plugin-transform-class-properties": "^7.28.6", + "@babel/plugin-transform-class-static-block": "^7.28.6", + "@babel/plugin-transform-classes": "^7.28.6", + "@babel/plugin-transform-computed-properties": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-dotall-regex": "^7.28.6", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.6", + "@babel/plugin-transform-exponentiation-operator": "^7.28.6", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.28.6", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.28.6", + "@babel/plugin-transform-modules-systemjs": "^7.29.0", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", + "@babel/plugin-transform-numeric-separator": "^7.28.6", + "@babel/plugin-transform-object-rest-spread": "^7.28.6", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.28.6", + "@babel/plugin-transform-optional-chaining": "^7.28.6", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.28.6", + "@babel/plugin-transform-private-property-in-object": "^7.28.6", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.29.0", + "@babel/plugin-transform-regexp-modifiers": "^7.28.6", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.28.6", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.28.6", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", + "semver": "^6.3.1" + }, + "dependencies": { + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + } + } + }, + "@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + } + }, + "@babel/preset-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", + "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.27.1" + } + }, + "@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==" + }, + "@babel/runtime-corejs3": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.2.tgz", + "integrity": "sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw==", + "requires": { + "core-js-pure": "^3.48.0" + } + }, + "@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + } + }, + "@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + } + }, + "@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "requires": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + } + }, + "@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "optional": true + }, + "@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "requires": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "@dependents/detective-less": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@dependents/detective-less/-/detective-less-5.0.0.tgz", + "integrity": "sha512-D/9dozteKcutI5OdxJd8rU+fL6XgaaRg60sPPJWkT33OCiRfkCu5wO5B/yXTaaL2e6EB0lcCBGe5E0XscZCvvQ==", + "dev": true, + "requires": { + "gonzales-pe": "^4.3.0", + "node-source-walk": "^7.0.0" + } + }, + "@emnapi/core": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.3.1.tgz", + "integrity": "sha512-pVGjBIt1Y6gg3EJN8jTcfpP/+uuRksIo055oE/OBkDNcjZqVbfkWCksG1Jp4yZnj3iKWyWX8fdG/j6UDYPbFog==", + "optional": true, + "requires": { + "@emnapi/wasi-threads": "1.0.1", + "tslib": "^2.4.0" + } + }, + "@emnapi/runtime": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", + "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, + "@emnapi/wasi-threads": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.1.tgz", + "integrity": "sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw==", + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, + "@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^3.4.3" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true + } + } + }, + "@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true + }, + "@eslint/config-array": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", + "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "dev": true, + "requires": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + } + }, + "@eslint/config-helpers": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz", + "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==", + "dev": true + }, + "@eslint/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.15" + } + }, + "@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "requires": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "dependencies": { + "globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true + } + } + }, + "@eslint/js": { + "version": "9.27.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.27.0.tgz", + "integrity": "sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==", + "dev": true + }, + "@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true + }, + "@eslint/plugin-kit": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", + "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", + "dev": true, + "requires": { + "@eslint/core": "^0.15.1", + "levn": "^0.4.1" + }, + "dependencies": { + "@eslint/core": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.15" + } + } + } + }, + "@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==" + }, + "@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==" + }, + "@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==" + }, + "@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==" + }, + "@firebase/component": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.2.tgz", + "integrity": "sha512-iyVDGc6Vjx7Rm0cAdccLH/NG6fADsgJak/XW9IA2lPf8AjIlsemOpFGKczYyPHxm4rnKdR8z6sK4+KEC7NwmEg==", + "requires": { + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + } + }, + "@firebase/database": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.2.tgz", + "integrity": "sha512-lP96CMjMPy/+d1d9qaaHjHHdzdwvEOuyyLq9ehX89e2XMKwS1jHNzYBO+42bdSumuj5ukPbmnFtViZu8YOMT+w==", + "requires": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + } + }, + "@firebase/database-compat": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.2.tgz", + "integrity": "sha512-j4A6IhVZbgxAzT6gJJC2PfOxYCK9SrDrUO7nTM4EscTYtKkAkzsbKoCnDdjFapQfnsncvPWjqVTr/0PffUwg3g==", + "requires": { + "@firebase/component": "0.7.2", + "@firebase/database": "1.1.2", + "@firebase/database-types": "1.0.18", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + } + }, + "@firebase/database-types": { + "version": "1.0.18", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.18.tgz", + "integrity": "sha512-yOY8IC2go9lfbVDMiy2ATun4EB2AFwocPaQADwMN/RHRUAZSM4rlAV7PGbWPSG/YhkJ2A9xQAiAENgSua9G5Fg==", + "requires": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.15.0" + } + }, + "@firebase/logger": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", + "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", + "requires": { + "tslib": "^2.1.0" + } + }, + "@firebase/util": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.15.0.tgz", + "integrity": "sha512-AmWf3cHAOMbrCPG4xdPKQaj5iHnyYfyLKZxwz+Xf55bqKbpAmcYifB4jQinT2W9XhDRHISOoPyBOariJpCG6FA==", + "requires": { + "tslib": "^2.1.0" + } + }, + "@google-cloud/firestore": { + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz", + "integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==", + "optional": true, + "requires": { + "@opentelemetry/api": "^1.3.0", + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^4.3.3", + "protobufjs": "^7.2.6" + } + }, + "@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "optional": true, + "requires": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + } + }, + "@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "optional": true + }, + "@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "optional": true + }, + "@google-cloud/storage": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", + "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", + "optional": true, + "requires": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0", + "uuid": "^8.0.0" + }, + "dependencies": { + "gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "optional": true, + "requires": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + } + }, + "google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "optional": true, + "requires": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + } + }, + "google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "optional": true + }, + "mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "optional": true + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "optional": true + } + } + }, + "@graphql-tools/merge": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.1.7.tgz", + "integrity": "sha512-Y5E1vTbTabvcXbkakdFUt4zUIzB1fyaEnVmIWN0l0GMed2gdD01TpZWLUm4RNAxpturvolrb24oGLQrBbPLSoQ==", + "requires": { + "@graphql-tools/utils": "^11.0.0", + "tslib": "^2.4.0" + } + }, + "@graphql-tools/schema": { + "version": "10.0.31", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.31.tgz", + "integrity": "sha512-ZewRgWhXef6weZ0WiP7/MV47HXiuFbFpiDUVLQl6mgXsWSsGELKFxQsyUCBos60Qqy1JEFAIu3Ns6GGYjGkqkQ==", + "requires": { + "@graphql-tools/merge": "^9.1.7", + "@graphql-tools/utils": "^11.0.0", + "tslib": "^2.4.0" + } + }, + "@graphql-tools/utils": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.0.0.tgz", + "integrity": "sha512-bM1HeZdXA2C3LSIeLOnH/bcqSgbQgKEDrjxODjqi3y58xai2TkNrtYcQSoWzGbt9VMN1dORGjR7Vem8SPnUFQA==", + "requires": { + "@graphql-typed-document-node/core": "^3.1.1", + "@whatwg-node/promise-helpers": "^1.0.0", + "cross-inspect": "1.0.1", + "tslib": "^2.4.0" + } + }, + "@graphql-typed-document-node/core": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.1.1.tgz", + "integrity": "sha512-NQ17ii0rK1b34VZonlmT2QMJFI70m0TRwbknO/ihlbatXyaktDhN/98vBiUU6kNBPljqGqyIrl2T4nY2RpFANg==", + "requires": {} + }, + "@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "optional": true, + "requires": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "dependencies": { + "@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "optional": true, + "requires": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + } + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "optional": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "optional": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "optional": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "optional": true + }, + "long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "optional": true + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "optional": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "optional": true + }, + "yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "optional": true, + "requires": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + } + } + } + }, + "@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "optional": true, + "requires": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "optional": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "optional": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "optional": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "optional": true + }, + "long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "optional": true + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "optional": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "optional": true + }, + "yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "optional": true, + "requires": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + } + } + } + }, + "@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true + }, + "@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "requires": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "dependencies": { + "@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true + } + } + }, + "@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true + }, + "@humanwhocodes/retry": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", + "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", + "dev": true + }, + "@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "devOptional": true, + "requires": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "devOptional": true + }, + "ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "devOptional": true + }, + "emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "devOptional": true + }, + "string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "devOptional": true, + "requires": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + } + }, + "strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "devOptional": true, + "requires": { + "ansi-regex": "^6.0.1" + } + }, + "wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "devOptional": true, + "requires": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + } + } + } + }, + "@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "dependencies": { + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + } + } + }, + "@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true + }, + "@jasminejs/reporters": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jasminejs/reporters/-/reporters-1.0.0.tgz", + "integrity": "sha512-rM3GG4vx2H1Gp5kYCTr9aKlOEJFd43pzpiMAiy5b1+FUc2ub4e6bS6yCi/WQNDzAa5MVp9++dwcoEtcIfoEnhA==", + "dev": true + }, + "@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", + "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", + "dev": true + }, + "@jridgewell/source-map": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", + "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "optional": true + }, + "@jsdoc/salty": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.5.tgz", + "integrity": "sha512-TfRP53RqunNe2HBobVBJ0VLhK1HbfvBYeTC1ahnN64PWvyYyGebmMiPkuwvD9fpw2ZbkoPb8Q7mwy0aR8Z9rvw==", + "dev": true, + "requires": { + "lodash": "^4.17.21" + } + }, + "@ldapjs/asn1": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/asn1/-/asn1-2.0.0.tgz", + "integrity": "sha512-G9+DkEOirNgdPmD0I8nu57ygQJKOOgFEMKknEuQvIHbGLwP3ny1mY+OTUYLCbCaGJP4sox5eYgBJRuSUpnAddA==" + }, + "@ldapjs/attribute": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/attribute/-/attribute-1.0.0.tgz", + "integrity": "sha512-ptMl2d/5xJ0q+RgmnqOi3Zgwk/TMJYG7dYMC0Keko+yZU6n+oFM59MjQOUht5pxJeS4FWrImhu/LebX24vJNRQ==", + "requires": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "@ldapjs/change": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/change/-/change-1.0.0.tgz", + "integrity": "sha512-EOQNFH1RIku3M1s0OAJOzGfAohuFYXFY4s73wOhRm4KFGhmQQ7MChOh2YtYu9Kwgvuq1B0xKciXVzHCGkB5V+Q==", + "requires": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/attribute": "1.0.0" + } + }, + "@ldapjs/controls": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@ldapjs/controls/-/controls-2.1.0.tgz", + "integrity": "sha512-2pFdD1yRC9V9hXfAWvCCO2RRWK9OdIEcJIos/9cCVP9O4k72BY1bLDQQ4KpUoJnl4y/JoD4iFgM+YWT3IfITWw==", + "requires": { + "@ldapjs/asn1": "^1.2.0", + "@ldapjs/protocol": "^1.2.1" + }, + "dependencies": { + "@ldapjs/asn1": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ldapjs/asn1/-/asn1-1.2.0.tgz", + "integrity": "sha512-KX/qQJ2xxzvO2/WOvr1UdQ+8P5dVvuOLk/C9b1bIkXxZss8BaR28njXdPgFCpj5aHaf1t8PmuVnea+N9YG9YMw==" + } + } + }, + "@ldapjs/dn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ldapjs/dn/-/dn-1.1.0.tgz", + "integrity": "sha512-R72zH5ZeBj/Fujf/yBu78YzpJjJXG46YHFo5E4W1EqfNpo1UsVPqdLrRMXeKIsJT3x9dJVIfR6OpzgINlKpi0A==", + "requires": { + "@ldapjs/asn1": "2.0.0", + "process-warning": "^2.1.0" + } + }, + "@ldapjs/filter": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@ldapjs/filter/-/filter-2.1.1.tgz", + "integrity": "sha512-TwPK5eEgNdUO1ABPBUQabcZ+h9heDORE4V9WNZqCtYLKc06+6+UAJ3IAbr0L0bYTnkkWC/JEQD2F+zAFsuikNw==", + "requires": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "@ldapjs/messages": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ldapjs/messages/-/messages-1.3.0.tgz", + "integrity": "sha512-K7xZpXJ21bj92jS35wtRbdcNrwmxAtPwy4myeh9duy/eR3xQKvikVycbdWVzkYEAVE5Ce520VXNOwCHjomjCZw==", + "requires": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.2.0" + } + }, + "@ldapjs/protocol": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@ldapjs/protocol/-/protocol-1.2.1.tgz", + "integrity": "sha512-O89xFDLW2gBoZWNXuXpBSM32/KealKCTb3JGtJdtUQc7RjAk8XzrRgyz02cPAwGKwKPxy0ivuC7UP9bmN87egQ==" + }, + "@mongodb-js/mongodb-downloader": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@mongodb-js/mongodb-downloader/-/mongodb-downloader-0.4.2.tgz", + "integrity": "sha512-uCd6nDtKuM2J12jgqPkApEvGQWfgZOq6yUitagvXYIqg6ofdqAnmMJO3e3wIph+Vi++dnLoMv0ME9geBzHYwDA==", + "dev": true, + "requires": { + "debug": "^4.4.0", + "decompress": "^4.2.1", + "mongodb-download-url": "^1.6.2", + "node-fetch": "^2.7.0", + "tar": "^6.1.15" + }, + "dependencies": { + "node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "requires": { + "whatwg-url": "^5.0.0" + } + }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true + }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } + }, + "@mongodb-js/saslprep": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.0.tgz", + "integrity": "sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ==", + "requires": { + "sparse-bitfield": "^3.0.3" + } + }, + "@napi-rs/wasm-runtime": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.5.tgz", + "integrity": "sha512-kwUxR7J9WLutBbulqg1dfOrMTwhMdXLdcGUhcbCcGwnPLt3gz19uHVdwH1syKVDbE022ZS2vZxOWflFLS0YTjw==", + "optional": true, + "requires": { + "@emnapi/core": "^1.1.0", + "@emnapi/runtime": "^1.1.0", + "@tybys/wasm-util": "^0.9.0" + } + }, + "@nicolo-ribaudo/chokidar-2": { + "version": "2.1.8-no-fsevents.3", + "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz", + "integrity": "sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ==", + "dev": true, + "optional": true + }, + "@nicolo-ribaudo/eslint-scope-5-internals": { + "version": "5.1.1-v1", + "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", + "integrity": "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==", + "dev": true, + "requires": { + "eslint-scope": "5.1.1" + } + }, + "@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==" + }, + "@node-rs/bcrypt": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt/-/bcrypt-1.10.7.tgz", + "integrity": "sha512-1wk0gHsUQC/ap0j6SJa2K34qNhomxXRcEe3T8cI5s+g6fgHBgLTN7U9LzWTG/HE6G4+2tWWLeCabk1wiYGEQSA==", + "optional": true, + "requires": { + "@node-rs/bcrypt-android-arm-eabi": "1.10.7", + "@node-rs/bcrypt-android-arm64": "1.10.7", + "@node-rs/bcrypt-darwin-arm64": "1.10.7", + "@node-rs/bcrypt-darwin-x64": "1.10.7", + "@node-rs/bcrypt-freebsd-x64": "1.10.7", + "@node-rs/bcrypt-linux-arm-gnueabihf": "1.10.7", + "@node-rs/bcrypt-linux-arm64-gnu": "1.10.7", + "@node-rs/bcrypt-linux-arm64-musl": "1.10.7", + "@node-rs/bcrypt-linux-x64-gnu": "1.10.7", + "@node-rs/bcrypt-linux-x64-musl": "1.10.7", + "@node-rs/bcrypt-wasm32-wasi": "1.10.7", + "@node-rs/bcrypt-win32-arm64-msvc": "1.10.7", + "@node-rs/bcrypt-win32-ia32-msvc": "1.10.7", + "@node-rs/bcrypt-win32-x64-msvc": "1.10.7" + } + }, + "@node-rs/bcrypt-android-arm-eabi": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm-eabi/-/bcrypt-android-arm-eabi-1.10.7.tgz", + "integrity": "sha512-8dO6/PcbeMZXS3VXGEtct9pDYdShp2WBOWlDvSbcRwVqyB580aCBh0BEFmKYtXLzLvUK8Wf+CG3U6sCdILW1lA==", + "optional": true + }, + "@node-rs/bcrypt-android-arm64": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm64/-/bcrypt-android-arm64-1.10.7.tgz", + "integrity": "sha512-UASFBS/CucEMHiCtL/2YYsAY01ZqVR1N7vSb94EOvG5iwW7BQO06kXXCTgj+Xbek9azxixrCUmo3WJnkJZ0hTQ==", + "optional": true + }, + "@node-rs/bcrypt-darwin-arm64": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-arm64/-/bcrypt-darwin-arm64-1.10.7.tgz", + "integrity": "sha512-DgzFdAt455KTuiJ/zYIyJcKFobjNDR/hnf9OS7pK5NRS13Nq4gLcSIIyzsgHwZHxsJWbLpHmFc1H23Y7IQoQBw==", + "optional": true + }, + "@node-rs/bcrypt-darwin-x64": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-x64/-/bcrypt-darwin-x64-1.10.7.tgz", + "integrity": "sha512-SPWVfQ6sxSokoUWAKWD0EJauvPHqOGQTd7CxmYatcsUgJ/bruvEHxZ4bIwX1iDceC3FkOtmeHO0cPwR480n/xA==", + "optional": true + }, + "@node-rs/bcrypt-freebsd-x64": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-freebsd-x64/-/bcrypt-freebsd-x64-1.10.7.tgz", + "integrity": "sha512-gpa+Ixs6GwEx6U6ehBpsQetzUpuAGuAFbOiuLB2oo4N58yU4AZz1VIcWyWAHrSWRs92O0SHtmo2YPrMrwfBbSw==", + "optional": true + }, + "@node-rs/bcrypt-linux-arm-gnueabihf": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm-gnueabihf/-/bcrypt-linux-arm-gnueabihf-1.10.7.tgz", + "integrity": "sha512-kYgJnTnpxrzl9sxYqzflobvMp90qoAlaX1oDL7nhNTj8OYJVDIk0jQgblj0bIkjmoPbBed53OJY/iu4uTS+wig==", + "optional": true + }, + "@node-rs/bcrypt-linux-arm64-gnu": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-gnu/-/bcrypt-linux-arm64-gnu-1.10.7.tgz", + "integrity": "sha512-7cEkK2RA+gBCj2tCVEI1rDSJV40oLbSq7bQ+PNMHNI6jCoXGmj9Uzo7mg7ZRbNZ7piIyNH5zlJqutjo8hh/tmA==", + "optional": true + }, + "@node-rs/bcrypt-linux-arm64-musl": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-musl/-/bcrypt-linux-arm64-musl-1.10.7.tgz", + "integrity": "sha512-X7DRVjshhwxUqzdUKDlF55cwzh+wqWJ2E/tILvZPboO3xaNO07Um568Vf+8cmKcz+tiZCGP7CBmKbBqjvKN/Pw==", + "optional": true + }, + "@node-rs/bcrypt-linux-x64-gnu": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-gnu/-/bcrypt-linux-x64-gnu-1.10.7.tgz", + "integrity": "sha512-LXRZsvG65NggPD12hn6YxVgH0W3VR5fsE/o1/o2D5X0nxKcNQGeLWnRzs5cP8KpoFOuk1ilctXQJn8/wq+Gn/Q==", + "optional": true + }, + "@node-rs/bcrypt-linux-x64-musl": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-musl/-/bcrypt-linux-x64-musl-1.10.7.tgz", + "integrity": "sha512-tCjHmct79OfcO3g5q21ME7CNzLzpw1MAsUXCLHLGWH+V6pp/xTvMbIcLwzkDj6TI3mxK6kehTn40SEjBkZ3Rog==", + "optional": true + }, + "@node-rs/bcrypt-wasm32-wasi": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-wasm32-wasi/-/bcrypt-wasm32-wasi-1.10.7.tgz", + "integrity": "sha512-4qXSihIKeVXYglfXZEq/QPtYtBUvR8d3S85k15Lilv3z5B6NSGQ9mYiNleZ7QHVLN2gEc5gmi7jM353DMH9GkA==", + "optional": true, + "requires": { + "@napi-rs/wasm-runtime": "^0.2.5" + } + }, + "@node-rs/bcrypt-win32-arm64-msvc": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-arm64-msvc/-/bcrypt-win32-arm64-msvc-1.10.7.tgz", + "integrity": "sha512-FdfUQrqmDfvC5jFhntMBkk8EI+fCJTx/I1v7Rj+Ezlr9rez1j1FmuUnywbBj2Cg15/0BDhwYdbyZ5GCMFli2aQ==", + "optional": true + }, + "@node-rs/bcrypt-win32-ia32-msvc": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-ia32-msvc/-/bcrypt-win32-ia32-msvc-1.10.7.tgz", + "integrity": "sha512-lZLf4Cx+bShIhU071p5BZft4OvP4PGhyp542EEsb3zk34U5GLsGIyCjOafcF/2DGewZL6u8/aqoxbSuROkgFXg==", + "optional": true + }, + "@node-rs/bcrypt-win32-x64-msvc": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-x64-msvc/-/bcrypt-win32-x64-msvc-1.10.7.tgz", + "integrity": "sha512-hdw7tGmN1DxVAMTzICLdaHpXjy+4rxaxnBMgI8seG1JL5e3VcRGsd1/1vVDogVp2cbsmgq+6d6yAY+D9lW/DCg==", + "optional": true + }, + "@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + } + }, + "@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true + }, + "@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "requires": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + } + }, + "@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "dev": true + }, + "@octokit/core": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.2.tgz", + "integrity": "sha512-ODsoD39Lq6vR6aBgvjTnA3nZGliknKboc9Gtxr7E4WDNqY24MxANKcuDQSF0jzapvGb3KWOEDrKfve4HoWGK+g==", + "dev": true, + "requires": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.1", + "@octokit/request": "^10.0.2", + "@octokit/request-error": "^7.0.0", + "@octokit/types": "^14.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "dependencies": { + "@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "dev": true + }, + "@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "dev": true, + "requires": { + "@octokit/openapi-types": "^25.1.0" + } + } + } + }, + "@octokit/endpoint": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.0.tgz", + "integrity": "sha512-hoYicJZaqISMAI3JfaDr1qMNi48OctWuOih1m80bkYow/ayPw6Jj52tqWJ6GEoFTk1gBqfanSoI1iY99Z5+ekQ==", + "dev": true, + "requires": { + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.2" + }, + "dependencies": { + "@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "dev": true + }, + "@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "dev": true, + "requires": { + "@octokit/openapi-types": "^25.1.0" + } + } + } + }, + "@octokit/graphql": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.1.tgz", + "integrity": "sha512-j1nQNU1ZxNFx2ZtKmL4sMrs4egy5h65OMDmSbVyuCzjOcwsHq6EaYjOTGXPQxgfiN8dJ4CriYHk6zF050WEULg==", + "dev": true, + "requires": { + "@octokit/request": "^10.0.2", + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.0" + }, + "dependencies": { + "@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "dev": true + }, + "@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "dev": true, + "requires": { + "@octokit/openapi-types": "^25.1.0" + } + } + } + }, + "@octokit/openapi-types": { + "version": "22.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", + "integrity": "sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==", + "dev": true + }, + "@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "dev": true, + "requires": { + "@octokit/types": "^16.0.0" + }, + "dependencies": { + "@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true + }, + "@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "requires": { + "@octokit/openapi-types": "^27.0.0" + } + } + } + }, + "@octokit/plugin-retry": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-8.0.1.tgz", + "integrity": "sha512-KUoYR77BjF5O3zcwDQHRRZsUvJwepobeqiSSdCJ8lWt27FZExzb0GgVxrhhfuyF6z2B2zpO0hN5pteni1sqWiw==", + "dev": true, + "requires": { + "@octokit/request-error": "^7.0.0", + "@octokit/types": "^14.0.0", + "bottleneck": "^2.15.3" + }, + "dependencies": { + "@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "dev": true + }, + "@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "dev": true, + "requires": { + "@octokit/openapi-types": "^25.1.0" + } + } + } + }, + "@octokit/plugin-throttling": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-11.0.1.tgz", + "integrity": "sha512-S+EVhy52D/272L7up58dr3FNSMXWuNZolkL4zMJBNIfIxyZuUcczsQAU4b5w6dewJXnKYVgSHSV5wxitMSW1kw==", + "dev": true, + "requires": { + "@octokit/types": "^14.0.0", + "bottleneck": "^2.15.3" + }, + "dependencies": { + "@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "dev": true + }, + "@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "dev": true, + "requires": { + "@octokit/openapi-types": "^25.1.0" + } + } + } + }, + "@octokit/request": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.2.tgz", + "integrity": "sha512-iYj4SJG/2bbhh+iIpFmG5u49DtJ4lipQ+aPakjL9OKpsGY93wM8w06gvFbEQxcMsZcCvk5th5KkIm2m8o14aWA==", + "dev": true, + "requires": { + "@octokit/endpoint": "^11.0.0", + "@octokit/request-error": "^7.0.0", + "@octokit/types": "^14.0.0", + "fast-content-type-parse": "^3.0.0", + "universal-user-agent": "^7.0.2" + }, + "dependencies": { + "@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "dev": true + }, + "@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "dev": true, + "requires": { + "@octokit/openapi-types": "^25.1.0" + } + } + } + }, + "@octokit/request-error": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.0.0.tgz", + "integrity": "sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg==", + "dev": true, + "requires": { + "@octokit/types": "^14.0.0" + }, + "dependencies": { + "@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "dev": true + }, + "@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "dev": true, + "requires": { + "@octokit/openapi-types": "^25.1.0" + } + } + } + }, + "@octokit/types": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.5.0.tgz", + "integrity": "sha512-HdqWTf5Z3qwDVlzCrP8UJquMwunpDiMPt5er+QjGzL4hqr/vBVY/MauQgS1xWxCDT1oMx1EULyqxncdCY/NVSQ==", + "dev": true, + "requires": { + "@octokit/openapi-types": "^22.2.0" + } + }, + "@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "optional": true + }, + "@parse/fs-files-adapter": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@parse/fs-files-adapter/-/fs-files-adapter-3.0.0.tgz", + "integrity": "sha512-Bb+qLtXQ/1SA2Ck6JLVhfD9JQf6cCwgeDZZJjcIdHzUtdPTFu1hj51xdD7tUCL47Ed2i3aAx6K/M6AjLWYVs3A==" + }, + "@parse/node-apn": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@parse/node-apn/-/node-apn-8.0.0.tgz", + "integrity": "sha512-blvU/V0FL3j7u2lstso1aInMw7yYrKg/6Ctr3Kc/7kleFatAfZswhzHk9d5lI4DUQBsUBun8nidgZHCY6sft+Q==", + "requires": { + "debug": "4.4.3", + "jsonwebtoken": "9.0.3", + "node-forge": "1.4.0", + "verror": "1.10.1" + } + }, + "@parse/push-adapter": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@parse/push-adapter/-/push-adapter-8.4.0.tgz", + "integrity": "sha512-sWinUJZvbWIH6cJfIRuwUCcsjvi6IkoJ3zp2JoCP/mLzItt6NPNk+j73RE9UJzIKlwt3NciWXeSHoxprPnNH/A==", + "requires": { + "@parse/node-apn": "8.0.0", + "expo-server-sdk": "6.1.0", + "firebase-admin": "13.7.0", + "npmlog": "7.0.1", + "parse": "8.5.0", + "web-push": "3.6.7" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==" + }, + "@babel/runtime-corejs3": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.0.tgz", + "integrity": "sha512-TgUkdp71C9pIbBcHudc+gXZnihEDOjUAmXO1VO4HHGES7QLZcShR0stfKIxLSNIYx2fqhmJChOjm/wkF8wv4gA==", + "requires": { + "core-js-pure": "^3.48.0" + } + }, + "parse": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/parse/-/parse-8.5.0.tgz", + "integrity": "sha512-X9gI4Yjbi9LPMPnCtKL4h0Nxe1aSCFMPWcB1zbu11qU/Be3eVSB5I5IMBunTuWlVz6Wchu3dtM5jl/1aBZ9wiQ==", + "requires": { + "@babel/runtime": "7.28.6", + "@babel/runtime-corejs3": "7.29.0", + "crypto-js": "4.2.0", + "idb-keyval": "6.2.2", + "react-native-crypto-js": "1.0.0", + "ws": "8.19.0" + } + }, + "ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "requires": {} + } + } + }, + "@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true + }, + "@pnpm/config.env-replace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", + "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", + "dev": true + }, + "@pnpm/network.ca-file": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", + "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", + "dev": true, + "requires": { + "graceful-fs": "4.2.10" + } + }, + "@pnpm/npm-conf": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-2.2.2.tgz", + "integrity": "sha512-UA91GwWPhFExt3IizW6bOeY/pQ0BkuNwKjk9iQW9KqxluGCrg4VenZ0/L+2Y0+ZOtme72EVvg6v0zo3AMQRCeA==", + "dev": true, + "requires": { + "@pnpm/config.env-replace": "^1.1.0", + "@pnpm/network.ca-file": "^1.0.1", + "config-chain": "^1.1.11" + } + }, + "@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + }, + "@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "requires": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" + }, + "@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" + }, + "@redis/bloom": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.11.0.tgz", + "integrity": "sha512-KYiVilAhAFN3057afUb/tfYJpsEyTkQB+tQcn5gVVA7DgcNOAj8lLxe4j8ov8BF6I9C1Fe/kwlbuAICcTMX8Lw==", + "requires": {} + }, + "@redis/client": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.11.0.tgz", + "integrity": "sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==", + "requires": { + "cluster-key-slot": "1.1.2" + } + }, + "@redis/json": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.11.0.tgz", + "integrity": "sha512-1iAy9kAtcD0quB21RbPTbUqqy+T2Uu2JxucwE+B4A+VaDbIRvpZR6DMqV8Iqaws2YxJYB3GC5JVNzPYio2ErUg==", + "requires": {} + }, + "@redis/search": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.11.0.tgz", + "integrity": "sha512-g1l7f3Rnyk/xI99oGHIgWHSKFl45Re5YTIcO8j/JE8olz389yUFyz2+A6nqVy/Zi031VgPDWscbbgOk8hlhZ3g==", + "requires": {} + }, + "@redis/time-series": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.11.0.tgz", + "integrity": "sha512-TWFeOcU4xkj0DkndnOyhtxvX1KWD+78UHT3XX3x3XRBUGWeQrKo3jqzDsZwxbggUgf9yLJr/akFHXru66X5UQA==", + "requires": {} + }, + "@saithodev/semantic-release-backmerge": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@saithodev/semantic-release-backmerge/-/semantic-release-backmerge-4.0.1.tgz", + "integrity": "sha512-WDsU28YrXSLx0xny7FgFlEk8DCKGcj6OOhA+4Q9k3te1jJD1GZuqY8sbIkVQaw9cqJ7CT+fCZUN6QDad8JW4Dg==", + "dev": true, + "requires": { + "@semantic-release/error": "^3.0.0", + "aggregate-error": "^3.1.0", + "debug": "^4.3.4", + "execa": "^5.1.1", + "lodash": "^4.17.21", + "semantic-release": "^22.0.7" + }, + "dependencies": { + "@octokit/auth-token": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", + "integrity": "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==", + "dev": true + }, + "@octokit/core": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.0.tgz", + "integrity": "sha512-1LFfa/qnMQvEOAdzlQymH0ulepxbxnCYAKJZfMci/5XJyIHWgEYnDmgnKakbTh7CH2tFQ5O60oYDvns4i9RAIg==", + "dev": true, + "requires": { + "@octokit/auth-token": "^4.0.0", + "@octokit/graphql": "^7.1.0", + "@octokit/request": "^8.3.1", + "@octokit/request-error": "^5.1.0", + "@octokit/types": "^13.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + } + }, + "@octokit/endpoint": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.5.tgz", + "integrity": "sha512-ekqR4/+PCLkEBF6qgj8WqJfvDq65RH85OAgrtnVp1mSxaXF03u2xW/hUdweGS5654IlC0wkNYC18Z50tSYTAFw==", + "dev": true, + "requires": { + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + } + }, + "@octokit/graphql": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.0.tgz", + "integrity": "sha512-r+oZUH7aMFui1ypZnAvZmn0KSqAUgE1/tUXIWaqUCa1758ts/Jio84GZuzsvUkme98kv0WFY8//n0J1Z+vsIsQ==", + "dev": true, + "requires": { + "@octokit/request": "^8.3.0", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^6.0.0" + } + }, + "@octokit/openapi-types": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-20.0.0.tgz", + "integrity": "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==", + "dev": true + }, + "@octokit/plugin-paginate-rest": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-9.2.1.tgz", + "integrity": "sha512-wfGhE/TAkXZRLjksFXuDZdmGnJQHvtU/joFQdweXUgzo1XwvBCD4o4+75NtFfjfLK5IwLf9vHTfSiU3sLRYpRw==", + "dev": true, + "requires": { + "@octokit/types": "^12.6.0" + }, + "dependencies": { + "@octokit/types": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.6.0.tgz", + "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", + "dev": true, + "requires": { + "@octokit/openapi-types": "^20.0.0" + } + } + } + }, + "@octokit/plugin-retry": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-6.0.1.tgz", + "integrity": "sha512-SKs+Tz9oj0g4p28qkZwl/topGcb0k0qPNX/i7vBKmDsjoeqnVfFUquqrE/O9oJY7+oLzdCtkiWSXLpLjvl6uog==", + "dev": true, + "requires": { + "@octokit/request-error": "^5.0.0", + "@octokit/types": "^12.0.0", + "bottleneck": "^2.15.3" + }, + "dependencies": { + "@octokit/types": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.6.0.tgz", + "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", + "dev": true, + "requires": { + "@octokit/openapi-types": "^20.0.0" + } + } + } + }, + "@octokit/plugin-throttling": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-8.2.0.tgz", + "integrity": "sha512-nOpWtLayKFpgqmgD0y3GqXafMFuKcA4tRPZIfu7BArd2lEZeb1988nhWhwx4aZWmjDmUfdgVf7W+Tt4AmvRmMQ==", + "dev": true, + "requires": { + "@octokit/types": "^12.2.0", + "bottleneck": "^2.15.3" + }, + "dependencies": { + "@octokit/types": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.6.0.tgz", + "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", + "dev": true, + "requires": { + "@octokit/openapi-types": "^20.0.0" + } + } + } + }, + "@octokit/request": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.4.0.tgz", + "integrity": "sha512-9Bb014e+m2TgBeEJGEbdplMVWwPmL1FPtggHQRkV+WVsMggPtEkLKPlcVYm/o8xKLkpJ7B+6N8WfQMtDLX2Dpw==", + "dev": true, + "requires": { + "@octokit/endpoint": "^9.0.1", + "@octokit/request-error": "^5.1.0", + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + } + }, + "@octokit/request-error": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.1.0.tgz", + "integrity": "sha512-GETXfE05J0+7H2STzekpKObFe765O5dlAKUTLNGeH+x47z7JjXHfsHKo5z21D/o/IOZTUEI6nyWyR+bZVP/n5Q==", + "dev": true, + "requires": { + "@octokit/types": "^13.1.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + } + }, + "@semantic-release/commit-analyzer": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@semantic-release/commit-analyzer/-/commit-analyzer-11.1.0.tgz", + "integrity": "sha512-cXNTbv3nXR2hlzHjAMgbuiQVtvWHTlwwISt60B+4NZv01y/QRY7p2HcJm8Eh2StzcTJoNnflvKjHH/cjFS7d5g==", + "dev": true, + "requires": { + "conventional-changelog-angular": "^7.0.0", + "conventional-commits-filter": "^4.0.0", + "conventional-commits-parser": "^5.0.0", + "debug": "^4.0.0", + "import-from-esm": "^1.0.3", + "lodash-es": "^4.17.21", + "micromatch": "^4.0.2" + } + }, + "@semantic-release/github": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-9.2.6.tgz", + "integrity": "sha512-shi+Lrf6exeNZF+sBhK+P011LSbhmIAoUEgEY6SsxF8irJ+J2stwI5jkyDQ+4gzYyDImzV6LCKdYB9FXnQRWKA==", + "dev": true, + "requires": { + "@octokit/core": "^5.0.0", + "@octokit/plugin-paginate-rest": "^9.0.0", + "@octokit/plugin-retry": "^6.0.0", + "@octokit/plugin-throttling": "^8.0.0", + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "debug": "^4.3.4", + "dir-glob": "^3.0.1", + "globby": "^14.0.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "issue-parser": "^6.0.0", + "lodash-es": "^4.17.21", + "mime": "^4.0.0", + "p-filter": "^4.0.0", + "url-join": "^5.0.0" + }, + "dependencies": { + "@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true + }, + "aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "requires": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + } + } + } + }, + "@semantic-release/npm": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-11.0.3.tgz", + "integrity": "sha512-KUsozQGhRBAnoVg4UMZj9ep436VEGwT536/jwSqB7vcEfA6oncCUU7UIYTRdLx7GvTtqn0kBjnkfLVkcnBa2YQ==", + "dev": true, + "requires": { + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "execa": "^8.0.0", + "fs-extra": "^11.0.0", + "lodash-es": "^4.17.21", + "nerf-dart": "^1.0.0", + "normalize-url": "^8.0.0", + "npm": "^10.5.0", + "rc": "^1.2.8", + "read-pkg": "^9.0.0", + "registry-auth-token": "^5.0.0", + "semver": "^7.1.2", + "tempy": "^3.0.0" + }, + "dependencies": { + "@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true + }, + "aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "requires": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + } + }, + "execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + } + }, + "get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true + } + } + }, + "@semantic-release/release-notes-generator": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-12.1.0.tgz", + "integrity": "sha512-g6M9AjUKAZUZnxaJZnouNBeDNTCUrJ5Ltj+VJ60gJeDaRRahcHsry9HW8yKrnKkKNkx5lbWiEP1FPMqVNQz8Kg==", + "dev": true, + "requires": { + "conventional-changelog-angular": "^7.0.0", + "conventional-changelog-writer": "^7.0.0", + "conventional-commits-filter": "^4.0.0", + "conventional-commits-parser": "^5.0.0", + "debug": "^4.0.0", + "get-stream": "^7.0.0", + "import-from-esm": "^1.0.3", + "into-stream": "^7.0.0", + "lodash-es": "^4.17.21", + "read-pkg-up": "^11.0.0" + }, + "dependencies": { + "get-stream": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-7.0.1.tgz", + "integrity": "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ==", + "dev": true + } + } + }, + "ansi-escapes": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.1.tgz", + "integrity": "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", + "dev": true + }, + "chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "dev": true + }, + "clean-stack": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.2.0.tgz", + "integrity": "sha512-TyUIUJgdFnCISzG5zu3291TAsE77ddchd0bepon1VVQrKLGKFED4iXFEDQ24mIPdPBbyE16PK3F8MYE1CmcBEQ==", + "dev": true, + "requires": { + "escape-string-regexp": "5.0.0" + } + }, + "cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "conventional-changelog-angular": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", + "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==", + "dev": true, + "requires": { + "compare-func": "^2.0.0" + } + }, + "conventional-changelog-writer": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-7.0.1.tgz", + "integrity": "sha512-Uo+R9neH3r/foIvQ0MKcsXkX642hdm9odUp7TqgFS7BsalTcjzRlIfWZrZR1gbxOozKucaKt5KAbjW8J8xRSmA==", + "dev": true, + "requires": { + "conventional-commits-filter": "^4.0.0", + "handlebars": "^4.7.7", + "json-stringify-safe": "^5.0.1", + "meow": "^12.0.1", + "semver": "^7.5.2", + "split2": "^4.0.0" + } + }, + "conventional-commits-filter": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-4.0.0.tgz", + "integrity": "sha512-rnpnibcSOdFcdclpFwWa+pPlZJhXE7l+XK04zxhbWrhgpR96h33QLz8hITTXbcYICxVr3HZFtbtUAQ+4LdBo9A==", + "dev": true + }, + "conventional-commits-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", + "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", + "dev": true, + "requires": { + "is-text-path": "^2.0.0", + "JSONStream": "^1.3.5", + "meow": "^12.0.1", + "split2": "^4.0.0" + } + }, + "cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "requires": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + } + }, + "env-ci": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-10.0.0.tgz", + "integrity": "sha512-U4xcd/utDYFgMh0yWj07R1H6L5fwhVbmxBCpnL0DbVSDZVnsC82HONw0wxtxNkIAcua3KtbomQvIk5xFZGAQJw==", + "dev": true, + "requires": { + "execa": "^8.0.0", + "java-properties": "^1.0.2" + }, + "dependencies": { + "execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + } + }, + "get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true + } + } + }, + "escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true + }, + "find-versions": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-5.1.0.tgz", + "integrity": "sha512-+iwzCJ7C5v5KgcBuueqVoNiHVoQpwiUK5XFLjf0affFTep+Wcw93tPvmb8tqujDNmzhBDPddnWV/qgWSXgq+Hg==", + "dev": true, + "requires": { + "semver-regex": "^4.0.5" + } + }, + "globby": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.2.tgz", + "integrity": "sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==", + "dev": true, + "requires": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.2", + "ignore": "^5.2.4", + "path-type": "^5.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.1.0" + }, + "dependencies": { + "path-type": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz", + "integrity": "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==", + "dev": true + } + } + }, + "human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true + }, + "indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true + }, + "is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true + }, + "issue-parser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-6.0.0.tgz", + "integrity": "sha512-zKa/Dxq2lGsBIXQ7CUZWTHfvxPC2ej0KfO7fIPqLlHB9J2hJ7rGhZ5rilhuufylr4RXYPzJUeFjKxz305OsNlA==", + "dev": true, + "requires": { + "lodash.capitalize": "^4.2.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.uniqby": "^4.7.0" + } + }, + "marked": { + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz", + "integrity": "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==", + "dev": true + }, + "marked-terminal": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/marked-terminal/-/marked-terminal-6.2.0.tgz", + "integrity": "sha512-ubWhwcBFHnXsjYNsu+Wndpg0zhY4CahSpPlA70PlO0rR9r2sZpkyU+rkCsOWH+KMEkx847UpALON+HWgxowFtw==", + "dev": true, + "requires": { + "ansi-escapes": "^6.2.0", + "cardinal": "^2.1.1", + "chalk": "^5.3.0", + "cli-table3": "^0.6.3", + "node-emoji": "^2.1.3", + "supports-hyperlinks": "^3.0.0" + } + }, + "meow": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", + "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", + "dev": true + }, + "mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true + }, + "npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "requires": { + "path-key": "^4.0.0" + } + }, + "onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "requires": { + "mimic-fn": "^4.0.0" + } + }, + "p-reduce": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-3.0.0.tgz", + "integrity": "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==", + "dev": true + }, + "path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, + "semantic-release": { + "version": "22.0.12", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-22.0.12.tgz", + "integrity": "sha512-0mhiCR/4sZb00RVFJIUlMuiBkW3NMpVIW2Gse7noqEMoFGkvfPPAImEQbkBV8xga4KOPP4FdTRYuLLy32R1fPw==", + "dev": true, + "requires": { + "@semantic-release/commit-analyzer": "^11.0.0", + "@semantic-release/error": "^4.0.0", + "@semantic-release/github": "^9.0.0", + "@semantic-release/npm": "^11.0.0", + "@semantic-release/release-notes-generator": "^12.0.0", + "aggregate-error": "^5.0.0", + "cosmiconfig": "^8.0.0", + "debug": "^4.0.0", + "env-ci": "^10.0.0", + "execa": "^8.0.0", + "figures": "^6.0.0", + "find-versions": "^5.1.0", + "get-stream": "^6.0.0", + "git-log-parser": "^1.2.0", + "hook-std": "^3.0.0", + "hosted-git-info": "^7.0.0", + "import-from-esm": "^1.3.1", + "lodash-es": "^4.17.21", + "marked": "^9.0.0", + "marked-terminal": "^6.0.0", + "micromatch": "^4.0.2", + "p-each-series": "^3.0.0", + "p-reduce": "^3.0.0", + "read-pkg-up": "^11.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.3.2", + "semver-diff": "^4.0.0", + "signale": "^1.2.1", + "yargs": "^17.5.1" + }, + "dependencies": { + "@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true + }, + "aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "requires": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + } + }, + "execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "dependencies": { + "get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true + } + } + } + } + }, + "signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true + }, + "slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true + }, + "strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true + }, + "universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", + "dev": true + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true + }, + "yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "requires": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + } + } + } + }, + "@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true + }, + "@semantic-release/changelog": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@semantic-release/changelog/-/changelog-6.0.3.tgz", + "integrity": "sha512-dZuR5qByyfe3Y03TpmCvAxCyTnp7r5XwtHRf/8vD9EAn4ZWbavUX8adMtXYzE86EVh0gyLA7lm5yW4IV30XUag==", + "dev": true, + "requires": { + "@semantic-release/error": "^3.0.0", + "aggregate-error": "^3.0.0", + "fs-extra": "^11.0.0", + "lodash": "^4.17.4" + } + }, + "@semantic-release/commit-analyzer": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/@semantic-release/commit-analyzer/-/commit-analyzer-13.0.1.tgz", + "integrity": "sha512-wdnBPHKkr9HhNhXOhZD5a2LNl91+hs8CC2vsAVYxtZH3y0dV3wKn+uZSN61rdJQZ8EGxzWB3inWocBHV9+u/CQ==", + "dev": true, + "requires": { + "conventional-changelog-angular": "^8.0.0", + "conventional-changelog-writer": "^8.0.0", + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.0.0", + "debug": "^4.0.0", + "import-from-esm": "^2.0.0", + "lodash-es": "^4.17.21", + "micromatch": "^4.0.2" + }, + "dependencies": { + "import-from-esm": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-2.0.0.tgz", + "integrity": "sha512-YVt14UZCgsX1vZQ3gKjkWVdBdHQ6eu3MPU1TBgL1H5orXe2+jWD006WCPPtOuwlQm10NuzOW5WawiF1Q9veW8g==", + "dev": true, + "requires": { + "debug": "^4.3.4", + "import-meta-resolve": "^4.0.0" + } + } + } + }, + "@semantic-release/error": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-3.0.0.tgz", + "integrity": "sha512-5hiM4Un+tpl4cKw3lV4UgzJj+SmfNIDCLLw0TepzQxz9ZGV5ixnqkzIVF+3tp0ZHgcMKE+VNGHJjEeyFG2dcSw==", + "dev": true + }, + "@semantic-release/git": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@semantic-release/git/-/git-10.0.1.tgz", + "integrity": "sha512-eWrx5KguUcU2wUPaO6sfvZI0wPafUKAMNC18aXY4EnNcrZL86dEmpNVnC9uMpGZkmZJ9EfCVJBQx4pV4EMGT1w==", + "dev": true, + "requires": { + "@semantic-release/error": "^3.0.0", + "aggregate-error": "^3.0.0", + "debug": "^4.0.0", + "dir-glob": "^3.0.0", + "execa": "^5.0.0", + "lodash": "^4.17.4", + "micromatch": "^4.0.0", + "p-reduce": "^2.0.0" + } + }, + "@semantic-release/github": { + "version": "12.0.6", + "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.6.tgz", + "integrity": "sha512-aYYFkwHW3c6YtHwQF0t0+lAjlU+87NFOZuH2CvWFD0Ylivc7MwhZMiHOJ0FMpIgPpCVib/VUAcOwvrW0KnxQtA==", + "dev": true, + "requires": { + "@octokit/core": "^7.0.0", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-retry": "^8.0.0", + "@octokit/plugin-throttling": "^11.0.0", + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "debug": "^4.3.4", + "dir-glob": "^3.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "issue-parser": "^7.0.0", + "lodash-es": "^4.17.21", + "mime": "^4.0.0", + "p-filter": "^4.0.0", + "tinyglobby": "^0.2.14", + "undici": "^7.0.0", + "url-join": "^5.0.0" + }, + "dependencies": { + "@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true + }, + "aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "requires": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + } + }, + "clean-stack": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.2.0.tgz", + "integrity": "sha512-TyUIUJgdFnCISzG5zu3291TAsE77ddchd0bepon1VVQrKLGKFED4iXFEDQ24mIPdPBbyE16PK3F8MYE1CmcBEQ==", + "dev": true, + "requires": { + "escape-string-regexp": "5.0.0" + } + }, + "escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true + }, + "indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true + }, + "undici": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", + "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", + "dev": true + } + } + }, + "@semantic-release/npm": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-13.0.0.tgz", + "integrity": "sha512-7RIx9nUdUekYbIZ0dG7k7G/iSvUCZb03LmmBPFqAQEhPVC+BnHfhFxj5ewSNP6zMUsYaEQSckcOhKD8AuS/EzQ==", + "dev": true, + "requires": { + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "execa": "^9.0.0", + "fs-extra": "^11.0.0", + "lodash-es": "^4.17.21", + "nerf-dart": "^1.0.0", + "normalize-url": "^8.0.0", + "npm": "^11.6.2", + "rc": "^1.2.8", + "read-pkg": "^9.0.0", + "registry-auth-token": "^5.0.0", + "semver": "^7.1.2", + "tempy": "^3.0.0" + }, + "dependencies": { + "@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true + }, + "@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true + }, + "aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "requires": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + } + }, + "clean-stack": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.2.0.tgz", + "integrity": "sha512-TyUIUJgdFnCISzG5zu3291TAsE77ddchd0bepon1VVQrKLGKFED4iXFEDQ24mIPdPBbyE16PK3F8MYE1CmcBEQ==", + "dev": true, + "requires": { + "escape-string-regexp": "5.0.0" + } + }, + "escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true + }, + "execa": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.3.0.tgz", + "integrity": "sha512-l6JFbqnHEadBoVAVpN5dl2yCyfX28WoBAGaoQcNmLLSedOxTxcn2Qa83s8I/PA5i56vWru2OHOtrwF7Om2vqlg==", + "dev": true, + "requires": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.3", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^7.0.0", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^5.2.0", + "pretty-ms": "^9.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.0.0" + } + }, + "get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "requires": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + } + }, + "human-signals": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-7.0.0.tgz", + "integrity": "sha512-74kytxOUSvNbjrT9KisAbaTZ/eJwD/LrbM/kh5j0IhPuJzwuA19dWvniFGwBzN9rVjg+O/e+F310PjObDXS+9Q==", + "dev": true + }, + "indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true + }, + "is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true + }, + "npm": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.12.0.tgz", + "integrity": "sha512-xPhOap4ZbJWyd7DAOukP564WFwNSGu/2FeTRFHhiiKthcauxhH/NpkJAQm24xD+cAn8av5tQ00phi98DqtfLsg==", + "dev": true, + "requires": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/arborist": "^9.4.2", + "@npmcli/config": "^10.8.0", + "@npmcli/fs": "^5.0.0", + "@npmcli/map-workspaces": "^5.0.3", + "@npmcli/metavuln-calculator": "^9.0.3", + "@npmcli/package-json": "^7.0.5", + "@npmcli/promise-spawn": "^9.0.1", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.4", + "@sigstore/tuf": "^4.0.2", + "abbrev": "^4.0.0", + "archy": "~1.0.0", + "cacache": "^20.0.4", + "chalk": "^5.6.2", + "ci-info": "^4.4.0", + "fastest-levenshtein": "^1.0.16", + "fs-minipass": "^3.0.3", + "glob": "^13.0.6", + "graceful-fs": "^4.2.11", + "hosted-git-info": "^9.0.2", + "ini": "^6.0.0", + "init-package-json": "^8.2.5", + "is-cidr": "^6.0.3", + "json-parse-even-better-errors": "^5.0.0", + "libnpmaccess": "^10.0.3", + "libnpmdiff": "^8.1.5", + "libnpmexec": "^10.2.5", + "libnpmfund": "^7.0.19", + "libnpmorg": "^8.0.1", + "libnpmpack": "^9.1.5", + "libnpmpublish": "^11.1.3", + "libnpmsearch": "^9.0.1", + "libnpmteam": "^8.0.2", + "libnpmversion": "^8.0.3", + "make-fetch-happen": "^15.0.5", + "minimatch": "^10.2.4", + "minipass": "^7.1.3", + "minipass-pipeline": "^1.2.4", + "ms": "^2.1.2", + "node-gyp": "^12.2.0", + "nopt": "^9.0.0", + "npm-audit-report": "^7.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.2", + "npm-pick-manifest": "^11.0.3", + "npm-profile": "^12.0.1", + "npm-registry-fetch": "^19.1.1", + "npm-user-validate": "^4.0.0", + "p-map": "^7.0.4", + "pacote": "^21.5.0", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.1.0", + "qrcode-terminal": "^0.12.0", + "read": "^5.0.1", + "semver": "^7.7.4", + "spdx-expression-parse": "^4.0.0", + "ssri": "^13.0.1", + "supports-color": "^10.2.2", + "tar": "^7.5.11", + "text-table": "~0.2.0", + "tiny-relative-date": "^2.0.2", + "treeverse": "^3.0.0", + "validate-npm-package-name": "^7.0.2", + "which": "^6.0.1" + }, + "dependencies": { + "@gar/promise-retry": { + "version": "1.0.3", + "bundled": true, + "dev": true + }, + "@isaacs/fs-minipass": { + "version": "4.0.1", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^7.0.4" + } + }, + "@isaacs/string-locale-compare": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "@npmcli/agent": { + "version": "4.0.0", + "bundled": true, + "dev": true, + "requires": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + } + }, + "@npmcli/arborist": { + "version": "9.4.2", + "bundled": true, + "dev": true, + "requires": { + "@gar/promise-retry": "^1.0.0", + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^5.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/metavuln-calculator": "^9.0.2", + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/query": "^5.0.0", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.0", + "bin-links": "^6.0.0", + "cacache": "^20.0.1", + "common-ancestor-path": "^2.0.0", + "hosted-git-info": "^9.0.0", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^11.2.1", + "minimatch": "^10.0.3", + "nopt": "^9.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.0", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "pacote": "^21.0.2", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.0.0", + "proggy": "^4.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "semver": "^7.3.7", + "ssri": "^13.0.0", + "treeverse": "^3.0.0", + "walk-up-path": "^4.0.0" + } + }, + "@npmcli/config": { + "version": "10.8.0", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "ci-info": "^4.0.0", + "ini": "^6.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "walk-up-path": "^4.0.0" + } + }, + "@npmcli/fs": { + "version": "5.0.0", + "bundled": true, + "dev": true, + "requires": { + "semver": "^7.3.5" + } + }, + "@npmcli/git": { + "version": "7.0.2", + "bundled": true, + "dev": true, + "requires": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "ini": "^6.0.0", + "lru-cache": "^11.2.1", + "npm-pick-manifest": "^11.0.1", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "which": "^6.0.0" + } + }, + "@npmcli/installed-package-contents": { + "version": "4.0.0", + "bundled": true, + "dev": true, + "requires": { + "npm-bundled": "^5.0.0", + "npm-normalize-package-bin": "^5.0.0" + } + }, + "@npmcli/map-workspaces": { + "version": "5.0.3", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "glob": "^13.0.0", + "minimatch": "^10.0.3" + } + }, + "@npmcli/metavuln-calculator": { + "version": "9.0.3", + "bundled": true, + "dev": true, + "requires": { + "cacache": "^20.0.0", + "json-parse-even-better-errors": "^5.0.0", + "pacote": "^21.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5" + } + }, + "@npmcli/name-from-folder": { + "version": "4.0.0", + "bundled": true, + "dev": true + }, + "@npmcli/node-gyp": { + "version": "5.0.0", + "bundled": true, + "dev": true + }, + "@npmcli/package-json": { + "version": "7.0.5", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/git": "^7.0.0", + "glob": "^13.0.0", + "hosted-git-info": "^9.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.5.3", + "spdx-expression-parse": "^4.0.0" + } + }, + "@npmcli/promise-spawn": { + "version": "9.0.1", + "bundled": true, + "dev": true, + "requires": { + "which": "^6.0.0" + } + }, + "@npmcli/query": { + "version": "5.0.0", + "bundled": true, + "dev": true, + "requires": { + "postcss-selector-parser": "^7.0.0" + } + }, + "@npmcli/redact": { + "version": "4.0.0", + "bundled": true, + "dev": true + }, + "@npmcli/run-script": { + "version": "10.0.4", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "node-gyp": "^12.1.0", + "proc-log": "^6.0.0" + } + }, + "@sigstore/bundle": { + "version": "4.0.0", + "bundled": true, + "dev": true, + "requires": { + "@sigstore/protobuf-specs": "^0.5.0" + } + }, + "@sigstore/core": { + "version": "3.2.0", + "bundled": true, + "dev": true + }, + "@sigstore/protobuf-specs": { + "version": "0.5.0", + "bundled": true, + "dev": true + }, + "@sigstore/sign": { + "version": "4.1.1", + "bundled": true, + "dev": true, + "requires": { + "@gar/promise-retry": "^1.0.2", + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.0", + "@sigstore/protobuf-specs": "^0.5.0", + "make-fetch-happen": "^15.0.4", + "proc-log": "^6.1.0" + } + }, + "@sigstore/tuf": { + "version": "4.0.2", + "bundled": true, + "dev": true, + "requires": { + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^4.1.0" + } + }, + "@sigstore/verify": { + "version": "3.1.0", + "bundled": true, + "dev": true, + "requires": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0" + } + }, + "@tufjs/canonical-json": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "@tufjs/models": { + "version": "4.1.0", + "bundled": true, + "dev": true, + "requires": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^10.1.1" + } + }, + "abbrev": { + "version": "4.0.0", + "bundled": true, + "dev": true + }, + "agent-base": { + "version": "7.1.4", + "bundled": true, + "dev": true + }, + "aproba": { + "version": "2.1.0", + "bundled": true, + "dev": true + }, + "archy": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "balanced-match": { + "version": "4.0.4", + "bundled": true, + "dev": true + }, + "bin-links": { + "version": "6.0.0", + "bundled": true, + "dev": true, + "requires": { + "cmd-shim": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "proc-log": "^6.0.0", + "read-cmd-shim": "^6.0.0", + "write-file-atomic": "^7.0.0" + } + }, + "binary-extensions": { + "version": "3.1.0", + "bundled": true, + "dev": true + }, + "brace-expansion": { + "version": "5.0.4", + "bundled": true, + "dev": true, + "requires": { + "balanced-match": "^4.0.2" + } + }, + "cacache": { + "version": "20.0.4", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0" + } + }, + "chalk": { + "version": "5.6.2", + "bundled": true, + "dev": true + }, + "chownr": { + "version": "3.0.0", + "bundled": true, + "dev": true + }, + "ci-info": { + "version": "4.4.0", + "bundled": true, + "dev": true + }, + "cidr-regex": { + "version": "5.0.3", + "bundled": true, + "dev": true + }, + "cmd-shim": { + "version": "8.0.0", + "bundled": true, + "dev": true + }, + "common-ancestor-path": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "cssesc": { + "version": "3.0.0", + "bundled": true, + "dev": true + }, + "debug": { + "version": "4.4.3", + "bundled": true, + "dev": true, + "requires": { + "ms": "^2.1.3" + } + }, + "diff": { + "version": "8.0.3", + "bundled": true, + "dev": true + }, + "env-paths": { + "version": "2.2.1", + "bundled": true, + "dev": true + }, + "exponential-backoff": { + "version": "3.1.3", + "bundled": true, + "dev": true + }, + "fastest-levenshtein": { + "version": "1.0.16", + "bundled": true, + "dev": true + }, + "fs-minipass": { + "version": "3.0.3", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^7.0.3" + } + }, + "glob": { + "version": "13.0.6", + "bundled": true, + "dev": true, + "requires": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + } + }, + "graceful-fs": { + "version": "4.2.11", + "bundled": true, + "dev": true + }, + "hosted-git-info": { + "version": "9.0.2", + "bundled": true, + "dev": true, + "requires": { + "lru-cache": "^11.1.0" + } + }, + "http-cache-semantics": { + "version": "4.2.0", + "bundled": true, + "dev": true + }, + "http-proxy-agent": { + "version": "7.0.2", + "bundled": true, + "dev": true, + "requires": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + } + }, + "https-proxy-agent": { + "version": "7.0.6", + "bundled": true, + "dev": true, + "requires": { + "agent-base": "^7.1.2", + "debug": "4" + } + }, + "iconv-lite": { + "version": "0.7.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + }, + "ignore-walk": { + "version": "8.0.0", + "bundled": true, + "dev": true, + "requires": { + "minimatch": "^10.0.3" + } + }, + "ini": { + "version": "6.0.0", + "bundled": true, + "dev": true + }, + "init-package-json": { + "version": "8.2.5", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/package-json": "^7.0.0", + "npm-package-arg": "^13.0.0", + "promzard": "^3.0.1", + "read": "^5.0.1", + "semver": "^7.7.2", + "validate-npm-package-name": "^7.0.0" + } + }, + "ip-address": { + "version": "10.1.0", + "bundled": true, + "dev": true + }, + "is-cidr": { + "version": "6.0.3", + "bundled": true, + "dev": true, + "requires": { + "cidr-regex": "^5.0.1" + } + }, + "isexe": { + "version": "4.0.0", + "bundled": true, + "dev": true + }, + "json-parse-even-better-errors": { + "version": "5.0.0", + "bundled": true, + "dev": true + }, + "json-stringify-nice": { + "version": "1.1.4", + "bundled": true, + "dev": true + }, + "jsonparse": { + "version": "1.3.1", + "bundled": true, + "dev": true + }, + "just-diff": { + "version": "6.0.2", + "bundled": true, + "dev": true + }, + "just-diff-apply": { + "version": "5.5.0", + "bundled": true, + "dev": true + }, + "libnpmaccess": { + "version": "10.0.3", + "bundled": true, + "dev": true, + "requires": { + "npm-package-arg": "^13.0.0", + "npm-registry-fetch": "^19.0.0" + } + }, + "libnpmdiff": { + "version": "8.1.5", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/arborist": "^9.4.2", + "@npmcli/installed-package-contents": "^4.0.0", + "binary-extensions": "^3.0.0", + "diff": "^8.0.2", + "minimatch": "^10.0.3", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2", + "tar": "^7.5.1" + } + }, + "libnpmexec": { + "version": "10.2.5", + "bundled": true, + "dev": true, + "requires": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/arborist": "^9.4.2", + "@npmcli/package-json": "^7.0.0", + "@npmcli/run-script": "^10.0.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2", + "proc-log": "^6.0.0", + "read": "^5.0.1", + "semver": "^7.3.7", + "signal-exit": "^4.1.0", + "walk-up-path": "^4.0.0" + } + }, + "libnpmfund": { + "version": "7.0.19", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/arborist": "^9.4.2" + } + }, + "libnpmorg": { + "version": "8.0.1", + "bundled": true, + "dev": true, + "requires": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^19.0.0" + } + }, + "libnpmpack": { + "version": "9.1.5", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/arborist": "^9.4.2", + "@npmcli/run-script": "^10.0.0", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2" + } + }, + "libnpmpublish": { + "version": "11.1.3", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/package-json": "^7.0.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^13.0.0", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.7", + "sigstore": "^4.0.0", + "ssri": "^13.0.0" + } + }, + "libnpmsearch": { + "version": "9.0.1", + "bundled": true, + "dev": true, + "requires": { + "npm-registry-fetch": "^19.0.0" + } + }, + "libnpmteam": { + "version": "8.0.2", + "bundled": true, + "dev": true, + "requires": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^19.0.0" + } + }, + "libnpmversion": { + "version": "8.0.3", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/git": "^7.0.0", + "@npmcli/run-script": "^10.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.7" + } + }, + "lru-cache": { + "version": "11.2.7", + "bundled": true, + "dev": true + }, + "make-fetch-happen": { + "version": "15.0.5", + "bundled": true, + "dev": true, + "requires": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" + } + }, + "minimatch": { + "version": "10.2.4", + "bundled": true, + "dev": true, + "requires": { + "brace-expansion": "^5.0.2" + } + }, + "minipass": { + "version": "7.1.3", + "bundled": true, + "dev": true + }, + "minipass-collect": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^7.0.3" + } + }, + "minipass-fetch": { + "version": "5.0.2", + "bundled": true, + "dev": true, + "requires": { + "iconv-lite": "^0.7.2", + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + } + }, + "minipass-flush": { + "version": "1.0.5", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^3.0.0" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "bundled": true, + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "bundled": true, + "dev": true + } + } + }, + "minipass-pipeline": { + "version": "1.2.4", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^3.0.0" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "bundled": true, + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "bundled": true, + "dev": true + } + } + }, + "minipass-sized": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^7.1.2" + } + }, + "minizlib": { + "version": "3.1.0", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^7.1.2" + } + }, + "ms": { + "version": "2.1.3", + "bundled": true, + "dev": true + }, + "mute-stream": { + "version": "3.0.0", + "bundled": true, + "dev": true + }, + "negotiator": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "node-gyp": { + "version": "12.2.0", + "bundled": true, + "dev": true, + "requires": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^15.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "which": "^6.0.0" + } + }, + "nopt": { + "version": "9.0.0", + "bundled": true, + "dev": true, + "requires": { + "abbrev": "^4.0.0" + } + }, + "npm-audit-report": { + "version": "7.0.0", + "bundled": true, + "dev": true + }, + "npm-bundled": { + "version": "5.0.0", + "bundled": true, + "dev": true, + "requires": { + "npm-normalize-package-bin": "^5.0.0" + } + }, + "npm-install-checks": { + "version": "8.0.0", + "bundled": true, + "dev": true, + "requires": { + "semver": "^7.1.1" + } + }, + "npm-normalize-package-bin": { + "version": "5.0.0", + "bundled": true, + "dev": true + }, + "npm-package-arg": { + "version": "13.0.2", + "bundled": true, + "dev": true, + "requires": { + "hosted-git-info": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^7.0.0" + } + }, + "npm-packlist": { + "version": "10.0.4", + "bundled": true, + "dev": true, + "requires": { + "ignore-walk": "^8.0.0", + "proc-log": "^6.0.0" + } + }, + "npm-pick-manifest": { + "version": "11.0.3", + "bundled": true, + "dev": true, + "requires": { + "npm-install-checks": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "npm-package-arg": "^13.0.0", + "semver": "^7.3.5" + } + }, + "npm-profile": { + "version": "12.0.1", + "bundled": true, + "dev": true, + "requires": { + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0" + } + }, + "npm-registry-fetch": { + "version": "19.1.1", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/redact": "^4.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^15.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^13.0.0", + "proc-log": "^6.0.0" + } + }, + "npm-user-validate": { + "version": "4.0.0", + "bundled": true, + "dev": true + }, + "p-map": { + "version": "7.0.4", + "bundled": true, + "dev": true + }, + "pacote": { + "version": "21.5.0", + "bundled": true, + "dev": true, + "requires": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/git": "^7.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "@npmcli/run-script": "^10.0.0", + "cacache": "^20.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^13.0.0", + "npm-packlist": "^10.0.1", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "sigstore": "^4.0.0", + "ssri": "^13.0.0", + "tar": "^7.4.3" + } + }, + "parse-conflict-json": { + "version": "5.0.1", + "bundled": true, + "dev": true, + "requires": { + "json-parse-even-better-errors": "^5.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + } + }, + "path-scurry": { + "version": "2.0.2", + "bundled": true, + "dev": true, + "requires": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + } + }, + "postcss-selector-parser": { + "version": "7.1.1", + "bundled": true, + "dev": true, + "requires": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + } + }, + "proc-log": { + "version": "6.1.0", + "bundled": true, + "dev": true + }, + "proggy": { + "version": "4.0.0", + "bundled": true, + "dev": true + }, + "promise-all-reject-late": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "promise-call-limit": { + "version": "3.0.2", + "bundled": true, + "dev": true + }, + "promzard": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "requires": { + "read": "^5.0.0" + } + }, + "qrcode-terminal": { + "version": "0.12.0", + "bundled": true, + "dev": true + }, + "read": { + "version": "5.0.1", + "bundled": true, + "dev": true, + "requires": { + "mute-stream": "^3.0.0" + } + }, + "read-cmd-shim": { + "version": "6.0.0", + "bundled": true, + "dev": true + }, + "safer-buffer": { + "version": "2.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "semver": { + "version": "7.7.4", + "bundled": true, + "dev": true + }, + "signal-exit": { + "version": "4.1.0", + "bundled": true, + "dev": true + }, + "sigstore": { + "version": "4.1.0", + "bundled": true, + "dev": true, + "requires": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^4.1.0", + "@sigstore/tuf": "^4.0.1", + "@sigstore/verify": "^3.1.0" + } + }, + "smart-buffer": { + "version": "4.2.0", + "bundled": true, + "dev": true + }, + "socks": { + "version": "2.8.7", + "bundled": true, + "dev": true, + "requires": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + } + }, + "socks-proxy-agent": { + "version": "8.0.5", + "bundled": true, + "dev": true, + "requires": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + } + }, + "spdx-exceptions": { + "version": "2.5.0", + "bundled": true, + "dev": true + }, + "spdx-expression-parse": { + "version": "4.0.0", + "bundled": true, + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.23", + "bundled": true, + "dev": true + }, + "ssri": { + "version": "13.0.1", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^7.0.3" + } + }, + "supports-color": { + "version": "10.2.2", + "bundled": true, + "dev": true + }, + "tar": { + "version": "7.5.11", + "bundled": true, + "dev": true, + "requires": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + } + }, + "text-table": { + "version": "0.2.0", + "bundled": true, + "dev": true + }, + "tiny-relative-date": { + "version": "2.0.2", + "bundled": true, + "dev": true + }, + "tinyglobby": { + "version": "0.2.15", + "bundled": true, + "dev": true, + "requires": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "dependencies": { + "fdir": { + "version": "6.5.0", + "bundled": true, + "dev": true, + "requires": {} + }, + "picomatch": { + "version": "4.0.3", + "bundled": true, + "dev": true + } + } + }, + "treeverse": { + "version": "3.0.0", + "bundled": true, + "dev": true + }, + "tuf-js": { + "version": "4.1.0", + "bundled": true, + "dev": true, + "requires": { + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" + } + }, + "util-deprecate": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "validate-npm-package-name": { + "version": "7.0.2", + "bundled": true, + "dev": true + }, + "walk-up-path": { + "version": "4.0.0", + "bundled": true, + "dev": true + }, + "which": { + "version": "6.0.1", + "bundled": true, + "dev": true, + "requires": { + "isexe": "^4.0.0" + } + }, + "write-file-atomic": { + "version": "7.0.1", + "bundled": true, + "dev": true, + "requires": { + "signal-exit": "^4.0.1" + } + }, + "yallist": { + "version": "5.0.0", + "bundled": true, + "dev": true + } + } + }, + "npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "requires": { + "path-key": "^4.0.0" + } + }, + "parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true + }, + "path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true + }, + "pretty-ms": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.0.0.tgz", + "integrity": "sha512-E9e9HJ9R9NasGOgPaPE8VMeiPKAyWR5jcFpNnwIejslIhWqdqOrb2wShBsncMPUb+BcCd2OPYfh7p2W6oemTng==", + "dev": true, + "requires": { + "parse-ms": "^4.0.0" + } + }, + "signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true + }, + "strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true + } + } + }, + "@semantic-release/release-notes-generator": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.0.tgz", + "integrity": "sha512-CcyDRk7xq+ON/20YNR+1I/jP7BYKICr1uKd1HHpROSnnTdGqOTburi4jcRiTYz0cpfhxSloQO3cGhnoot7IEkA==", + "dev": true, + "requires": { + "conventional-changelog-angular": "^8.0.0", + "conventional-changelog-writer": "^8.0.0", + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.0.0", + "debug": "^4.0.0", + "get-stream": "^7.0.0", + "import-from-esm": "^2.0.0", + "into-stream": "^7.0.0", + "lodash-es": "^4.17.21", + "read-package-up": "^11.0.0" + }, + "dependencies": { + "get-stream": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-7.0.1.tgz", + "integrity": "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ==", + "dev": true + }, + "import-from-esm": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-2.0.0.tgz", + "integrity": "sha512-YVt14UZCgsX1vZQ3gKjkWVdBdHQ6eu3MPU1TBgL1H5orXe2+jWD006WCPPtOuwlQm10NuzOW5WawiF1Q9veW8g==", + "dev": true, + "requires": { + "debug": "^4.3.4", + "import-meta-resolve": "^4.0.0" + } + } + } + }, + "@sindresorhus/is": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", + "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", + "dev": true + }, + "@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true + }, + "@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "requires": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "@szmarczak/http-timer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", + "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", + "dev": true, + "requires": { + "defer-to-connect": "^2.0.1" + } + }, + "@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "optional": true + }, + "@ts-graphviz/adapter": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@ts-graphviz/adapter/-/adapter-2.0.5.tgz", + "integrity": "sha512-K/xd2SJskbSLcUz9uYW9IDy26I3Oyutj/LREjJgcuLMxT3um4sZfy9LiUhGErHjxLRaNcaDVGSsmWeiNuhidXg==", + "dev": true, + "requires": { + "@ts-graphviz/common": "^2.1.4" + } + }, + "@ts-graphviz/ast": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@ts-graphviz/ast/-/ast-2.0.5.tgz", + "integrity": "sha512-HVT+Bn/smDzmKNJFccwgrpJaEUMPzXQ8d84JcNugzTHNUVgxAIe2Vbf4ug351YJpowivQp6/N7XCluQMjtgi5w==", + "dev": true, + "requires": { + "@ts-graphviz/common": "^2.1.4" + } + }, + "@ts-graphviz/common": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@ts-graphviz/common/-/common-2.1.4.tgz", + "integrity": "sha512-PNEzOgE4vgvorp/a4Ev26jVNtiX200yODoyPa8r6GfpPZbxWKW6bdXF6xWqzMkQoO1CnJOYJx2VANDbGqCqCCw==", + "dev": true + }, + "@ts-graphviz/core": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@ts-graphviz/core/-/core-2.0.5.tgz", + "integrity": "sha512-YwaCGAG3Hs0nhxl+2lVuwuTTAK3GO2XHqOGvGIwXQB16nV858rrR5w2YmWCw9nhd11uLTStxLsCAhI9koWBqDA==", + "dev": true, + "requires": { + "@ts-graphviz/ast": "^2.0.5", + "@ts-graphviz/common": "^2.1.4" + } + }, + "@tybys/wasm-util": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", + "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, + "@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "@types/busboy": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.3.tgz", + "integrity": "sha512-YMBLFN/xBD8bnqywIlGyYqsNFXu6bsiY7h3Ae0kO17qEuTjsqeyYMRPSUDacIKIquws2Y6KjmxAyNx8xB3xQbw==", + "requires": { + "@types/node": "*" + } + }, + "@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "optional": true + }, + "@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "requires": { + "@types/node": "*" + } + }, + "@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true + }, + "@types/express": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.17.43", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.43.tgz", + "integrity": "sha512-oaYtiBirUOPQGSWNGPWnzyAFJ0BP3cwvN4oWZQY+zUBwpVIGsKUkpBpSztp74drYcjavs7SKFZ4DX1V2QeN8rg==", + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "dev": true + }, + "@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" + }, + "@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "@types/jsonwebtoken": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", + "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", + "requires": { + "@types/node": "*" + } + }, + "@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true + }, + "@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==" + }, + "@types/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-4NpsnpYl2Gt1ljyBGrKMxFYAYvpqbnnkgP/i/g+NLpjEUa3obn1XJCur9YbEXKDAkaXqsR1LbDnGEJ0MmKFxfg==", + "dev": true, + "requires": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true + }, + "@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" + }, + "@types/node": { + "version": "22.9.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", + "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==", + "requires": { + "undici-types": "~6.19.8" + } + }, + "@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true + }, + "@types/object-path": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/@types/object-path/-/object-path-0.11.4.tgz", + "integrity": "sha512-4tgJ1Z3elF/tOMpA8JLVuR9spt9Ynsf7+JjqsQ2IqtiPJtcLoHoXcT6qU4E10cPFqyXX5HDm9QwIzZhBSkLxsw==" + }, + "@types/qs": { + "version": "6.9.11", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.11.tgz", + "integrity": "sha512-oGk0gmhnEJK4Yyk+oI7EfXsLayXatCWPHary1MtcmbAifkobT9cM9yutG/hZKIseOU0MqbIwQ/u2nn/Gb+ltuQ==" + }, + "@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" + }, + "@types/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "optional": true, + "requires": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + }, + "dependencies": { + "form-data": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "optional": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + } + } + } + }, + "@types/semver": { + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", + "dev": true + }, + "@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "requires": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "@types/serve-static": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.5.tgz", + "integrity": "sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==", + "requires": { + "@types/http-errors": "*", + "@types/mime": "*", + "@types/node": "*" + } + }, + "@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "optional": true + }, + "@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==" + }, + "@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" + }, + "@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "dev": true, + "requires": { + "@types/webidl-conversions": "*" + } + }, + "@typescript-eslint/eslint-plugin": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", + "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", + "dev": true, + "requires": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/type-utils": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "dependencies": { + "@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true + }, + "@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + } + }, + "eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true + }, + "ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true + }, + "ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "requires": {} + } + } + }, + "@typescript-eslint/parser": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", + "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", + "dev": true, + "requires": { + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3" + }, + "dependencies": { + "@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "requires": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + } + }, + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true + }, + "brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "requires": { + "balanced-match": "^4.0.2" + } + }, + "eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true + }, + "minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "requires": { + "brace-expansion": "^5.0.5" + } + }, + "ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "requires": {} + } + } + }, + "@typescript-eslint/project-service": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "dev": true, + "requires": { + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "debug": "^4.4.3" + }, + "dependencies": { + "@typescript-eslint/types": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", + "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", + "dev": true + } + } + }, + "@typescript-eslint/scope-manager": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" + }, + "dependencies": { + "@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true + }, + "@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + } + }, + "eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true + } + } + }, + "@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "dev": true, + "requires": {} + }, + "@typescript-eslint/type-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", + "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "dependencies": { + "@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "requires": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + } + }, + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true + }, + "brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "requires": { + "balanced-match": "^4.0.2" + } + }, + "eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true + }, + "minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "requires": { + "brace-expansion": "^5.0.5" + } + }, + "ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "requires": {} + } + } + }, + "@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "requires": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + } + } + }, + "@typescript-eslint/utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", + "dev": true, + "requires": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" + }, + "dependencies": { + "@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "requires": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + } + }, + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true + }, + "brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "requires": { + "balanced-match": "^4.0.2" + } + }, + "eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true + }, + "minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "requires": { + "brace-expansion": "^5.0.5" + } + }, + "ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "requires": {} + } + } + }, + "@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true + } + } + }, + "@vue/compiler-core": { + "version": "3.5.11", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.11.tgz", + "integrity": "sha512-PwAdxs7/9Hc3ieBO12tXzmTD+Ln4qhT/56S+8DvrrZ4kLDn4Z/AMUr8tXJD0axiJBS0RKIoNaR0yMuQB9v9Udg==", + "dev": true, + "requires": { + "@babel/parser": "^7.25.3", + "@vue/shared": "3.5.11", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.0" + } + }, + "@vue/compiler-dom": { + "version": "3.5.11", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.11.tgz", + "integrity": "sha512-pyGf8zdbDDRkBrEzf8p7BQlMKNNF5Fk/Cf/fQ6PiUz9at4OaUfyXW0dGJTo2Vl1f5U9jSLCNf0EZJEogLXoeew==", + "dev": true, + "requires": { + "@vue/compiler-core": "3.5.11", + "@vue/shared": "3.5.11" + } + }, + "@vue/compiler-sfc": { + "version": "3.5.11", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.11.tgz", + "integrity": "sha512-gsbBtT4N9ANXXepprle+X9YLg2htQk1sqH/qGJ/EApl+dgpUBdTv3yP7YlR535uHZY3n6XaR0/bKo0BgwwDniw==", + "dev": true, + "requires": { + "@babel/parser": "^7.25.3", + "@vue/compiler-core": "3.5.11", + "@vue/compiler-dom": "3.5.11", + "@vue/compiler-ssr": "3.5.11", + "@vue/shared": "3.5.11", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.11", + "postcss": "^8.4.47", + "source-map-js": "^1.2.0" + } + }, + "@vue/compiler-ssr": { + "version": "3.5.11", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.11.tgz", + "integrity": "sha512-P4+GPjOuC2aFTk1Z4WANvEhyOykcvEd5bIj2KVNGKGfM745LaXGr++5njpdBTzVz5pZifdlR1kpYSJJpIlSePA==", + "dev": true, + "requires": { + "@vue/compiler-dom": "3.5.11", + "@vue/shared": "3.5.11" + } + }, + "@vue/shared": { + "version": "3.5.11", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.11.tgz", + "integrity": "sha512-W8GgysJVnFo81FthhzurdRAWP/byq3q2qIw70e0JWblzVhjgOMiC2GyovXrZTFQJnFVryYaKGP3Tc9vYzYm6PQ==", + "dev": true + }, + "@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", + "requires": { + "tslib": "^2.6.3" + } + }, + "@wry/caches": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz", + "integrity": "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==", + "dev": true, + "requires": { + "tslib": "^2.3.0" + } + }, + "@wry/context": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.4.tgz", + "integrity": "sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==", + "dev": true, + "requires": { + "tslib": "^2.3.0" + } + }, + "@wry/equality": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.7.tgz", + "integrity": "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==", + "dev": true, + "requires": { + "tslib": "^2.3.0" + } + }, + "@wry/trie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.5.0.tgz", + "integrity": "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==", + "dev": true, + "requires": { + "tslib": "^2.3.0" + } + }, + "abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "optional": true, + "requires": { + "event-target-shim": "^5.0.0" + } + }, + "abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==" + }, + "accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "requires": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "dependencies": { + "mime-db": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", + "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==" + }, + "mime-types": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz", + "integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==", + "requires": { + "mime-db": "^1.53.0" + } + } + } + }, + "acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true + }, + "acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "requires": {} + }, + "agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==" + }, + "aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "requires": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + } + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "all-node-versions": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/all-node-versions/-/all-node-versions-13.0.1.tgz", + "integrity": "sha512-5pG14FNgn5ClyGv8diB7uTcsmi2NWk9rDH+cGbVsqHjeqptegK0UfCsBA/vNUOZPNOPnYNzk31EM9OjJktld/g==", + "dev": true, + "requires": { + "fetch-node-website": "^9.0.1", + "filter-obj": "^6.1.0", + "global-cache-dir": "^6.0.1", + "is-plain-obj": "^4.1.0", + "path-exists": "^5.0.0", + "semver": "^7.7.1", + "write-file-atomic": "^6.0.0" + }, + "dependencies": { + "path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true + }, + "signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true + }, + "write-file-atomic": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-6.0.0.tgz", + "integrity": "sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ==", + "dev": true, + "requires": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + } + } + } + }, + "ansi-escapes": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", + "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", + "dev": true, + "requires": { + "environment": "^1.0.0" + } + }, + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "ansicolors": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/ansicolors/-/ansicolors-0.3.2.tgz", + "integrity": "sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==", + "dev": true + }, + "any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "optional": true, + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "apollo-upload-client": { + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/apollo-upload-client/-/apollo-upload-client-18.0.1.tgz", + "integrity": "sha512-OQvZg1rK05VNI79D658FUmMdoI2oB/KJKb6QGMa2Si25QXOaAvLMBFUEwJct7wf+19U8vk9ILhidBOU1ZWv6QA==", + "dev": true, + "requires": { + "extract-files": "^13.0.0" + } + }, + "app-module-path": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/app-module-path/-/app-module-path-2.2.0.tgz", + "integrity": "sha512-gkco+qxENJV+8vFcDiiFhuoSvRXb2a/QPqpSoWhVz829VNJfOTnELbBmPmNKFxf3xdNnw4DWCkzkDaavcX/1YQ==", + "dev": true + }, + "append-transform": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", + "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", + "dev": true, + "requires": { + "default-require-extensions": "^3.0.0" + } + }, + "aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" + }, + "archy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", + "dev": true + }, + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "argv-formatter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/argv-formatter/-/argv-formatter-1.0.0.tgz", + "integrity": "sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw==", + "dev": true + }, + "array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "dev": true + }, + "array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true + }, + "arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "optional": true + }, + "asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "requires": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "assert-options": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/assert-options/-/assert-options-0.8.3.tgz", + "integrity": "sha512-s6v4HnA+vYSGO4eZX+F+I3gvF74wPk+m6Z1Q3w1Dsg4Pnv/R24vhKAasoMVZGvDpOOfTg1Qz4ptZnEbuy95XsQ==" + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==" + }, + "ast-module-types": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ast-module-types/-/ast-module-types-6.0.0.tgz", + "integrity": "sha512-LFRg7178Fw5R4FAEwZxVqiRI8IxSM+Ay2UBrHoCerXNme+kMMMfz7T3xDGV/c2fer87hcrtgJGsnSOfUrPK6ng==", + "dev": true + }, + "async": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" + }, + "async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "requires": { + "retry": "0.13.1" + } + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "devOptional": true + }, + "available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "requires": { + "possible-typed-array-names": "^1.0.0" + } + }, + "babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "dependencies": { + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + } + } + }, + "babel-plugin-polyfill-corejs3": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", + "dev": true, + "requires": { + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" + } + }, + "babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "dev": true, + "requires": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + } + }, + "backo2": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", + "integrity": "sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA==", + "dev": true, + "optional": true, + "peer": true + }, + "backoff": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz", + "integrity": "sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==", + "requires": { + "precond": "0.2" + } + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "devOptional": true + }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "baseline-browser-mapping": { + "version": "2.10.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", + "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", + "dev": true + }, + "bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==" + }, + "before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true + }, + "bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==" + }, + "binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "optional": true + }, + "bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "requires": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + }, + "dependencies": { + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, + "bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true + }, + "bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==" + }, + "body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "requires": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + } + }, + "bottleneck": { + "version": "2.19.5", + "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", + "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", + "dev": true + }, + "brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "requires": { + "fill-range": "^7.1.1" + } + }, + "browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "requires": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + } + }, + "bson": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.1.1.tgz", + "integrity": "sha512-TtJgBB+QyOlWjrbM+8bRgH84VM/xrDjyBFgSgGrfZF4xvt6gbEDtcswm27Tn9F9TWsjQybxT8b8VpCP/oJK4Dw==" + }, + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "buffer-alloc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", + "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", + "dev": true, + "requires": { + "buffer-alloc-unsafe": "^1.1.0", + "buffer-fill": "^1.0.0" + } + }, + "buffer-alloc-unsafe": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", + "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", + "dev": true + }, + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true + }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, + "buffer-fill": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", + "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==", + "dev": true + }, + "buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "requires": { + "streamsearch": "^1.1.0" + } + }, + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" + }, + "cacheable-lookup": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", + "dev": true + }, + "cacheable-request": { + "version": "10.2.14", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz", + "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==", + "dev": true, + "requires": { + "@types/http-cache-semantics": "^4.0.2", + "get-stream": "^6.0.1", + "http-cache-semantics": "^4.1.1", + "keyv": "^4.5.3", + "mimic-response": "^4.0.0", + "normalize-url": "^8.0.0", + "responselike": "^3.0.0" + } + }, + "cachedir": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", + "integrity": "sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==", + "dev": true + }, + "caching-transform": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", + "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", + "dev": true, + "requires": { + "hasha": "^5.0.0", + "make-dir": "^3.0.0", + "package-hash": "^4.0.0", + "write-file-atomic": "^3.0.0" + }, + "dependencies": { + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "requires": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + } + }, + "call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "requires": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + } + }, + "call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + } + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "dev": true, + "requires": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "caniuse-lite": { + "version": "1.0.30001782", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", + "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", + "dev": true + }, + "cardinal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/cardinal/-/cardinal-2.1.1.tgz", + "integrity": "sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==", + "dev": true, + "requires": { + "ansicolors": "~0.3.2", + "redeyed": "~2.1.0" + } + }, + "catharsis": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/catharsis/-/catharsis-0.9.0.tgz", + "integrity": "sha512-prMTQVpcns/tzFgFVkVp6ak6RykZyWb3gu8ckUpd6YkTlacOd3DXGJjIpD4Q6zJirizvaiAjSSHlOsA+6sNh2A==", + "dev": true, + "requires": { + "lodash": "^4.17.15" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true + }, + "chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "optional": true, + "requires": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + } + }, + "chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true + }, + "clean-css": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", + "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==", + "dev": true, + "requires": { + "source-map": "~0.6.0" + } + }, + "clean-jsdoc-theme": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/clean-jsdoc-theme/-/clean-jsdoc-theme-4.3.0.tgz", + "integrity": "sha512-QMrBdZ2KdPt6V2Ytg7dIt0/q32U4COpxvR0UDhPjRRKRL0o0MvRCR5YpY37/4rPF1SI1AYEKAWyof7ndCb/dzA==", + "dev": true, + "requires": { + "@jsdoc/salty": "^0.2.4", + "fs-extra": "^10.1.0", + "html-minifier-terser": "^7.2.0", + "klaw-sync": "^6.0.0", + "lodash": "^4.17.21", + "showdown": "^2.1.0" + }, + "dependencies": { + "fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + } + } + } + }, + "clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true + }, + "cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "requires": { + "restore-cursor": "^3.1.0" + } + }, + "cli-highlight": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", + "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "highlight.js": "^10.7.1", + "mz": "^2.4.0", + "parse5": "^5.1.1", + "parse5-htmlparser2-tree-adapter": "^6.0.0", + "yargs": "^16.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true + }, + "yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "requires": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + } + }, + "yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true + } + } + }, + "cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "dev": true, + "requires": { + "string-width": "^4.2.3" + } + }, + "cli-spinners": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.7.0.tgz", + "integrity": "sha512-qu3pN8Y3qHNgE2AFweciB1IfMnmZ/fsNTEE+NOFjmGB2F/7rLhnhzppvpCnN4FovtP26k8lHyy9ptEbNwWFLzw==", + "dev": true + }, + "cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "requires": { + "@colors/colors": "1.5.0", + "string-width": "^4.2.0" + } + }, + "cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "requires": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "dependencies": { + "ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true + }, + "string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "dev": true, + "requires": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + } + }, + "strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "requires": { + "ansi-regex": "^6.2.2" + } + } + } + }, + "cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true + }, + "cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==" + }, + "color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "requires": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "dependencies": { + "color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "requires": { + "color-name": "^2.0.0" + } + }, + "color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==" + } + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "requires": { + "color-name": "^2.0.0" + }, + "dependencies": { + "color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==" + } + } + }, + "color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==" + }, + "colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, + "colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true + }, + "colors-option": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/colors-option/-/colors-option-6.0.1.tgz", + "integrity": "sha512-FsAlu5KTTN+W6Xc4NpxNAhl8iCKwVBzjL7Y2ZK6G9zMv50AfMDlU7Mi16lzaDK8Iwpoq/GfAXX+WrYx38gfSHA==", + "dev": true, + "requires": { + "chalk": "^5.4.1", + "is-plain-obj": "^4.1.0" + }, + "dependencies": { + "chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "dev": true + } + } + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "devOptional": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==" + }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, + "compare-func": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", + "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "dev": true, + "requires": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "requires": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" + }, + "content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "requires": { + "safe-buffer": "5.2.1" + } + }, + "content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" + }, + "conventional-changelog-angular": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.0.0.tgz", + "integrity": "sha512-CLf+zr6St0wIxos4bmaKHRXWAcsCXrJU6F4VdNDrGRK3B8LDLKoX3zuMV5GhtbGkVR/LohZ6MT6im43vZLSjmA==", + "dev": true, + "requires": { + "compare-func": "^2.0.0" + } + }, + "conventional-changelog-writer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-8.0.0.tgz", + "integrity": "sha512-TQcoYGRatlAnT2qEWDON/XSfnVG38JzA7E0wcGScu7RElQBkg9WWgZd1peCWFcWDh1xfb2CfsrcvOn1bbSzztA==", + "dev": true, + "requires": { + "@types/semver": "^7.5.5", + "conventional-commits-filter": "^5.0.0", + "handlebars": "^4.7.7", + "meow": "^13.0.0", + "semver": "^7.5.2" + } + }, + "conventional-commits-filter": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz", + "integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==", + "dev": true + }, + "conventional-commits-parser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.0.0.tgz", + "integrity": "sha512-TbsINLp48XeMXR8EvGjTnKGsZqBemisPoyWESlpRyR8lif0lcwzqz+NMtYSj1ooF/WYjSuu7wX0CtdeeMEQAmA==", + "dev": true, + "requires": { + "meow": "^13.0.0" + } + }, + "convert-hrtime": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-5.0.0.tgz", + "integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==", + "dev": true + }, + "convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true + }, + "cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==" + }, + "cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==" + }, + "core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "dev": true, + "requires": { + "browserslist": "^4.28.1" + } + }, + "core-js-pure": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.49.0.tgz", + "integrity": "sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==" + }, + "core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true + }, + "cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, + "cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dev": true, + "requires": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + } + }, + "cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.1" + } + }, + "cross-inspect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz", + "integrity": "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==", + "requires": { + "tslib": "^2.4.0" + } + }, + "cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "devOptional": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, + "crypto-random-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", + "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", + "dev": true, + "requires": { + "type-fest": "^1.0.1" + }, + "dependencies": { + "type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true + } + } + }, + "data-uri-to-buffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz", + "integrity": "sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA==" + }, + "debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "requires": { + "ms": "^2.1.3" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true + }, + "decompress": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress/-/decompress-4.2.1.tgz", + "integrity": "sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==", + "dev": true, + "requires": { + "decompress-tar": "^4.0.0", + "decompress-tarbz2": "^4.0.0", + "decompress-targz": "^4.0.0", + "decompress-unzip": "^4.0.1", + "graceful-fs": "^4.1.10", + "make-dir": "^1.0.0", + "pify": "^2.3.0", + "strip-dirs": "^2.0.0" + }, + "dependencies": { + "make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "requires": { + "pify": "^3.0.0" + }, + "dependencies": { + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true + } + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true + } + } + }, + "decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "requires": { + "mimic-response": "^3.1.0" + }, + "dependencies": { + "mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true + } + } + }, + "decompress-tar": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tar/-/decompress-tar-4.1.1.tgz", + "integrity": "sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==", + "dev": true, + "requires": { + "file-type": "^5.2.0", + "is-stream": "^1.1.0", + "tar-stream": "^1.5.2" + }, + "dependencies": { + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true + } + } + }, + "decompress-tarbz2": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz", + "integrity": "sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==", + "dev": true, + "requires": { + "decompress-tar": "^4.1.0", + "file-type": "^6.1.0", + "is-stream": "^1.1.0", + "seek-bzip": "^1.0.5", + "unbzip2-stream": "^1.0.9" + }, + "dependencies": { + "file-type": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-6.2.0.tgz", + "integrity": "sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==", + "dev": true + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true + } + } + }, + "decompress-targz": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-targz/-/decompress-targz-4.1.1.tgz", + "integrity": "sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==", + "dev": true, + "requires": { + "decompress-tar": "^4.1.1", + "file-type": "^5.2.0", + "is-stream": "^1.1.0" + }, + "dependencies": { + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true + } + } + }, + "decompress-unzip": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/decompress-unzip/-/decompress-unzip-4.0.1.tgz", + "integrity": "sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw==", + "dev": true, + "requires": { + "file-type": "^3.8.0", + "get-stream": "^2.2.0", + "pify": "^2.3.0", + "yauzl": "^2.4.2" + }, + "dependencies": { + "file-type": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", + "integrity": "sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==", + "dev": true + }, + "get-stream": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", + "integrity": "sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA==", + "dev": true, + "requires": { + "object-assign": "^4.0.1", + "pinkie-promise": "^2.0.0" + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true + } + } + }, + "deep-diff": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz", + "integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==", + "dev": true + }, + "deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true + }, + "deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "default-require-extensions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz", + "integrity": "sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==", + "dev": true, + "requires": { + "strip-bom": "^4.0.0" + } + }, + "defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "requires": { + "clone": "^1.0.2" + } + }, + "defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true + }, + "define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "requires": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "devOptional": true + }, + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + }, + "dependency-tree": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/dependency-tree/-/dependency-tree-11.0.1.tgz", + "integrity": "sha512-eCt7HSKIC9NxgIykG2DRq3Aewn9UhVS14MB3rEn6l/AsEI1FBg6ZGSlCU0SZ6Tjm2kkhj6/8c2pViinuyKELhg==", + "dev": true, + "requires": { + "commander": "^12.0.0", + "filing-cabinet": "^5.0.1", + "precinct": "^12.0.2", + "typescript": "^5.4.5" + }, + "dependencies": { + "commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true + } + } + }, + "deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", + "dev": true + }, + "detective-amd": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/detective-amd/-/detective-amd-6.0.0.tgz", + "integrity": "sha512-NTqfYfwNsW7AQltKSEaWR66hGkTeD52Kz3eRQ+nfkA9ZFZt3iifRCWh+yZ/m6t3H42JFwVFTrml/D64R2PAIOA==", + "dev": true, + "requires": { + "ast-module-types": "^6.0.0", + "escodegen": "^2.1.0", + "get-amd-module-type": "^6.0.0", + "node-source-walk": "^7.0.0" + } + }, + "detective-cjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/detective-cjs/-/detective-cjs-6.0.0.tgz", + "integrity": "sha512-R55jTS6Kkmy6ukdrbzY4x+I7KkXiuDPpFzUViFV/tm2PBGtTCjkh9ZmTuJc1SaziMHJOe636dtiZLEuzBL9drg==", + "dev": true, + "requires": { + "ast-module-types": "^6.0.0", + "node-source-walk": "^7.0.0" + } + }, + "detective-es6": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/detective-es6/-/detective-es6-5.0.0.tgz", + "integrity": "sha512-NGTnzjvgeMW1khUSEXCzPDoraLenWbUjCFjwxReH+Ir+P6LGjYtaBbAvITWn2H0VSC+eM7/9LFOTAkrta6hNYg==", + "dev": true, + "requires": { + "node-source-walk": "^7.0.0" + } + }, + "detective-postcss": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/detective-postcss/-/detective-postcss-7.0.0.tgz", + "integrity": "sha512-pSXA6dyqmBPBuERpoOKKTUUjQCZwZPLRbd1VdsTbt6W+m/+6ROl4BbE87yQBUtLoK7yX8pvXHdKyM/xNIW9F7A==", + "dev": true, + "requires": { + "is-url": "^1.2.4", + "postcss-values-parser": "^6.0.2" + } + }, + "detective-sass": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/detective-sass/-/detective-sass-6.0.0.tgz", + "integrity": "sha512-h5GCfFMkPm4ZUUfGHVPKNHKT8jV7cSmgK+s4dgQH4/dIUNh9/huR1fjEQrblOQNDalSU7k7g+tiW9LJ+nVEUhg==", + "dev": true, + "requires": { + "gonzales-pe": "^4.3.0", + "node-source-walk": "^7.0.0" + } + }, + "detective-scss": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/detective-scss/-/detective-scss-5.0.0.tgz", + "integrity": "sha512-Y64HyMqntdsCh1qAH7ci95dk0nnpA29g319w/5d/oYcHolcGUVJbIhOirOFjfN1KnMAXAFm5FIkZ4l2EKFGgxg==", + "dev": true, + "requires": { + "gonzales-pe": "^4.3.0", + "node-source-walk": "^7.0.0" + } + }, + "detective-stylus": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/detective-stylus/-/detective-stylus-5.0.0.tgz", + "integrity": "sha512-KMHOsPY6aq3196WteVhkY5FF+6Nnc/r7q741E+Gq+Ax9mhE2iwj8Hlw8pl+749hPDRDBHZ2WlgOjP+twIG61vQ==", + "dev": true + }, + "detective-typescript": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/detective-typescript/-/detective-typescript-13.0.0.tgz", + "integrity": "sha512-tcMYfiFWoUejSbvSblw90NDt76/4mNftYCX0SMnVRYzSXv8Fvo06hi4JOPdNvVNxRtCAKg3MJ3cBJh+ygEMH+A==", + "dev": true, + "requires": { + "@typescript-eslint/typescript-estree": "^7.6.0", + "ast-module-types": "^6.0.0", + "node-source-walk": "^7.0.0" + } + }, + "detective-vue2": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detective-vue2/-/detective-vue2-2.0.3.tgz", + "integrity": "sha512-AgWdSfVnft8uPGnUkdvE1EDadEENDCzoSRMt2xZfpxsjqVO617zGWXbB8TGIxHaqHz/nHa6lOSgAB8/dt0yEug==", + "dev": true, + "requires": { + "@vue/compiler-sfc": "^3.4.27", + "detective-es6": "^5.0.0", + "detective-sass": "^6.0.0", + "detective-scss": "^5.0.0", + "detective-stylus": "^5.0.0", + "detective-typescript": "^13.0.0" + } + }, + "dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "requires": { + "path-type": "^4.0.0" + } + }, + "dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "requires": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "requires": { + "is-obj": "^2.0.0" + } + }, + "dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "requires": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + } + }, + "duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "requires": { + "readable-stream": "^2.0.2" + } + }, + "duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "optional": true, + "requires": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + }, + "dependencies": { + "readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "optional": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, + "eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "devOptional": true + }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "electron-to-chromium": { + "version": "1.5.328", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz", + "integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==", + "dev": true + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "emojilib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", + "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", + "dev": true + }, + "enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" + }, + "encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" + }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "devOptional": true, + "requires": { + "once": "^1.4.0" + } + }, + "enhanced-resolve": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + } + }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true + }, + "env-ci": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-11.2.0.tgz", + "integrity": "sha512-D5kWfzkmaOQDioPmiviWAVtKmpPT4/iJmMVQxWxMPJTFyTkdc5JQUfc5iXEeWxcOdsYTKSAiA/Age4NUOqKsRA==", + "dev": true, + "requires": { + "execa": "^8.0.0", + "java-properties": "^1.0.2" + }, + "dependencies": { + "execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + } + }, + "get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true + }, + "human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true + }, + "is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true + }, + "mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true + }, + "npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "requires": { + "path-key": "^4.0.0" + } + }, + "onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "requires": { + "mimic-fn": "^4.0.0" + } + }, + "path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true + }, + "signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true + }, + "strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true + } + } + }, + "env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true + }, + "environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true + }, + "err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==" + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "requires": { + "es-errors": "^1.3.0" + } + }, + "es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "devOptional": true, + "requires": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + } + }, + "es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true + }, + "escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "devOptional": true + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true + }, + "escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "requires": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2", + "source-map": "~0.6.1" + }, + "dependencies": { + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + } + } + }, + "eslint": { + "version": "9.27.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.27.0.tgz", + "integrity": "sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==", + "dev": true, + "requires": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.20.0", + "@eslint/config-helpers": "^0.2.1", + "@eslint/core": "^0.14.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.27.0", + "@eslint/plugin-kit": "^0.3.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.3.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, + "eslint-scope": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", + "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + } + }, + "eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true + }, + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + }, + "glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "requires": { + "is-glob": "^4.0.3" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "eslint-plugin-expect-type": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-expect-type/-/eslint-plugin-expect-type-0.6.2.tgz", + "integrity": "sha512-XWgtpplzr6GlpPUFG9ZApnSTv7QJXAPNN6hNmrlleVVCkAK23f/3E2BiCoA3Xtb0rIKfVKh7TLe+D1tcGt8/1w==", + "dev": true, + "requires": { + "@typescript-eslint/utils": "^6.10.0 || ^7.0.1 || ^8", + "fs-extra": "^11.1.1", + "get-tsconfig": "^4.8.1" + } + }, + "eslint-plugin-unused-imports": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz", + "integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==", + "dev": true, + "requires": {} + }, + "eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + } + }, + "eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true + }, + "espree": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "dev": true, + "requires": { + "acorn": "^8.14.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true + } + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, + "esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "requires": { + "estraverse": "^5.1.0" + }, + "dependencies": { + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + } + } + }, + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "requires": { + "estraverse": "^5.2.0" + }, + "dependencies": { + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + } + } + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true + }, + "estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" + }, + "event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "optional": true + }, + "eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", + "dev": true, + "optional": true, + "peer": true + }, + "execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + } + }, + "expo-server-sdk": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/expo-server-sdk/-/expo-server-sdk-6.1.0.tgz", + "integrity": "sha512-ISuax1AQ7cpM5RAqcu8gVcoLL0ZKskJ5OLoMWmdITBe9nYjTucjdGyBq817YkIvTcj1pAUwx+9toUT7l/V7thA==", + "requires": { + "promise-limit": "^2.7.0", + "promise-retry": "^2.0.1", + "undici": "^7.2.0" + }, + "dependencies": { + "undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==" + } + } + }, + "express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "requires": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "dependencies": { + "mime-db": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", + "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==" + }, + "mime-types": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz", + "integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==", + "requires": { + "mime-db": "^1.53.0" + } + } + } + }, + "express-rate-limit": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "requires": { + "ip-address": "10.1.0" + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "extract-files": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/extract-files/-/extract-files-13.0.0.tgz", + "integrity": "sha512-FXD+2Tsr8Iqtm3QZy1Zmwscca7Jx3mMC5Crr+sEP1I303Jy1CYMuYCm7hRTplFNg3XdUavErkxnTzpaqdSoi6g==", + "dev": true, + "requires": { + "is-plain-obj": "^4.1.0" + } + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==" + }, + "farmhash-modern": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", + "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==" + }, + "fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "dev": true + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + } + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "optional": true, + "requires": { + "path-expression-matcher": "^1.1.3" + } + }, + "fast-xml-parser": { + "version": "5.5.9", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz", + "integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==", + "optional": true, + "requires": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.2" + } + }, + "fastq": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.14.0.tgz", + "integrity": "sha512-eR2D+V9/ExcbF9ls441yIuN6TI2ED1Y2ZcA5BmMtJsOkWOFRJQ0Jt0g1UwqXJJVAb+V+umH5Dfr8oh4EVP7VVg==", + "dev": true, + "requires": { + "reusify": "^1.0.4" + } + }, + "faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "requires": { + "websocket-driver": ">=0.5.1" + } + }, + "fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "requires": { + "pend": "~1.2.0" + } + }, + "fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" + }, + "fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "requires": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + } + }, + "fetch-node-website": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/fetch-node-website/-/fetch-node-website-9.0.1.tgz", + "integrity": "sha512-htQY+YRRFdMAxmQG8EpnVy32lQyXBjgFAvyfaaq7VCn53Py1gorggPMYAt1Zmp0AlNS1X/YnGt641RAkUbsETw==", + "dev": true, + "requires": { + "cli-progress": "^3.12.0", + "colors-option": "^6.0.1", + "figures": "^6.0.1", + "got": "^13.0.0", + "is-plain-obj": "^4.1.0" + } + }, + "figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "requires": { + "is-unicode-supported": "^2.0.0" + }, + "dependencies": { + "is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true + } + } + }, + "file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "requires": { + "flat-cache": "^4.0.0" + } + }, + "file-stream-rotator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz", + "integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==", + "requires": { + "moment": "^2.29.1" + } + }, + "file-type": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", + "integrity": "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==", + "dev": true + }, + "filing-cabinet": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/filing-cabinet/-/filing-cabinet-5.0.2.tgz", + "integrity": "sha512-RZlFj8lzyu6jqtFBeXNqUjjNG6xm+gwXue3T70pRxw1W40kJwlgq0PSWAmh0nAnn5DHuBIecLXk9+1VKS9ICXA==", + "dev": true, + "requires": { + "app-module-path": "^2.2.0", + "commander": "^12.0.0", + "enhanced-resolve": "^5.16.0", + "module-definition": "^6.0.0", + "module-lookup-amd": "^9.0.1", + "resolve": "^1.22.8", + "resolve-dependency-path": "^4.0.0", + "sass-lookup": "^6.0.1", + "stylus-lookup": "^6.0.0", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.4.4" + }, + "dependencies": { + "commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true + } + } + }, + "fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "filter-obj": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-6.1.0.tgz", + "integrity": "sha512-xdMtCAODmPloU9qtmPcdBV9Kd27NtMse+4ayThxqIHUES5Z2S6bGpap5PpdmNM56ub7y3i1eyr+vJJIIgWGKmA==", + "dev": true + }, + "finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "requires": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + } + }, + "find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "dev": true + }, + "find-versions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-6.0.0.tgz", + "integrity": "sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==", + "dev": true, + "requires": { + "semver-regex": "^4.0.5", + "super-regex": "^1.0.0" + } + }, + "firebase-admin": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.7.0.tgz", + "integrity": "sha512-o3qS8zCJbApe7aKzkO2Pa380t9cHISqeSd3blqYTtOuUUUua3qZTLwNWgGUOss3td6wbzrZhiHIj3c8+fC046Q==", + "requires": { + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "^2.0.0", + "@firebase/database-types": "^1.0.6", + "@google-cloud/firestore": "^7.11.0", + "@google-cloud/storage": "^7.19.0", + "farmhash-modern": "^1.1.0", + "fast-deep-equal": "^3.1.1", + "google-auth-library": "^10.6.1", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.1.0", + "node-forge": "^1.3.1", + "uuid": "^11.0.2" + }, + "dependencies": { + "uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==" + } + } + }, + "flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "requires": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + } + }, + "flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true + }, + "fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" + }, + "follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==" + }, + "for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "requires": { + "is-callable": "^1.2.7" + } + }, + "foreground-child": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", + "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.0", + "signal-exit": "^3.0.2" + } + }, + "form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + } + }, + "form-data-encoder": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", + "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==", + "dev": true + }, + "formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "requires": { + "fetch-blob": "^3.1.2" + } + }, + "forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" + }, + "fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==" + }, + "from2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", + "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.0" + } + }, + "fromentries": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz", + "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==", + "dev": true + }, + "fs-capacitor": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/fs-capacitor/-/fs-capacitor-6.2.0.tgz", + "integrity": "sha512-nKcE1UduoSKX27NSZlg879LdQc94OtbOsEmKMN2MBNudXREvijRKx2GEBsTMTfws+BrbkJoEuynbGSVRSpauvw==" + }, + "fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true + }, + "fs-extra": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + } + }, + "fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "requires": { + "minipass": "^3.0.0" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + } + } + }, + "fs-readdir-recursive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz", + "integrity": "sha512-GNanXlVr2pf02+sPN40XN8HG+ePaNcvM0q5mZBd668Obwb0yD5GiUbZOFgwn8kGMY6I3mdyDJzieUy3PTYyTRA==", + "dev": true + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, + "function-timeout": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-1.0.2.tgz", + "integrity": "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==", + "dev": true + }, + "functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "optional": true + }, + "gauge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-5.0.1.tgz", + "integrity": "sha512-CmykPMJGuNan/3S4kZOpvvPYSNqSHANiWnh9XcMU2pSjtBfF0XzZ2p1bFAxTbnFxyBuPxQYHhzwaoOmUdqzvxQ==", + "requires": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^4.0.1", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "dependencies": { + "signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==" + } + } + }, + "gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "optional": true, + "requires": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "dependencies": { + "node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "optional": true, + "requires": { + "whatwg-url": "^5.0.0" + } + }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "optional": true + }, + "uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "optional": true + }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "optional": true + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "optional": true, + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } + }, + "gcp-metadata": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-7.0.1.tgz", + "integrity": "sha512-UcO3kefx6dCcZkgcTGgVOTFb7b1LlQ02hY1omMjjrrBzkajRMCFgYOjs7J71WqnuG1k2b+9ppGL7FsOfhZMQKQ==", + "optional": true, + "peer": true, + "requires": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "optional": true, + "peer": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "optional": true, + "peer": true, + "requires": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + } + }, + "gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "optional": true, + "peer": true, + "requires": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + } + }, + "glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "optional": true, + "peer": true, + "requires": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + } + }, + "minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "optional": true, + "peer": true, + "requires": { + "brace-expansion": "^2.0.1" + } + }, + "minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "optional": true, + "peer": true + }, + "rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "optional": true, + "peer": true, + "requires": { + "glob": "^10.3.7" + } + }, + "signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "optional": true, + "peer": true + } + } + }, + "gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true + }, + "get-amd-module-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/get-amd-module-type/-/get-amd-module-type-6.0.0.tgz", + "integrity": "sha512-hFM7oivtlgJ3d6XWD6G47l8Wyh/C6vFw5G24Kk1Tbq85yh5gcM8Fne5/lFhiuxB+RT6+SI7I1ThB9lG4FBh3jw==", + "dev": true, + "requires": { + "ast-module-types": "^6.0.0", + "node-source-walk": "^7.0.0" + } + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "devOptional": true + }, + "get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "dev": true + }, + "get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + } + }, + "get-own-enumerable-property-symbols": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", + "dev": true + }, + "get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true + }, + "get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "requires": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + } + }, + "get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true + }, + "get-tsconfig": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.0.tgz", + "integrity": "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==", + "dev": true, + "requires": { + "resolve-pkg-maps": "^1.0.0" + } + }, + "git-log-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/git-log-parser/-/git-log-parser-1.2.0.tgz", + "integrity": "sha512-rnCVNfkTL8tdNryFuaY0fYiBWEBcgF748O6ZI61rslBvr2o7U65c2/6npCRqH40vuAhtgtDiqLTJjBVdrejCzA==", + "dev": true, + "requires": { + "argv-formatter": "~1.0.0", + "spawn-error-forwarder": "~1.0.0", + "split2": "~1.0.0", + "stream-combiner2": "~1.1.1", + "through2": "~2.0.0", + "traverse": "~0.6.6" + }, + "dependencies": { + "split2": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-1.0.0.tgz", + "integrity": "sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg==", + "dev": true, + "requires": { + "through2": "~2.0.0" + } + }, + "through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "requires": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + } + } + }, + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "global-cache-dir": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/global-cache-dir/-/global-cache-dir-6.0.1.tgz", + "integrity": "sha512-HOOgvCW8le14HM0sTTvyYkTMRot7hq5ERIzNTUcDyZ4Vr9qF/IHUZeIcz4+v6vpwTFMqZ8QHKJYpXYRy/DSb6A==", + "dev": true, + "requires": { + "cachedir": "^2.4.0", + "path-exists": "^5.0.0" + }, + "dependencies": { + "path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true + } + } + }, + "globals": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz", + "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==", + "dev": true + }, + "globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "requires": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "dependencies": { + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + } + } + }, + "gonzales-pe": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/gonzales-pe/-/gonzales-pe-4.3.0.tgz", + "integrity": "sha512-otgSPpUmdWJ43VXyiNgEYE4luzHCL2pz4wQ0OnDluC6Eg4Ko3Vexy/SrSynglw/eR+OhkzmqFCZa/OFa/RgAOQ==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + }, + "google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "requires": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "dependencies": { + "gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "requires": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + } + }, + "gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "requires": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + } + } + } + }, + "google-gax": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", + "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", + "optional": true, + "requires": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "dependencies": { + "gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "optional": true, + "requires": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + } + }, + "google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "optional": true, + "requires": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + } + }, + "google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "optional": true + }, + "node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "optional": true, + "requires": { + "whatwg-url": "^5.0.0" + } + }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "optional": true + }, + "uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "optional": true + }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "optional": true + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "optional": true, + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } + }, + "google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==" + }, + "gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, + "got": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/got/-/got-13.0.0.tgz", + "integrity": "sha512-XfBk1CxOOScDcMr9O1yKkNaQyy865NbYs+F7dr4H0LZMVgCj2Le59k6PqbNHoL5ToeaEQUYh6c6yMfVcc6SJxA==", + "dev": true, + "requires": { + "@sindresorhus/is": "^5.2.0", + "@szmarczak/http-timer": "^5.0.1", + "cacheable-lookup": "^7.0.0", + "cacheable-request": "^10.2.8", + "decompress-response": "^6.0.0", + "form-data-encoder": "^2.1.2", + "get-stream": "^6.0.1", + "http2-wrapper": "^2.1.10", + "lowercase-keys": "^3.0.0", + "p-cancelable": "^3.0.0", + "responselike": "^3.0.0" + } + }, + "graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "dev": true + }, + "graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==" + }, + "graphql-list-fields": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/graphql-list-fields/-/graphql-list-fields-2.0.4.tgz", + "integrity": "sha512-q3prnhAL/dBsD+vaGr83B8DzkBijg+Yh+lbt7qp2dW1fpuO+q/upzDXvFJstVsSAA8m11MHGkSxxyxXeLou4MA==" + }, + "graphql-relay": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/graphql-relay/-/graphql-relay-0.10.2.tgz", + "integrity": "sha512-abybva1hmlNt7Y9pMpAzHuFnM2Mme/a2Usd8S4X27fNteLGRAECMYfhmsrpZFvGn3BhmBZugMXYW/Mesv3P1Kw==", + "requires": {} + }, + "graphql-tag": { + "version": "2.12.6", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", + "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==", + "dev": true, + "requires": { + "tslib": "^2.1.0" + } + }, + "graphql-upload": { + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/graphql-upload/-/graphql-upload-15.0.2.tgz", + "integrity": "sha512-ufJAkZJBKWRDD/4wJR3VZMy9QWTwqIYIciPtCEF5fCNgWF+V1p7uIgz+bP2YYLiS4OJBhCKR8rnqE/Wg3XPUiw==", + "requires": { + "@types/busboy": "^1.5.0", + "@types/node": "*", + "@types/object-path": "^0.11.1", + "busboy": "^1.6.0", + "fs-capacitor": "^6.2.0", + "http-errors": "^2.0.0", + "object-path": "^0.11.8" + } + }, + "gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "optional": true, + "requires": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + } + }, + "handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "requires": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "uglify-js": "^3.1.4", + "wordwrap": "^1.0.0" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true + }, + "has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "requires": { + "es-define-property": "^1.0.0" + } + }, + "has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + }, + "has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "requires": { + "has-symbols": "^1.0.3" + } + }, + "has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" + }, + "hasha": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", + "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, + "requires": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + }, + "dependencies": { + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true + } + } + }, + "hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "requires": { + "function-bind": "^1.1.2" + } + }, + "highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true + }, + "hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dev": true, + "requires": { + "react-is": "^16.7.0" + } + }, + "hook-std": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hook-std/-/hook-std-3.0.0.tgz", + "integrity": "sha512-jHRQzjSDzMtFy34AGj1DN+vq54WVuhSvKgrHf0OMiFQTwDD4L/qqofVEWjLOBMTn5+lCD3fPg32W9yOfnEJTTw==", + "dev": true + }, + "hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "requires": { + "lru-cache": "^10.0.1" + }, + "dependencies": { + "lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + } + } + }, + "html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "optional": true + }, + "html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "html-minifier-terser": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz", + "integrity": "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==", + "dev": true, + "requires": { + "camel-case": "^4.1.2", + "clean-css": "~5.3.2", + "commander": "^10.0.0", + "entities": "^4.4.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.15.1" + }, + "dependencies": { + "commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true + } + } + }, + "http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==" + }, + "http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "dev": true + }, + "http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "requires": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + } + }, + "http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==" + }, + "http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "requires": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + } + }, + "http2-wrapper": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", + "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", + "dev": true, + "requires": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.0" + } + }, + "https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "requires": { + "agent-base": "^7.1.2", + "debug": "4" + } + }, + "human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true + }, + "iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + }, + "idb-keyval": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz", + "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==" + }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true + }, + "ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true + }, + "import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "import-from-esm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-1.3.4.tgz", + "integrity": "sha512-7EyUlPFC0HOlBDpUFGfYstsU7XHxZJKAAMzCT8wZ0hMW7b+hG51LIKTDcsgtz8Pu6YC0HqRVbX+rVUtsGMUKvg==", + "dev": true, + "requires": { + "debug": "^4.3.4", + "import-meta-resolve": "^4.0.0" + } + }, + "import-meta-resolve": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", + "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==", + "dev": true + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true + }, + "indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true + }, + "index-to-position": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-0.1.2.tgz", + "integrity": "sha512-MWDKS3AS1bGCHLBA2VLImJz42f7bJh8wQsTGCzI3j519/CASStoDONUBVz2I/VID0MpiX3SGSnbOD2xUalbE5g==", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true + }, + "intersect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/intersect/-/intersect-1.0.1.tgz", + "integrity": "sha512-qsc720yevCO+4NydrJWgEWKccAQwTOvj2m73O/VBA6iUL2HGZJ9XqBiyraNrBXX/W1IAjdpXdRZk24sq8TzBRg==" + }, + "into-stream": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-7.0.0.tgz", + "integrity": "sha512-2dYz766i9HprMBasCMvHMuazJ7u4WzhJwo5kb3iPSiW/iRYV6uPari3zHoqZlnuaR7V1bEiNMxikhp37rdBXbw==", + "dev": true, + "requires": { + "from2": "^2.3.0", + "p-is-promise": "^3.0.0" + } + }, + "ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==" + }, + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "optional": true, + "requires": { + "binary-extensions": "^2.0.0" + } + }, + "is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==" + }, + "is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "requires": { + "hasown": "^2.0.2" + } + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true + }, + "is-natural-number": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz", + "integrity": "sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ==", + "dev": true + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true + }, + "is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true + }, + "is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", + "dev": true + }, + "is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==" + }, + "is-text-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz", + "integrity": "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==", + "dev": true, + "requires": { + "text-extensions": "^2.0.0" + } + }, + "is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "requires": { + "which-typed-array": "^1.1.16" + } + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true + }, + "is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true + }, + "is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "dev": true + }, + "is-url-superb": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-url-superb/-/is-url-superb-4.0.0.tgz", + "integrity": "sha512-GI+WjezhPPcbM+tqE9LnmsY5qqjwHzTvjJ36wxYX5ujNXefSUJ/T17r5bqDV8yLhcgB59KTPNOc9O9cmHTPWsA==", + "dev": true + }, + "is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "devOptional": true + }, + "issue-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.1.tgz", + "integrity": "sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg==", + "dev": true, + "requires": { + "lodash.capitalize": "^4.2.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.uniqby": "^4.7.0" + } + }, + "istanbul-lib-coverage": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", + "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", + "dev": true + }, + "istanbul-lib-hook": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", + "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", + "dev": true, + "requires": { + "append-transform": "^2.0.0" + } + }, + "istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "requires": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + } + }, + "istanbul-lib-processinfo": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz", + "integrity": "sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==", + "dev": true, + "requires": { + "archy": "^1.0.0", + "cross-spawn": "^7.0.3", + "istanbul-lib-coverage": "^3.2.0", + "p-map": "^3.0.0", + "rimraf": "^3.0.0", + "uuid": "^8.3.2" + }, + "dependencies": { + "p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "requires": { + "aggregate-error": "^3.0.0" + } + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true + } + } + }, + "istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^3.0.0", + "supports-color": "^7.1.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "requires": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + } + }, + "istanbul-reports": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.5.tgz", + "integrity": "sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==", + "dev": true, + "requires": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + } + }, + "iterall": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/iterall/-/iterall-1.3.0.tgz", + "integrity": "sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg==", + "dev": true, + "optional": true, + "peer": true + }, + "jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "devOptional": true, + "requires": { + "@isaacs/cliui": "^8.0.2", + "@pkgjs/parseargs": "^0.11.0" + } + }, + "jasmine": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-6.1.0.tgz", + "integrity": "sha512-WPphPqEMY0uBRMjuhRHoVoxQNvJuxIMqz0yIcJ3k3oYxBedeGoH60/NXNgasxnx2FvfXrq5/r+2wssJ7WE8ABw==", + "dev": true, + "requires": { + "@jasminejs/reporters": "^1.0.0", + "glob": "^10.2.2 || ^11.0.3 || ^12.0.0 || ^13.0.0", + "jasmine-core": "~6.1.0" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + } + }, + "glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "requires": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + } + }, + "minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + }, + "minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true + }, + "signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true + } + } + }, + "jasmine-core": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-6.1.0.tgz", + "integrity": "sha512-p/tjBw58O6vxKIWMlrU+yys8lqR3+l3UrqwNTT7wpj+dQ7N4etQekFM8joI+cWzPDYqZf54kN+hLC1+s5TvZvg==", + "dev": true + }, + "jasmine-spec-reporter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/jasmine-spec-reporter/-/jasmine-spec-reporter-7.0.0.tgz", + "integrity": "sha512-OtC7JRasiTcjsaCBPtMO0Tl8glCejM4J4/dNuOJdA8lBjz4PmWjYQ6pzb0uzpBNAWJMDudYuj9OdXJWqM2QTJg==", + "dev": true, + "requires": { + "colors": "1.4.0" + } + }, + "java-properties": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz", + "integrity": "sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==", + "dev": true + }, + "jose": { + "version": "4.15.5", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", + "integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==" + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + }, + "js2xmlparser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-4.0.2.tgz", + "integrity": "sha512-6n4D8gLlLf1n5mNLQPRfViYzu9RATblzPEtm1SthMX1Pjao0r9YI9nw7ZIfRxQMERS87mcswrg+r/OYrPRX6jA==", + "dev": true, + "requires": { + "xmlcreate": "^2.0.4" + } + }, + "jsdoc": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.5.tgz", + "integrity": "sha512-P4C6MWP9yIlMiK8nwoZvxN84vb6MsnXcHuy7XzVOvQoCizWX5JFCBsWIIWKXBltpoRZXddUOVQmCTOZt9yDj9g==", + "dev": true, + "requires": { + "@babel/parser": "^7.20.15", + "@jsdoc/salty": "^0.2.1", + "@types/markdown-it": "^14.1.1", + "bluebird": "^3.7.2", + "catharsis": "^0.9.0", + "escape-string-regexp": "^2.0.0", + "js2xmlparser": "^4.0.2", + "klaw": "^3.0.0", + "markdown-it": "^14.1.0", + "markdown-it-anchor": "^8.6.7", + "marked": "^4.0.10", + "mkdirp": "^1.0.4", + "requizzle": "^0.2.3", + "strip-json-comments": "^3.1.0", + "underscore": "~1.13.2" + }, + "dependencies": { + "escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true + } + } + }, + "jsdoc-babel": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsdoc-babel/-/jsdoc-babel-0.5.0.tgz", + "integrity": "sha512-PYfTbc3LNTeR8TpZs2M94NLDWqARq0r9gx3SvuziJfmJS7/AeMKvtj0xjzOX0R/4MOVA7/FqQQK7d6U0iEoztQ==", + "dev": true, + "requires": { + "jsdoc-regex": "^1.0.1", + "lodash": "^4.17.10" + } + }, + "jsdoc-regex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/jsdoc-regex/-/jsdoc-regex-1.0.1.tgz", + "integrity": "sha512-CMFgT3K8GbmChWEfLWe6jlv9x33E8wLPzBjxIlh/eHLMcnDF+TF3CL265ZGBe029o1QdFepwVrQu0WuqqNPncg==", + "dev": true + }, + "jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true + }, + "json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "requires": { + "bignumber.js": "^9.0.0" + } + }, + "json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true + }, + "json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true + }, + "jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^2.0.0" + } + }, + "jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true + }, + "JSONStream": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "dev": true, + "requires": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + } + }, + "jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "requires": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + } + }, + "jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "requires": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jwks-rsa": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz", + "integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==", + "requires": { + "@types/express": "^4.17.20", + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + } + }, + "jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "requires": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "requires": { + "json-buffer": "3.0.1" + } + }, + "klaw": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", + "integrity": "sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.9" + } + }, + "klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.11" + } + }, + "kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" + }, + "ldapjs": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-3.0.7.tgz", + "integrity": "sha512-1ky+WrN+4CFMuoekUOv7Y1037XWdjKpu0xAPwSP+9KdvmV9PG+qOKlssDV6a+U32apwxdD3is/BZcWOYzN30cg==", + "requires": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/messages": "^1.3.0", + "@ldapjs/protocol": "^1.2.1", + "abstract-logging": "^2.0.1", + "assert-plus": "^1.0.0", + "backoff": "^2.5.0", + "once": "^1.4.0", + "vasync": "^2.2.1", + "verror": "^1.10.1" + } + }, + "levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + } + }, + "limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "requires": { + "uc.micro": "^2.0.0" + } + }, + "lint-staged": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", + "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", + "dev": true, + "requires": { + "commander": "^14.0.3", + "listr2": "^9.0.5", + "picomatch": "^4.0.3", + "string-argv": "^0.3.2", + "tinyexec": "^1.0.4", + "yaml": "^2.8.2" + }, + "dependencies": { + "picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true + } + } + }, + "listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "requires": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true + }, + "ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true + }, + "emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true + }, + "eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true + }, + "string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "requires": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + } + }, + "strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "requires": { + "ansi-regex": "^6.2.2" + } + }, + "wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "requires": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + } + } + } + }, + "load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "dependencies": { + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true + } + } + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" + }, + "lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true + }, + "lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "optional": true + }, + "lodash.capitalize": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz", + "integrity": "sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==", + "dev": true + }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, + "lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true + }, + "lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "dev": true + }, + "lodash.flattendeep": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", + "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", + "dev": true + }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, + "lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==" + }, + "lodash.uniqby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz", + "integrity": "sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==", + "dev": true + }, + "log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "requires": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "requires": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true + }, + "ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true + }, + "cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "requires": { + "restore-cursor": "^5.0.0" + } + }, + "emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "requires": { + "get-east-asian-width": "^1.3.1" + } + }, + "onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "requires": { + "mimic-function": "^5.0.0" + } + }, + "restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "requires": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + } + }, + "signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true + }, + "slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "requires": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + } + }, + "string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "requires": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + } + }, + "strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "requires": { + "ansi-regex": "^6.2.2" + } + }, + "wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "requires": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + } + } + } + }, + "logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "requires": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "dependencies": { + "@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==" + } + } + }, + "loglevel": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz", + "integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==" + }, + "long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "requires": { + "tslib": "^2.0.3" + } + }, + "lowercase-keys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", + "dev": true + }, + "lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==" + }, + "lru-memoizer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.2.0.tgz", + "integrity": "sha512-QfOZ6jNkxCcM/BkIPnFsqDhtrazLRsghi9mBwFAzol5GCvj4EkFT899Za3+QwikCg5sRX8JstioBDwOxEyzaNw==", + "requires": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "~4.0.0" + }, + "dependencies": { + "lru-cache": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.0.2.tgz", + "integrity": "sha512-uQw9OqphAGiZhkuPlpFGmdTU2tEuhxTourM/19qGJrxBPHAr/f8BT1a0i/lOclESnGatdJG/UCkP9kZB/Lh1iw==", + "requires": { + "pseudomap": "^1.0.1", + "yallist": "^2.0.0" + } + }, + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==" + } + } + }, + "m": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/m/-/m-1.10.0.tgz", + "integrity": "sha512-+vXing+uUyCeZZlY2RIteWHSPHgVcFyBoeWrBU5F3ibDt847sVPGHK41GriFP05uMvfHZkhlaAMYEHoQkfksvA==", + "dev": true + }, + "madge": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/madge/-/madge-8.0.0.tgz", + "integrity": "sha512-9sSsi3TBPhmkTCIpVQF0SPiChj1L7Rq9kU2KDG1o6v2XH9cCw086MopjVCD+vuoL5v8S77DTbVopTO8OUiQpIw==", + "dev": true, + "requires": { + "chalk": "^4.1.2", + "commander": "^7.2.0", + "commondir": "^1.0.1", + "debug": "^4.3.4", + "dependency-tree": "^11.0.0", + "ora": "^5.4.1", + "pluralize": "^8.0.0", + "pretty-ms": "^7.0.1", + "rc": "^1.2.8", + "stream-to-array": "^2.3.0", + "ts-graphviz": "^2.1.2", + "walkdir": "^0.4.1" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "magic-string": { + "version": "0.30.11", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", + "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", + "dev": true, + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "requires": { + "pify": "^4.0.1", + "semver": "^5.6.0" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "dev": true, + "requires": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + } + }, + "markdown-it-anchor": { + "version": "8.6.7", + "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.7.tgz", + "integrity": "sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==", + "dev": true, + "requires": {} + }, + "marked": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", + "dev": true + }, + "marked-terminal": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/marked-terminal/-/marked-terminal-7.3.0.tgz", + "integrity": "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==", + "dev": true, + "requires": { + "ansi-escapes": "^7.0.0", + "ansi-regex": "^6.1.0", + "chalk": "^5.4.1", + "cli-highlight": "^2.1.11", + "cli-table3": "^0.6.5", + "node-emoji": "^2.2.0", + "supports-hyperlinks": "^3.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true + }, + "chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "dev": true + } + } + }, + "math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, + "mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true + }, + "media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==" + }, + "memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" + }, + "meow": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "dev": true + }, + "merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==" + }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true + }, + "micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "requires": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + } + }, + "mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==" + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "devOptional": true + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "devOptional": true, + "requires": { + "mime-db": "1.52.0" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, + "mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true + }, + "mimic-response": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", + "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", + "dev": true + }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" + }, + "minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "devOptional": true + }, + "minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "requires": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + } + } + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true + }, + "mock-files-adapter": { + "version": "file:spec/dependencies/mock-files-adapter" + }, + "mock-mail-adapter": { + "version": "file:spec/dependencies/mock-mail-adapter" + }, + "module-definition": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/module-definition/-/module-definition-6.0.0.tgz", + "integrity": "sha512-sEGP5nKEXU7fGSZUML/coJbrO+yQtxcppDAYWRE9ovWsTbFoUHB2qDUx564WUzDaBHXsD46JBbIK5WVTwCyu3w==", + "dev": true, + "requires": { + "ast-module-types": "^6.0.0", + "node-source-walk": "^7.0.0" + } + }, + "module-lookup-amd": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/module-lookup-amd/-/module-lookup-amd-9.0.2.tgz", + "integrity": "sha512-p7PzSVEWiW9fHRX9oM+V4aV5B2nCVddVNv4DZ/JB6t9GsXY4E+ZVhPpnwUX7bbJyGeeVZqhS8q/JZ/H77IqPFA==", + "dev": true, + "requires": { + "commander": "^12.1.0", + "glob": "^7.2.3", + "requirejs": "^2.3.7", + "requirejs-config-file": "^4.0.0" + }, + "dependencies": { + "commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true + } + } + }, + "moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==" + }, + "mongodb": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.0.tgz", + "integrity": "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg==", + "requires": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^7.1.1", + "mongodb-connection-string-url": "^7.0.0" + }, + "dependencies": { + "@types/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "requires": { + "@types/webidl-conversions": "*" + } + }, + "mongodb-connection-string-url": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", + "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", + "requires": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + } + } + } + }, + "mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "dev": true, + "requires": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "mongodb-download-url": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/mongodb-download-url/-/mongodb-download-url-1.6.2.tgz", + "integrity": "sha512-89g7A+ktFQ6L3fcjV1ClCj5ftlMSuVy22q76N6vhuzxBdYcD2O0Wxt+i16SQ7BAD1QtOPsGpSQVL4bUtLvY6+w==", + "dev": true, + "requires": { + "debug": "^4.4.0", + "minimist": "^1.2.8", + "node-fetch": "^2.7.0", + "semver": "^7.7.1" + }, + "dependencies": { + "node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "requires": { + "whatwg-url": "^5.0.0" + } + }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true + }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } + }, + "mongodb-runner": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/mongodb-runner/-/mongodb-runner-5.9.3.tgz", + "integrity": "sha512-2n2fCyUITi0UrAs0eg/zLSehSVOoWWUsgJleEBh6p1otHaiqMSAMURS6W7PLJvvGxFlnO3tjiDB6T11gjqAkUQ==", + "dev": true, + "requires": { + "@mongodb-js/mongodb-downloader": "^0.4.2", + "@mongodb-js/saslprep": "^1.3.0", + "debug": "^4.4.0", + "mongodb": "^6.9.0", + "mongodb-connection-string-url": "^3.0.0", + "yargs": "^17.7.2" + }, + "dependencies": { + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "debug": "4" + } + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "dev": true + }, + "cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "gaxios": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", + "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9" + } + }, + "gcp-metadata": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", + "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "gaxios": "^5.0.0", + "json-bigint": "^1.0.0" + } + }, + "https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "agent-base": "6", + "debug": "4" + } + }, + "mongodb": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz", + "integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==", + "dev": true, + "requires": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" + } + }, + "node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "whatwg-url": "^5.0.0" + } + }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "optional": true, + "peer": true + }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "optional": true, + "peer": true + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true + }, + "yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "requires": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + } + } + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==" + }, + "mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "requires": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==" + }, + "neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "nerf-dart": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/nerf-dart/-/nerf-dart-1.0.0.tgz", + "integrity": "sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==", + "dev": true + }, + "no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "requires": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "dev": true + }, + "node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==" + }, + "node-emoji": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", + "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", + "dev": true, + "requires": { + "@sindresorhus/is": "^4.6.0", + "char-regex": "^1.0.2", + "emojilib": "^2.4.0", + "skin-tone": "^2.0.0" + }, + "dependencies": { + "@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true + } + } + }, + "node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "requires": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + } + }, + "node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==" + }, + "node-preload": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", + "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", + "dev": true, + "requires": { + "process-on-spawn": "^1.0.0" + } + }, + "node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true + }, + "node-source-walk": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/node-source-walk/-/node-source-walk-7.0.0.tgz", + "integrity": "sha512-1uiY543L+N7Og4yswvlm5NCKgPKDEXd9AUR9Jh3gen6oOeBsesr6LqhXom1er3eRzSUcVRWXzhv8tSNrIfGHKw==", + "dev": true, + "requires": { + "@babel/parser": "^7.24.4" + } + }, + "normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "requires": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + } + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "optional": true + }, + "normalize-url": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.1.tgz", + "integrity": "sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==", + "dev": true + }, + "npm": { + "version": "10.8.1", + "resolved": "https://registry.npmjs.org/npm/-/npm-10.8.1.tgz", + "integrity": "sha512-Dp1C6SvSMYQI7YHq/y2l94uvI+59Eqbu1EpuKQHQ8p16txXRuRit5gH3Lnaagk2aXDIjg/Iru9pd05bnneKgdw==", + "dev": true, + "requires": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/arborist": "^7.5.3", + "@npmcli/config": "^8.3.3", + "@npmcli/fs": "^3.1.1", + "@npmcli/map-workspaces": "^3.0.6", + "@npmcli/package-json": "^5.1.1", + "@npmcli/promise-spawn": "^7.0.2", + "@npmcli/redact": "^2.0.0", + "@npmcli/run-script": "^8.1.0", + "@sigstore/tuf": "^2.3.4", + "abbrev": "^2.0.0", + "archy": "~1.0.0", + "cacache": "^18.0.3", + "chalk": "^5.3.0", + "ci-info": "^4.0.0", + "cli-columns": "^4.0.0", + "fastest-levenshtein": "^1.0.16", + "fs-minipass": "^3.0.3", + "glob": "^10.4.1", + "graceful-fs": "^4.2.11", + "hosted-git-info": "^7.0.2", + "ini": "^4.1.3", + "init-package-json": "^6.0.3", + "is-cidr": "^5.1.0", + "json-parse-even-better-errors": "^3.0.2", + "libnpmaccess": "^8.0.6", + "libnpmdiff": "^6.1.3", + "libnpmexec": "^8.1.2", + "libnpmfund": "^5.0.11", + "libnpmhook": "^10.0.5", + "libnpmorg": "^6.0.6", + "libnpmpack": "^7.0.3", + "libnpmpublish": "^9.0.9", + "libnpmsearch": "^7.0.6", + "libnpmteam": "^6.0.5", + "libnpmversion": "^6.0.3", + "make-fetch-happen": "^13.0.1", + "minimatch": "^9.0.4", + "minipass": "^7.1.1", + "minipass-pipeline": "^1.2.4", + "ms": "^2.1.2", + "node-gyp": "^10.1.0", + "nopt": "^7.2.1", + "normalize-package-data": "^6.0.1", + "npm-audit-report": "^5.0.0", + "npm-install-checks": "^6.3.0", + "npm-package-arg": "^11.0.2", + "npm-pick-manifest": "^9.0.1", + "npm-profile": "^10.0.0", + "npm-registry-fetch": "^17.0.1", + "npm-user-validate": "^2.0.1", + "p-map": "^4.0.0", + "pacote": "^18.0.6", + "parse-conflict-json": "^3.0.1", + "proc-log": "^4.2.0", + "qrcode-terminal": "^0.12.0", + "read": "^3.0.1", + "semver": "^7.6.2", + "spdx-expression-parse": "^4.0.0", + "ssri": "^10.0.6", + "supports-color": "^9.4.0", + "tar": "^6.2.1", + "text-table": "~0.2.0", + "tiny-relative-date": "^1.3.0", + "treeverse": "^3.0.0", + "validate-npm-package-name": "^5.0.1", + "which": "^4.0.0", + "write-file-atomic": "^5.0.1" + }, + "dependencies": { + "@isaacs/cliui": { + "version": "8.0.2", + "bundled": true, + "dev": true, + "requires": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "6.0.1", + "bundled": true, + "dev": true + }, + "emoji-regex": { + "version": "9.2.2", + "bundled": true, + "dev": true + }, + "string-width": { + "version": "5.1.2", + "bundled": true, + "dev": true, + "requires": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + } + }, + "strip-ansi": { + "version": "7.1.0", + "bundled": true, + "dev": true, + "requires": { + "ansi-regex": "^6.0.1" + } + } + } + }, + "@isaacs/string-locale-compare": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "@npmcli/agent": { + "version": "2.2.2", + "bundled": true, + "dev": true, + "requires": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + } + }, + "@npmcli/arborist": { + "version": "7.5.3", + "bundled": true, + "dev": true, + "requires": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^3.1.1", + "@npmcli/installed-package-contents": "^2.1.0", + "@npmcli/map-workspaces": "^3.0.2", + "@npmcli/metavuln-calculator": "^7.1.1", + "@npmcli/name-from-folder": "^2.0.0", + "@npmcli/node-gyp": "^3.0.0", + "@npmcli/package-json": "^5.1.0", + "@npmcli/query": "^3.1.0", + "@npmcli/redact": "^2.0.0", + "@npmcli/run-script": "^8.1.0", + "bin-links": "^4.0.4", + "cacache": "^18.0.3", + "common-ancestor-path": "^1.0.1", + "hosted-git-info": "^7.0.2", + "json-parse-even-better-errors": "^3.0.2", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^10.2.2", + "minimatch": "^9.0.4", + "nopt": "^7.2.1", + "npm-install-checks": "^6.2.0", + "npm-package-arg": "^11.0.2", + "npm-pick-manifest": "^9.0.1", + "npm-registry-fetch": "^17.0.1", + "pacote": "^18.0.6", + "parse-conflict-json": "^3.0.0", + "proc-log": "^4.2.0", + "proggy": "^2.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "read-package-json-fast": "^3.0.2", + "semver": "^7.3.7", + "ssri": "^10.0.6", + "treeverse": "^3.0.0", + "walk-up-path": "^3.0.1" + } + }, + "@npmcli/config": { + "version": "8.3.3", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/map-workspaces": "^3.0.2", + "ci-info": "^4.0.0", + "ini": "^4.1.2", + "nopt": "^7.2.1", + "proc-log": "^4.2.0", + "read-package-json-fast": "^3.0.2", + "semver": "^7.3.5", + "walk-up-path": "^3.0.1" + } + }, + "@npmcli/fs": { + "version": "3.1.1", + "bundled": true, + "dev": true, + "requires": { + "semver": "^7.3.5" + } + }, + "@npmcli/git": { + "version": "5.0.7", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/promise-spawn": "^7.0.0", + "lru-cache": "^10.0.1", + "npm-pick-manifest": "^9.0.0", + "proc-log": "^4.0.0", + "promise-inflight": "^1.0.1", + "promise-retry": "^2.0.1", + "semver": "^7.3.5", + "which": "^4.0.0" + } + }, + "@npmcli/installed-package-contents": { + "version": "2.1.0", + "bundled": true, + "dev": true, + "requires": { + "npm-bundled": "^3.0.0", + "npm-normalize-package-bin": "^3.0.0" + } + }, + "@npmcli/map-workspaces": { + "version": "3.0.6", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/name-from-folder": "^2.0.0", + "glob": "^10.2.2", + "minimatch": "^9.0.0", + "read-package-json-fast": "^3.0.0" + } + }, + "@npmcli/metavuln-calculator": { + "version": "7.1.1", + "bundled": true, + "dev": true, + "requires": { + "cacache": "^18.0.0", + "json-parse-even-better-errors": "^3.0.0", + "pacote": "^18.0.0", + "proc-log": "^4.1.0", + "semver": "^7.3.5" + } + }, + "@npmcli/name-from-folder": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "@npmcli/node-gyp": { + "version": "3.0.0", + "bundled": true, + "dev": true + }, + "@npmcli/package-json": { + "version": "5.1.1", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/git": "^5.0.0", + "glob": "^10.2.2", + "hosted-git-info": "^7.0.0", + "json-parse-even-better-errors": "^3.0.0", + "normalize-package-data": "^6.0.0", + "proc-log": "^4.0.0", + "semver": "^7.5.3" + } + }, + "@npmcli/promise-spawn": { + "version": "7.0.2", + "bundled": true, + "dev": true, + "requires": { + "which": "^4.0.0" + } + }, + "@npmcli/query": { + "version": "3.1.0", + "bundled": true, + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.10" + } + }, + "@npmcli/redact": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "@npmcli/run-script": { + "version": "8.1.0", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/node-gyp": "^3.0.0", + "@npmcli/package-json": "^5.0.0", + "@npmcli/promise-spawn": "^7.0.0", + "node-gyp": "^10.0.0", + "proc-log": "^4.0.0", + "which": "^4.0.0" + } + }, + "@pkgjs/parseargs": { + "version": "0.11.0", + "bundled": true, + "dev": true, + "optional": true + }, + "@sigstore/bundle": { + "version": "2.3.2", + "bundled": true, + "dev": true, + "requires": { + "@sigstore/protobuf-specs": "^0.3.2" + } + }, + "@sigstore/core": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "@sigstore/protobuf-specs": { + "version": "0.3.2", + "bundled": true, + "dev": true + }, + "@sigstore/sign": { + "version": "2.3.2", + "bundled": true, + "dev": true, + "requires": { + "@sigstore/bundle": "^2.3.2", + "@sigstore/core": "^1.0.0", + "@sigstore/protobuf-specs": "^0.3.2", + "make-fetch-happen": "^13.0.1", + "proc-log": "^4.2.0", + "promise-retry": "^2.0.1" + } + }, + "@sigstore/tuf": { + "version": "2.3.4", + "bundled": true, + "dev": true, + "requires": { + "@sigstore/protobuf-specs": "^0.3.2", + "tuf-js": "^2.2.1" + } + }, + "@sigstore/verify": { + "version": "1.2.1", + "bundled": true, + "dev": true, + "requires": { + "@sigstore/bundle": "^2.3.2", + "@sigstore/core": "^1.1.0", + "@sigstore/protobuf-specs": "^0.3.2" + } + }, + "@tufjs/canonical-json": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "@tufjs/models": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "requires": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^9.0.4" + } + }, + "abbrev": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "agent-base": { + "version": "7.1.1", + "bundled": true, + "dev": true, + "requires": { + "debug": "^4.3.4" + } + }, + "aggregate-error": { + "version": "3.1.0", + "bundled": true, + "dev": true, + "requires": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + } + }, + "ansi-regex": { + "version": "5.0.1", + "bundled": true, + "dev": true + }, + "ansi-styles": { + "version": "6.2.1", + "bundled": true, + "dev": true + }, + "aproba": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "archy": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "balanced-match": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "bin-links": { + "version": "4.0.4", + "bundled": true, + "dev": true, + "requires": { + "cmd-shim": "^6.0.0", + "npm-normalize-package-bin": "^3.0.0", + "read-cmd-shim": "^4.0.0", + "write-file-atomic": "^5.0.0" + } + }, + "binary-extensions": { + "version": "2.3.0", + "bundled": true, + "dev": true + }, + "brace-expansion": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "cacache": { + "version": "18.0.3", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/fs": "^3.1.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^4.0.0", + "ssri": "^10.0.0", + "tar": "^6.1.11", + "unique-filename": "^3.0.0" + } + }, + "chalk": { + "version": "5.3.0", + "bundled": true, + "dev": true + }, + "chownr": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "ci-info": { + "version": "4.0.0", + "bundled": true, + "dev": true + }, + "cidr-regex": { + "version": "4.1.1", + "bundled": true, + "dev": true, + "requires": { + "ip-regex": "^5.0.0" + } + }, + "clean-stack": { + "version": "2.2.0", + "bundled": true, + "dev": true + }, + "cli-columns": { + "version": "4.0.0", + "bundled": true, + "dev": true, + "requires": { + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + } + }, + "cmd-shim": { + "version": "6.0.3", + "bundled": true, + "dev": true + }, + "color-convert": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "bundled": true, + "dev": true + }, + "common-ancestor-path": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "cross-spawn": { + "version": "7.0.3", + "bundled": true, + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "dependencies": { + "which": { + "version": "2.0.2", + "bundled": true, + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, + "cssesc": { + "version": "3.0.0", + "bundled": true, + "dev": true + }, + "debug": { + "version": "4.3.4", + "bundled": true, + "dev": true, + "requires": { + "ms": "2.1.2" + }, + "dependencies": { + "ms": { + "version": "2.1.2", + "bundled": true, + "dev": true + } + } + }, + "diff": { + "version": "5.2.0", + "bundled": true, + "dev": true + }, + "eastasianwidth": { + "version": "0.2.0", + "bundled": true, + "dev": true + }, + "emoji-regex": { + "version": "8.0.0", + "bundled": true, + "dev": true + }, + "encoding": { + "version": "0.1.13", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "iconv-lite": "^0.6.2" + } + }, + "env-paths": { + "version": "2.2.1", + "bundled": true, + "dev": true + }, + "err-code": { + "version": "2.0.3", + "bundled": true, + "dev": true + }, + "exponential-backoff": { + "version": "3.1.1", + "bundled": true, + "dev": true + }, + "fastest-levenshtein": { + "version": "1.0.16", + "bundled": true, + "dev": true + }, + "foreground-child": { + "version": "3.1.1", + "bundled": true, + "dev": true, + "requires": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + } + }, + "fs-minipass": { + "version": "3.0.3", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^7.0.3" + } + }, + "function-bind": { + "version": "1.1.2", + "bundled": true, + "dev": true + }, + "glob": { + "version": "10.4.1", + "bundled": true, + "dev": true, + "requires": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "path-scurry": "^1.11.1" + } + }, + "graceful-fs": { + "version": "4.2.11", + "bundled": true, + "dev": true + }, + "hasown": { + "version": "2.0.2", + "bundled": true, + "dev": true, + "requires": { + "function-bind": "^1.1.2" + } + }, + "hosted-git-info": { + "version": "7.0.2", + "bundled": true, + "dev": true, + "requires": { + "lru-cache": "^10.0.1" + } + }, + "http-cache-semantics": { + "version": "4.1.1", + "bundled": true, + "dev": true + }, + "http-proxy-agent": { + "version": "7.0.2", + "bundled": true, + "dev": true, + "requires": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + } + }, + "https-proxy-agent": { + "version": "7.0.4", + "bundled": true, + "dev": true, + "requires": { + "agent-base": "^7.0.2", + "debug": "4" + } + }, + "iconv-lite": { + "version": "0.6.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + }, + "ignore-walk": { + "version": "6.0.5", + "bundled": true, + "dev": true, + "requires": { + "minimatch": "^9.0.0" + } + }, + "imurmurhash": { + "version": "0.1.4", + "bundled": true, + "dev": true + }, + "indent-string": { + "version": "4.0.0", + "bundled": true, + "dev": true + }, + "ini": { + "version": "4.1.3", + "bundled": true, + "dev": true + }, + "init-package-json": { + "version": "6.0.3", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/package-json": "^5.0.0", + "npm-package-arg": "^11.0.0", + "promzard": "^1.0.0", + "read": "^3.0.1", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4", + "validate-npm-package-name": "^5.0.0" + } + }, + "ip-address": { + "version": "9.0.5", + "bundled": true, + "dev": true, + "requires": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + } + }, + "ip-regex": { + "version": "5.0.0", + "bundled": true, + "dev": true + }, + "is-cidr": { + "version": "5.1.0", + "bundled": true, + "dev": true, + "requires": { + "cidr-regex": "^4.1.1" + } + }, + "is-core-module": { + "version": "2.13.1", + "bundled": true, + "dev": true, + "requires": { + "hasown": "^2.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "bundled": true, + "dev": true + }, + "is-lambda": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "isexe": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "jackspeak": { + "version": "3.1.2", + "bundled": true, + "dev": true, + "requires": { + "@isaacs/cliui": "^8.0.2", + "@pkgjs/parseargs": "^0.11.0" + } + }, + "jsbn": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "json-parse-even-better-errors": { + "version": "3.0.2", + "bundled": true, + "dev": true + }, + "json-stringify-nice": { + "version": "1.1.4", + "bundled": true, + "dev": true + }, + "jsonparse": { + "version": "1.3.1", + "bundled": true, + "dev": true + }, + "just-diff": { + "version": "6.0.2", + "bundled": true, + "dev": true + }, + "just-diff-apply": { + "version": "5.5.0", + "bundled": true, + "dev": true + }, + "libnpmaccess": { + "version": "8.0.6", + "bundled": true, + "dev": true, + "requires": { + "npm-package-arg": "^11.0.2", + "npm-registry-fetch": "^17.0.1" + } + }, + "libnpmdiff": { + "version": "6.1.3", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/arborist": "^7.5.3", + "@npmcli/installed-package-contents": "^2.1.0", + "binary-extensions": "^2.3.0", + "diff": "^5.1.0", + "minimatch": "^9.0.4", + "npm-package-arg": "^11.0.2", + "pacote": "^18.0.6", + "tar": "^6.2.1" + } + }, + "libnpmexec": { + "version": "8.1.2", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/arborist": "^7.5.3", + "@npmcli/run-script": "^8.1.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^11.0.2", + "pacote": "^18.0.6", + "proc-log": "^4.2.0", + "read": "^3.0.1", + "read-package-json-fast": "^3.0.2", + "semver": "^7.3.7", + "walk-up-path": "^3.0.1" + } + }, + "libnpmfund": { + "version": "5.0.11", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/arborist": "^7.5.3" + } + }, + "libnpmhook": { + "version": "10.0.5", + "bundled": true, + "dev": true, + "requires": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^17.0.1" + } + }, + "libnpmorg": { + "version": "6.0.6", + "bundled": true, + "dev": true, + "requires": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^17.0.1" + } + }, + "libnpmpack": { + "version": "7.0.3", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/arborist": "^7.5.3", + "@npmcli/run-script": "^8.1.0", + "npm-package-arg": "^11.0.2", + "pacote": "^18.0.6" + } + }, + "libnpmpublish": { + "version": "9.0.9", + "bundled": true, + "dev": true, + "requires": { + "ci-info": "^4.0.0", + "normalize-package-data": "^6.0.1", + "npm-package-arg": "^11.0.2", + "npm-registry-fetch": "^17.0.1", + "proc-log": "^4.2.0", + "semver": "^7.3.7", + "sigstore": "^2.2.0", + "ssri": "^10.0.6" + } + }, + "libnpmsearch": { + "version": "7.0.6", + "bundled": true, + "dev": true, + "requires": { + "npm-registry-fetch": "^17.0.1" + } + }, + "libnpmteam": { + "version": "6.0.5", + "bundled": true, + "dev": true, + "requires": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^17.0.1" + } + }, + "libnpmversion": { + "version": "6.0.3", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/git": "^5.0.7", + "@npmcli/run-script": "^8.1.0", + "json-parse-even-better-errors": "^3.0.2", + "proc-log": "^4.2.0", + "semver": "^7.3.7" + } + }, + "lru-cache": { + "version": "10.2.2", + "bundled": true, + "dev": true + }, + "make-fetch-happen": { + "version": "13.0.1", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/agent": "^2.0.0", + "cacache": "^18.0.0", + "http-cache-semantics": "^4.1.1", + "is-lambda": "^1.0.1", + "minipass": "^7.0.2", + "minipass-fetch": "^3.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "proc-log": "^4.2.0", + "promise-retry": "^2.0.1", + "ssri": "^10.0.0" + } + }, + "minimatch": { + "version": "9.0.4", + "bundled": true, + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + }, + "minipass": { + "version": "7.1.2", + "bundled": true, + "dev": true + }, + "minipass-collect": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^7.0.3" + } + }, + "minipass-fetch": { + "version": "3.0.5", + "bundled": true, + "dev": true, + "requires": { + "encoding": "^0.1.13", + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + } + }, + "minipass-flush": { + "version": "1.0.5", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^3.0.0" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "bundled": true, + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + } + } + }, + "minipass-json-stream": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "requires": { + "jsonparse": "^1.3.1", + "minipass": "^3.0.0" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "bundled": true, + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + } + } + }, + "minipass-pipeline": { + "version": "1.2.4", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^3.0.0" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "bundled": true, + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + } + } + }, + "minipass-sized": { + "version": "1.0.3", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^3.0.0" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "bundled": true, + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + } + } + }, + "minizlib": { + "version": "2.1.2", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "bundled": true, + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + } + } + }, + "mkdirp": { + "version": "1.0.4", + "bundled": true, + "dev": true + }, + "ms": { + "version": "2.1.3", + "bundled": true, + "dev": true + }, + "mute-stream": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "negotiator": { + "version": "0.6.3", + "bundled": true, + "dev": true + }, + "node-gyp": { + "version": "10.1.0", + "bundled": true, + "dev": true, + "requires": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^13.0.0", + "nopt": "^7.0.0", + "proc-log": "^3.0.0", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^4.0.0" + }, + "dependencies": { + "proc-log": { + "version": "3.0.0", + "bundled": true, + "dev": true + } + } + }, + "nopt": { + "version": "7.2.1", + "bundled": true, + "dev": true, + "requires": { + "abbrev": "^2.0.0" + } + }, + "normalize-package-data": { + "version": "6.0.1", + "bundled": true, + "dev": true, + "requires": { + "hosted-git-info": "^7.0.0", + "is-core-module": "^2.8.1", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + } + }, + "npm-audit-report": { + "version": "5.0.0", + "bundled": true, + "dev": true + }, + "npm-bundled": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "requires": { + "npm-normalize-package-bin": "^3.0.0" + } + }, + "npm-install-checks": { + "version": "6.3.0", + "bundled": true, + "dev": true, + "requires": { + "semver": "^7.1.1" + } + }, + "npm-normalize-package-bin": { + "version": "3.0.1", + "bundled": true, + "dev": true + }, + "npm-package-arg": { + "version": "11.0.2", + "bundled": true, + "dev": true, + "requires": { + "hosted-git-info": "^7.0.0", + "proc-log": "^4.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^5.0.0" + } + }, + "npm-packlist": { + "version": "8.0.2", + "bundled": true, + "dev": true, + "requires": { + "ignore-walk": "^6.0.4" + } + }, + "npm-pick-manifest": { + "version": "9.0.1", + "bundled": true, + "dev": true, + "requires": { + "npm-install-checks": "^6.0.0", + "npm-normalize-package-bin": "^3.0.0", + "npm-package-arg": "^11.0.0", + "semver": "^7.3.5" + } + }, + "npm-profile": { + "version": "10.0.0", + "bundled": true, + "dev": true, + "requires": { + "npm-registry-fetch": "^17.0.1", + "proc-log": "^4.0.0" + } + }, + "npm-registry-fetch": { + "version": "17.0.1", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/redact": "^2.0.0", + "make-fetch-happen": "^13.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^3.0.0", + "minipass-json-stream": "^1.0.1", + "minizlib": "^2.1.2", + "npm-package-arg": "^11.0.0", + "proc-log": "^4.0.0" + } + }, + "npm-user-validate": { + "version": "2.0.1", + "bundled": true, + "dev": true + }, + "p-map": { + "version": "4.0.0", + "bundled": true, + "dev": true, + "requires": { + "aggregate-error": "^3.0.0" + } + }, + "pacote": { + "version": "18.0.6", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/git": "^5.0.0", + "@npmcli/installed-package-contents": "^2.0.1", + "@npmcli/package-json": "^5.1.0", + "@npmcli/promise-spawn": "^7.0.0", + "@npmcli/run-script": "^8.0.0", + "cacache": "^18.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^11.0.0", + "npm-packlist": "^8.0.0", + "npm-pick-manifest": "^9.0.0", + "npm-registry-fetch": "^17.0.0", + "proc-log": "^4.0.0", + "promise-retry": "^2.0.1", + "sigstore": "^2.2.0", + "ssri": "^10.0.0", + "tar": "^6.1.11" + } + }, + "parse-conflict-json": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "requires": { + "json-parse-even-better-errors": "^3.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + } + }, + "path-key": { + "version": "3.1.1", + "bundled": true, + "dev": true + }, + "path-scurry": { + "version": "1.11.1", + "bundled": true, + "dev": true, + "requires": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + } + }, + "postcss-selector-parser": { + "version": "6.1.0", + "bundled": true, + "dev": true, + "requires": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + } + }, + "proc-log": { + "version": "4.2.0", + "bundled": true, + "dev": true + }, + "proggy": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "promise-all-reject-late": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "promise-call-limit": { + "version": "3.0.1", + "bundled": true, + "dev": true + }, + "promise-inflight": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "promise-retry": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "requires": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + } + }, + "promzard": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "requires": { + "read": "^3.0.1" + } + }, + "qrcode-terminal": { + "version": "0.12.0", + "bundled": true, + "dev": true + }, + "read": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "requires": { + "mute-stream": "^1.0.0" + } + }, + "read-cmd-shim": { + "version": "4.0.0", + "bundled": true, + "dev": true + }, + "read-package-json-fast": { + "version": "3.0.2", + "bundled": true, + "dev": true, + "requires": { + "json-parse-even-better-errors": "^3.0.0", + "npm-normalize-package-bin": "^3.0.0" + } + }, + "retry": { + "version": "0.12.0", + "bundled": true, + "dev": true + }, + "safer-buffer": { + "version": "2.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "semver": { + "version": "7.6.2", + "bundled": true, + "dev": true + }, + "shebang-command": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "bundled": true, + "dev": true + }, + "signal-exit": { + "version": "4.1.0", + "bundled": true, + "dev": true + }, + "sigstore": { + "version": "2.3.1", + "bundled": true, + "dev": true, + "requires": { + "@sigstore/bundle": "^2.3.2", + "@sigstore/core": "^1.0.0", + "@sigstore/protobuf-specs": "^0.3.2", + "@sigstore/sign": "^2.3.2", + "@sigstore/tuf": "^2.3.4", + "@sigstore/verify": "^1.2.1" + } + }, + "smart-buffer": { + "version": "4.2.0", + "bundled": true, + "dev": true + }, + "socks": { + "version": "2.8.3", + "bundled": true, + "dev": true, + "requires": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + } + }, + "socks-proxy-agent": { + "version": "8.0.3", + "bundled": true, + "dev": true, + "requires": { + "agent-base": "^7.1.1", + "debug": "^4.3.4", + "socks": "^2.7.1" + } + }, + "spdx-correct": { + "version": "3.2.0", + "bundled": true, + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + }, + "dependencies": { + "spdx-expression-parse": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + } + } + }, + "spdx-exceptions": { + "version": "2.5.0", + "bundled": true, + "dev": true + }, + "spdx-expression-parse": { + "version": "4.0.0", + "bundled": true, + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.18", + "bundled": true, + "dev": true + }, + "sprintf-js": { + "version": "1.1.3", + "bundled": true, + "dev": true + }, + "ssri": { + "version": "10.0.6", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^7.0.3" + } + }, + "string-width": { + "version": "4.2.3", + "bundled": true, + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "string-width-cjs": { + "version": "npm:string-width@4.2.3", + "bundled": true, + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "strip-ansi": { + "version": "6.0.1", + "bundled": true, + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "strip-ansi-cjs": { + "version": "npm:strip-ansi@6.0.1", + "bundled": true, + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "supports-color": { + "version": "9.4.0", + "bundled": true, + "dev": true + }, + "tar": { + "version": "6.2.1", + "bundled": true, + "dev": true, + "requires": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "dependencies": { + "fs-minipass": { + "version": "2.1.0", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^3.0.0" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "bundled": true, + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + } + } + }, + "minipass": { + "version": "5.0.0", + "bundled": true, + "dev": true + } + } + }, + "text-table": { + "version": "0.2.0", + "bundled": true, + "dev": true + }, + "tiny-relative-date": { + "version": "1.3.0", + "bundled": true, + "dev": true + }, + "treeverse": { + "version": "3.0.0", + "bundled": true, + "dev": true + }, + "tuf-js": { + "version": "2.2.1", + "bundled": true, + "dev": true, + "requires": { + "@tufjs/models": "2.0.1", + "debug": "^4.3.4", + "make-fetch-happen": "^13.0.1" + } + }, + "unique-filename": { + "version": "3.0.0", + "bundled": true, + "dev": true, + "requires": { + "unique-slug": "^4.0.0" + } + }, + "unique-slug": { + "version": "4.0.0", + "bundled": true, + "dev": true, + "requires": { + "imurmurhash": "^0.1.4" + } + }, + "util-deprecate": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "validate-npm-package-license": { + "version": "3.0.4", + "bundled": true, + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + }, + "dependencies": { + "spdx-expression-parse": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + } + } + }, + "validate-npm-package-name": { + "version": "5.0.1", + "bundled": true, + "dev": true + }, + "walk-up-path": { + "version": "3.0.1", + "bundled": true, + "dev": true + }, + "which": { + "version": "4.0.0", + "bundled": true, + "dev": true, + "requires": { + "isexe": "^3.1.1" + }, + "dependencies": { + "isexe": { + "version": "3.1.1", + "bundled": true, + "dev": true + } + } + }, + "wrap-ansi": { + "version": "8.1.0", + "bundled": true, + "dev": true, + "requires": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "dependencies": { + "ansi-regex": { + "version": "6.0.1", + "bundled": true, + "dev": true + }, + "emoji-regex": { + "version": "9.2.2", + "bundled": true, + "dev": true + }, + "string-width": { + "version": "5.1.2", + "bundled": true, + "dev": true, + "requires": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + } + }, + "strip-ansi": { + "version": "7.1.0", + "bundled": true, + "dev": true, + "requires": { + "ansi-regex": "^6.0.1" + } + } + } + }, + "wrap-ansi-cjs": { + "version": "npm:wrap-ansi@7.0.0", + "bundled": true, + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "bundled": true, + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + } + } + }, + "write-file-atomic": { + "version": "5.0.1", + "bundled": true, + "dev": true, + "requires": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + } + }, + "yallist": { + "version": "4.0.0", + "bundled": true, + "dev": true + } + } + }, + "npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "requires": { + "path-key": "^3.0.0" + } + }, + "npmlog": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-7.0.1.tgz", + "integrity": "sha512-uJ0YFk/mCQpLBt+bxN88AKd+gyqZvZDbtiNxk6Waqcj2aPRyfVx8ITawkyQynxUagInjdYT1+qj4NfA5KJJUxg==", + "requires": { + "are-we-there-yet": "^4.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^5.0.0", + "set-blocking": "^2.0.0" + }, + "dependencies": { + "are-we-there-yet": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-4.0.2.tgz", + "integrity": "sha512-ncSWAawFhKMJDTdoAeOV+jyW1VCMj5QIAwULIBV0SSR7B/RLPPEQiknKcg/RIIZlUQrxELpsxMiTUoAQ4sIUyg==" + } + } + }, + "nyc": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-17.1.0.tgz", + "integrity": "sha512-U42vQ4czpKa0QdI1hu950XuNhYqgoM+ZF1HT+VuUHL9hPfDPVvNQyltmMqdE9bUHMVa+8yNbc3QKTj8zQhlVxQ==", + "dev": true, + "requires": { + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "caching-transform": "^4.0.0", + "convert-source-map": "^1.7.0", + "decamelize": "^1.2.0", + "find-cache-dir": "^3.2.0", + "find-up": "^4.1.0", + "foreground-child": "^3.3.0", + "get-package-type": "^0.1.0", + "glob": "^7.1.6", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-hook": "^3.0.0", + "istanbul-lib-instrument": "^6.0.2", + "istanbul-lib-processinfo": "^2.0.2", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.0.2", + "make-dir": "^3.0.0", + "node-preload": "^0.2.1", + "p-map": "^3.0.0", + "process-on-spawn": "^1.0.0", + "resolve-from": "^5.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "spawn-wrap": "^2.0.0", + "test-exclude": "^6.0.0", + "yargs": "^15.0.2" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "dependencies": { + "signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true + } + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "requires": { + "aggregate-error": "^3.0.0" + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + }, + "object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==" + }, + "object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" + }, + "object-path": { + "version": "0.11.8", + "resolved": "https://registry.npmjs.org/object-path/-/object-path-0.11.8.tgz", + "integrity": "sha512-YJjNZrlXJFM42wTBn6zgOJVar9KFJvzx6sTWDte8sWZF//cnjl0BxHNpfZx+ZffXX63A9q0b1zsFiBX4g4X5KA==" + }, + "on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "requires": { + "ee-first": "1.1.1" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "requires": { + "wrappy": "1" + } + }, + "one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "requires": { + "fn.name": "1.x.x" + } + }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "optimism": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.0.tgz", + "integrity": "sha512-tGn8+REwLRNFnb9WmcY5IfpOqeX2kpaYJ1s6Ae3mn12AeydLkR3j+jSCmVQFoXqU8D41PAJ1RG1rCRNWmNZVmQ==", + "dev": true, + "requires": { + "@wry/caches": "^1.0.0", + "@wry/context": "^0.7.0", + "@wry/trie": "^0.4.3", + "tslib": "^2.3.0" + }, + "dependencies": { + "@wry/trie": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.4.3.tgz", + "integrity": "sha512-I6bHwH0fSf6RqQcnnXLJKhkSXG45MFral3GxPaY4uAl0LYDZM+YDVDAiU9bYwjTuysy1S0IeecWtmq1SZA3M1w==", + "dev": true, + "requires": { + "tslib": "^2.3.0" + } + } + } + }, + "optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "requires": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + } + }, + "ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "requires": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "otpauth": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.0.tgz", + "integrity": "sha512-Ldhc6UYl4baR5toGr8nfKC+L/b8/RgHKoIixAebgoNGzUUCET02g04rMEZ2ZsPfeVQhMHcuaOgb28nwMr81zCA==", + "requires": { + "@noble/hashes": "2.0.1" + } + }, + "p-cancelable": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", + "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==", + "dev": true + }, + "p-each-series": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-3.0.0.tgz", + "integrity": "sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==", + "dev": true + }, + "p-filter": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-4.1.0.tgz", + "integrity": "sha512-37/tPdZ3oJwHaS3gNJdenCDB3Tz26i9sjhnguBtvN0vYlRIiDNnvTWkuh+0hETV9rLPdJ3rlL3yVOYPIAnM8rw==", + "dev": true, + "requires": { + "p-map": "^7.0.1" + }, + "dependencies": { + "p-map": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.2.tgz", + "integrity": "sha512-z4cYYMMdKHzw4O5UkWJImbZynVIo0lSGTXc7bzB1e/rrDqkgGUNysK/o4bTr+0+xKvvLoTyGqYC4Fgljy9qe1Q==", + "dev": true + } + } + }, + "p-is-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", + "integrity": "sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==", + "dev": true + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "devOptional": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + }, + "p-reduce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-2.1.0.tgz", + "integrity": "sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw==", + "dev": true + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "package-hash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", + "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.15", + "hasha": "^5.0.0", + "lodash.flattendeep": "^4.4.0", + "release-zalgo": "^1.0.0" + } + }, + "package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "devOptional": true + }, + "param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "dev": true, + "requires": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "requires": { + "callsites": "^3.0.0" + } + }, + "parse": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/parse/-/parse-8.6.0.tgz", + "integrity": "sha512-AZjc8yGo8/iTZFpCXWw/r1qNusiUGWtq9i92/u0jNd+Iupg3EJUSV/OOyTrCeav8NDyo92wVS5O3iKAYPlhlsA==", + "requires": { + "@babel/runtime": "7.29.2", + "@babel/runtime-corejs3": "7.29.2", + "crypto-js": "4.2.0", + "idb-keyval": "6.2.2", + "react-native-crypto-js": "1.0.0", + "ws": "8.20.0" + } + }, + "parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + } + }, + "parse-ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz", + "integrity": "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==", + "dev": true + }, + "parse5": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", + "dev": true + }, + "parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "dev": true, + "requires": { + "parse5": "^6.0.1" + }, + "dependencies": { + "parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true + } + } + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "dev": true, + "requires": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "path-expression-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "optional": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "devOptional": true + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "devOptional": true, + "requires": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "dependencies": { + "lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "devOptional": true + } + } + }, + "path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==" + }, + "path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true + }, + "pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true + }, + "pg": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "requires": { + "pg-cloudflare": "^1.3.0", + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + } + }, + "pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "optional": true + }, + "pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==" + }, + "pg-cursor": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.17.0.tgz", + "integrity": "sha512-2Uio3Xfl5ldwJfls+RgGL+YbPcKQncWACWjYQFqlamvHZ4HJFjZhhZBbqd7jQ2LIkZYSvU90bm2dNW0rno+QFQ==", + "peer": true, + "requires": {} + }, + "pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==" + }, + "pg-minify": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/pg-minify/-/pg-minify-1.8.0.tgz", + "integrity": "sha512-jO/oJOununpx8DzKgvSsWm61P8JjwXlaxSlbbfTBo1nvSWoo/+I6qZYaSN96jm/KDwa5d+JMQwPGgcP6HXDRow==" + }, + "pg-monitor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pg-monitor/-/pg-monitor-3.1.0.tgz", + "integrity": "sha512-giK0h52AOO/v8iu6hZCdZ/X9W8oAM9Dm1VReQQtki532X8g4z1LVIm4Z/3cGvDcETWW+Ty0FrtU8iTrGFYIZfA==", + "requires": { + "picocolors": "1.1.1" + } + }, + "pg-pool": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "requires": {} + }, + "pg-promise": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/pg-promise/-/pg-promise-12.6.0.tgz", + "integrity": "sha512-ZnfNn7c0U2p1OWYqoENcke8eSTe+yCGOpuMurExTuot/pe3POodbakE9Sj5MWUsyPpyreARUUPwe4j/5Dfs9Dw==", + "requires": { + "assert-options": "0.8.3", + "pg": "8.18.0", + "pg-minify": "1.8.0", + "spex": "4.1.0" + } + }, + "pg-protocol": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==" + }, + "pg-query-stream": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/pg-query-stream/-/pg-query-stream-4.12.0.tgz", + "integrity": "sha512-H97oiVPQ0+eRqIFOeYMUnjDcv9od7vHHMjiVDAhg2SEzAUr3M/dT83UEV1B+fm+tcVnymI8j2LSp57/+yjF6Fg==", + "peer": true, + "requires": { + "pg-cursor": "^2.17.0" + } + }, + "pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "requires": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + } + }, + "pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "requires": { + "split2": "^4.1.0" + } + }, + "picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true + }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", + "dev": true + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", + "dev": true, + "requires": { + "pinkie": "^2.0.0" + } + }, + "pkg-conf": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-2.1.0.tgz", + "integrity": "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==", + "dev": true, + "requires": { + "find-up": "^2.0.0", + "load-json-file": "^4.0.0" + }, + "dependencies": { + "find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==", + "dev": true, + "requires": { + "locate-path": "^2.0.0" + } + }, + "locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==", + "dev": true, + "requires": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + } + }, + "p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "requires": { + "p-try": "^1.0.0" + } + }, + "p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==", + "dev": true, + "requires": { + "p-limit": "^1.1.0" + } + }, + "p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", + "dev": true + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true + } + } + }, + "pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==" + }, + "possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==" + }, + "postcss": { + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "dev": true, + "requires": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" + } + }, + "postcss-values-parser": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-values-parser/-/postcss-values-parser-6.0.2.tgz", + "integrity": "sha512-YLJpK0N1brcNJrs9WatuJFtHaV9q5aAOj+S4DI5S7jgHlRfm0PIbDCAFRYMQD5SHq7Fy6xsDhyutgS0QOAs0qw==", + "dev": true, + "requires": { + "color-name": "^1.1.4", + "is-url-superb": "^4.0.0", + "quote-unquote": "^1.0.0" + }, + "dependencies": { + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + } + } + }, + "postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==" + }, + "postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==" + }, + "postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==" + }, + "postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "requires": { + "xtend": "^4.0.0" + } + }, + "precinct": { + "version": "12.1.2", + "resolved": "https://registry.npmjs.org/precinct/-/precinct-12.1.2.tgz", + "integrity": "sha512-x2qVN3oSOp3D05ihCd8XdkIPuEQsyte7PSxzLqiRgktu79S5Dr1I75/S+zAup8/0cwjoiJTQztE9h0/sWp9bJQ==", + "dev": true, + "requires": { + "@dependents/detective-less": "^5.0.0", + "commander": "^12.1.0", + "detective-amd": "^6.0.0", + "detective-cjs": "^6.0.0", + "detective-es6": "^5.0.0", + "detective-postcss": "^7.0.0", + "detective-sass": "^6.0.0", + "detective-scss": "^5.0.0", + "detective-stylus": "^5.0.0", + "detective-typescript": "^13.0.0", + "detective-vue2": "^2.0.3", + "module-definition": "^6.0.0", + "node-source-walk": "^7.0.0", + "postcss": "^8.4.40", + "typescript": "^5.5.4" + }, + "dependencies": { + "commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true + } + } + }, + "precond": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz", + "integrity": "sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==" + }, + "prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true + }, + "prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true + }, + "pretty-ms": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-7.0.1.tgz", + "integrity": "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==", + "dev": true, + "requires": { + "parse-ms": "^2.1.0" + } + }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "process-on-spawn": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", + "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==", + "dev": true, + "requires": { + "fromentries": "^1.2.0" + } + }, + "process-warning": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-2.3.2.tgz", + "integrity": "sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==" + }, + "promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==" + }, + "promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "requires": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "dependencies": { + "retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==" + } + } + }, + "prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true + }, + "proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "optional": true, + "requires": { + "protobufjs": "^7.2.5" + } + }, + "protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "optional": true, + "requires": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "dependencies": { + "long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "optional": true + } + } + }, + "proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "requires": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + } + }, + "pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==" + }, + "punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" + }, + "punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true + }, + "qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "requires": { + "side-channel": "^1.1.0" + } + }, + "queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true + }, + "quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true + }, + "quote-unquote": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/quote-unquote/-/quote-unquote-1.0.0.tgz", + "integrity": "sha512-twwRO/ilhlG/FIgYeKGFqyHhoEhqgnKVkcmqMKi2r524gz3ZbDTcyFt38E9xjJI2vT+KbRNHVbnJ/e0I25Azwg==", + "dev": true + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "rate-limit-redis": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/rate-limit-redis/-/rate-limit-redis-4.3.1.tgz", + "integrity": "sha512-+a1zU8+D7L8siDK9jb14refQXz60vq427VuiplgnaLk9B2LnvGe/APLTfhwb4uNIL7eWVknh8GnRp/unCj+lMA==", + "requires": {} + }, + "raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "requires": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "dependencies": { + "http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "requires": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + } + }, + "statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==" + } + } + }, + "rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true + } + } + }, + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true + }, + "react-native-crypto-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/react-native-crypto-js/-/react-native-crypto-js-1.0.0.tgz", + "integrity": "sha512-FNbLuG/HAdapQoybeZSoes1PWdOj0w242gb+e1R0hicf3Gyj/Mf8M9NaED2AnXVOX01b2FXomwUiw1xP1K+8sA==" + }, + "read-package-up": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", + "integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==", + "dev": true, + "requires": { + "find-up-simple": "^1.0.0", + "read-pkg": "^9.0.0", + "type-fest": "^4.6.0" + } + }, + "read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "dev": true, + "requires": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, + "dependencies": { + "parse-json": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.1.0.tgz", + "integrity": "sha512-rum1bPifK5SSar35Z6EKZuYPJx85pkNaFrxBK3mwdfSJ1/WKbYrjoW/zTPSjRRamfmVX1ACBIdFAO0VRErW/EA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.22.13", + "index-to-position": "^0.1.2", + "type-fest": "^4.7.1" + } + } + } + }, + "read-pkg-up": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-11.0.0.tgz", + "integrity": "sha512-LOVbvF1Q0SZdjClSefZ0Nz5z8u+tIE7mV5NibzmE9VYmDe9CaBbAVtz1veOSZbofrdsilxuDAYnFenukZVp8/Q==", + "dev": true, + "requires": { + "find-up-simple": "^1.0.0", + "read-pkg": "^9.0.0", + "type-fest": "^4.6.0" + } + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + } + } + }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "optional": true, + "requires": { + "picomatch": "^2.2.1" + } + }, + "redeyed": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/redeyed/-/redeyed-2.1.1.tgz", + "integrity": "sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ==", + "dev": true, + "requires": { + "esprima": "~4.0.0" + } + }, + "redis": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.11.0.tgz", + "integrity": "sha512-YwXjATVDT+AuxcyfOwZn046aml9jMlQPvU1VXIlLDVAExe0u93aTfPYSeRgG4p9Q/Jlkj+LXJ1XEoFV+j2JKcQ==", + "requires": { + "@redis/bloom": "5.11.0", + "@redis/client": "5.11.0", + "@redis/json": "5.11.0", + "@redis/search": "5.11.0", + "@redis/time-series": "5.11.0" + } + }, + "regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true + }, + "regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "requires": { + "regenerate": "^1.4.2" + } + }, + "regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "requires": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + } + }, + "registry-auth-token": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.0.2.tgz", + "integrity": "sha512-o/3ikDxtXaA59BmZuZrJZDJv8NMDGSj+6j6XaeBmHw8eY1i1qd9+6H+LjVvQXx3HN6aRCGa1cUdJ9RaJZUugnQ==", + "dev": true, + "requires": { + "@pnpm/npm-conf": "^2.1.0" + } + }, + "regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true + }, + "regjsparser": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", + "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "dev": true, + "requires": { + "jsesc": "~3.1.0" + } + }, + "rehackt": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/rehackt/-/rehackt-0.1.0.tgz", + "integrity": "sha512-7kRDOuLHB87D/JESKxQoRwv4DzbIdwkAGQ7p6QKGdVlY1IZheUnVhlk/4UZlNUVxdAXpyxikE3URsG067ybVzw==", + "dev": true, + "requires": {} + }, + "relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "dev": true + }, + "release-zalgo": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", + "integrity": "sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==", + "dev": true, + "requires": { + "es6-error": "^4.0.1" + } + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "devOptional": true + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "requirejs": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/requirejs/-/requirejs-2.3.7.tgz", + "integrity": "sha512-DouTG8T1WanGok6Qjg2SXuCMzszOo0eHeH9hDZ5Y4x8Je+9JB38HdTLT4/VA8OaUhBa0JPVHJ0pyBkM1z+pDsw==", + "dev": true + }, + "requirejs-config-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/requirejs-config-file/-/requirejs-config-file-4.0.0.tgz", + "integrity": "sha512-jnIre8cbWOyvr8a5F2KuqBnY+SDA4NXr/hzEZJG79Mxm2WiFQz2dzhC8ibtPJS7zkmBEl1mxSwp5HhC1W4qpxw==", + "dev": true, + "requires": { + "esprima": "^4.0.0", + "stringify-object": "^3.2.1" + } + }, + "requizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/requizzle/-/requizzle-0.2.4.tgz", + "integrity": "sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==", + "dev": true, + "requires": { + "lodash": "^4.17.21" + } + }, + "resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "requires": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + }, + "resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true + }, + "resolve-dependency-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-dependency-path/-/resolve-dependency-path-4.0.0.tgz", + "integrity": "sha512-hlY1SybBGm5aYN3PC4rp15MzsJLM1w+MEA/4KU3UBPfz4S0lL3FL6mgv7JgaA8a+ZTeEQAiF1a1BuN2nkqiIlg==", + "dev": true + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + }, + "resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true + }, + "responselike": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", + "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", + "dev": true, + "requires": { + "lowercase-keys": "^3.0.0" + } + }, + "restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "requires": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + } + }, + "retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==" + }, + "retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "optional": true, + "requires": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + } + }, + "reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true + }, + "rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "requires": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "dependencies": { + "is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" + } + } + }, + "run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "requires": { + "queue-microtask": "^1.2.2" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "safe-stable-stringify": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.1.tgz", + "integrity": "sha512-dVHE6bMtS/bnL2mwualjc6IxEv1F+OCUpA46pKUj6F8uDbUM0jCCulPqRNPSnWwGNKx5etqMjZYdXtrm5KJZGA==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "sass-lookup": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/sass-lookup/-/sass-lookup-6.0.1.tgz", + "integrity": "sha512-nl9Wxbj9RjEJA5SSV0hSDoU2zYGtE+ANaDS4OFUR7nYrquvBFvPKZZtQHe3lvnxCcylEDV00KUijjdMTUElcVQ==", + "dev": true, + "requires": { + "commander": "^12.0.0" + }, + "dependencies": { + "commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true + } + } + }, + "seek-bzip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.6.tgz", + "integrity": "sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==", + "dev": true, + "requires": { + "commander": "^2.8.1" + }, + "dependencies": { + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + } + } + }, + "semantic-release": { + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.3.tgz", + "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", + "dev": true, + "requires": { + "@semantic-release/commit-analyzer": "^13.0.1", + "@semantic-release/error": "^4.0.0", + "@semantic-release/github": "^12.0.0", + "@semantic-release/npm": "^13.1.1", + "@semantic-release/release-notes-generator": "^14.1.0", + "aggregate-error": "^5.0.0", + "cosmiconfig": "^9.0.0", + "debug": "^4.0.0", + "env-ci": "^11.0.0", + "execa": "^9.0.0", + "figures": "^6.0.0", + "find-versions": "^6.0.0", + "get-stream": "^6.0.0", + "git-log-parser": "^1.2.0", + "hook-std": "^4.0.0", + "hosted-git-info": "^9.0.0", + "import-from-esm": "^2.0.0", + "lodash-es": "^4.17.21", + "marked": "^15.0.0", + "marked-terminal": "^7.3.0", + "micromatch": "^4.0.2", + "p-each-series": "^3.0.0", + "p-reduce": "^3.0.0", + "read-package-up": "^12.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.3.2", + "signale": "^1.2.1", + "yargs": "^18.0.0" + }, + "dependencies": { + "@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true + }, + "@semantic-release/npm": { + "version": "13.1.5", + "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-13.1.5.tgz", + "integrity": "sha512-Hq5UxzoatN3LHiq2rTsWS54nCdqJHlsssGERCo8WlvdfFA9LoN0vO+OuKVSjtNapIc/S8C2LBj206wKLHg62mg==", + "dev": true, + "requires": { + "@actions/core": "^3.0.0", + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "env-ci": "^11.2.0", + "execa": "^9.0.0", + "fs-extra": "^11.0.0", + "lodash-es": "^4.17.21", + "nerf-dart": "^1.0.0", + "normalize-url": "^9.0.0", + "npm": "^11.6.2", + "rc": "^1.2.8", + "read-pkg": "^10.0.0", + "registry-auth-token": "^5.0.0", + "semver": "^7.1.2", + "tempy": "^3.0.0" + } + }, + "@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true + }, + "aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "requires": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + } + }, + "ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true + }, + "ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true + }, + "clean-stack": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.2.0.tgz", + "integrity": "sha512-TyUIUJgdFnCISzG5zu3291TAsE77ddchd0bepon1VVQrKLGKFED4iXFEDQ24mIPdPBbyE16PK3F8MYE1CmcBEQ==", + "dev": true, + "requires": { + "escape-string-regexp": "5.0.0" + } + }, + "cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "requires": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + } + }, + "emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true + }, + "escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true + }, + "execa": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.3.0.tgz", + "integrity": "sha512-l6JFbqnHEadBoVAVpN5dl2yCyfX28WoBAGaoQcNmLLSedOxTxcn2Qa83s8I/PA5i56vWru2OHOtrwF7Om2vqlg==", + "dev": true, + "requires": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.3", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^7.0.0", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^5.2.0", + "pretty-ms": "^9.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.0.0" + }, + "dependencies": { + "get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "requires": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + } + } + } + }, + "hook-std": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hook-std/-/hook-std-4.0.0.tgz", + "integrity": "sha512-IHI4bEVOt3vRUDJ+bFA9VUJlo7SzvFARPNLw75pqSmAOP2HmTWfFJtPvLBrDrlgjEYXY9zs7SFdHPQaJShkSCQ==", + "dev": true + }, + "hosted-git-info": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", + "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", + "dev": true, + "requires": { + "lru-cache": "^11.1.0" + } + }, + "human-signals": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-7.0.0.tgz", + "integrity": "sha512-74kytxOUSvNbjrT9KisAbaTZ/eJwD/LrbM/kh5j0IhPuJzwuA19dWvniFGwBzN9rVjg+O/e+F310PjObDXS+9Q==", + "dev": true + }, + "import-from-esm": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-2.0.0.tgz", + "integrity": "sha512-YVt14UZCgsX1vZQ3gKjkWVdBdHQ6eu3MPU1TBgL1H5orXe2+jWD006WCPPtOuwlQm10NuzOW5WawiF1Q9veW8g==", + "dev": true, + "requires": { + "debug": "^4.3.4", + "import-meta-resolve": "^4.0.0" + } + }, + "indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true + }, + "index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true + }, + "is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true + }, + "marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "dev": true + }, + "normalize-package-data": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", + "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", + "dev": true, + "requires": { + "hosted-git-info": "^9.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + } + }, + "normalize-url": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.0.tgz", + "integrity": "sha512-z9nC87iaZXXySbWWtTHfCFJyFvKaUAW6lODhikG7ILSbVgmwuFjUqkgnheHvAUcGedO29e2QGBRXMUD64aurqQ==", + "dev": true + }, + "npm": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.12.0.tgz", + "integrity": "sha512-xPhOap4ZbJWyd7DAOukP564WFwNSGu/2FeTRFHhiiKthcauxhH/NpkJAQm24xD+cAn8av5tQ00phi98DqtfLsg==", + "dev": true, + "requires": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/arborist": "^9.4.2", + "@npmcli/config": "^10.8.0", + "@npmcli/fs": "^5.0.0", + "@npmcli/map-workspaces": "^5.0.3", + "@npmcli/metavuln-calculator": "^9.0.3", + "@npmcli/package-json": "^7.0.5", + "@npmcli/promise-spawn": "^9.0.1", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.4", + "@sigstore/tuf": "^4.0.2", + "abbrev": "^4.0.0", + "archy": "~1.0.0", + "cacache": "^20.0.4", + "chalk": "^5.6.2", + "ci-info": "^4.4.0", + "fastest-levenshtein": "^1.0.16", + "fs-minipass": "^3.0.3", + "glob": "^13.0.6", + "graceful-fs": "^4.2.11", + "hosted-git-info": "^9.0.2", + "ini": "^6.0.0", + "init-package-json": "^8.2.5", + "is-cidr": "^6.0.3", + "json-parse-even-better-errors": "^5.0.0", + "libnpmaccess": "^10.0.3", + "libnpmdiff": "^8.1.5", + "libnpmexec": "^10.2.5", + "libnpmfund": "^7.0.19", + "libnpmorg": "^8.0.1", + "libnpmpack": "^9.1.5", + "libnpmpublish": "^11.1.3", + "libnpmsearch": "^9.0.1", + "libnpmteam": "^8.0.2", + "libnpmversion": "^8.0.3", + "make-fetch-happen": "^15.0.5", + "minimatch": "^10.2.4", + "minipass": "^7.1.3", + "minipass-pipeline": "^1.2.4", + "ms": "^2.1.2", + "node-gyp": "^12.2.0", + "nopt": "^9.0.0", + "npm-audit-report": "^7.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.2", + "npm-pick-manifest": "^11.0.3", + "npm-profile": "^12.0.1", + "npm-registry-fetch": "^19.1.1", + "npm-user-validate": "^4.0.0", + "p-map": "^7.0.4", + "pacote": "^21.5.0", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.1.0", + "qrcode-terminal": "^0.12.0", + "read": "^5.0.1", + "semver": "^7.7.4", + "spdx-expression-parse": "^4.0.0", + "ssri": "^13.0.1", + "supports-color": "^10.2.2", + "tar": "^7.5.11", + "text-table": "~0.2.0", + "tiny-relative-date": "^2.0.2", + "treeverse": "^3.0.0", + "validate-npm-package-name": "^7.0.2", + "which": "^6.0.1" + }, + "dependencies": { + "@gar/promise-retry": { + "version": "1.0.3", + "bundled": true, + "dev": true + }, + "@isaacs/fs-minipass": { + "version": "4.0.1", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^7.0.4" + } + }, + "@isaacs/string-locale-compare": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "@npmcli/agent": { + "version": "4.0.0", + "bundled": true, + "dev": true, + "requires": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + } + }, + "@npmcli/arborist": { + "version": "9.4.2", + "bundled": true, + "dev": true, + "requires": { + "@gar/promise-retry": "^1.0.0", + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^5.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/metavuln-calculator": "^9.0.2", + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/query": "^5.0.0", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.0", + "bin-links": "^6.0.0", + "cacache": "^20.0.1", + "common-ancestor-path": "^2.0.0", + "hosted-git-info": "^9.0.0", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^11.2.1", + "minimatch": "^10.0.3", + "nopt": "^9.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.0", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "pacote": "^21.0.2", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.0.0", + "proggy": "^4.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "semver": "^7.3.7", + "ssri": "^13.0.0", + "treeverse": "^3.0.0", + "walk-up-path": "^4.0.0" + } + }, + "@npmcli/config": { + "version": "10.8.0", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "ci-info": "^4.0.0", + "ini": "^6.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "walk-up-path": "^4.0.0" + } + }, + "@npmcli/fs": { + "version": "5.0.0", + "bundled": true, + "dev": true, + "requires": { + "semver": "^7.3.5" + } + }, + "@npmcli/git": { + "version": "7.0.2", + "bundled": true, + "dev": true, + "requires": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "ini": "^6.0.0", + "lru-cache": "^11.2.1", + "npm-pick-manifest": "^11.0.1", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "which": "^6.0.0" + } + }, + "@npmcli/installed-package-contents": { + "version": "4.0.0", + "bundled": true, + "dev": true, + "requires": { + "npm-bundled": "^5.0.0", + "npm-normalize-package-bin": "^5.0.0" + } + }, + "@npmcli/map-workspaces": { + "version": "5.0.3", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "glob": "^13.0.0", + "minimatch": "^10.0.3" + } + }, + "@npmcli/metavuln-calculator": { + "version": "9.0.3", + "bundled": true, + "dev": true, + "requires": { + "cacache": "^20.0.0", + "json-parse-even-better-errors": "^5.0.0", + "pacote": "^21.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5" + } + }, + "@npmcli/name-from-folder": { + "version": "4.0.0", + "bundled": true, + "dev": true + }, + "@npmcli/node-gyp": { + "version": "5.0.0", + "bundled": true, + "dev": true + }, + "@npmcli/package-json": { + "version": "7.0.5", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/git": "^7.0.0", + "glob": "^13.0.0", + "hosted-git-info": "^9.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.5.3", + "spdx-expression-parse": "^4.0.0" + } + }, + "@npmcli/promise-spawn": { + "version": "9.0.1", + "bundled": true, + "dev": true, + "requires": { + "which": "^6.0.0" + } + }, + "@npmcli/query": { + "version": "5.0.0", + "bundled": true, + "dev": true, + "requires": { + "postcss-selector-parser": "^7.0.0" + } + }, + "@npmcli/redact": { + "version": "4.0.0", + "bundled": true, + "dev": true + }, + "@npmcli/run-script": { + "version": "10.0.4", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "node-gyp": "^12.1.0", + "proc-log": "^6.0.0" + } + }, + "@sigstore/bundle": { + "version": "4.0.0", + "bundled": true, + "dev": true, + "requires": { + "@sigstore/protobuf-specs": "^0.5.0" + } + }, + "@sigstore/core": { + "version": "3.2.0", + "bundled": true, + "dev": true + }, + "@sigstore/protobuf-specs": { + "version": "0.5.0", + "bundled": true, + "dev": true + }, + "@sigstore/sign": { + "version": "4.1.1", + "bundled": true, + "dev": true, + "requires": { + "@gar/promise-retry": "^1.0.2", + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.0", + "@sigstore/protobuf-specs": "^0.5.0", + "make-fetch-happen": "^15.0.4", + "proc-log": "^6.1.0" + } + }, + "@sigstore/tuf": { + "version": "4.0.2", + "bundled": true, + "dev": true, + "requires": { + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^4.1.0" + } + }, + "@sigstore/verify": { + "version": "3.1.0", + "bundled": true, + "dev": true, + "requires": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0" + } + }, + "@tufjs/canonical-json": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "@tufjs/models": { + "version": "4.1.0", + "bundled": true, + "dev": true, + "requires": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^10.1.1" + } + }, + "abbrev": { + "version": "4.0.0", + "bundled": true, + "dev": true + }, + "agent-base": { + "version": "7.1.4", + "bundled": true, + "dev": true + }, + "aproba": { + "version": "2.1.0", + "bundled": true, + "dev": true + }, + "archy": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "balanced-match": { + "version": "4.0.4", + "bundled": true, + "dev": true + }, + "bin-links": { + "version": "6.0.0", + "bundled": true, + "dev": true, + "requires": { + "cmd-shim": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "proc-log": "^6.0.0", + "read-cmd-shim": "^6.0.0", + "write-file-atomic": "^7.0.0" + } + }, + "binary-extensions": { + "version": "3.1.0", + "bundled": true, + "dev": true + }, + "brace-expansion": { + "version": "5.0.4", + "bundled": true, + "dev": true, + "requires": { + "balanced-match": "^4.0.2" + } + }, + "cacache": { + "version": "20.0.4", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0" + } + }, + "chalk": { + "version": "5.6.2", + "bundled": true, + "dev": true + }, + "chownr": { + "version": "3.0.0", + "bundled": true, + "dev": true + }, + "ci-info": { + "version": "4.4.0", + "bundled": true, + "dev": true + }, + "cidr-regex": { + "version": "5.0.3", + "bundled": true, + "dev": true + }, + "cmd-shim": { + "version": "8.0.0", + "bundled": true, + "dev": true + }, + "common-ancestor-path": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "cssesc": { + "version": "3.0.0", + "bundled": true, + "dev": true + }, + "debug": { + "version": "4.4.3", + "bundled": true, + "dev": true, + "requires": { + "ms": "^2.1.3" + } + }, + "diff": { + "version": "8.0.3", + "bundled": true, + "dev": true + }, + "env-paths": { + "version": "2.2.1", + "bundled": true, + "dev": true + }, + "exponential-backoff": { + "version": "3.1.3", + "bundled": true, + "dev": true + }, + "fastest-levenshtein": { + "version": "1.0.16", + "bundled": true, + "dev": true + }, + "fs-minipass": { + "version": "3.0.3", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^7.0.3" + } + }, + "glob": { + "version": "13.0.6", + "bundled": true, + "dev": true, + "requires": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + } + }, + "graceful-fs": { + "version": "4.2.11", + "bundled": true, + "dev": true + }, + "hosted-git-info": { + "version": "9.0.2", + "bundled": true, + "dev": true, + "requires": { + "lru-cache": "^11.1.0" + } + }, + "http-cache-semantics": { + "version": "4.2.0", + "bundled": true, + "dev": true + }, + "http-proxy-agent": { + "version": "7.0.2", + "bundled": true, + "dev": true, + "requires": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + } + }, + "https-proxy-agent": { + "version": "7.0.6", + "bundled": true, + "dev": true, + "requires": { + "agent-base": "^7.1.2", + "debug": "4" + } + }, + "iconv-lite": { + "version": "0.7.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + }, + "ignore-walk": { + "version": "8.0.0", + "bundled": true, + "dev": true, + "requires": { + "minimatch": "^10.0.3" + } + }, + "ini": { + "version": "6.0.0", + "bundled": true, + "dev": true + }, + "init-package-json": { + "version": "8.2.5", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/package-json": "^7.0.0", + "npm-package-arg": "^13.0.0", + "promzard": "^3.0.1", + "read": "^5.0.1", + "semver": "^7.7.2", + "validate-npm-package-name": "^7.0.0" + } + }, + "ip-address": { + "version": "10.1.0", + "bundled": true, + "dev": true + }, + "is-cidr": { + "version": "6.0.3", + "bundled": true, + "dev": true, + "requires": { + "cidr-regex": "^5.0.1" + } + }, + "isexe": { + "version": "4.0.0", + "bundled": true, + "dev": true + }, + "json-parse-even-better-errors": { + "version": "5.0.0", + "bundled": true, + "dev": true + }, + "json-stringify-nice": { + "version": "1.1.4", + "bundled": true, + "dev": true + }, + "jsonparse": { + "version": "1.3.1", + "bundled": true, + "dev": true + }, + "just-diff": { + "version": "6.0.2", + "bundled": true, + "dev": true + }, + "just-diff-apply": { + "version": "5.5.0", + "bundled": true, + "dev": true + }, + "libnpmaccess": { + "version": "10.0.3", + "bundled": true, + "dev": true, + "requires": { + "npm-package-arg": "^13.0.0", + "npm-registry-fetch": "^19.0.0" + } + }, + "libnpmdiff": { + "version": "8.1.5", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/arborist": "^9.4.2", + "@npmcli/installed-package-contents": "^4.0.0", + "binary-extensions": "^3.0.0", + "diff": "^8.0.2", + "minimatch": "^10.0.3", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2", + "tar": "^7.5.1" + } + }, + "libnpmexec": { + "version": "10.2.5", + "bundled": true, + "dev": true, + "requires": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/arborist": "^9.4.2", + "@npmcli/package-json": "^7.0.0", + "@npmcli/run-script": "^10.0.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2", + "proc-log": "^6.0.0", + "read": "^5.0.1", + "semver": "^7.3.7", + "signal-exit": "^4.1.0", + "walk-up-path": "^4.0.0" + } + }, + "libnpmfund": { + "version": "7.0.19", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/arborist": "^9.4.2" + } + }, + "libnpmorg": { + "version": "8.0.1", + "bundled": true, + "dev": true, + "requires": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^19.0.0" + } + }, + "libnpmpack": { + "version": "9.1.5", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/arborist": "^9.4.2", + "@npmcli/run-script": "^10.0.0", + "npm-package-arg": "^13.0.0", + "pacote": "^21.0.2" + } + }, + "libnpmpublish": { + "version": "11.1.3", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/package-json": "^7.0.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^13.0.0", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.7", + "sigstore": "^4.0.0", + "ssri": "^13.0.0" + } + }, + "libnpmsearch": { + "version": "9.0.1", + "bundled": true, + "dev": true, + "requires": { + "npm-registry-fetch": "^19.0.0" + } + }, + "libnpmteam": { + "version": "8.0.2", + "bundled": true, + "dev": true, + "requires": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^19.0.0" + } + }, + "libnpmversion": { + "version": "8.0.3", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/git": "^7.0.0", + "@npmcli/run-script": "^10.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.7" + } + }, + "lru-cache": { + "version": "11.2.7", + "bundled": true, + "dev": true + }, + "make-fetch-happen": { + "version": "15.0.5", + "bundled": true, + "dev": true, + "requires": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" + } + }, + "minimatch": { + "version": "10.2.4", + "bundled": true, + "dev": true, + "requires": { + "brace-expansion": "^5.0.2" + } + }, + "minipass": { + "version": "7.1.3", + "bundled": true, + "dev": true + }, + "minipass-collect": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^7.0.3" + } + }, + "minipass-fetch": { + "version": "5.0.2", + "bundled": true, + "dev": true, + "requires": { + "iconv-lite": "^0.7.2", + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + } + }, + "minipass-flush": { + "version": "1.0.5", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^3.0.0" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "bundled": true, + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "bundled": true, + "dev": true + } + } + }, + "minipass-pipeline": { + "version": "1.2.4", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^3.0.0" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "bundled": true, + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "bundled": true, + "dev": true + } + } + }, + "minipass-sized": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^7.1.2" + } + }, + "minizlib": { + "version": "3.1.0", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^7.1.2" + } + }, + "ms": { + "version": "2.1.3", + "bundled": true, + "dev": true + }, + "mute-stream": { + "version": "3.0.0", + "bundled": true, + "dev": true + }, + "negotiator": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "node-gyp": { + "version": "12.2.0", + "bundled": true, + "dev": true, + "requires": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^15.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "which": "^6.0.0" + } + }, + "nopt": { + "version": "9.0.0", + "bundled": true, + "dev": true, + "requires": { + "abbrev": "^4.0.0" + } + }, + "npm-audit-report": { + "version": "7.0.0", + "bundled": true, + "dev": true + }, + "npm-bundled": { + "version": "5.0.0", + "bundled": true, + "dev": true, + "requires": { + "npm-normalize-package-bin": "^5.0.0" + } + }, + "npm-install-checks": { + "version": "8.0.0", + "bundled": true, + "dev": true, + "requires": { + "semver": "^7.1.1" + } + }, + "npm-normalize-package-bin": { + "version": "5.0.0", + "bundled": true, + "dev": true + }, + "npm-package-arg": { + "version": "13.0.2", + "bundled": true, + "dev": true, + "requires": { + "hosted-git-info": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^7.0.0" + } + }, + "npm-packlist": { + "version": "10.0.4", + "bundled": true, + "dev": true, + "requires": { + "ignore-walk": "^8.0.0", + "proc-log": "^6.0.0" + } + }, + "npm-pick-manifest": { + "version": "11.0.3", + "bundled": true, + "dev": true, + "requires": { + "npm-install-checks": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "npm-package-arg": "^13.0.0", + "semver": "^7.3.5" + } + }, + "npm-profile": { + "version": "12.0.1", + "bundled": true, + "dev": true, + "requires": { + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0" + } + }, + "npm-registry-fetch": { + "version": "19.1.1", + "bundled": true, + "dev": true, + "requires": { + "@npmcli/redact": "^4.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^15.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^13.0.0", + "proc-log": "^6.0.0" + } + }, + "npm-user-validate": { + "version": "4.0.0", + "bundled": true, + "dev": true + }, + "p-map": { + "version": "7.0.4", + "bundled": true, + "dev": true + }, + "pacote": { + "version": "21.5.0", + "bundled": true, + "dev": true, + "requires": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/git": "^7.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "@npmcli/run-script": "^10.0.0", + "cacache": "^20.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^13.0.0", + "npm-packlist": "^10.0.1", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "sigstore": "^4.0.0", + "ssri": "^13.0.0", + "tar": "^7.4.3" + } + }, + "parse-conflict-json": { + "version": "5.0.1", + "bundled": true, + "dev": true, + "requires": { + "json-parse-even-better-errors": "^5.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + } + }, + "path-scurry": { + "version": "2.0.2", + "bundled": true, + "dev": true, + "requires": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + } + }, + "postcss-selector-parser": { + "version": "7.1.1", + "bundled": true, + "dev": true, + "requires": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + } + }, + "proc-log": { + "version": "6.1.0", + "bundled": true, + "dev": true + }, + "proggy": { + "version": "4.0.0", + "bundled": true, + "dev": true + }, + "promise-all-reject-late": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "promise-call-limit": { + "version": "3.0.2", + "bundled": true, + "dev": true + }, + "promzard": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "requires": { + "read": "^5.0.0" + } + }, + "qrcode-terminal": { + "version": "0.12.0", + "bundled": true, + "dev": true + }, + "read": { + "version": "5.0.1", + "bundled": true, + "dev": true, + "requires": { + "mute-stream": "^3.0.0" + } + }, + "read-cmd-shim": { + "version": "6.0.0", + "bundled": true, + "dev": true + }, + "safer-buffer": { + "version": "2.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "semver": { + "version": "7.7.4", + "bundled": true, + "dev": true + }, + "signal-exit": { + "version": "4.1.0", + "bundled": true, + "dev": true + }, + "sigstore": { + "version": "4.1.0", + "bundled": true, + "dev": true, + "requires": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.1.0", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^4.1.0", + "@sigstore/tuf": "^4.0.1", + "@sigstore/verify": "^3.1.0" + } + }, + "smart-buffer": { + "version": "4.2.0", + "bundled": true, + "dev": true + }, + "socks": { + "version": "2.8.7", + "bundled": true, + "dev": true, + "requires": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + } + }, + "socks-proxy-agent": { + "version": "8.0.5", + "bundled": true, + "dev": true, + "requires": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + } + }, + "spdx-exceptions": { + "version": "2.5.0", + "bundled": true, + "dev": true + }, + "spdx-expression-parse": { + "version": "4.0.0", + "bundled": true, + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.23", + "bundled": true, + "dev": true + }, + "ssri": { + "version": "13.0.1", + "bundled": true, + "dev": true, + "requires": { + "minipass": "^7.0.3" + } + }, + "supports-color": { + "version": "10.2.2", + "bundled": true, + "dev": true + }, + "tar": { + "version": "7.5.11", + "bundled": true, + "dev": true, + "requires": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + } + }, + "text-table": { + "version": "0.2.0", + "bundled": true, + "dev": true + }, + "tiny-relative-date": { + "version": "2.0.2", + "bundled": true, + "dev": true + }, + "tinyglobby": { + "version": "0.2.15", + "bundled": true, + "dev": true, + "requires": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "dependencies": { + "fdir": { + "version": "6.5.0", + "bundled": true, + "dev": true, + "requires": {} + }, + "picomatch": { + "version": "4.0.3", + "bundled": true, + "dev": true + } + } + }, + "treeverse": { + "version": "3.0.0", + "bundled": true, + "dev": true + }, + "tuf-js": { + "version": "4.1.0", + "bundled": true, + "dev": true, + "requires": { + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" + } + }, + "util-deprecate": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "validate-npm-package-name": { + "version": "7.0.2", + "bundled": true, + "dev": true + }, + "walk-up-path": { + "version": "4.0.0", + "bundled": true, + "dev": true + }, + "which": { + "version": "6.0.1", + "bundled": true, + "dev": true, + "requires": { + "isexe": "^4.0.0" + } + }, + "write-file-atomic": { + "version": "7.0.1", + "bundled": true, + "dev": true, + "requires": { + "signal-exit": "^4.0.1" + } + }, + "yallist": { + "version": "5.0.0", + "bundled": true, + "dev": true + } + } + }, + "npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "requires": { + "path-key": "^4.0.0" + } + }, + "p-reduce": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-3.0.0.tgz", + "integrity": "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==", + "dev": true + }, + "parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "dependencies": { + "type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true + } + } + }, + "parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true + }, + "path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true + }, + "pretty-ms": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.0.0.tgz", + "integrity": "sha512-E9e9HJ9R9NasGOgPaPE8VMeiPKAyWR5jcFpNnwIejslIhWqdqOrb2wShBsncMPUb+BcCd2OPYfh7p2W6oemTng==", + "dev": true, + "requires": { + "parse-ms": "^4.0.0" + } + }, + "read-package-up": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-12.0.0.tgz", + "integrity": "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw==", + "dev": true, + "requires": { + "find-up-simple": "^1.0.1", + "read-pkg": "^10.0.0", + "type-fest": "^5.2.0" + } + }, + "read-pkg": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.1.0.tgz", + "integrity": "sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg==", + "dev": true, + "requires": { + "@types/normalize-package-data": "^2.4.4", + "normalize-package-data": "^8.0.0", + "parse-json": "^8.3.0", + "type-fest": "^5.4.4", + "unicorn-magic": "^0.4.0" + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, + "signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true + }, + "string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "requires": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + } + }, + "strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "requires": { + "ansi-regex": "^6.2.2" + } + }, + "strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true + }, + "type-fest": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "dev": true, + "requires": { + "tagged-tag": "^1.0.0" + } + }, + "unicorn-magic": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", + "dev": true + }, + "wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "requires": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + } + }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true + }, + "yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "requires": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + } + }, + "yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true + } + } + }, + "semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==" + }, + "semver-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-4.0.0.tgz", + "integrity": "sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==", + "dev": true, + "requires": { + "semver": "^7.3.5" + } + }, + "semver-regex": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-4.0.5.tgz", + "integrity": "sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==", + "dev": true + }, + "send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "requires": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "dependencies": { + "mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" + }, + "mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "requires": { + "mime-db": "^1.54.0" + } + } + } + }, + "serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "requires": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + } + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, + "set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "requires": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + } + }, + "setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "requires": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + } + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "devOptional": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "devOptional": true + }, + "showdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz", + "integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==", + "dev": true, + "requires": { + "commander": "^9.0.0" + }, + "dependencies": { + "commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true + } + } + }, + "side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "requires": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + } + }, + "side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "requires": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + } + }, + "side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + } + }, + "side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + } + }, + "signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "signale": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/signale/-/signale-1.4.0.tgz", + "integrity": "sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w==", + "dev": true, + "requires": { + "chalk": "^2.3.2", + "figures": "^2.0.0", + "pkg-conf": "^2.1.0" + }, + "dependencies": { + "figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + } + } + }, + "skin-tone": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", + "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", + "dev": true, + "requires": { + "unicode-emoji-modifier-base": "^1.0.0" + } + }, + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + }, + "slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "requires": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "dependencies": { + "ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "requires": { + "get-east-asian-width": "^1.3.1" + } + } + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true + }, + "source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "requires": { + "memory-pager": "^1.0.2" + } + }, + "spawn-error-forwarder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/spawn-error-forwarder/-/spawn-error-forwarder-1.0.0.tgz", + "integrity": "sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g==", + "dev": true + }, + "spawn-wrap": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", + "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", + "dev": true, + "requires": { + "foreground-child": "^2.0.0", + "is-windows": "^1.0.2", + "make-dir": "^3.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "which": "^2.0.1" + }, + "dependencies": { + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true + }, + "spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.18.tgz", + "integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==", + "dev": true + }, + "spex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/spex/-/spex-4.1.0.tgz", + "integrity": "sha512-ktgNAQ1X9x1A3IMChM6XBDeVjhGPbLgPQ8aEzGOaUIhZTnLeJSBApvi3gXT789hee6h73N3jOeWkXDwoPbYT/A==" + }, + "split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==" + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, + "stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==" + }, + "statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" + }, + "stream-combiner2": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", + "integrity": "sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==", + "dev": true, + "requires": { + "duplexer2": "~0.1.0", + "readable-stream": "^2.0.2" + } + }, + "stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "optional": true, + "requires": { + "stubs": "^3.0.0" + } + }, + "stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "optional": true + }, + "stream-to-array": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/stream-to-array/-/stream-to-array-2.3.0.tgz", + "integrity": "sha512-UsZtOYEn4tWU2RGLOXr/o/xjRBftZRlG3dEWoaHr8j4GuypJ3isitGbVyjQKAuMu+xbiop8q224TjiZWc4XTZA==", + "dev": true, + "requires": { + "any-promise": "^1.1.0" + } + }, + "streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==" + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, + "string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true + }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "string-width-cjs": { + "version": "npm:string-width@4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "devOptional": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dev": true, + "requires": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "dependencies": { + "is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "dev": true + } + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "strip-ansi-cjs": { + "version": "npm:strip-ansi@6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "devOptional": true, + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true + }, + "strip-dirs": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/strip-dirs/-/strip-dirs-2.1.0.tgz", + "integrity": "sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==", + "dev": true, + "requires": { + "is-natural-number": "^4.0.1" + } + }, + "strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true + }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true + }, + "strnum": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", + "optional": true + }, + "stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "optional": true + }, + "stylus-lookup": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/stylus-lookup/-/stylus-lookup-6.0.0.tgz", + "integrity": "sha512-RaWKxAvPnIXrdby+UWCr1WRfa+lrPMSJPySte4Q6a+rWyjeJyFOLJxr5GrAVfcMCsfVlCuzTAJ/ysYT8p8do7Q==", + "dev": true, + "requires": { + "commander": "^12.0.0" + }, + "dependencies": { + "commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true + } + } + }, + "subscriptions-transport-ws": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.11.0.tgz", + "integrity": "sha512-8D4C6DIH5tGiAIpp5I0wD/xRlNiZAPGHygzCe7VzyzUoxHtawzjNAY9SUTXU05/EY2NMY9/9GF0ycizkXr1CWQ==", + "dev": true, + "optional": true, + "peer": true, + "requires": { + "backo2": "^1.0.2", + "eventemitter3": "^3.1.0", + "iterall": "^1.2.1", + "symbol-observable": "^1.0.4", + "ws": "^5.2.0 || ^6.0.0 || ^7.0.0" + }, + "dependencies": { + "symbol-observable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==", + "dev": true, + "optional": true, + "peer": true + }, + "ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "dev": true, + "optional": true, + "peer": true, + "requires": {} + } + } + }, + "super-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.0.0.tgz", + "integrity": "sha512-CY8u7DtbvucKuquCmOFEKhr9Besln7n9uN8eFbwcoGYWXOMW07u2o8njWaiXt11ylS3qoGF55pILjRmPlbodyg==", + "dev": true, + "requires": { + "function-timeout": "^1.0.1", + "time-span": "^5.1.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "requires": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true + }, + "symbol-observable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", + "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", + "dev": true + }, + "tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true + }, + "tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true + }, + "tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "requires": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + } + }, + "tar-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz", + "integrity": "sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==", + "dev": true, + "requires": { + "bl": "^1.0.0", + "buffer-alloc": "^1.2.0", + "end-of-stream": "^1.0.0", + "fs-constants": "^1.0.0", + "readable-stream": "^2.3.0", + "to-buffer": "^1.1.1", + "xtend": "^4.0.0" + }, + "dependencies": { + "bl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", + "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", + "dev": true, + "requires": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + } + } + }, + "teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "optional": true, + "requires": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "dependencies": { + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "optional": true, + "requires": { + "debug": "4" + } + }, + "http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "optional": true, + "requires": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + } + }, + "https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "optional": true, + "requires": { + "agent-base": "6", + "debug": "4" + } + }, + "node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "optional": true, + "requires": { + "whatwg-url": "^5.0.0" + } + }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "optional": true + }, + "uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "optional": true + }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "optional": true + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "optional": true, + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } + }, + "temp-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", + "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", + "dev": true + }, + "tempy": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.1.0.tgz", + "integrity": "sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g==", + "dev": true, + "requires": { + "is-stream": "^3.0.0", + "temp-dir": "^3.0.0", + "type-fest": "^2.12.2", + "unique-string": "^3.0.0" + }, + "dependencies": { + "is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true + }, + "type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true + } + } + }, + "terser": { + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.30.0.tgz", + "integrity": "sha512-Y/SblUl5kEyEFzhMAQdsxVHh+utAxd4IuRNJzKywY/4uzSogh3G219jqbDDxYu4MXO9CzY3tSEqmZvW6AoEDJw==", + "dev": true, + "requires": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "dependencies": { + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + } + } + }, + "test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "requires": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + } + }, + "text-extensions": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz", + "integrity": "sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==", + "dev": true + }, + "text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" + }, + "thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "requires": { + "any-promise": "^1.0.0" + } + }, + "thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "requires": { + "thenify": ">= 3.1.0 < 4" + } + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true + }, + "time-span": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz", + "integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==", + "dev": true, + "requires": { + "convert-hrtime": "^5.0.0" + } + }, + "tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true + }, + "tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "requires": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "dependencies": { + "fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "requires": {} + }, + "picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true + } + } + }, + "to-buffer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.1.tgz", + "integrity": "sha512-tB82LpAIWjhLYbqjx3X4zEeHN6M8CiuOEy2JY8SEQVdYRe3CCHOFaqrBW1doLDrfpWhplcW7BL+bO3/6S3pcDQ==", + "requires": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "dependencies": { + "isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + } + } + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" + }, + "tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "requires": { + "punycode": "^2.3.1" + } + }, + "traverse": { + "version": "0.6.7", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.7.tgz", + "integrity": "sha512-/y956gpUo9ZNCb99YjxG7OaslxZWHfCHAUUfshwqOXmxUIvqLjVO581BT+gM59+QV9tFe6/CGG53tsA1Y7RSdg==", + "dev": true + }, + "triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==" + }, + "ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dev": true, + "requires": {} + }, + "ts-graphviz": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/ts-graphviz/-/ts-graphviz-2.1.4.tgz", + "integrity": "sha512-0g465/ES70H0h5rcLUqaenKqNYekQaR9W0m0xUGy3FxueGujpGr+0GN2YWlgLIYSE2Xg0W7Uq1Qqnn7Cg+Af2w==", + "dev": true, + "requires": { + "@ts-graphviz/adapter": "^2.0.5", + "@ts-graphviz/ast": "^2.0.5", + "@ts-graphviz/common": "^2.1.4", + "@ts-graphviz/core": "^2.0.5" + } + }, + "ts-invariant": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.10.3.tgz", + "integrity": "sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ==", + "dev": true, + "requires": { + "tslib": "^2.1.0" + } + }, + "tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "requires": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "dependencies": { + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true + } + } + }, + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true + }, + "tv4": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz", + "integrity": "sha512-afizzfpJgvPr+eDkREK4MxJ/+r8nEEHcmitwgnPUqpaP+FpwQyadnxNoSACbgc/b1LsZYtODGoPiFxQrgJgjvw==" + }, + "type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1" + } + }, + "type-fest": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.21.0.tgz", + "integrity": "sha512-ADn2w7hVPcK6w1I0uWnM//y1rLXZhzB9mr0a3OirzclKF1Wp6VzevUmzz/NRAWunOT6E8HrnpGY7xOfc6K57fA==", + "dev": true + }, + "type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "requires": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "dependencies": { + "mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" + }, + "mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "requires": { + "mime-db": "^1.54.0" + } + } + } + }, + "typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "requires": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + } + }, + "typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "requires": { + "is-typedarray": "^1.0.0" + } + }, + "typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true + }, + "typescript-eslint": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.0.tgz", + "integrity": "sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==", + "dev": true, + "requires": { + "@typescript-eslint/eslint-plugin": "8.58.0", + "@typescript-eslint/parser": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0" + }, + "dependencies": { + "@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "requires": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + } + }, + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true + }, + "brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "requires": { + "balanced-match": "^4.0.2" + } + }, + "eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true + }, + "minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "requires": { + "brace-expansion": "^5.0.5" + } + }, + "ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "requires": {} + } + } + }, + "uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true + }, + "uglify-js": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.18.0.tgz", + "integrity": "sha512-SyVVbcNBCk0dzr9XL/R/ySrmYf0s372K6/hFklzgcp2lBFyXtw4I7BOdDjlLhE1aVqaI/SHWXWmYdlZxuyF38A==", + "dev": true, + "optional": true + }, + "unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "dev": true, + "requires": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, + "underscore": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz", + "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", + "dev": true + }, + "undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "dev": true + }, + "undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" + }, + "unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true + }, + "unicode-emoji-modifier-base": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", + "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", + "dev": true + }, + "unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "requires": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + } + }, + "unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true + }, + "unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true + }, + "unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true + }, + "unique-string": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", + "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", + "dev": true, + "requires": { + "crypto-random-string": "^4.0.0" + } + }, + "universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "dev": true + }, + "universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "dev": true + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + }, + "update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "requires": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + } + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "url-join": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", + "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", + "dev": true + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" + }, + "vasync": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/vasync/-/vasync-2.2.1.tgz", + "integrity": "sha512-Hq72JaTpcTFdWiNA4Y22Amej2GH3BFmBaKPPlDZ4/oC8HNn2ISHLkFrJU4Ds8R3jcUi7oo5Y9jcMHKjES+N9wQ==", + "requires": { + "verror": "1.10.0" + }, + "dependencies": { + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + } + } + }, + "verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "dependencies": { + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" + } + } + }, + "walkdir": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/walkdir/-/walkdir-0.4.1.tgz", + "integrity": "sha512-3eBwRyEln6E1MSzcxcVpQIhRG8Q1jLvEqRmCZqS3dsfXEDR/AhOF4d+jHg1qvDCpYaVRZjENPQyrVxAkQqxPgQ==", + "dev": true + }, + "wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "requires": { + "defaults": "^1.0.3" + } + }, + "web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "requires": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + } + }, + "web-streams-polyfill": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", + "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==" + }, + "webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" + }, + "websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "requires": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + } + }, + "websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==" + }, + "whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==" + }, + "whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "requires": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "devOptional": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==", + "dev": true + }, + "which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "requires": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + } + }, + "wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "requires": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "winston": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + "requires": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "dependencies": { + "@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==" + }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, + "winston-daily-rotate-file": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz", + "integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==", + "requires": { + "file-stream-rotator": "^0.6.1", + "object-hash": "^3.0.0", + "triple-beam": "^1.4.1", + "winston-transport": "^4.7.0" + } + }, + "winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "requires": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "dependencies": { + "readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, + "word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true + }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true + }, + "wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + } + } + }, + "wrap-ansi-cjs": { + "version": "npm:wrap-ansi@7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "devOptional": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "devOptional": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "devOptional": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "devOptional": true + } + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "requires": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "requires": {} + }, + "xmlcreate": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.4.tgz", + "integrity": "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==", + "dev": true + }, + "xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" + }, + "y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "dev": true + }, + "yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dev": true, + "requires": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, + "yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "devOptional": true + }, + "yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "requires": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "devOptional": true + }, + "yoctocolors": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.1.tgz", + "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==", + "dev": true + }, + "zen-observable": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz", + "integrity": "sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==", + "dev": true + }, + "zen-observable-ts": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-1.2.5.tgz", + "integrity": "sha512-QZWQekv6iB72Naeake9hS1KxHlotfRpe+WGNbNx5/ta+R3DNjVO2bswf63gXlWDcs+EMd7XY8HfVQyP1X6T4Zg==", + "dev": true, + "requires": { + "zen-observable": "0.8.15" + } + } + } +} diff --git a/package.json b/package.json index 8d121f6e43..7edb5697d1 100644 --- a/package.json +++ b/package.json @@ -1,85 +1,172 @@ { "name": "parse-server", - "version": "2.2.17", + "version": "9.9.0", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { "type": "git", - "url": "https://github.com/ParsePlatform/parse-server" + "url": "https://github.com/parse-community/parse-server" }, "files": [ "bin/", "lib/", - "public_html/", + "public/", "views/", "LICENSE", - "PATENTS", - "README.md" + "NOTICE", + "postinstall.js", + "README.md", + "types" ], - "license": "BSD-3-Clause", + "license": "Apache-2.0", "dependencies": { - "babel-polyfill": "6.8.0", - "babel-runtime": "6.6.1", - "bcrypt-nodejs": "0.0.3", - "body-parser": "1.15.2", - "colors": "1.1.2", - "commander": "2.9.0", - "deepcopy": "0.6.3", - "express": "4.14.0", + "@apollo/server": "5.5.0", + "@as-integrations/express5": "1.1.2", + "@fastify/busboy": "3.2.0", + "@graphql-tools/merge": "9.1.7", + "@graphql-tools/schema": "10.0.31", + "@graphql-tools/utils": "11.0.0", + "@parse/fs-files-adapter": "3.0.0", + "@parse/push-adapter": "8.4.0", + "bcryptjs": "3.0.3", + "commander": "14.0.3", + "cors": "2.8.6", + "express": "5.2.1", + "express-rate-limit": "8.3.1", + "follow-redirects": "1.15.11", + "graphql": "16.13.2", + "graphql-list-fields": "2.0.4", + "graphql-relay": "0.10.2", + "graphql-upload": "15.0.2", "intersect": "1.0.1", - "lodash": "4.14.0", - "lru-cache": "4.0.1", - "mailgun-js": "0.7.10", - "mime": "1.3.4", - "mongodb": "2.2.4", - "multer": "1.1.0", - "parse": "1.9.0", - "parse-server-fs-adapter": "1.0.0", - "parse-server-push-adapter": "1.0.4", - "parse-server-s3-adapter": "1.0.4", - "parse-server-simple-mailgun-adapter": "1.0.0", - "pg-promise": "5.2.5", - "redis": "2.6.2", - "request": "2.74.0", - "request-promise": "4.0.1", - "semver": "^5.2.0", - "tv4": "1.2.7", - "winston": "2.2.0", - "winston-daily-rotate-file": "1.2.0", - "ws": "1.1.1" + "jsonwebtoken": "9.0.3", + "jwks-rsa": "3.2.0", + "ldapjs": "3.0.7", + "lodash": "4.18.1", + "lru-cache": "11.2.7", + "mime": "4.1.0", + "mongodb": "7.1.0", + "mustache": "4.2.0", + "otpauth": "9.5.0", + "parse": "8.6.0", + "path-to-regexp": "8.4.2", + "pg-monitor": "3.1.0", + "pg-promise": "12.6.0", + "pluralize": "8.0.0", + "punycode": "2.3.1", + "rate-limit-redis": "4.3.1", + "redis": "5.11.0", + "semver": "7.7.4", + "tv4": "1.3.0", + "winston": "3.19.0", + "winston-daily-rotate-file": "5.0.0", + "ws": "8.20.0" }, "devDependencies": { - "babel-cli": "6.11.4", - "babel-core": "6.11.4", - "babel-plugin-syntax-flow": "6.8.0", - "babel-plugin-transform-flow-strip-types": "6.8.0", - "babel-preset-es2015": "6.6.0", - "babel-preset-stage-0": "6.5.0", - "babel-register": "6.9.0", - "codecov": "1.0.1", - "cross-env": "2.0.0", - "deep-diff": "0.3.4", - "gaze": "1.1.0", - "istanbul": "1.0.0-alpha.1", - "jasmine": "2.4.1", - "mongodb-runner": "3.3.2", - "nodemon": "1.10.0" + "@actions/core": "3.0.0", + "@apollo/client": "3.13.8", + "@babel/cli": "7.28.6", + "@babel/core": "7.29.0", + "@babel/eslint-parser": "7.28.6", + "@babel/plugin-proposal-object-rest-spread": "7.20.7", + "@babel/plugin-transform-flow-strip-types": "7.27.1", + "@babel/preset-env": "7.29.2", + "@babel/preset-typescript": "7.27.1", + "@saithodev/semantic-release-backmerge": "4.0.1", + "@semantic-release/changelog": "6.0.3", + "@semantic-release/commit-analyzer": "13.0.1", + "@semantic-release/git": "10.0.1", + "@semantic-release/github": "12.0.6", + "@semantic-release/npm": "13.0.0", + "@semantic-release/release-notes-generator": "14.1.0", + "all-node-versions": "13.0.1", + "apollo-upload-client": "18.0.1", + "clean-jsdoc-theme": "4.3.0", + "cross-env": "7.0.3", + "deep-diff": "1.0.2", + "eslint": "9.27.0", + "eslint-plugin-expect-type": "0.6.2", + "eslint-plugin-unused-imports": "4.4.1", + "form-data": "4.0.5", + "globals": "17.3.0", + "graphql-tag": "2.12.6", + "jasmine": "6.1.0", + "jasmine-spec-reporter": "7.0.0", + "jsdoc": "4.0.5", + "jsdoc-babel": "0.5.0", + "lint-staged": "16.4.0", + "m": "1.10.0", + "madge": "8.0.0", + "mock-files-adapter": "file:spec/dependencies/mock-files-adapter", + "mock-mail-adapter": "file:spec/dependencies/mock-mail-adapter", + "mongodb-runner": "5.9.3", + "node-abort-controller": "3.1.1", + "node-fetch": "3.3.2", + "nyc": "17.1.0", + "prettier": "3.8.1", + "semantic-release": "25.0.3", + "typescript": "5.9.3", + "typescript-eslint": "8.58.0", + "yaml": "2.8.3" }, "scripts": { - "dev": "npm run build && node bin/dev", - "build": "babel src/ -d lib/", - "pretest": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=3.2.6} MONGODB_STORAGE_ENGINE=mmapv1 mongodb-runner start", - "test": "cross-env NODE_ENV=test TESTING=1 babel-node $COVERAGE_OPTION ./node_modules/.bin/jasmine", - "test:win": "npm run pretest && cross-env NODE_ENV=test TESTING=1 babel-node ./node_modules/.bin/istanbul cover jasmine && npm run posttest", - "posttest": "mongodb-runner stop", - "coverage": "cross-env COVERAGE_OPTION='./node_modules/.bin/istanbul cover' npm test", + "ci:check": "node ./ci/ciCheck.js", + "ci:checkNodeEngine": "node ./ci/nodeEngineCheck.js", + "ci:definitionsCheck": "node ./ci/definitionsCheck.js", + "definitions": "node ./resources/buildConfigDefinitions.js && prettier --write 'src/Options/*.js'", + "docs": "jsdoc -c ./jsdoc-conf.json", + "lint": "eslint --cache ./ --flag unstable_config_lookup_from_file", + "lint-fix": "eslint --fix --cache ./ --flag unstable_config_lookup_from_file", + "build": "babel src/ -d lib/ --copy-files --extensions '.ts,.js'", + "build:types": "tsc", + "watch": "babel --watch src/ -d lib/ --copy-files", + "watch:ts": "tsc --watch", + "test:mongodb:7.0.16": "MONGODB_VERSION=7.0.16 npm run test", + "test:mongodb:8.0.4": "MONGODB_VERSION=8.0.4 npm run test", + "test:postgres:testonly": "cross-env PARSE_SERVER_TEST_DB=postgres PARSE_SERVER_TEST_DATABASE_URI=postgres://postgres:password@localhost:5432/parse_server_postgres_adapter_test_database npm run testonly", + "testonly": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=8.0.4} MONGODB_TOPOLOGY=${MONGODB_TOPOLOGY:=standalone} TESTING=1 jasmine", + "test": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=8.0.4} MONGODB_TOPOLOGY=${MONGODB_TOPOLOGY:=standalone} mongodb-runner exec -t ${MONGODB_TOPOLOGY} --version ${MONGODB_VERSION} -- --port 27017 -- npm run testonly", + "test:types": "eslint types/tests.ts -c ./types/eslint.config.mjs", + "coverage:mongodb": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=8.0.4} MONGODB_TOPOLOGY=${MONGODB_TOPOLOGY:=standalone} mongodb-runner exec -t ${MONGODB_TOPOLOGY} --version ${MONGODB_VERSION} -- --port 27017 -- npm run coverage", + "coverage": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=8.0.4} MONGODB_TOPOLOGY=${MONGODB_TOPOLOGY:=standalone} TESTING=1 nyc jasmine", "start": "node ./bin/parse-server", - "prepublish": "npm run build" + "prettier": "prettier --write {src,spec}/{**/*,*}.js", + "prepare": "npm run build", + "postinstall": "node -p 'require(\"./postinstall.js\")()'", + "madge:circular": "node_modules/.bin/madge ./src --circular", + "benchmark": "cross-env MONGODB_VERSION=8.0.4 MONGODB_TOPOLOGY=standalone mongodb-runner exec -t standalone --version 8.0.4 -- --port 27017 -- npm run benchmark:only", + "benchmark:only": "node --expose-gc --max-old-space-size=1024 benchmark/performance.js", + "benchmark:quick": "cross-env BENCHMARK_ITERATIONS=10 npm run benchmark:only" }, + "types": "types/index.d.ts", "engines": { - "node": ">=4.3" + "node": ">=20.19.0 <21.0.0 || >=22.13.0 <23.0.0 || >=24.11.0 <25.0.0" }, "bin": { - "parse-server": "./bin/parse-server" + "parse-server": "bin/parse-server" + }, + "optionalDependencies": { + "@node-rs/bcrypt": "1.10.7" + }, + "collective": { + "type": "opencollective", + "url": "https://opencollective.com/parse-server", + "logo": "https://opencollective.com/parse-server/logo.txt?reverse=true&variant=binary" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parse-server" + }, + "husky": { + "hooks": { + "pre-commit": "lint-staged" + } + }, + "lint-staged": { + "{src,spec}/{**/*,*}.js": [ + "prettier --write", + "eslint --fix --cache", + "git add" + ] } } diff --git a/postinstall.js b/postinstall.js new file mode 100644 index 0000000000..409ad04e05 --- /dev/null +++ b/postinstall.js @@ -0,0 +1,38 @@ +const message = ` + 1111111111 + 1111111111111111 + 1111111111111111111111 + 11111111111111111111111111 + 111111111111111 11111111 + 1111111111111 111 111111 + 1111111111111 111111111 111111 + 111111111111 11111111111 111111 + 1111111111111 11111111111 111111 + 1111111111111 1111111111 111111 + 1111111111111111111111111 1111111 + 11111111 11111111 + 111111 111 1111111111111111111 + 11111 11111 111111111111111111 + 11111 1 11111111111111111 + 111111 111111111111111111 + 11111111111111111111111111 + 1111111111111111111111 + 111111111111111111 + 11111111111 + + Thank you for using Parse Platform! + https://parseplatform.org + +Please consider donating to help us maintain + this package: + +👉 https://opencollective.com/parse-server 👈 + +`; + +function main() { + process.stdout.write(message); + process.exit(0); +} + +module.exports = main; diff --git a/public/custom_json.html b/public/custom_json.html new file mode 100644 index 0000000000..7e280bfc05 --- /dev/null +++ b/public/custom_json.html @@ -0,0 +1,17 @@ + + + + + + {{title}} + + + +

{{heading}}

+

{{body}}

+ + + diff --git a/public/custom_json.json b/public/custom_json.json new file mode 100644 index 0000000000..06d78f1d9d --- /dev/null +++ b/public/custom_json.json @@ -0,0 +1,23 @@ +{ + "en": { + "translation": { + "title": "Hello!", + "heading": "Welcome to {{appName}}!", + "body": "We are delighted to welcome you on board." + } + }, + "de": { + "translation": { + "title": "Hallo!", + "heading": "Willkommen bei {{appName}}!", + "body": "Wir freuen uns, dich begrÃŧßen zu dÃŧrfen." + } + }, + "de-AT": { + "translation": { + "title": "Servus!", + "heading": "Willkommen bei {{appName}}!", + "body": "Wir freuen uns, dich begrÃŧßen zu dÃŧrfen." + } + } +} \ No newline at end of file diff --git a/public/custom_page.html b/public/custom_page.html new file mode 100644 index 0000000000..08a2b3e63c --- /dev/null +++ b/public/custom_page.html @@ -0,0 +1,15 @@ + + + + + + {{appName}} + + + +

{{appName}}

+ + + diff --git a/public/de-AT/email_verification_link_expired.html b/public/de-AT/email_verification_link_expired.html new file mode 100644 index 0000000000..6a664c48cd --- /dev/null +++ b/public/de-AT/email_verification_link_expired.html @@ -0,0 +1,24 @@ + + + + + + Email Verification + + + +

{{appName}}

+

Expired verification link!

+
+ + + +
+ + + diff --git a/public/de-AT/email_verification_link_invalid.html b/public/de-AT/email_verification_link_invalid.html new file mode 100644 index 0000000000..3a99265a66 --- /dev/null +++ b/public/de-AT/email_verification_link_invalid.html @@ -0,0 +1,21 @@ + + + + + + Email Verification + + + +

{{appName}}

+

Invalid verification link!

+ + + diff --git a/public/de-AT/email_verification_send_fail.html b/public/de-AT/email_verification_send_fail.html new file mode 100644 index 0000000000..afd59407b8 --- /dev/null +++ b/public/de-AT/email_verification_send_fail.html @@ -0,0 +1,21 @@ + + + + + + Email Verification + + + +

{{appName}}

+

Invalid link!

+

No link sent. User not found or email already verified.

+ + + diff --git a/public/de-AT/email_verification_send_success.html b/public/de-AT/email_verification_send_success.html new file mode 100644 index 0000000000..192a33142b --- /dev/null +++ b/public/de-AT/email_verification_send_success.html @@ -0,0 +1,19 @@ + + + + + + Email Verification + + + +

{{appName}}

+

Link sent!

+

A new link has been sent. Check your email.

+ + + diff --git a/public/de-AT/email_verification_success.html b/public/de-AT/email_verification_success.html new file mode 100644 index 0000000000..e8db182551 --- /dev/null +++ b/public/de-AT/email_verification_success.html @@ -0,0 +1,18 @@ + + + + + + Email Verification + + + +

{{appName}}

+

Email verified!

+

Successfully verified your email for account: {{username}}.

+ + + diff --git a/public/de-AT/password_reset.html b/public/de-AT/password_reset.html new file mode 100644 index 0000000000..73cb1e3d52 --- /dev/null +++ b/public/de-AT/password_reset.html @@ -0,0 +1,65 @@ + + + + + +Password Reset + + + +

{{appName}}

+

Reset Your Password

+ +

You can set a new Password for your account: {{username}}

+
+

{{error}}

+
+ + + + + +

New Password

+ +

Confirm New Password

+ +
+

+
+ +
+ + + + + \ No newline at end of file diff --git a/public/de-AT/password_reset_link_invalid.html b/public/de-AT/password_reset_link_invalid.html new file mode 100644 index 0000000000..5db34de15e --- /dev/null +++ b/public/de-AT/password_reset_link_invalid.html @@ -0,0 +1,19 @@ + + + + + + Password Reset + + + +

{{appName}}

+

Invalid password reset link!

+ + + diff --git a/public/de-AT/password_reset_success.html b/public/de-AT/password_reset_success.html new file mode 100644 index 0000000000..4b4e4c7104 --- /dev/null +++ b/public/de-AT/password_reset_success.html @@ -0,0 +1,18 @@ + + + + + + Password Reset + + + +

{{appName}}

+

Success!

+

Your password has been updated.

+ + + diff --git a/public/de/email_verification_link_expired.html b/public/de/email_verification_link_expired.html new file mode 100644 index 0000000000..6a664c48cd --- /dev/null +++ b/public/de/email_verification_link_expired.html @@ -0,0 +1,24 @@ + + + + + + Email Verification + + + +

{{appName}}

+

Expired verification link!

+
+ + + +
+ + + diff --git a/public/de/email_verification_link_invalid.html b/public/de/email_verification_link_invalid.html new file mode 100644 index 0000000000..3a99265a66 --- /dev/null +++ b/public/de/email_verification_link_invalid.html @@ -0,0 +1,21 @@ + + + + + + Email Verification + + + +

{{appName}}

+

Invalid verification link!

+ + + diff --git a/public/de/email_verification_send_fail.html b/public/de/email_verification_send_fail.html new file mode 100644 index 0000000000..afd59407b8 --- /dev/null +++ b/public/de/email_verification_send_fail.html @@ -0,0 +1,21 @@ + + + + + + Email Verification + + + +

{{appName}}

+

Invalid link!

+

No link sent. User not found or email already verified.

+ + + diff --git a/public/de/email_verification_send_success.html b/public/de/email_verification_send_success.html new file mode 100644 index 0000000000..192a33142b --- /dev/null +++ b/public/de/email_verification_send_success.html @@ -0,0 +1,19 @@ + + + + + + Email Verification + + + +

{{appName}}

+

Link sent!

+

A new link has been sent. Check your email.

+ + + diff --git a/public/de/email_verification_success.html b/public/de/email_verification_success.html new file mode 100644 index 0000000000..e8db182551 --- /dev/null +++ b/public/de/email_verification_success.html @@ -0,0 +1,18 @@ + + + + + + Email Verification + + + +

{{appName}}

+

Email verified!

+

Successfully verified your email for account: {{username}}.

+ + + diff --git a/public/de/password_reset.html b/public/de/password_reset.html new file mode 100644 index 0000000000..73cb1e3d52 --- /dev/null +++ b/public/de/password_reset.html @@ -0,0 +1,65 @@ + + + + + +Password Reset + + + +

{{appName}}

+

Reset Your Password

+ +

You can set a new Password for your account: {{username}}

+
+

{{error}}

+
+ + + + + +

New Password

+ +

Confirm New Password

+ +
+

+
+ +
+ + + + + \ No newline at end of file diff --git a/public/de/password_reset_link_invalid.html b/public/de/password_reset_link_invalid.html new file mode 100644 index 0000000000..5db34de15e --- /dev/null +++ b/public/de/password_reset_link_invalid.html @@ -0,0 +1,19 @@ + + + + + + Password Reset + + + +

{{appName}}

+

Invalid password reset link!

+ + + diff --git a/public/de/password_reset_success.html b/public/de/password_reset_success.html new file mode 100644 index 0000000000..4b4e4c7104 --- /dev/null +++ b/public/de/password_reset_success.html @@ -0,0 +1,18 @@ + + + + + + Password Reset + + + +

{{appName}}

+

Success!

+

Your password has been updated.

+ + + diff --git a/public/email_verification_link_expired.html b/public/email_verification_link_expired.html new file mode 100644 index 0000000000..6a664c48cd --- /dev/null +++ b/public/email_verification_link_expired.html @@ -0,0 +1,24 @@ + + + + + + Email Verification + + + +

{{appName}}

+

Expired verification link!

+
+ + + +
+ + + diff --git a/public/email_verification_link_invalid.html b/public/email_verification_link_invalid.html new file mode 100644 index 0000000000..3a99265a66 --- /dev/null +++ b/public/email_verification_link_invalid.html @@ -0,0 +1,21 @@ + + + + + + Email Verification + + + +

{{appName}}

+

Invalid verification link!

+ + + diff --git a/public/email_verification_send_fail.html b/public/email_verification_send_fail.html new file mode 100644 index 0000000000..afd59407b8 --- /dev/null +++ b/public/email_verification_send_fail.html @@ -0,0 +1,21 @@ + + + + + + Email Verification + + + +

{{appName}}

+

Invalid link!

+

No link sent. User not found or email already verified.

+ + + diff --git a/public/email_verification_send_success.html b/public/email_verification_send_success.html new file mode 100644 index 0000000000..192a33142b --- /dev/null +++ b/public/email_verification_send_success.html @@ -0,0 +1,19 @@ + + + + + + Email Verification + + + +

{{appName}}

+

Link sent!

+

A new link has been sent. Check your email.

+ + + diff --git a/public/email_verification_success.html b/public/email_verification_success.html new file mode 100644 index 0000000000..e8db182551 --- /dev/null +++ b/public/email_verification_success.html @@ -0,0 +1,18 @@ + + + + + + Email Verification + + + +

{{appName}}

+

Email verified!

+

Successfully verified your email for account: {{username}}.

+ + + diff --git a/public/password_reset.html b/public/password_reset.html new file mode 100644 index 0000000000..73cb1e3d52 --- /dev/null +++ b/public/password_reset.html @@ -0,0 +1,65 @@ + + + + + +Password Reset + + + +

{{appName}}

+

Reset Your Password

+ +

You can set a new Password for your account: {{username}}

+
+

{{error}}

+
+ + + + + +

New Password

+ +

Confirm New Password

+ +
+

+
+ +
+ + + + + \ No newline at end of file diff --git a/public/password_reset_link_invalid.html b/public/password_reset_link_invalid.html new file mode 100644 index 0000000000..5db34de15e --- /dev/null +++ b/public/password_reset_link_invalid.html @@ -0,0 +1,19 @@ + + + + + + Password Reset + + + +

{{appName}}

+

Invalid password reset link!

+ + + diff --git a/public/password_reset_success.html b/public/password_reset_success.html new file mode 100644 index 0000000000..4b4e4c7104 --- /dev/null +++ b/public/password_reset_success.html @@ -0,0 +1,18 @@ + + + + + + Password Reset + + + +

{{appName}}

+

Success!

+

Your password has been updated.

+ + + diff --git a/public_html/invalid_link.html b/public_html/invalid_link.html deleted file mode 100644 index 66bdc788fb..0000000000 --- a/public_html/invalid_link.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - Invalid Link - - -
-

Invalid Link

-
- - diff --git a/public_html/password_reset_success.html b/public_html/password_reset_success.html deleted file mode 100644 index 774cbb350c..0000000000 --- a/public_html/password_reset_success.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - Password Reset - - -

Successfully updated your password!

- - diff --git a/public_html/verify_email_success.html b/public_html/verify_email_success.html deleted file mode 100644 index 774ea38a0d..0000000000 --- a/public_html/verify_email_success.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - Email Verification - - -

Successfully verified your email!

- - diff --git a/release_docs.sh b/release_docs.sh new file mode 100755 index 0000000000..0c7cc2b395 --- /dev/null +++ b/release_docs.sh @@ -0,0 +1,41 @@ +#!/bin/sh -e +set -x +# GITHUB_ACTIONS=true SOURCE_TAG=test ./release_docs.sh + +if [ "${GITHUB_ACTIONS}" = "" ]; +then + echo "Cannot release docs without GITHUB_ACTIONS set" + exit 0; +fi +if [ "${SOURCE_TAG}" = "" ]; +then + echo "Cannot release docs without SOURCE_TAG set" + exit 0; +fi +REPO="https://github.com/parse-community/parse-server" + +rm -rf docs +git clone -b gh-pages --single-branch $REPO ./docs +cd docs +git pull origin gh-pages +cd .. + +RELEASE="release" +VERSION="${SOURCE_TAG}" + +# change the default page to the latest +echo "" > "docs/api/index.html" + +npm run definitions +npm run docs + +mkdir -p "docs/api/${RELEASE}" +cp -R out/* "docs/api/${RELEASE}" + +mkdir -p "docs/api/${VERSION}" +cp -R out/* "docs/api/${VERSION}" + +# Copy other resources +RESOURCE_DIR=".github" +mkdir -p "docs/${RESOURCE_DIR}" +cp "./.github/parse-server-logo.png" "docs/${RESOURCE_DIR}/" diff --git a/resources/buildConfigDefinitions.js b/resources/buildConfigDefinitions.js new file mode 100644 index 0000000000..1a3cbb4b55 --- /dev/null +++ b/resources/buildConfigDefinitions.js @@ -0,0 +1,405 @@ +/** + * Parse Server Configuration Builder + * + * This module builds the definitions file (src/Options/Definitions.js) + * from the src/Options/index.js options interfaces. + * The Definitions.js module is responsible for the default values as well + * as the mappings for the CLI. + * + * To rebuild the definitions file, run + * `$ node resources/buildConfigDefinitions.js` + */ +const parsers = require('../src/Options/parsers'); + +/** The types of nested options. */ +const nestedOptionTypes = [ + 'CustomPagesOptions', + 'DatabaseOptions', + 'FileDownloadOptions', + 'FileUploadOptions', + 'IdempotencyOptions', + 'InstallationOptions', + 'Object', + 'PagesCustomUrlsOptions', + 'PagesOptions', + 'PagesRoute', + 'PasswordPolicyOptions', + 'QueryServerOptions', + 'RequestComplexityOptions', + 'SecurityOptions', + 'SchemaOptions', + 'LogLevels', +]; + +/** The prefix of environment variables for nested options. */ +const nestedOptionEnvPrefix = { + AccountLockoutOptions: 'PARSE_SERVER_ACCOUNT_LOCKOUT_', + DatabaseOptionsClientMetadata: 'PARSE_SERVER_DATABASE_CLIENT_METADATA_', + CustomPagesOptions: 'PARSE_SERVER_CUSTOM_PAGES_', + DatabaseOptions: 'PARSE_SERVER_DATABASE_', + FileDownloadOptions: 'PARSE_SERVER_FILE_DOWNLOAD_', + FileUploadOptions: 'PARSE_SERVER_FILE_UPLOAD_', + IdempotencyOptions: 'PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_', + InstallationOptions: 'PARSE_SERVER_INSTALLATION_', + LiveQueryOptions: 'PARSE_SERVER_LIVEQUERY_', + LiveQueryServerOptions: 'PARSE_LIVE_QUERY_SERVER_', + LogClientEvent: 'PARSE_SERVER_DATABASE_LOG_CLIENT_EVENTS_', + LogLevel: 'PARSE_SERVER_LOG_LEVEL_', + LogLevels: 'PARSE_SERVER_LOG_LEVELS_', + PagesCustomUrlsOptions: 'PARSE_SERVER_PAGES_CUSTOM_URL_', + PagesOptions: 'PARSE_SERVER_PAGES_', + PagesRoute: 'PARSE_SERVER_PAGES_ROUTE_', + ParseServerOptions: 'PARSE_SERVER_', + PasswordPolicyOptions: 'PARSE_SERVER_PASSWORD_POLICY_', + QueryServerOptions: 'PARSE_SERVER_QUERY_', + RateLimitOptions: 'PARSE_SERVER_RATE_LIMIT_', + RequestComplexityOptions: 'PARSE_SERVER_REQUEST_COMPLEXITY_', + SchemaOptions: 'PARSE_SERVER_SCHEMA_', + SecurityOptions: 'PARSE_SERVER_SECURITY_', +}; + +function last(array) { + return array[array.length - 1]; +} + +const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; +function toENV(key) { + let str = ''; + let previousIsUpper = false; + for (let i = 0; i < key.length; i++) { + const char = key[i]; + if (letters.indexOf(char) >= 0) { + if (!previousIsUpper) { + str += '_'; + previousIsUpper = true; + } + } else { + previousIsUpper = false; + } + str += char; + } + return str.toUpperCase(); +} + +function getCommentValue(comment) { + if (!comment) { + return; + } + return comment.value.trim(); +} + +function getENVPrefix(iface) { + if (nestedOptionEnvPrefix[iface.id.name]) { + return nestedOptionEnvPrefix[iface.id.name]; + } +} + +function processProperty(property, iface) { + const firstComment = getCommentValue(last(property.leadingComments || [])); + const name = property.key.name; + const prefix = getENVPrefix(iface); + + if (!firstComment) { + return; + } + const lines = firstComment.split('\n').map(line => line.trim()); + let help = ''; + let envLine; + let defaultLine; + lines.forEach(line => { + if (line.indexOf(':ENV:') === 0) { + envLine = line; + } else if (line.indexOf(':DEFAULT:') === 0) { + defaultLine = line; + } else { + help += line; + } + }); + let env; + if (envLine) { + env = envLine.split(' ')[1]; + } else { + env = prefix + toENV(name); + } + let defaultValue; + if (defaultLine) { + const defaultArray = defaultLine.split(' '); + defaultArray.shift(); + defaultValue = defaultArray.join(' '); + } + let type = property.value.type; + let isRequired = true; + if (type == 'NullableTypeAnnotation') { + isRequired = false; + type = property.value.typeAnnotation.type; + } + return { + name, + env, + help, + type, + defaultValue, + types: property.value.types, + typeAnnotation: property.value.typeAnnotation, + required: isRequired, + }; +} + +function doInterface(iface) { + return iface.body.properties + .sort((a, b) => a.key.name.localeCompare(b.key.name)) + .map(prop => processProperty(prop, iface)) + .filter(e => e !== undefined); +} + +function mapperFor(elt, t) { + const p = t.identifier('parsers'); + const wrap = identifier => t.memberExpression(p, identifier); + + if (t.isNumberTypeAnnotation(elt)) { + return t.callExpression(wrap(t.identifier('numberParser')), [t.stringLiteral(elt.name)]); + } else if (t.isArrayTypeAnnotation(elt)) { + return wrap(t.identifier('arrayParser')); + } else if (t.isAnyTypeAnnotation(elt)) { + return wrap(t.identifier('objectParser')); + } else if (t.isBooleanTypeAnnotation(elt)) { + return wrap(t.identifier('booleanParser')); + } else if (t.isObjectTypeAnnotation(elt)) { + return wrap(t.identifier('objectParser')); + } else if (t.isUnionTypeAnnotation(elt)) { + const unionTypes = elt.typeAnnotation?.types || elt.types; + if (unionTypes?.some(type => t.isBooleanTypeAnnotation(type)) && unionTypes?.some(type => t.isFunctionTypeAnnotation(type))) { + return wrap(t.identifier('booleanOrFunctionParser')); + } + } else if (t.isGenericTypeAnnotation(elt)) { + const type = elt.typeAnnotation.id.name; + if (type == 'Adapter') { + return wrap(t.identifier('moduleOrObjectParser')); + } + if (type == 'NumberOrBoolean') { + return wrap(t.identifier('numberOrBooleanParser')); + } + if (type == 'NumberOrString') { + return t.callExpression(wrap(t.identifier('numberOrStringParser')), [t.stringLiteral(elt.name)]); + } + if (type === 'StringOrStringArray') { + return wrap(t.identifier('arrayParser')); + } + return wrap(t.identifier('objectParser')); + } +} + +function parseDefaultValue(elt, value, t) { + let literalValue; + if (t.isStringTypeAnnotation(elt)) { + if (value == '""' || value == "''") { + literalValue = t.stringLiteral(''); + } else { + literalValue = t.stringLiteral(value); + } + } else if (t.isNumberTypeAnnotation(elt)) { + literalValue = t.numericLiteral(parsers.numberOrBoolParser('')(value)); + } else if (t.isArrayTypeAnnotation(elt)) { + const array = parsers.objectParser(value); + literalValue = t.arrayExpression( + array.map(value => { + if (typeof value == 'string') { + return t.stringLiteral(value); + } else if (typeof value == 'number') { + return t.numericLiteral(value); + } else if (typeof value == 'object') { + const object = parsers.objectParser(value); + const props = Object.entries(object).map(([k, v]) => { + if (typeof v == 'string') { + return t.objectProperty(t.identifier(k), t.stringLiteral(v)); + } else if (typeof v == 'number') { + return t.objectProperty(t.identifier(k), t.numericLiteral(v)); + } else if (typeof v == 'boolean') { + return t.objectProperty(t.identifier(k), t.booleanLiteral(v)); + } + }); + return t.objectExpression(props); + } else { + throw new Error('Unable to parse array'); + } + }) + ); + } else if (t.isAnyTypeAnnotation(elt)) { + literalValue = t.arrayExpression([]); + } else if (t.isBooleanTypeAnnotation(elt)) { + literalValue = t.booleanLiteral(parsers.booleanParser(value)); + } else if (t.isGenericTypeAnnotation(elt)) { + const type = elt.typeAnnotation.id.name; + if (type == 'NumberOrBoolean') { + literalValue = t.numericLiteral(parsers.numberOrBoolParser('')(value)); + } + if (type == 'NumberOrString') { + literalValue = t.numericLiteral(parsers.numberOrStringParser('')(value)); + } + + if (nestedOptionTypes.includes(type)) { + 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([ + t.objectProperty(t.stringLiteral('*'), t.arrayExpression([t.stringLiteral('email')])), + ]) + ); + literalValue = t.objectExpression([prop]); + } + } + return literalValue; +} + +function inject(t, list) { + let comments = ''; + const results = list + .map(elt => { + if (!elt.name) { + return; + } + const props = ['env', 'help'] + .map(key => { + if (elt[key]) { + return t.objectProperty(t.stringLiteral(key), t.stringLiteral(elt[key])); + } + }) + .filter(e => e !== undefined); + if (elt.required) { + props.push(t.objectProperty(t.stringLiteral('required'), t.booleanLiteral(true))); + } + const action = mapperFor(elt, t); + if (action) { + props.push(t.objectProperty(t.stringLiteral('action'), action)); + } + + if (t.isGenericTypeAnnotation(elt)) { + if (elt.typeAnnotation.id.name in nestedOptionEnvPrefix) { + props.push( + t.objectProperty(t.stringLiteral('type'), t.stringLiteral(elt.typeAnnotation.id.name)) + ); + } + } else if (t.isArrayTypeAnnotation(elt)) { + const elementType = elt.typeAnnotation.elementType; + if (t.isGenericTypeAnnotation(elementType)) { + if (elementType.id.name in nestedOptionEnvPrefix) { + props.push( + t.objectProperty(t.stringLiteral('type'), t.stringLiteral(elementType.id.name + '[]')) + ); + } + } + } + if (elt.defaultValue) { + let parsedValue = parseDefaultValue(elt, elt.defaultValue, t); + if (!parsedValue) { + for (const type of elt.typeAnnotation.types) { + elt.type = type.type; + parsedValue = parseDefaultValue(elt, elt.defaultValue, t); + if (parsedValue) { + break; + } + } + } + if (parsedValue) { + props.push(t.objectProperty(t.stringLiteral('default'), parsedValue)); + } else { + throw new Error(`Unable to parse value for ${elt.name} `); + } + } + let type = elt.type.replace('TypeAnnotation', ''); + if (type === 'Generic') { + type = elt.typeAnnotation.id.name; + } + if (type === 'Array') { + type = elt.typeAnnotation.elementType.id + ? `${elt.typeAnnotation.elementType.id.name}[]` + : `${elt.typeAnnotation.elementType.type.replace('TypeAnnotation', '')}[]`; + } + if (type === 'NumberOrBoolean') { + type = 'Number|Boolean'; + } + if (type === 'NumberOrString') { + type = 'Number|String'; + } + if (type === 'Adapter') { + const adapterType = elt.typeAnnotation.typeParameters.params[0].id.name; + type = `Adapter<${adapterType}>`; + } + if (type === 'StringOrStringArray') { + type = 'String|String[]'; + } + comments += ` * @property {${type}} ${elt.name} ${elt.help}\n`; + const obj = t.objectExpression(props); + return t.objectProperty(t.stringLiteral(elt.name), obj); + }) + .filter(elt => { + return elt != undefined; + }); + return { results, comments }; +} + +const makeRequire = function (variableName, module, t) { + const decl = t.variableDeclarator( + t.identifier(variableName), + t.callExpression(t.identifier('require'), [t.stringLiteral(module)]) + ); + return t.variableDeclaration('var', [decl]); +}; +let docs = ``; +const plugin = function (babel) { + const t = babel.types; + const moduleExports = t.memberExpression(t.identifier('module'), t.identifier('exports')); + return { + visitor: { + ImportDeclaration: function (path) { + path.remove(); + }, + Program: function (path) { + // Inject the parser's loader + path.unshiftContainer('body', makeRequire('parsers', './parsers', t)); + }, + ExportDeclaration: function (path) { + // Export declaration on an interface + if ( + path.node && + path.node.declaration && + path.node.declaration.type == 'InterfaceDeclaration' + ) { + const { results, comments } = inject(t, doInterface(path.node.declaration)); + const id = path.node.declaration.id.name; + const exports = t.memberExpression(moduleExports, t.identifier(id)); + docs += `/**\n * @interface ${id}\n${comments} */\n\n`; + path.replaceWith(t.assignmentExpression('=', exports, t.objectExpression(results))); + } + }, + }, + }; +}; + +const auxiliaryCommentBefore = ` +**** GENERATED CODE **** +This code has been generated by resources/buildConfigDefinitions.js +Do not edit manually, but update Options/index.js +`; + +// Only run the transformation when executed directly, not when imported by tests +if (require.main === module) { + const babel = require('@babel/core'); + const res = babel.transformFileSync('./src/Options/index.js', { + plugins: [plugin, '@babel/transform-flow-strip-types'], + babelrc: false, + auxiliaryCommentBefore, + sourceMaps: false, + }); + require('fs').writeFileSync('./src/Options/Definitions.js', res.code + '\n'); + require('fs').writeFileSync('./src/Options/docs.js', docs); +} + +// Export mapperFor for testing +module.exports = { mapperFor }; diff --git a/scripts/before_script_postgres.sh b/scripts/before_script_postgres.sh new file mode 100755 index 0000000000..5c445c4df1 --- /dev/null +++ b/scripts/before_script_postgres.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -e + +echo "[SCRIPT] Before Script :: Setup Parse DB for Postgres" + +PGPASSWORD=postgres psql -v ON_ERROR_STOP=1 -h localhost -U postgres <<-EOSQL + CREATE DATABASE parse_server_postgres_adapter_test_database; + \c parse_server_postgres_adapter_test_database; + CREATE EXTENSION pgcrypto; + CREATE EXTENSION postgis; + CREATE EXTENSION postgis_topology; +EOSQL diff --git a/scripts/before_script_postgres_conf.sh b/scripts/before_script_postgres_conf.sh new file mode 100755 index 0000000000..ec471d9c3f --- /dev/null +++ b/scripts/before_script_postgres_conf.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +set -e + +echo "[SCRIPT] Before Script :: Setup Parse Postgres configuration file" + +# DB Version: 13 +# OS Type: linux +# DB Type: web +# Total Memory (RAM): 6 GB +# CPUs num: 1 +# Data Storage: ssd + +PGPASSWORD=postgres psql -v ON_ERROR_STOP=1 -h localhost -U postgres <<-EOSQL + ALTER SYSTEM SET max_connections TO '200'; + ALTER SYSTEM SET shared_buffers TO '1536MB'; + ALTER SYSTEM SET effective_cache_size TO '4608MB'; + ALTER SYSTEM SET maintenance_work_mem TO '384MB'; + ALTER SYSTEM SET checkpoint_completion_target TO '0.9'; + ALTER SYSTEM SET wal_buffers TO '16MB'; + ALTER SYSTEM SET default_statistics_target TO '100'; + ALTER SYSTEM SET random_page_cost TO '1.1'; + ALTER SYSTEM SET effective_io_concurrency TO '200'; + ALTER SYSTEM SET work_mem TO '3932kB'; + ALTER SYSTEM SET min_wal_size TO '1GB'; + ALTER SYSTEM SET max_wal_size TO '4GB'; + SELECT pg_reload_conf(); +EOSQL + +exec "$@" diff --git a/spec/.babelrc b/spec/.babelrc new file mode 100644 index 0000000000..633eaf7fac --- /dev/null +++ b/spec/.babelrc @@ -0,0 +1,14 @@ +{ + "plugins": [ + "@babel/plugin-proposal-object-rest-spread" + ], + "presets": [ + "@babel/preset-typescript", + ["@babel/preset-env", { + "targets": { + "node": "18" + } + }] + ], + "sourceMaps": "inline" +} diff --git a/spec/AccountLockoutPolicy.spec.js b/spec/AccountLockoutPolicy.spec.js new file mode 100644 index 0000000000..91d30e55fa --- /dev/null +++ b/spec/AccountLockoutPolicy.spec.js @@ -0,0 +1,516 @@ +'use strict'; + +const Config = require('../lib/Config'); +const Definitions = require('../lib/Options/Definitions'); +const request = require('../lib/request'); + +const loginWithWrongCredentialsShouldFail = function (username, password) { + return new Promise((resolve, reject) => { + Parse.User.logIn(username, password) + .then(() => reject('login should have failed')) + .catch(err => { + if (err.message === 'Invalid username/password.') { + resolve(); + } else { + reject(err); + } + }); + }); +}; + +const isAccountLockoutError = function (username, password, duration, waitTime) { + return new Promise((resolve, reject) => { + setTimeout(() => { + Parse.User.logIn(username, password) + .then(() => reject('login should have failed')) + .catch(err => { + if ( + err.message === + 'Your account is locked due to multiple failed login attempts. Please try again after ' + + duration + + ' minute(s)' + ) { + resolve(); + } else { + reject(err); + } + }); + }, waitTime); + }); +}; + +describe('Account Lockout Policy: ', () => { + it('account should not be locked even after failed login attempts if account lockout policy is not set', done => { + reconfigureServer({ + appName: 'unlimited', + publicServerURL: 'http://localhost:1337/1', + }) + .then(() => { + const user = new Parse.User(); + user.setUsername('username1'); + user.setPassword('password'); + return user.signUp(null); + }) + .then(() => { + return loginWithWrongCredentialsShouldFail('username1', 'incorrect password 1'); + }) + .then(() => { + return loginWithWrongCredentialsShouldFail('username1', 'incorrect password 2'); + }) + .then(() => { + return loginWithWrongCredentialsShouldFail('username1', 'incorrect password 3'); + }) + .then(() => done()) + .catch(err => { + fail('allow unlimited failed login attempts failed: ' + JSON.stringify(err)); + done(); + }); + }); + + it('throw error if duration is set to an invalid number', done => { + reconfigureServer({ + appName: 'duration', + accountLockout: { + duration: 'invalid value', + threshold: 5, + }, + publicServerURL: 'https://my.public.server.com/1', + }) + .then(() => { + Config.get('test'); + fail('set duration to an invalid number test failed'); + done(); + }) + .catch(err => { + if ( + err && + err === 'Account lockout duration should be greater than 0 and less than 100000' + ) { + done(); + } else { + fail('set duration to an invalid number test failed: ' + JSON.stringify(err)); + done(); + } + }); + }); + + it('throw error if threshold is set to an invalid number', done => { + reconfigureServer({ + appName: 'threshold', + accountLockout: { + duration: 5, + threshold: 'invalid number', + }, + publicServerURL: 'https://my.public.server.com/1', + }) + .then(() => { + Config.get('test'); + fail('set threshold to an invalid number test failed'); + done(); + }) + .catch(err => { + if ( + err && + err === 'Account lockout threshold should be an integer greater than 0 and less than 1000' + ) { + done(); + } else { + fail('set threshold to an invalid number test failed: ' + JSON.stringify(err)); + done(); + } + }); + }); + + it('throw error if threshold is < 1', done => { + reconfigureServer({ + appName: 'threshold', + accountLockout: { + duration: 5, + threshold: 0, + }, + publicServerURL: 'https://my.public.server.com/1', + }) + .then(() => { + Config.get('test'); + fail('threshold value < 1 is invalid test failed'); + done(); + }) + .catch(err => { + if ( + err && + err === 'Account lockout threshold should be an integer greater than 0 and less than 1000' + ) { + done(); + } else { + fail('threshold value < 1 is invalid test failed: ' + JSON.stringify(err)); + done(); + } + }); + }); + + it('throw error if threshold is > 999', done => { + reconfigureServer({ + appName: 'threshold', + accountLockout: { + duration: 5, + threshold: 1000, + }, + publicServerURL: 'https://my.public.server.com/1', + }) + .then(() => { + Config.get('test'); + fail('threshold value > 999 is invalid test failed'); + done(); + }) + .catch(err => { + if ( + err && + err === 'Account lockout threshold should be an integer greater than 0 and less than 1000' + ) { + done(); + } else { + fail('threshold value > 999 is invalid test failed: ' + JSON.stringify(err)); + done(); + } + }); + }); + + it('throw error if duration is <= 0', done => { + reconfigureServer({ + appName: 'duration', + accountLockout: { + duration: 0, + threshold: 5, + }, + publicServerURL: 'https://my.public.server.com/1', + }) + .then(() => { + Config.get('test'); + fail('duration value < 1 is invalid test failed'); + done(); + }) + .catch(err => { + if ( + err && + err === 'Account lockout duration should be greater than 0 and less than 100000' + ) { + done(); + } else { + fail('duration value < 1 is invalid test failed: ' + JSON.stringify(err)); + done(); + } + }); + }); + + it('throw error if duration is > 99999', done => { + reconfigureServer({ + appName: 'duration', + accountLockout: { + duration: 100000, + threshold: 5, + }, + publicServerURL: 'https://my.public.server.com/1', + }) + .then(() => { + Config.get('test'); + fail('duration value > 99999 is invalid test failed'); + done(); + }) + .catch(err => { + if ( + err && + err === 'Account lockout duration should be greater than 0 and less than 100000' + ) { + done(); + } else { + fail('duration value > 99999 is invalid test failed: ' + JSON.stringify(err)); + done(); + } + }); + }); + + it('lock account if failed login attempts are above threshold', done => { + reconfigureServer({ + appName: 'lockout threshold', + accountLockout: { + duration: 1, + threshold: 2, + }, + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + const user = new Parse.User(); + user.setUsername('username2'); + user.setPassword('failedLoginAttemptsThreshold'); + return user.signUp(); + }) + .then(() => { + return loginWithWrongCredentialsShouldFail('username2', 'wrong password'); + }) + .then(() => { + return loginWithWrongCredentialsShouldFail('username2', 'wrong password'); + }) + .then(() => { + return isAccountLockoutError('username2', 'wrong password', 1, 1); + }) + .then(() => { + done(); + }) + .catch(err => { + fail('lock account after failed login attempts test failed: ' + JSON.stringify(err)); + done(); + }); + }); + + it('lock account for accountPolicy.duration minutes if failed login attempts are above threshold', done => { + reconfigureServer({ + appName: 'lockout threshold', + accountLockout: { + duration: 0.05, // 0.05*60 = 3 secs + threshold: 2, + }, + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + const user = new Parse.User(); + user.setUsername('username3'); + user.setPassword('failedLoginAttemptsThreshold'); + return user.signUp(); + }) + .then(() => { + return loginWithWrongCredentialsShouldFail('username3', 'wrong password'); + }) + .then(() => { + return loginWithWrongCredentialsShouldFail('username3', 'wrong password'); + }) + .then(() => { + return isAccountLockoutError('username3', 'wrong password', 0.05, 1); + }) + .then(() => { + // account should still be locked even after 2 seconds. + return isAccountLockoutError('username3', 'wrong password', 0.05, 2000); + }) + .then(() => { + done(); + }) + .catch(err => { + fail('account should be locked for duration mins test failed: ' + JSON.stringify(err)); + done(); + }); + }); + + it('allow login for locked account after accountPolicy.duration minutes', done => { + reconfigureServer({ + appName: 'lockout threshold', + accountLockout: { + duration: 0.05, // 0.05*60 = 3 secs + threshold: 2, + }, + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + const user = new Parse.User(); + user.setUsername('username4'); + user.setPassword('correct password'); + return user.signUp(); + }) + .then(() => { + return loginWithWrongCredentialsShouldFail('username4', 'wrong password'); + }) + .then(() => { + return loginWithWrongCredentialsShouldFail('username4', 'wrong password'); + }) + .then(() => { + // allow locked user to login after 3 seconds with a valid userid and password + return new Promise((resolve, reject) => { + setTimeout(() => { + Parse.User.logIn('username4', 'correct password') + .then(() => resolve()) + .catch(err => reject(err)); + }, 3001); + }); + }) + .then(() => { + done(); + }) + .catch(err => { + fail( + 'allow login for locked account after accountPolicy.duration minutes test failed: ' + + JSON.stringify(err) + ); + done(); + }); + }); + + it('should enforce lockout threshold under concurrent failed login attempts', async () => { + const threshold = 3; + await reconfigureServer({ + appName: 'lockout race', + accountLockout: { + duration: 5, + threshold, + }, + publicServerURL: 'http://localhost:8378/1', + }); + + const user = new Parse.User(); + user.setUsername('race_user'); + user.setPassword('correct_password'); + await user.signUp(); + + const concurrency = 30; + const results = await Promise.all( + Array.from({ length: concurrency }, () => + request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username: 'race_user', password: 'wrong_password' }), + }).catch(err => err) + ) + ); + + const lockoutError = + 'Your account is locked due to multiple failed login attempts. Please try again after 5 minute(s)'; + const errors = results.map(r => { + const body = typeof r.data === 'string' ? JSON.parse(r.data) : r.data; + return body?.error; + }); + const invalidPassword = errors.filter(error => error === 'Invalid username/password.'); + const lockoutResponses = errors.filter(error => error === lockoutError); + + expect( + errors.every( + error => error === 'Invalid username/password.' || error === lockoutError + ) + ).toBeTrue(); + expect(lockoutResponses.length).toBeGreaterThan(0); + expect(invalidPassword.length).toBeLessThanOrEqual(threshold); + }); +}); + +describe('lockout with password reset option', () => { + let sendPasswordResetEmail; + + async function setup(options = {}) { + const accountLockout = Object.assign( + { + duration: 10000, + threshold: 1, + }, + options + ); + const config = { + appName: 'exampleApp', + accountLockout: accountLockout, + publicServerURL: 'http://localhost:8378/1', + emailAdapter: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + }; + await reconfigureServer(config); + + sendPasswordResetEmail = spyOn(config.emailAdapter, 'sendPasswordResetEmail').and.callThrough(); + } + + it('accepts valid unlockOnPasswordReset option', async () => { + const values = [true, false]; + + for (const value of values) { + await expectAsync(setup({ unlockOnPasswordReset: value })).toBeResolved(); + } + }); + + it('rejects invalid unlockOnPasswordReset option', async () => { + const values = ['a', 0, {}, [], null]; + + for (const value of values) { + await expectAsync(setup({ unlockOnPasswordReset: value })).toBeRejected(); + } + }); + + it('uses default value if unlockOnPasswordReset is not set', async () => { + await expectAsync(setup({ unlockOnPasswordReset: undefined })).toBeResolved(); + + const parseConfig = Config.get(Parse.applicationId); + expect(parseConfig.accountLockout.unlockOnPasswordReset).toBe( + Definitions.AccountLockoutOptions.unlockOnPasswordReset.default + ); + }); + + it('allow login for locked account after password reset', async () => { + await setup({ unlockOnPasswordReset: true }); + const config = Config.get(Parse.applicationId); + + const user = new Parse.User(); + const username = 'exampleUsername'; + const password = 'examplePassword'; + user.setUsername(username); + user.setPassword(password); + user.setEmail('mail@example.com'); + await user.signUp(); + + await expectAsync(Parse.User.logIn(username, 'incorrectPassword')).toBeRejected(); + await expectAsync(Parse.User.logIn(username, password)).toBeRejected(); + + await Parse.User.requestPasswordReset(user.getEmail()); + await expectAsync(Parse.User.logIn(username, password)).toBeRejected(); + + const link = sendPasswordResetEmail.calls.all()[0].args[0].link; + const linkUrl = new URL(link); + const token = linkUrl.searchParams.get('token'); + const newPassword = 'newPassword'; + await request({ + method: 'POST', + url: `${config.publicServerURL}/apps/test/request_password_reset`, + body: `new_password=${newPassword}&token=${token}`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + followRedirects: false, + }); + + await expectAsync(Parse.User.logIn(username, newPassword)).toBeResolved(); + }); + + it('reject login for locked account after password reset (default)', async () => { + await setup(); + const config = Config.get(Parse.applicationId); + + const user = new Parse.User(); + const username = 'exampleUsername'; + const password = 'examplePassword'; + user.setUsername(username); + user.setPassword(password); + user.setEmail('mail@example.com'); + await user.signUp(); + + await expectAsync(Parse.User.logIn(username, 'incorrectPassword')).toBeRejected(); + await expectAsync(Parse.User.logIn(username, password)).toBeRejected(); + + await Parse.User.requestPasswordReset(user.getEmail()); + await expectAsync(Parse.User.logIn(username, password)).toBeRejected(); + + const link = sendPasswordResetEmail.calls.all()[0].args[0].link; + const linkUrl = new URL(link); + const token = linkUrl.searchParams.get('token'); + const newPassword = 'newPassword'; + await request({ + method: 'POST', + url: `${config.publicServerURL}/apps/test/request_password_reset`, + body: `new_password=${newPassword}&token=${token}`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + followRedirects: false, + }); + + await expectAsync(Parse.User.logIn(username, newPassword)).toBeRejected(); + }); +}); diff --git a/spec/AdaptableController.spec.js b/spec/AdaptableController.spec.js index a5d5994662..4cda42e162 100644 --- a/spec/AdaptableController.spec.js +++ b/spec/AdaptableController.spec.js @@ -1,83 +1,84 @@ +const AdaptableController = require('../lib/Controllers/AdaptableController').AdaptableController; +const FilesAdapter = require('../lib/Adapters/Files/FilesAdapter').default; +const FilesController = require('../lib/Controllers/FilesController').FilesController; -var AdaptableController = require("../src/Controllers/AdaptableController").AdaptableController; -var FilesAdapter = require("../src/Adapters/Files/FilesAdapter").default; -var FilesController = require("../src/Controllers/FilesController").FilesController; - -var MockController = function(options) { +const MockController = function (options) { AdaptableController.call(this, options); -} +}; MockController.prototype = Object.create(AdaptableController.prototype); MockController.prototype.constructor = AdaptableController; -describe("AdaptableController", ()=>{ - it("should use the provided adapter", (done) => { - var adapter = new FilesAdapter(); - var controller = new FilesController(adapter); +describe('AdaptableController', () => { + it('should use the provided adapter', done => { + const adapter = new FilesAdapter(); + const controller = new FilesController(adapter); expect(controller.adapter).toBe(adapter); // make sure _adapter is private expect(controller._adapter).toBe(undefined); // Override _adapter is not doing anything - controller._adapter = "Hello"; + controller._adapter = 'Hello'; expect(controller.adapter).toBe(adapter); done(); }); - it("should throw when creating a new mock controller", (done) => { - var adapter = new FilesAdapter(); + it('should throw when creating a new mock controller', done => { + const adapter = new FilesAdapter(); expect(() => { new MockController(adapter); }).toThrow(); done(); }); - it("should fail setting the wrong adapter to the controller", (done) => { - function WrongAdapter() {}; - var adapter = new FilesAdapter(); - var controller = new FilesController(adapter); - var otherAdapter = new WrongAdapter(); + it('should fail setting the wrong adapter to the controller', done => { + function WrongAdapter() {} + const adapter = new FilesAdapter(); + const controller = new FilesController(adapter); + const otherAdapter = new WrongAdapter(); expect(() => { controller.adapter = otherAdapter; }).toThrow(); done(); }); - it("should fail to instantiate a controller with wrong adapter", (done) => { - function WrongAdapter() {}; - var adapter = new WrongAdapter(); + it('should fail to instantiate a controller with wrong adapter', done => { + function WrongAdapter() {} + const adapter = new WrongAdapter(); expect(() => { new FilesController(adapter); }).toThrow(); done(); }); - it("should fail to instantiate a controller without an adapter", (done) => { + it('should fail to instantiate a controller without an adapter', done => { expect(() => { new FilesController(); }).toThrow(); done(); }); - it("should accept an object adapter", (done) => { - var adapter = { - createFile: function(config, filename, data) { }, - deleteFile: function(config, filename) { }, - getFileData: function(config, filename) { }, - getFileLocation: function(config, filename) { }, - } + it('should accept an object adapter', done => { + const adapter = { + createFile: function () {}, + deleteFile: function () {}, + getFileData: function () {}, + getFileLocation: function () {}, + validateFilename: function () {}, + }; expect(() => { new FilesController(adapter); }).not.toThrow(); done(); }); - it("should accept an object adapter", (done) => { - function AGoodAdapter() {}; - AGoodAdapter.prototype.createFile = function(config, filename, data) { }; - AGoodAdapter.prototype.deleteFile = function(config, filename) { }; - AGoodAdapter.prototype.getFileData = function(config, filename) { }; - AGoodAdapter.prototype.getFileLocation = function(config, filename) { }; + it('should accept an prototype based object adapter', done => { + function AGoodAdapter() {} + AGoodAdapter.prototype.createFile = function () {}; + AGoodAdapter.prototype.deleteFile = function () {}; + AGoodAdapter.prototype.getFileData = function () {}; + AGoodAdapter.prototype.getFileLocation = function () {}; + AGoodAdapter.prototype.validateFilename = function () {}; - var adapter = new AGoodAdapter(); + const adapter = new AGoodAdapter(); expect(() => { new FilesController(adapter); }).not.toThrow(); diff --git a/spec/AdapterLoader.spec.js b/spec/AdapterLoader.spec.js index 250a8a7c49..8d33bf2094 100644 --- a/spec/AdapterLoader.spec.js +++ b/spec/AdapterLoader.spec.js @@ -1,40 +1,39 @@ +const { loadAdapter, loadModule } = require('../lib/Adapters/AdapterLoader'); +const FilesAdapter = require('@parse/fs-files-adapter').default; +const MockFilesAdapter = require('mock-files-adapter'); +const Config = require('../lib/Config'); +const Utils = require('../lib/Utils'); -var loadAdapter = require("../src/Adapters/AdapterLoader").loadAdapter; -var FilesAdapter = require("parse-server-fs-adapter").default; -var S3Adapter = require("parse-server-s3-adapter").default; -var ParsePushAdapter = require("parse-server-push-adapter").default; +describe('AdapterLoader', () => { + it('should instantiate an adapter from string in object', done => { + const adapterPath = require('path').resolve('./spec/support/MockAdapter'); -describe("AdapterLoader", ()=>{ - - it("should instantiate an adapter from string in object", (done) => { - var adapterPath = require('path').resolve("./spec/MockAdapter"); - - var adapter = loadAdapter({ + const adapter = loadAdapter({ adapter: adapterPath, options: { - key: "value", - foo: "bar" - } + key: 'value', + foo: 'bar', + }, }); - expect(adapter instanceof Object).toBe(true); - expect(adapter.options.key).toBe("value"); - expect(adapter.options.foo).toBe("bar"); + expect(Utils.isObject(adapter)).toBe(true); + expect(adapter.options.key).toBe('value'); + expect(adapter.options.foo).toBe('bar'); done(); }); - it("should instantiate an adapter from string", (done) => { - var adapterPath = require('path').resolve("./spec/MockAdapter"); - var adapter = loadAdapter(adapterPath); + it('should instantiate an adapter from string', done => { + const adapterPath = require('path').resolve('./spec/support/MockAdapter'); + const adapter = loadAdapter(adapterPath); - expect(adapter instanceof Object).toBe(true); + expect(Utils.isObject(adapter)).toBe(true); done(); }); - it("should instantiate an adapter from string that is module", (done) => { - var adapterPath = require('path').resolve("./src/Adapters/Files/FilesAdapter"); - var adapter = loadAdapter({ - adapter: adapterPath + it('should instantiate an adapter from string that is module', done => { + const adapterPath = require('path').resolve('./lib/Adapters/Files/FilesAdapter'); + const adapter = loadAdapter({ + adapter: adapterPath, }); expect(typeof adapter).toBe('object'); @@ -45,72 +44,141 @@ describe("AdapterLoader", ()=>{ done(); }); - it("should instantiate an adapter from function/Class", (done) => { - var adapter = loadAdapter({ - adapter: FilesAdapter + it('should instantiate an adapter from npm module', done => { + const adapter = loadAdapter({ + module: '@parse/fs-files-adapter', + }); + + expect(typeof adapter).toBe('object'); + expect(typeof adapter.createFile).toBe('function'); + expect(typeof adapter.deleteFile).toBe('function'); + expect(typeof adapter.getFileData).toBe('function'); + expect(typeof adapter.getFileLocation).toBe('function'); + done(); + }); + + it('should instantiate an adapter from function/Class', done => { + const adapter = loadAdapter({ + adapter: FilesAdapter, }); expect(adapter instanceof FilesAdapter).toBe(true); done(); }); - it("should instantiate the default adapter from Class", (done) => { - var adapter = loadAdapter(null, FilesAdapter); + it('should instantiate the default adapter from Class', done => { + const adapter = loadAdapter(null, FilesAdapter); expect(adapter instanceof FilesAdapter).toBe(true); done(); }); - it("should use the default adapter", (done) => { - var defaultAdapter = new FilesAdapter(); - var adapter = loadAdapter(null, defaultAdapter); + it('should use the default adapter', done => { + const defaultAdapter = new FilesAdapter(); + const adapter = loadAdapter(null, defaultAdapter); expect(adapter instanceof FilesAdapter).toBe(true); done(); }); - it("should use the provided adapter", (done) => { - var originalAdapter = new FilesAdapter(); - var adapter = loadAdapter(originalAdapter); + it('should use the provided adapter', done => { + const originalAdapter = new FilesAdapter(); + const adapter = loadAdapter(originalAdapter); expect(adapter).toBe(originalAdapter); done(); }); - it("should fail loading an improperly configured adapter", (done) => { - var Adapter = function(options) { + it('should fail loading an improperly configured adapter', done => { + const Adapter = function (options) { if (!options.foo) { - throw "foo is required for that adapter"; + throw 'foo is required for that adapter'; } - } - var adapterOptions = { - param: "key", - doSomething: function() {} + }; + const adapterOptions = { + param: 'key', + doSomething: function () {}, }; expect(() => { - var adapter = loadAdapter(adapterOptions, Adapter); + const adapter = loadAdapter(adapterOptions, Adapter); expect(adapter).toEqual(adapterOptions); - }).not.toThrow("foo is required for that adapter"); + }).not.toThrow('foo is required for that adapter'); done(); }); - it("should load push adapter from options", (done) => { - var options = { - ios: { - bundleId: 'bundle.id' - } - } + it('should load push adapter from options', async () => { + const options = { + android: { + firebaseServiceAccount: { + "type": "service_account", + "project_id": "example-xxxx", + "private_key_id": "xxxx", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCxFcVMD9L2xJWW\nEMi4w/XIBPvX5bTStIEdt4GY+yfrmCHspaVdgpTcHlTLA60sAGTFdorPprOwAm6f\njaTG4j86zfW25GF6AlFO/8vE2B0tjreuQQtcP9gkWJmsTp8yzXDirDQ43Kv93Kbc\nUPmsyAN5WB8XiFjjWLnFCeDiOVdd8sHfG0HYldNzyYwXrOTLE5kOjASYSJDzdrfI\nwN9PzZC7+cCy/DDzTRKQCqfz9pEZmxqJk4Id5HLVNkGKgji3C3b6o3MXWPS+1+zD\nGheKC9WLDZnCVycAnNHFiPpsp7R82lLKC3Dth37b6qzJO+HwfTmzCb0/xCVJ0/mZ\nC4Mxih/bAgMBAAECggEACbL1DvDw75Yd0U3TCJenDxEC0DTjHgVH6x5BaWUcLyGy\nffkmoQQFbjb1Evd9FSNiYZRYDv6E6feAIpoJ8+CxcOGV+zHwCtQ0qtyExx/FHVkr\nQ06JtkBC8N6vcAoQWyJ4c9nVtGWVv/5FX1zKCAYedpd2gH31zGHwLtQXLpzQZbNO\nO/0rcggg4unGSUIyw5437XiyckJ3QdneSEPe9HvY2wxLn/f1PjMpRYiNLBSuaFBJ\n+MYXr//Vh7cMInQk5/pMFbGxugNb7dtjgvm3LKRssKnubEOyrKldo8DVJmAvjhP4\nWboOOBVEo2ZhXgnBjeMvI8btXlJ85h9lZ7xwqfWsjQKBgQDkrrLpA3Mm21rsP1Ar\nMLEnYTdMZ7k+FTm5pJffPOsC7wiLWdRLwwrtb0V3kC3jr2K4SZY/OEV8IAWHfut/\n8mP8cPQPJiFp92iOgde4Xq/Ycwx4ZAXUj7mHHgywFi2K0xATzgc9sgX3NCVl9utR\nIU/FbEDCLxyD4T3Jb5gL3xFdhwKBgQDGPS46AiHuYmV7OG4gEOsNdczTppBJCgTt\nKGSJOxZg8sQodNJeWTPP2iQr4yJ4EY57NQmH7WSogLrGj8tmorEaL7I2kYlHJzGm\nniwApWEZlFc00xgXwV5d8ATfmAf8W1ZSZ6THbHesDUGjXSoL95k3KKXhnztjUT6I\n8d5qkCygDQKBgFN7p1rDZKVZzO6UCntJ8lJS/jIJZ6nPa9xmxv67KXxPsQnWSFdE\nI9gcF/sXCnmlTF/ElXIM4+j1c69MWULDRVciESb6n5YkuOnVYuAuyPk2vuWwdiRs\nN6mpAa7C2etlM+hW/XO7aswdIE4B/1QF2i5TX6zEMB/A+aJw98vVqmw/AoGADOm9\nUiADb9DPBXjGi6YueYD756mI6okRixU/f0TvDz+hEXWSonyzCE4QXx97hlC2dEYf\nKdCH5wYDpJ2HRVdBrBABTtaqF41xCYZyHVSof48PIyzA/AMnj3zsBFiV5JVaiSGh\nNTBWl0mBxg9yhrcJLvOh4pGJv81yAl+m+lAL6B0CgYEArtqtQ1YVLIUn4Pb/HDn8\nN8o7WbhloWQnG34iSsAG8yNtzbbxdugFrEm5ejPSgZ+dbzSzi/hizOFS/+/fwEdl\nay9jqY1fngoqSrS8eddUsY1/WAcmd6wPWEamsSjazA4uxQERruuFOi94E4b895KA\nqYe0A3xb0JL2ieAOZsn8XNA=\n-----END PRIVATE KEY-----\n", + "client_email": "test@example.com", + "client_id": "1", + "auth_uri": "https://example.com", + "token_uri": "https://example.com", + "auth_provider_x509_cert_url": "https://example.com", + "client_x509_cert_url": "https://example.com", + "universe_domain": "example.com" + } + }, + }; + const ParsePushAdapter = await loadModule('@parse/push-adapter'); expect(() => { - var adapter = loadAdapter(undefined, ParsePushAdapter, options); + const adapter = loadAdapter(undefined, ParsePushAdapter, options); expect(adapter.constructor).toBe(ParsePushAdapter); expect(adapter).not.toBe(undefined); }).not.toThrow(); + }); + + it('should load custom push adapter from string (#3544)', done => { + const adapterPath = require('path').resolve('./spec/support/MockPushAdapter'); + const options = { + ios: { + bundleId: 'bundle.id', + }, + }; + const pushAdapterOptions = { + adapter: adapterPath, + options, + }; + expect(() => { + reconfigureServer({ + push: pushAdapterOptions, + }).then(() => { + const config = Config.get(Parse.applicationId); + const pushAdapter = config.pushWorker.adapter; + expect(pushAdapter.getValidPushTypes()).toEqual(['ios']); + expect(pushAdapter.options).toEqual(pushAdapterOptions); + done(); + }); + }).not.toThrow(); + }); + + it('should load custom database adapter from config', done => { + const adapterPath = require('path').resolve('./spec/support/MockDatabaseAdapter'); + const options = { + databaseURI: 'oracledb://user:password@localhost:1521/freepdb1', + collectionPrefix: '', + }; + const databaseAdapterOptions = { + adapter: adapterPath, + options, + }; + expect(() => { + const databaseAdapter = loadAdapter(databaseAdapterOptions); + expect(databaseAdapter).not.toBe(undefined); + expect(databaseAdapter.options).toEqual(options); + expect(databaseAdapter.getDatabaseURI()).toEqual(options.databaseURI); + }).not.toThrow(); done(); }); - it("should load S3Adapter from direct passing", (done) => { - var s3Adapter = new S3Adapter("key", "secret", "bucket") + it('should load file adapter from direct passing', done => { + spyOn(console, 'warn').and.callFake(() => {}); + const mockFilesAdapter = new MockFilesAdapter('key', 'secret', 'bucket'); expect(() => { - var adapter = loadAdapter(s3Adapter, FilesAdapter); - expect(adapter).toBe(s3Adapter); + const adapter = loadAdapter(mockFilesAdapter, FilesAdapter); + expect(adapter).toBe(mockFilesAdapter); }).not.toThrow(); done(); - }) + }); }); diff --git a/spec/Adapters/Auth/BaseCodeAdapter.spec.js b/spec/Adapters/Auth/BaseCodeAdapter.spec.js new file mode 100644 index 0000000000..fef4b43306 --- /dev/null +++ b/spec/Adapters/Auth/BaseCodeAdapter.spec.js @@ -0,0 +1,182 @@ +const BaseAuthCodeAdapter = require('../../../lib/Adapters/Auth/BaseCodeAuthAdapter').default; + +describe('BaseAuthCodeAdapter', function () { + let adapter; + const adapterName = 'TestAdapter'; + const validOptions = { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + }; + + class TestAuthCodeAdapter extends BaseAuthCodeAdapter { + async getUserFromAccessToken(accessToken) { + if (accessToken === 'validAccessToken') { + return { id: 'validUserId' }; + } + throw new Error('Invalid access token'); + } + + async getAccessTokenFromCode(authData) { + if (authData.code === 'validCode') { + return 'validAccessToken'; + } + throw new Error('Invalid code'); + } + } + + beforeEach(function () { + adapter = new TestAuthCodeAdapter(adapterName); + }); + + describe('validateOptions', function () { + it('should throw error if options are missing', function () { + expect(() => adapter.validateOptions(null)).toThrowError(`${adapterName} options are required.`); + }); + + it('should throw error if clientId is missing in secure mode', function () { + expect(() => + adapter.validateOptions({ clientSecret: 'validClientSecret' }) + ).toThrowError(`${adapterName} clientId is required.`); + }); + + it('should throw error if clientSecret is missing in secure mode', function () { + expect(() => + adapter.validateOptions({ clientId: 'validClientId' }) + ).toThrowError(`${adapterName} clientSecret is required.`); + }); + + it('should not throw error for valid options', function () { + expect(() => adapter.validateOptions(validOptions)).not.toThrow(); + expect(adapter.clientId).toBe('validClientId'); + expect(adapter.clientSecret).toBe('validClientSecret'); + expect(adapter.enableInsecureAuth).toBeUndefined(); + }); + + it('should allow insecure mode without clientId or clientSecret', function () { + const options = { enableInsecureAuth: true }; + expect(() => adapter.validateOptions(options)).not.toThrow(); + expect(adapter.enableInsecureAuth).toBe(true); + }); + }); + + describe('beforeFind', function () { + it('should throw error if code is missing in secure mode', async function () { + adapter.validateOptions(validOptions); + const authData = { access_token: 'validAccessToken' }; + + await expectAsync(adapter.beforeFind(authData)).toBeRejectedWithError( + `${adapterName} code is required.` + ); + }); + + it('should throw error if access token is missing in insecure mode', async function () { + adapter.validateOptions({ enableInsecureAuth: true }); + const authData = {}; + + await expectAsync(adapter.beforeFind(authData)).toBeRejectedWithError( + `${adapterName} auth is invalid for this user.` + ); + }); + + it('should throw error if user ID does not match in insecure mode', async function () { + adapter.validateOptions({ enableInsecureAuth: true }); + const authData = { id: 'invalidUserId', access_token: 'validAccessToken' }; + + await expectAsync(adapter.beforeFind(authData)).toBeRejectedWithError( + `${adapterName} auth is invalid for this user.` + ); + }); + + it('should process valid secure payload and update authData', async function () { + adapter.validateOptions(validOptions); + const authData = { code: 'validCode' }; + + await adapter.beforeFind(authData); + + expect(authData.access_token).toBe('validAccessToken'); + expect(authData.id).toBe('validUserId'); + expect(authData.code).toBeUndefined(); + }); + + it('should process valid insecure payload', async function () { + adapter.validateOptions({ enableInsecureAuth: true }); + const authData = { id: 'validUserId', access_token: 'validAccessToken' }; + + await expectAsync(adapter.beforeFind(authData)).toBeResolved(); + }); + }); + + describe('getUserFromAccessToken', function () { + it('should throw error if not implemented in base class', async function () { + const baseAdapter = new BaseAuthCodeAdapter(adapterName); + + await expectAsync(baseAdapter.getUserFromAccessToken('test')).toBeRejectedWithError( + 'getUserFromAccessToken is not implemented' + ); + }); + + it('should return valid user for valid access token', async function () { + const user = await adapter.getUserFromAccessToken('validAccessToken', {}); + expect(user).toEqual({ id: 'validUserId' }); + }); + + it('should throw error for invalid access token', async function () { + await expectAsync(adapter.getUserFromAccessToken('invalidAccessToken', {})).toBeRejectedWithError( + 'Invalid access token' + ); + }); + }); + + describe('getAccessTokenFromCode', function () { + it('should throw error if not implemented in base class', async function () { + const baseAdapter = new BaseAuthCodeAdapter(adapterName); + + await expectAsync(baseAdapter.getAccessTokenFromCode({ code: 'test' })).toBeRejectedWithError( + 'getAccessTokenFromCode is not implemented' + ); + }); + + it('should return valid access token for valid code', async function () { + const accessToken = await adapter.getAccessTokenFromCode({ code: 'validCode' }); + expect(accessToken).toBe('validAccessToken'); + }); + + it('should throw error for invalid code', async function () { + await expectAsync(adapter.getAccessTokenFromCode({ code: 'invalidCode' })).toBeRejectedWithError( + 'Invalid code' + ); + }); + }); + + describe('validateLogin', function () { + it('should return user id from authData', function () { + const authData = { id: 'validUserId' }; + const result = adapter.validateLogin(authData); + expect(result).toEqual({ id: 'validUserId' }); + }); + }); + + describe('validateSetUp', function () { + it('should return user id from authData', function () { + const authData = { id: 'validUserId' }; + const result = adapter.validateSetUp(authData); + expect(result).toEqual({ id: 'validUserId' }); + }); + }); + + describe('afterFind', function () { + it('should return user id from authData', function () { + const authData = { id: 'validUserId' }; + const result = adapter.afterFind(authData); + expect(result).toEqual({ id: 'validUserId' }); + }); + }); + + describe('validateUpdate', function () { + it('should return user id from authData', function () { + const authData = { id: 'validUserId' }; + const result = adapter.validateUpdate(authData); + expect(result).toEqual({ id: 'validUserId' }); + }); + }); +}); diff --git a/spec/Adapters/Auth/gcenter.spec.js b/spec/Adapters/Auth/gcenter.spec.js new file mode 100644 index 0000000000..45e94a527f --- /dev/null +++ b/spec/Adapters/Auth/gcenter.spec.js @@ -0,0 +1,220 @@ +const GameCenterAuth = require('../../../lib/Adapters/Auth/gcenter').default; +const { pki } = require('node-forge'); +const fs = require('fs'); +const path = require('path'); + +describe('GameCenterAuth Adapter', function () { + let adapter; + + beforeEach(function () { + adapter = new GameCenterAuth.constructor(); + + const gcProd4 = fs.readFileSync(path.resolve(__dirname, '../../support/cert/gc-prod-4.cer')); + const digicertPem = fs.readFileSync(path.resolve(__dirname, '../../support/cert/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem')).toString(); + + mockFetch([ + { + url: 'https://static.gc.apple.com/public-key/gc-prod-4.cer', + method: 'GET', + response: { + ok: true, + headers: new Map(), + arrayBuffer: () => Promise.resolve( + gcProd4.buffer.slice(gcProd4.byteOffset, gcProd4.byteOffset + gcProd4.length) + ), + }, + }, + { + url: 'https://cacerts.digicert.com/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem', + method: 'GET', + response: { + ok: true, + headers: new Map([['content-type', 'application/x-pem-file'], ['content-length', digicertPem.length.toString()]]), + text: () => Promise.resolve(digicertPem), + }, + } + ]); + }); + + describe('Test config failing due to missing params or wrong types', function () { + it('should throw error for invalid options', async function () { + const invalidOptions = [ + null, + undefined, + {}, + { bundleId: '' }, + { enableInsecureAuth: false }, // Missing bundleId in secure mode + ]; + + for (const options of invalidOptions) { + expect(() => adapter.validateOptions(options)).withContext(JSON.stringify(options)).toThrow() + } + }); + + it('should validate options successfully with valid parameters', function () { + const validOptions = { bundleId: 'com.valid.app', enableInsecureAuth: false }; + expect(() => adapter.validateOptions(validOptions)).not.toThrow(); + }); + }); + + describe('Test payload failing due to missing params or wrong types', function () { + it('should throw error for missing authData fields', async function () { + await expectAsync(adapter.validateAuthData({})).toBeRejectedWithError( + 'AuthData id is missing.' + ); + }); + }); + + describe('Test payload fails due to incorrect appId / certificate', function () { + it('should throw error for invalid publicKeyUrl', async function () { + const invalidPublicKeyUrl = 'https://malicious.url.com/key.cer'; + + spyOn(adapter, 'fetchCertificate').and.throwError( + new Error('Invalid publicKeyUrl') + ); + + await expectAsync( + adapter.getAppleCertificate(invalidPublicKeyUrl) + ).toBeRejectedWithError('Invalid publicKeyUrl: https://malicious.url.com/key.cer'); + }); + + it('should throw error for invalid signature verification', async function () { + const fakePublicKey = 'invalid-key'; + const fakeAuthData = { + id: '1234567', + publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer', + timestamp: 1460981421303, + salt: 'saltST==', + signature: 'invalidSignature', + }; + + spyOn(adapter, 'getAppleCertificate').and.returnValue(Promise.resolve(fakePublicKey)); + spyOn(adapter, 'verifySignature').and.throwError('Invalid signature.'); + + await expectAsync(adapter.validateAuthData(fakeAuthData)).toBeRejectedWithError( + 'Invalid signature.' + ); + }); + }); + + describe('Test payload passing', function () { + it('should successfully process valid payload and save auth data', async function () { + const validAuthData = { + id: '1234567', + publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer', + timestamp: 1460981421303, + salt: 'saltST==', + signature: 'validSignature', + bundleId: 'com.valid.app', + }; + + spyOn(adapter, 'getAppleCertificate').and.returnValue(Promise.resolve('validKey')); + spyOn(adapter, 'verifySignature').and.returnValue(true); + + await expectAsync(adapter.validateAuthData(validAuthData)).toBeResolved(); + }); + }); + + describe('Certificate and Signature Validation', function () { + it('should fetch and validate Apple certificate', async function () { + const certUrl = 'https://static.gc.apple.com/public-key/gc-prod-4.cer'; + const mockCertificate = 'mockCertificate'; + + spyOn(adapter, 'fetchCertificate').and.returnValue( + Promise.resolve({ certificate: mockCertificate, headers: new Map() }) + ); + spyOn(pki, 'certificateFromPem').and.returnValue({}); + + adapter.cache[certUrl] = mockCertificate; + + const cert = await adapter.getAppleCertificate(certUrl); + expect(cert).toBe(mockCertificate); + }); + + it('should verify signature successfully', async function () { + const authData = { + id: 'G:1965586982', + publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer', + timestamp: 1565257031287, + signature: + 'uqLBTr9Uex8zCpc1UQ1MIDMitb+HUat2Mah4Kw6AVLSGe0gGNJXlih2i5X+0ZwVY0S9zY2NHWi2gFjmhjt/4kxWGMkupqXX5H/qhE2m7hzox6lZJpH98ZEUbouWRfZX2ZhUlCkAX09oRNi7fI7mWL1/o88MaI/y6k6tLr14JTzmlxgdyhw+QRLxRPA6NuvUlRSJpyJ4aGtNH5/wHdKQWL8nUnFYiYmaY8R7IjzNxPfy8UJTUWmeZvMSgND4u8EjADPsz7ZtZyWAPi8kYcAb6M8k0jwLD3vrYCB8XXyO2RQb/FY2TM4zJuI7PzLlvvgOJXbbfVtHx7Evnm5NYoyzgzw==', + salt: 'DzqqrQ==', + }; + + adapter.bundleId = 'cloud.xtralife.gamecenterauth'; + adapter.enableInsecureAuth = false; + + spyOn(adapter, 'verifyPublicKeyIssuer').and.returnValue(); + + const publicKey = await adapter.getAppleCertificate(authData.publicKeyUrl); + + expect(() => adapter.verifySignature(publicKey, authData)).not.toThrow(); + + }); + + it('should not use bundle id from authData payload in secure mode', async function () { + const authData = { + id: 'G:1965586982', + publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer', + timestamp: 1565257031287, + signature: + 'uqLBTr9Uex8zCpc1UQ1MIDMitb+HUat2Mah4Kw6AVLSGe0gGNJXlih2i5X+0ZwVY0S9zY2NHWi2gFjmhjt/4kxWGMkupqXX5H/qhE2m7hzox6lZJpH98ZEUbouWRfZX2ZhUlCkAX09oRNi7fI7mWL1/o88MaI/y6k6tLr14JTzmlxgdyhw+QRLxRPA6NuvUlRSJpyJ4aGtNH5/wHdKQWL8nUnFYiYmaY8R7IjzNxPfy8UJTUWmeZvMSgND4u8EjADPsz7ZtZyWAPi8kYcAb6M8k0jwLD3vrYCB8XXyO2RQb/FY2TM4zJuI7PzLlvvgOJXbbfVtHx7Evnm5NYoyzgzw==', + salt: 'DzqqrQ==', + bundleId: 'com.example.insecure.app', + }; + + adapter.bundleId = 'cloud.xtralife.gamecenterauth'; + adapter.enableInsecureAuth = false; + + spyOn(adapter, 'verifyPublicKeyIssuer').and.returnValue(); + + const publicKey = await adapter.getAppleCertificate(authData.publicKeyUrl); + + expect(() => adapter.verifySignature(publicKey, authData)).not.toThrow(); + + }); + + it('should not use bundle id from authData payload in insecure mode', async function () { + const authData = { + id: 'G:1965586982', + publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer', + timestamp: 1565257031287, + signature: + 'uqLBTr9Uex8zCpc1UQ1MIDMitb+HUat2Mah4Kw6AVLSGe0gGNJXlih2i5X+0ZwVY0S9zY2NHWi2gFjmhjt/4kxWGMkupqXX5H/qhE2m7hzox6lZJpH98ZEUbouWRfZX2ZhUlCkAX09oRNi7fI7mWL1/o88MaI/y6k6tLr14JTzmlxgdyhw+QRLxRPA6NuvUlRSJpyJ4aGtNH5/wHdKQWL8nUnFYiYmaY8R7IjzNxPfy8UJTUWmeZvMSgND4u8EjADPsz7ZtZyWAPi8kYcAb6M8k0jwLD3vrYCB8XXyO2RQb/FY2TM4zJuI7PzLlvvgOJXbbfVtHx7Evnm5NYoyzgzw==', + salt: 'DzqqrQ==', + bundleId: 'com.example.insecure.app', + }; + + adapter.bundleId = 'cloud.xtralife.gamecenterauth'; + adapter.enableInsecureAuth = true; + + spyOn(adapter, 'verifyPublicKeyIssuer').and.returnValue(); + + const publicKey = await adapter.getAppleCertificate(authData.publicKeyUrl); + + expect(() => adapter.verifySignature(publicKey, authData)).not.toThrow(); + + }); + + it('can use bundle id from authData payload in insecure mode', async function () { + const authData = { + id: 'G:1965586982', + publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer', + timestamp: 1565257031287, + signature: + 'uqLBTr9Uex8zCpc1UQ1MIDMitb+HUat2Mah4Kw6AVLSGe0gGNJXlih2i5X+0ZwVY0S9zY2NHWi2gFjmhjt/4kxWGMkupqXX5H/qhE2m7hzox6lZJpH98ZEUbouWRfZX2ZhUlCkAX09oRNi7fI7mWL1/o88MaI/y6k6tLr14JTzmlxgdyhw+QRLxRPA6NuvUlRSJpyJ4aGtNH5/wHdKQWL8nUnFYiYmaY8R7IjzNxPfy8UJTUWmeZvMSgND4u8EjADPsz7ZtZyWAPi8kYcAb6M8k0jwLD3vrYCB8XXyO2RQb/FY2TM4zJuI7PzLlvvgOJXbbfVtHx7Evnm5NYoyzgzw==', + salt: 'DzqqrQ==', + bundleId: 'cloud.xtralife.gamecenterauth', + }; + + adapter.enableInsecureAuth = true; + + spyOn(adapter, 'verifyPublicKeyIssuer').and.returnValue(); + + const publicKey = await adapter.getAppleCertificate(authData.publicKeyUrl); + + expect(() => adapter.verifySignature(publicKey, authData)).not.toThrow(); + + }); + }); +}); diff --git a/spec/Adapters/Auth/github.spec.js b/spec/Adapters/Auth/github.spec.js new file mode 100644 index 0000000000..c12d002ed9 --- /dev/null +++ b/spec/Adapters/Auth/github.spec.js @@ -0,0 +1,285 @@ +const GitHubAdapter = require('../../../lib/Adapters/Auth/github').default; + +describe('GitHubAdapter', function () { + let adapter; + const validOptions = { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + }; + + beforeEach(function () { + adapter = new GitHubAdapter.constructor(); + adapter.validateOptions(validOptions); + }); + + describe('getAccessTokenFromCode', function () { + it('should fetch an access token successfully', async function () { + mockFetch([ + { + url: 'https://github.com/login/oauth/access_token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken', + }), + }, + }, + ]); + + const code = 'validCode'; + const token = await adapter.getAccessTokenFromCode(code); + + expect(token).toBe('mockAccessToken'); + }); + + it('should throw an error if the response is not ok', async function () { + mockFetch([ + { + url: 'https://github.com/login/oauth/access_token', + method: 'POST', + response: { + ok: false, + statusText: 'Bad Request', + }, + }, + ]); + + const code = 'invalidCode'; + + await expectAsync(adapter.getAccessTokenFromCode(code)).toBeRejectedWithError( + 'Failed to exchange code for token: Bad Request' + ); + }); + + it('should throw an error if the response contains an error', async function () { + mockFetch([ + { + url: 'https://github.com/login/oauth/access_token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + error: 'invalid_grant', + error_description: 'Code is invalid', + }), + }, + }, + ]); + + const code = 'invalidCode'; + + await expectAsync(adapter.getAccessTokenFromCode(code)).toBeRejectedWithError('Code is invalid'); + }); + }); + + describe('getUserFromAccessToken', function () { + it('should fetch user data successfully', async function () { + mockFetch([ + { + url: 'https://api.github.com/user', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + id: 'mockUserId', + login: 'mockUserLogin', + }), + }, + }, + ]); + + const accessToken = 'validAccessToken'; + const user = await adapter.getUserFromAccessToken(accessToken); + + expect(user).toEqual({ id: 'mockUserId', login: 'mockUserLogin' }); + }); + + it('should throw an error if the response is not ok', async function () { + mockFetch([ + { + url: 'https://api.github.com/user', + method: 'GET', + response: { + ok: false, + statusText: 'Unauthorized', + }, + }, + ]); + + const accessToken = 'invalidAccessToken'; + + await expectAsync(adapter.getUserFromAccessToken(accessToken)).toBeRejectedWithError( + 'Failed to fetch GitHub user: Unauthorized' + ); + }); + + it('should throw an error if user data is invalid', async function () { + mockFetch([ + { + url: 'https://api.github.com/user', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({}), + }, + }, + ]); + + const accessToken = 'validAccessToken'; + + await expectAsync(adapter.getUserFromAccessToken(accessToken)).toBeRejectedWithError( + 'Invalid GitHub user data received.' + ); + }); + }); + + describe('GitHubAdapter E2E Test', function () { + beforeEach(async function () { + await reconfigureServer({ + auth: { + github: { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + }, + }, + }); + }); + + it('should log in user using GitHub adapter successfully', async function () { + mockFetch([ + { + url: 'https://github.com/login/oauth/access_token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://api.github.com/user', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + id: 'mockUserId', + login: 'mockUserLogin', + }), + }, + }, + ]); + + const authData = { code: 'validCode' }; + const user = await Parse.User.logInWith('github', { authData }); + + expect(user.id).toBeDefined(); + }); + + it('should handle error when GitHub returns invalid code', async function () { + mockFetch([ + { + url: 'https://github.com/login/oauth/access_token', + method: 'POST', + response: { + ok: false, + statusText: 'Invalid code', + }, + }, + ]); + + const authData = { code: 'invalidCode' }; + + await expectAsync(Parse.User.logInWith('github', { authData })).toBeRejectedWithError( + 'Failed to exchange code for token: Invalid code' + ); + }); + + it('should handle error when GitHub returns invalid user data', async function () { + mockFetch([ + { + url: 'https://github.com/login/oauth/access_token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://api.github.com/user', + method: 'GET', + response: { + ok: false, + statusText: 'Unauthorized', + }, + }, + ]); + + const authData = { code: 'validCode' }; + + await expectAsync(Parse.User.logInWith('github', { authData })).toBeRejectedWithError( + 'Failed to fetch GitHub user: Unauthorized' + ); + }); + + it('e2e secure does not support insecure payload', async function () { + mockFetch(); + const authData = { id: 'mockUserId', access_token: 'mockAccessToken123' }; + await expectAsync(Parse.User.logInWith('github', { authData })).toBeRejectedWithError( + 'GitHub code is required.' + ); + }); + + it('e2e insecure does support secure payload', async function () { + await reconfigureServer({ + auth: { + github: { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + enableInsecureAuth: true, + }, + }, + }); + + mockFetch([ + { + url: 'https://github.com/login/oauth/access_token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://api.github.com/user', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + id: 'mockUserId', + login: 'mockUserLogin', + }), + }, + }, + ]); + + const authData = { code: 'validCode' }; + const user = await Parse.User.logInWith('github', { authData }); + + expect(user.id).toBeDefined(); + }); + }); +}); diff --git a/spec/Adapters/Auth/gpgames.spec.js b/spec/Adapters/Auth/gpgames.spec.js new file mode 100644 index 0000000000..8f3a71e46c --- /dev/null +++ b/spec/Adapters/Auth/gpgames.spec.js @@ -0,0 +1,356 @@ +const GooglePlayGamesServicesAdapter = require('../../../lib/Adapters/Auth/gpgames').default; + +describe('GooglePlayGamesServicesAdapter', function () { + let adapter; + + beforeEach(function () { + adapter = new GooglePlayGamesServicesAdapter.constructor(); + adapter.clientId = 'validClientId'; + adapter.clientSecret = 'validClientSecret'; + }); + + describe('getAccessTokenFromCode', function () { + it('should fetch an access token successfully', async function () { + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken', + }), + }, + }, + ]); + + const code = 'validCode'; + const authData = { redirectUri: 'http://example.com' }; + const token = await adapter.getAccessTokenFromCode(code, authData); + + expect(token).toBe('mockAccessToken'); + }); + + it('should throw an error if the response is not ok', async function () { + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: false, + statusText: 'Bad Request', + }, + }, + ]); + + const code = 'invalidCode'; + const authData = { redirectUri: 'http://example.com' }; + + await expectAsync(adapter.getAccessTokenFromCode(code, authData)).toBeRejectedWithError( + 'Failed to exchange code for token: Bad Request' + ); + }); + + it('should throw an error if the response contains an error', async function () { + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + error: 'invalid_grant', + error_description: 'Code is invalid', + }), + }, + }, + ]); + + const code = 'invalidCode'; + const authData = { redirectUri: 'http://example.com' }; + + await expectAsync(adapter.getAccessTokenFromCode(code, authData)).toBeRejectedWithError( + 'Code is invalid' + ); + }); + }); + + describe('getUserFromAccessToken', function () { + it('should fetch user data successfully', async function () { + mockFetch([ + { + url: 'https://www.googleapis.com/games/v1/players/mockUserId', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + playerId: 'mockUserId', + }), + }, + }, + ]); + + const accessToken = 'validAccessToken'; + const authData = { id: 'mockUserId' }; + const user = await adapter.getUserFromAccessToken(accessToken, authData); + + expect(user).toEqual({ id: 'mockUserId' }); + }); + + it('should throw an error if the response is not ok', async function () { + mockFetch([ + { + url: 'https://www.googleapis.com/games/v1/players/mockUserId', + method: 'GET', + response: { + ok: false, + statusText: 'Unauthorized', + }, + }, + ]); + + const accessToken = 'invalidAccessToken'; + const authData = { id: 'mockUserId' }; + + await expectAsync(adapter.getUserFromAccessToken(accessToken, authData)).toBeRejectedWithError( + 'Failed to fetch Google Play Games Services user: Unauthorized' + ); + }); + + it('should throw an error if user data is invalid', async function () { + mockFetch([ + { + url: 'https://www.googleapis.com/games/v1/players/mockUserId', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({}), + }, + }, + ]); + + const accessToken = 'validAccessToken'; + const authData = { id: 'mockUserId' }; + + await expectAsync(adapter.getUserFromAccessToken(accessToken, authData)).toBeRejectedWithError( + 'Invalid Google Play Games Services user data received.' + ); + }); + + it('should throw an error if playerId does not match the provided user ID', async function () { + mockFetch([ + { + url: 'https://www.googleapis.com/games/v1/players/mockUserId', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + playerId: 'anotherUserId', + }), + }, + }, + ]); + + const accessToken = 'validAccessToken'; + const authData = { id: 'mockUserId' }; + + await expectAsync(adapter.getUserFromAccessToken(accessToken, authData)).toBeRejectedWithError( + 'Invalid Google Play Games Services user data received.' + ); + }); + }); + + describe('GooglePlayGamesServicesAdapter E2E Test', function () { + beforeEach(async function () { + await reconfigureServer({ + auth: { + gpgames: { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + }, + }, + }); + }); + + it('should log in user successfully with valid code', async function () { + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://www.googleapis.com/games/v1/players/mockUserId', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + playerId: 'mockUserId', + }), + }, + }, + ]); + + const authData = { + code: 'validCode', + id: 'mockUserId', + redirectUri: 'http://example.com', + }; + + const user = await Parse.User.logInWith('gpgames', { authData }); + + expect(user.id).toBeDefined(); + expect(global.fetch).toHaveBeenCalledWith( + 'https://oauth2.googleapis.com/token', + jasmine.any(Object) + ); + expect(global.fetch).toHaveBeenCalledWith( + 'https://www.googleapis.com/games/v1/players/mockUserId', + jasmine.any(Object) + ); + }); + + it('should handle error when the token exchange fails', async function () { + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: false, + statusText: 'Invalid code', + }, + }, + ]); + + const authData = { + code: 'invalidCode', + redirectUri: 'http://example.com', + }; + + await expectAsync(Parse.User.logInWith('gpgames', { authData })).toBeRejectedWithError( + 'Failed to exchange code for token: Invalid code' + ); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://oauth2.googleapis.com/token', + jasmine.any(Object) + ); + }); + + it('should handle error when user data fetch fails', async function () { + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://www.googleapis.com/games/v1/players/mockUserId', + method: 'GET', + response: { + ok: false, + statusText: 'Unauthorized', + }, + }, + ]); + + const authData = { + code: 'validCode', + id: 'mockUserId', + redirectUri: 'http://example.com', + }; + + await expectAsync(Parse.User.logInWith('gpgames', { authData })).toBeRejectedWithError( + 'Failed to fetch Google Play Games Services user: Unauthorized' + ); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://oauth2.googleapis.com/token', + jasmine.any(Object) + ); + expect(global.fetch).toHaveBeenCalledWith( + 'https://www.googleapis.com/games/v1/players/mockUserId', + jasmine.any(Object) + ); + }); + + it('should handle error when user data is invalid', async function () { + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://www.googleapis.com/games/v1/players/mockUserId', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + playerId: 'anotherUserId', + }), + }, + }, + ]); + + const authData = { + code: 'validCode', + id: 'mockUserId', + redirectUri: 'http://example.com', + }; + + await expectAsync(Parse.User.logInWith('gpgames', { authData })).toBeRejectedWithError( + 'Invalid Google Play Games Services user data received.' + ); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://oauth2.googleapis.com/token', + jasmine.any(Object) + ); + expect(global.fetch).toHaveBeenCalledWith( + 'https://www.googleapis.com/games/v1/players/mockUserId', + jasmine.any(Object) + ); + }); + + it('should handle error when no code or access token is provided', async function () { + mockFetch(); + + const authData = { + id: 'mockUserId', + }; + + await expectAsync(Parse.User.logInWith('gpgames', { authData })).toBeRejectedWithError( + 'gpgames code is required.' + ); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + }); + +}); + diff --git a/spec/Adapters/Auth/instagram.spec.js b/spec/Adapters/Auth/instagram.spec.js new file mode 100644 index 0000000000..9b9fd27aa4 --- /dev/null +++ b/spec/Adapters/Auth/instagram.spec.js @@ -0,0 +1,283 @@ +const InstagramAdapter = require('../../../lib/Adapters/Auth/instagram').default; + +describe('InstagramAdapter', function () { + let adapter; + + beforeEach(function () { + adapter = new InstagramAdapter.constructor(); + adapter.clientId = 'validClientId'; + adapter.clientSecret = 'validClientSecret'; + adapter.redirectUri = 'https://example.com/callback'; + }); + + describe('getAccessTokenFromCode', function () { + it('should fetch an access token successfully', async function () { + mockFetch([ + { + url: 'https://api.instagram.com/oauth/access_token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken', + }), + }, + }, + ]); + + const authData = { code: 'validCode' }; + const token = await adapter.getAccessTokenFromCode(authData); + + expect(token).toBe('mockAccessToken'); + }); + + it('should throw an error if the response contains an error', async function () { + mockFetch([ + { + url: 'https://api.instagram.com/oauth/access_token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + error: 'invalid_grant', + error_description: 'Code is invalid', + }), + }, + }, + ]); + + const authData = { code: 'invalidCode' }; + + await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError( + 'Code is invalid' + ); + }); + }); + + describe('getUserFromAccessToken', function () { + it('should fetch user data successfully', async function () { + mockFetch([ + { + url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + id: 'mockUserId', + }), + }, + }, + ]); + + const accessToken = 'mockAccessToken'; + const authData = { id: 'mockUserId' }; + const user = await adapter.getUserFromAccessToken(accessToken, authData); + + expect(user).toEqual({ id: 'mockUserId' }); + }); + + it('should throw an error if user ID does not match authData', async function () { + mockFetch([ + { + url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + id: 'differentUserId', + }), + }, + }, + ]); + + const accessToken = 'mockAccessToken'; + const authData = { id: 'mockUserId' }; + + await expectAsync(adapter.getUserFromAccessToken(accessToken, authData)).toBeRejectedWithError( + 'Instagram auth is invalid for this user.' + ); + }); + + it('should ignore client-provided apiURL and use hardcoded endpoint', async () => { + const accessToken = 'mockAccessToken'; + const authData = { + id: 'mockUserId', + apiURL: 'https://example.com/', + }; + + mockFetch([ + { + url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + id: 'mockUserId', + }), + }, + }, + ]); + + const user = await adapter.getUserFromAccessToken(accessToken, authData); + expect(user).toEqual({ id: 'mockUserId' }); + }); + }); + + describe('InstagramAdapter E2E Test', function () { + beforeEach(async function () { + await reconfigureServer({ + auth: { + instagram: { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + redirectUri: 'https://example.com/callback', + }, + }, + }); + }); + + it('should log in user successfully with valid code', async function () { + mockFetch([ + { + url: 'https://api.instagram.com/oauth/access_token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken123', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + id: 'mockUserId', + }), + }, + }, + ]); + + const authData = { + code: 'validCode', + id: 'mockUserId', + }; + + const user = await Parse.User.logInWith('instagram', { authData }); + + expect(user.id).toBeDefined(); + }); + + it('should handle error when access token exchange fails', async function () { + mockFetch([ + { + url: 'https://api.instagram.com/oauth/access_token', + method: 'POST', + response: { + ok: false, + statusText: 'Invalid code', + }, + }, + ]); + + const authData = { + code: 'invalidCode', + }; + + await expectAsync(Parse.User.logInWith('instagram', { authData })).toBeRejectedWith( + new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Instagram API request failed.') + ); + }); + + it('should handle error when user data fetch fails', async function () { + mockFetch([ + { + url: 'https://api.instagram.com/oauth/access_token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken123', + method: 'GET', + response: { + ok: false, + statusText: 'Unauthorized', + }, + }, + ]); + + const authData = { + code: 'validCode', + id: 'mockUserId', + }; + + await expectAsync(Parse.User.logInWith('instagram', { authData })).toBeRejectedWith( + new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Instagram API request failed.') + ); + }); + + it('should handle error when user data is invalid', async function () { + mockFetch([ + { + url: 'https://api.instagram.com/oauth/access_token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken123', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + id: 'differentUserId', + }), + }, + }, + ]); + + const authData = { + code: 'validCode', + id: 'mockUserId', + }; + + await expectAsync(Parse.User.logInWith('instagram', { authData })).toBeRejectedWithError( + 'Instagram auth is invalid for this user.' + ); + }); + + it('should handle error when no code or access token is provided', async function () { + mockFetch(); + + const authData = { + id: 'mockUserId', + }; + + await expectAsync(Parse.User.logInWith('instagram', { authData })).toBeRejectedWithError( + 'Instagram code is required.' + ); + }); + }); + +}); diff --git a/spec/Adapters/Auth/line.spec.js b/spec/Adapters/Auth/line.spec.js new file mode 100644 index 0000000000..bde4c906b8 --- /dev/null +++ b/spec/Adapters/Auth/line.spec.js @@ -0,0 +1,309 @@ +const LineAdapter = require('../../../lib/Adapters/Auth/line').default; +describe('LineAdapter', function () { + let adapter; + + beforeEach(function () { + adapter = new LineAdapter.constructor(); + adapter.clientId = 'validClientId'; + adapter.clientSecret = 'validClientSecret'; + }); + + describe('getAccessTokenFromCode', function () { + it('should throw an error if code is missing in authData', async function () { + const authData = { redirect_uri: 'http://example.com' }; + + await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError( + 'Line auth is invalid for this user.' + ); + }); + + it('should fetch an access token successfully', async function () { + mockFetch([ + { + url: 'https://api.line.me/oauth2/v2.1/token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken', + }), + }, + }, + ]); + + const authData = { + code: 'validCode', + redirect_uri: 'http://example.com', + }; + + const token = await adapter.getAccessTokenFromCode(authData); + + expect(token).toBe('mockAccessToken'); + }); + + it('should throw an error if response is not ok', async function () { + mockFetch([ + { + url: 'https://api.line.me/oauth2/v2.1/token', + method: 'POST', + response: { + ok: false, + statusText: 'Bad Request', + }, + }, + ]); + + const authData = { + code: 'invalidCode', + redirect_uri: 'http://example.com', + }; + + await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError( + 'Failed to exchange code for token: Bad Request' + ); + }); + + it('should throw an error if response contains an error object', async function () { + mockFetch([ + { + url: 'https://api.line.me/oauth2/v2.1/token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + error: 'invalid_grant', + error_description: 'Code is invalid', + }), + }, + }, + ]); + + const authData = { + code: 'invalidCode', + redirect_uri: 'http://example.com', + }; + + await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError( + 'Code is invalid' + ); + }); + }); + + describe('getUserFromAccessToken', function () { + it('should fetch user data successfully', async function () { + mockFetch([ + { + url: 'https://api.line.me/v2/profile', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + userId: 'mockUserId', + displayName: 'mockDisplayName', + }), + }, + }, + ]); + + const accessToken = 'validAccessToken'; + const user = await adapter.getUserFromAccessToken(accessToken); + + expect(user).toEqual({ + userId: 'mockUserId', + displayName: 'mockDisplayName', + }); + }); + + it('should throw an error if response is not ok', async function () { + mockFetch([ + { + url: 'https://api.line.me/v2/profile', + method: 'GET', + response: { + ok: false, + statusText: 'Unauthorized', + }, + }, + ]); + + const accessToken = 'invalidAccessToken'; + + await expectAsync(adapter.getUserFromAccessToken(accessToken)).toBeRejectedWithError( + 'Failed to fetch Line user: Unauthorized' + ); + }); + + it('should throw an error if user data is invalid', async function () { + mockFetch([ + { + url: 'https://api.line.me/v2/profile', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({}), + }, + }, + ]); + + const accessToken = 'validAccessToken'; + + await expectAsync(adapter.getUserFromAccessToken(accessToken)).toBeRejectedWithError( + 'Invalid Line user data received.' + ); + }); + }); + + describe('LineAdapter E2E Test', function () { + beforeEach(async function () { + await reconfigureServer({ + auth: { + line: { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + }, + }, + }); + }); + + it('should log in user successfully with valid code', async function () { + mockFetch([ + { + url: 'https://api.line.me/oauth2/v2.1/token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://api.line.me/v2/profile', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + userId: 'mockUserId', + displayName: 'mockDisplayName', + }), + }, + }, + ]); + + const authData = { + code: 'validCode', + redirect_uri: 'http://example.com', + }; + + const user = await Parse.User.logInWith('line', { authData }); + + expect(user.id).toBeDefined(); + }); + + it('should handle error when token exchange fails', async function () { + mockFetch([ + { + url: 'https://api.line.me/oauth2/v2.1/token', + method: 'POST', + response: { + ok: false, + statusText: 'Invalid code', + }, + }, + ]); + + const authData = { + code: 'invalidCode', + redirect_uri: 'http://example.com', + }; + + await expectAsync(Parse.User.logInWith('line', { authData })).toBeRejectedWithError( + 'Failed to exchange code for token: Invalid code' + ); + }); + + it('should handle error when user data fetch fails', async function () { + mockFetch([ + { + url: 'https://api.line.me/oauth2/v2.1/token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://api.line.me/v2/profile', + method: 'GET', + response: { + ok: false, + statusText: 'Unauthorized', + }, + }, + ]); + + const authData = { + code: 'validCode', + redirect_uri: 'http://example.com', + }; + + await expectAsync(Parse.User.logInWith('line', { authData })).toBeRejectedWithError( + 'Failed to fetch Line user: Unauthorized' + ); + }); + + it('should handle error when user data is invalid', async function () { + mockFetch([ + { + url: 'https://api.line.me/oauth2/v2.1/token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://api.line.me/v2/profile', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({}), + }, + }, + ]); + + const authData = { + code: 'validCode', + redirect_uri: 'http://example.com', + }; + + await expectAsync(Parse.User.logInWith('line', { authData })).toBeRejectedWithError( + 'Invalid Line user data received.' + ); + }); + + it('should handle error when no code is provided', async function () { + mockFetch(); + + const authData = { + redirect_uri: 'http://example.com', + }; + + await expectAsync(Parse.User.logInWith('line', { authData })).toBeRejectedWithError( + 'Line code is required.' + ); + }); + }); + +}); diff --git a/spec/Adapters/Auth/linkedIn.spec.js b/spec/Adapters/Auth/linkedIn.spec.js new file mode 100644 index 0000000000..9f5a4b37ae --- /dev/null +++ b/spec/Adapters/Auth/linkedIn.spec.js @@ -0,0 +1,333 @@ + +const LinkedInAdapter = require('../../../lib/Adapters/Auth/linkedin').default; +describe('LinkedInAdapter', function () { + let adapter; + const validOptions = { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + enableInsecureAuth: false, + }; + + beforeEach(function () { + adapter = new LinkedInAdapter.constructor(); + }); + + describe('Test configuration errors', function () { + it('should throw error for missing options', function () { + const invalidOptions = [null, undefined, {}, { clientId: 'validClientId' }]; + + for (const options of invalidOptions) { + expect(() => { + adapter.validateOptions(options); + }).toThrow(); + } + }); + + it('should validate options successfully with valid parameters', function () { + expect(() => { + adapter.validateOptions(validOptions); + }).not.toThrow(); + expect(adapter.clientId).toBe(validOptions.clientId); + expect(adapter.clientSecret).toBe(validOptions.clientSecret); + expect(adapter.enableInsecureAuth).toBe(validOptions.enableInsecureAuth); + }); + }); + + describe('Test beforeFind', function () { + it('should throw error for invalid payload', async function () { + adapter.enableInsecureAuth = true; + + const payloads = [{}, { access_token: null }]; + + for (const payload of payloads) { + await expectAsync(adapter.beforeFind(payload)).toBeRejectedWith( + new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'LinkedIn auth is invalid for this user.') + ); + } + }); + + it('should process secure payload and set auth data', async function () { + spyOn(adapter, 'getAccessTokenFromCode').and.returnValue( + Promise.resolve('validToken') + ); + spyOn(adapter, 'getUserFromAccessToken').and.returnValue( + Promise.resolve({ id: 'validUserId' }) + ); + + const authData = { code: 'validCode', redirect_uri: 'http://example.com', is_mobile_sdk: false }; + + await adapter.beforeFind(authData); + + expect(authData.access_token).toBe('validToken'); + expect(authData.id).toBe('validUserId'); + }); + + it('should validate insecure auth and match user id', async function () { + adapter.enableInsecureAuth = true; + spyOn(adapter, 'getUserFromAccessToken').and.returnValue( + Promise.resolve({ id: 'validUserId' }) + ); + + const authData = { access_token: 'validToken', id: 'validUserId', is_mobile_sdk: false }; + + await expectAsync(adapter.beforeFind(authData)).toBeResolved(); + }); + + it('should throw error if insecure auth user id does not match', async function () { + adapter.enableInsecureAuth = true; + spyOn(adapter, 'getUserFromAccessToken').and.returnValue( + Promise.resolve({ id: 'invalidUserId' }) + ); + + const authData = { access_token: 'validToken', id: 'validUserId', is_mobile_sdk: false }; + + await expectAsync(adapter.beforeFind(authData)).toBeRejectedWith( + new Error('LinkedIn auth is invalid for this user.') + ); + }); + }); + + describe('Test getUserFromAccessToken', function () { + it('should fetch user successfully', async function () { + mockFetch([ + { + url: 'https://api.linkedin.com/v2/me', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ id: 'validUserId' }), + }, + }, + ]); + + const user = await adapter.getUserFromAccessToken('validToken', false); + + expect(global.fetch).toHaveBeenCalledWith('https://api.linkedin.com/v2/me', { + headers: { + Authorization: `Bearer validToken`, + 'x-li-format': 'json', + 'x-li-src': undefined, + }, + method: 'GET', + }); + expect(user).toEqual({ id: 'validUserId' }); + }); + + it('should throw error for invalid response', async function () { + mockFetch([ + { + url: 'https://api.linkedin.com/v2/me', + method: 'GET', + response: { + ok: false, + }, + }, + ]); + + await expectAsync(adapter.getUserFromAccessToken('invalidToken', false)).toBeRejectedWith( + new Error('LinkedIn API request failed.') + ); + }); + }); + + describe('Test getAccessTokenFromCode', function () { + it('should fetch token successfully', async function () { + mockFetch([ + { + url: 'https://www.linkedin.com/oauth/v2/accessToken', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'validToken' }), + }, + }, + ]); + + const tokenResponse = await adapter.getAccessTokenFromCode('validCode', 'http://example.com'); + + expect(global.fetch).toHaveBeenCalledWith('https://www.linkedin.com/oauth/v2/accessToken', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: jasmine.any(URLSearchParams), + }); + expect(tokenResponse).toEqual('validToken'); + }); + + it('should throw error for invalid response', async function () { + mockFetch([ + { + url: 'https://www.linkedin.com/oauth/v2/accessToken', + method: 'POST', + response: { + ok: false, + }, + }, + ]); + + await expectAsync( + adapter.getAccessTokenFromCode('invalidCode', 'http://example.com') + ).toBeRejectedWith(new Error('LinkedIn API request failed.')); + }); + }); + + describe('Test validate methods', function () { + const authData = { id: 'validUserId', access_token: 'validToken' }; + + it('validateLogin should return user id', function () { + const result = adapter.validateLogin(authData); + expect(result).toEqual({ id: 'validUserId' }); + }); + + it('validateSetUp should return user id', function () { + const result = adapter.validateSetUp(authData); + expect(result).toEqual({ id: 'validUserId' }); + }); + + it('validateUpdate should return user id', function () { + const result = adapter.validateUpdate(authData); + expect(result).toEqual({ id: 'validUserId' }); + }); + + it('afterFind should return user id', function () { + const result = adapter.afterFind(authData); + expect(result).toEqual({ id: 'validUserId' }); + }); + }); + + describe('LinkedInAdapter E2E Test', function () { + beforeEach(async function () { + await reconfigureServer({ + auth: { + linkedin: { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + }, + }, + }); + }); + + it('should log in user using LinkedIn adapter successfully (secure)', async function () { + mockFetch([ + { + url: 'https://www.linkedin.com/oauth/v2/accessToken', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://api.linkedin.com/v2/me', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + id: 'mockUserId', + }), + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'https://example.com/callback' }; + const user = await Parse.User.logInWith('linkedin', { authData }); + + expect(user.id).toBeDefined(); + expect(global.fetch).toHaveBeenCalledWith( + 'https://www.linkedin.com/oauth/v2/accessToken', + jasmine.any(Object) + ); + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.linkedin.com/v2/me', + jasmine.any(Object) + ); + }); + + it('should handle error when LinkedIn returns invalid user data', async function () { + mockFetch([ + { + url: 'https://www.linkedin.com/oauth/v2/accessToken', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://api.linkedin.com/v2/me', + method: 'GET', + response: { + ok: false, + statusText: 'Unauthorized', + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'https://example.com/callback' }; + + await expectAsync(Parse.User.logInWith('linkedin', { authData })).toBeRejectedWithError( + 'LinkedIn API request failed.' + ); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://www.linkedin.com/oauth/v2/accessToken', + jasmine.any(Object) + ); + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.linkedin.com/v2/me', + jasmine.any(Object) + ); + }); + + it('secure does not support insecure payload if not enabled', async function () { + mockFetch(); + const authData = { id: 'mockUserId', access_token: 'mockAccessToken123' }; + await expectAsync(Parse.User.logInWith('linkedin', { authData })).toBeRejectedWithError( + 'LinkedIn code is required.' + ); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('insecure mode supports insecure payload if enabled', async function () { + await reconfigureServer({ + auth: { + linkedin: { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + enableInsecureAuth: true, + }, + }, + }); + + mockFetch([ + { + url: 'https://api.linkedin.com/v2/me', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + id: 'mockUserId', + }), + }, + }, + ]); + + const authData = { id: 'mockUserId', access_token: 'mockAccessToken123' }; + const user = await Parse.User.logInWith('linkedin', { authData }); + + expect(user.id).toBeDefined(); + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.linkedin.com/v2/me', + jasmine.any(Object) + ); + }); + }); +}); diff --git a/spec/Adapters/Auth/microsoft.spec.js b/spec/Adapters/Auth/microsoft.spec.js new file mode 100644 index 0000000000..c5cf58b807 --- /dev/null +++ b/spec/Adapters/Auth/microsoft.spec.js @@ -0,0 +1,307 @@ +const MicrosoftAdapter = require('../../../lib/Adapters/Auth/microsoft').default; + +describe('MicrosoftAdapter', function () { + let adapter; + const validOptions = { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + enableInsecureAuth: false, + }; + + beforeEach(function () { + adapter = new MicrosoftAdapter.constructor(); + }); + + describe('Test configuration errors', function () { + it('should throw error for missing options', function () { + const invalidOptions = [null, undefined, {}, { clientId: 'validClientId' }]; + + for (const options of invalidOptions) { + expect(() => { + adapter.validateOptions(options); + }).toThrow(); + } + }); + + it('should validate options successfully with valid parameters', function () { + expect(() => { + adapter.validateOptions(validOptions); + }).not.toThrow(); + expect(adapter.clientId).toBe(validOptions.clientId); + expect(adapter.clientSecret).toBe(validOptions.clientSecret); + expect(adapter.enableInsecureAuth).toBe(validOptions.enableInsecureAuth); + }); + }); + + describe('Test getUserFromAccessToken', function () { + it('should fetch user successfully', async function () { + mockFetch([ + { + url: 'https://graph.microsoft.com/v1.0/me', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ id: 'validUserId' }), + }, + }, + ]); + + const user = await adapter.getUserFromAccessToken('validToken'); + + expect(global.fetch).toHaveBeenCalledWith('https://graph.microsoft.com/v1.0/me', { + headers: { + Authorization: 'Bearer validToken', + }, + method: 'GET', + }); + expect(user).toEqual({ id: 'validUserId' }); + }); + + it('should throw error for invalid response', async function () { + mockFetch([ + { + url: 'https://graph.microsoft.com/v1.0/me', + method: 'GET', + response: { ok: false }, + }, + ]); + + await expectAsync(adapter.getUserFromAccessToken('invalidToken')).toBeRejectedWith( + new Error('Microsoft API request failed.') + ); + }); + }); + + describe('Test getAccessTokenFromCode', function () { + it('should fetch token successfully', async function () { + mockFetch([ + { + url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'validToken' }), + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'http://example.com' }; + const token = await adapter.getAccessTokenFromCode(authData); + + expect(global.fetch).toHaveBeenCalledWith('https://login.microsoftonline.com/common/oauth2/v2.0/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: jasmine.any(URLSearchParams), + }); + expect(token).toEqual('validToken'); + }); + + it('should throw error for invalid response', async function () { + mockFetch([ + { + url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + method: 'POST', + response: { ok: false }, + }, + ]); + + const authData = { code: 'invalidCode', redirect_uri: 'http://example.com' }; + await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWith( + new Error('Microsoft API request failed.') + ); + }); + }); + + describe('Test secure authentication flow', function () { + it('should exchange code for access token and fetch user data', async function () { + spyOn(adapter, 'getAccessTokenFromCode').and.returnValue(Promise.resolve('validToken')); + spyOn(adapter, 'getUserFromAccessToken').and.returnValue(Promise.resolve({ id: 'validUserId' })); + + const authData = { code: 'validCode', redirect_uri: 'http://example.com' }; + await adapter.beforeFind(authData); + + expect(authData.access_token).toBe('validToken'); + expect(authData.id).toBe('validUserId'); + }); + + it('should throw error if user data cannot be fetched', async function () { + spyOn(adapter, 'getAccessTokenFromCode').and.returnValue(Promise.resolve('validToken')); + spyOn(adapter, 'getUserFromAccessToken').and.throwError('Microsoft API request failed.'); + + const authData = { code: 'validCode', redirect_uri: 'http://example.com' }; + await expectAsync(adapter.beforeFind(authData)).toBeRejectedWith( + new Error('Microsoft API request failed.') + ); + }); + }); + + describe('Test insecure authentication flow', function () { + beforeEach(function () { + adapter.enableInsecureAuth = true; + }); + + it('should validate insecure auth and match user id', async function () { + spyOn(adapter, 'getUserFromAccessToken').and.returnValue( + Promise.resolve({ id: 'validUserId' }) + ); + + const authData = { access_token: 'validToken', id: 'validUserId' }; + await expectAsync(adapter.beforeFind(authData)).toBeResolved(); + }); + + it('should throw error if insecure auth user id does not match', async function () { + spyOn(adapter, 'getUserFromAccessToken').and.returnValue( + Promise.resolve({ id: 'invalidUserId' }) + ); + + const authData = { access_token: 'validToken', id: 'validUserId' }; + await expectAsync(adapter.beforeFind(authData)).toBeRejectedWith( + new Error('Microsoft auth is invalid for this user.') + ); + }); + }); + + describe('MicrosoftAdapter E2E Tests', () => { + beforeEach(async () => { + // Simulate reconfiguring the server with Microsoft auth options + await reconfigureServer({ + auth: { + microsoft: { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + enableInsecureAuth: false, + }, + }, + }); + }); + + it('should authenticate user successfully using MicrosoftAdapter', async () => { + mockFetch([ + { + url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'validAccessToken' }), + }, + }, + { + url: 'https://graph.microsoft.com/v1.0/me', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ id: 'user123' }), + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' }; + const user = await Parse.User.logInWith('microsoft', { authData }); + + expect(user.id).toBeDefined(); + }); + + it('should handle invalid code error gracefully', async () => { + mockFetch([ + { + url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + method: 'POST', + response: { ok: false, statusText: 'Invalid code' }, + }, + ]); + + const authData = { code: 'invalidCode', redirect_uri: 'http://example.com/callback' }; + + await expectAsync(Parse.User.logInWith('microsoft', { authData })).toBeRejectedWithError( + 'Microsoft API request failed.' + ); + }); + + it('should handle error when fetching user data fails', async () => { + mockFetch([ + { + url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'validAccessToken' }), + }, + }, + { + url: 'https://graph.microsoft.com/v1.0/me', + method: 'GET', + response: { ok: false, statusText: 'Unauthorized' }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' }; + + await expectAsync(Parse.User.logInWith('microsoft', { authData })).toBeRejectedWithError( + 'Microsoft API request failed.' + ); + }); + + it('should allow insecure auth when enabled', async () => { + + mockFetch([ + { + url: 'https://graph.microsoft.com/v1.0/me', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ + id: 'user123', + }), + }, + }, + ]) + + await reconfigureServer({ + auth: { + microsoft: { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + enableInsecureAuth: true, + }, + }, + }); + + const authData = { access_token: 'validAccessToken', id: 'user123' }; + const user = await Parse.User.logInWith('microsoft', { authData }); + + expect(user.id).toBeDefined(); + }); + + it('should reject insecure auth when user id does not match', async () => { + + mockFetch([ + { + url: 'https://graph.microsoft.com/v1.0/me', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ + id: 'incorrectUser', + }), + }, + }, + ]) + + await reconfigureServer({ + auth: { + microsoft: { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + enableInsecureAuth: true, + }, + }, + }); + + const authData = { access_token: 'validAccessToken', id: 'incorrectUserId' }; + await expectAsync(Parse.User.logInWith('microsoft', { authData })).toBeRejectedWithError( + 'Microsoft auth is invalid for this user.' + ); + }); + }); + +}); diff --git a/spec/Adapters/Auth/oauth2.spec.js b/spec/Adapters/Auth/oauth2.spec.js new file mode 100644 index 0000000000..e5de368962 --- /dev/null +++ b/spec/Adapters/Auth/oauth2.spec.js @@ -0,0 +1,448 @@ +const OAuth2Adapter = require('../../../lib/Adapters/Auth/oauth2').default; + +describe('OAuth2Adapter', () => { + let adapter; + + const validOptions = { + tokenIntrospectionEndpointUrl: 'https://provider.com/introspect', + useridField: 'sub', + appidField: 'aud', + appIds: ['valid-app-id'], + authorizationHeader: 'Bearer validAuthToken', + }; + + beforeEach(() => { + adapter = new OAuth2Adapter.constructor(); + adapter.validateOptions(validOptions); + }); + + describe('validateAppId', () => { + it('should validate app ID successfully', async () => { + const authData = { access_token: 'validAccessToken' }; + const mockResponse = { + [validOptions.appidField]: 'valid-app-id', + }; + + mockFetch([ + { + url: validOptions.tokenIntrospectionEndpointUrl, + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve(mockResponse), + }, + }, + ]); + + await expectAsync( + adapter.validateAppId(validOptions.appIds, authData, validOptions) + ).toBeResolved(); + }); + + it('should throw an error if app ID is invalid', async () => { + const authData = { access_token: 'validAccessToken' }; + const mockResponse = { + [validOptions.appidField]: 'invalid-app-id', + }; + + mockFetch([ + { + url: validOptions.tokenIntrospectionEndpointUrl, + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve(mockResponse), + }, + }, + ]); + + await expectAsync( + adapter.validateAppId(validOptions.appIds, authData, validOptions) + ).toBeRejectedWithError('OAuth2: Invalid app ID.'); + }); + }); + + describe('validateAuthData', () => { + it('should validate auth data successfully', async () => { + const authData = { id: 'user-id', access_token: 'validAccessToken' }; + const mockResponse = { + active: true, + [validOptions.useridField]: 'user-id', + }; + + mockFetch([ + { + url: validOptions.tokenIntrospectionEndpointUrl, + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve(mockResponse), + }, + }, + ]); + + await expectAsync( + adapter.validateAuthData(authData, null, validOptions) + ).toBeResolvedTo({}); + }); + + it('should throw an error if the token is inactive', async () => { + const authData = { id: 'user-id', access_token: 'validAccessToken' }; + const mockResponse = { active: false }; + + mockFetch([ + { + url: validOptions.tokenIntrospectionEndpointUrl, + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve(mockResponse), + }, + }, + ]); + + await expectAsync( + adapter.validateAuthData(authData, null, validOptions) + ).toBeRejectedWith(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 access token is invalid for this user.')); + }); + + it('should throw an error if user ID does not match', async () => { + const authData = { id: 'user-id', access_token: 'validAccessToken' }; + const mockResponse = { + active: true, + [validOptions.useridField]: 'different-user-id', + }; + + mockFetch([ + { + url: validOptions.tokenIntrospectionEndpointUrl, + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve(mockResponse), + }, + }, + ]); + + await expectAsync( + adapter.validateAuthData(authData, null, validOptions) + ).toBeRejectedWithError('OAuth2 access token is invalid for this user.'); + }); + + it('should default useridField to sub and reject mismatched user ID', async () => { + const adapterNoUseridField = new OAuth2Adapter.constructor(); + adapterNoUseridField.validateOptions({ + tokenIntrospectionEndpointUrl: 'https://provider.example.com/introspect', + }); + + const authData = { id: 'victim-user-id', access_token: 'attackerToken' }; + const mockResponse = { + active: true, + sub: 'attacker-user-id', + }; + + mockFetch([ + { + url: 'https://provider.example.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve(mockResponse), + }, + }, + ]); + + await expectAsync( + adapterNoUseridField.validateAuthData(authData, null, {}) + ).toBeRejectedWithError('OAuth2 access token is invalid for this user.'); + }); + + it('should default useridField to sub and accept matching user ID', async () => { + const adapterNoUseridField = new OAuth2Adapter.constructor(); + adapterNoUseridField.validateOptions({ + tokenIntrospectionEndpointUrl: 'https://provider.example.com/introspect', + }); + + const authData = { id: 'user-id', access_token: 'validAccessToken' }; + const mockResponse = { + active: true, + sub: 'user-id', + }; + + mockFetch([ + { + url: 'https://provider.example.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve(mockResponse), + }, + }, + ]); + + await expectAsync( + adapterNoUseridField.validateAuthData(authData, null, {}) + ).toBeResolvedTo({}); + }); + }); + + describe('requestTokenInfo', () => { + it('should fetch token info successfully', async () => { + const mockResponse = { active: true }; + + mockFetch([ + { + url: validOptions.tokenIntrospectionEndpointUrl, + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve(mockResponse), + }, + }, + ]); + + const result = await adapter.requestTokenInfo( + 'validAccessToken', + validOptions + ); + + expect(result).toEqual(mockResponse); + }); + + it('should throw an error if the introspection endpoint URL is missing', async () => { + const options = { ...validOptions, tokenIntrospectionEndpointUrl: null }; + + expect( + () => adapter.validateOptions(options) + ).toThrow(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 token introspection endpoint URL is missing.')); + }); + + it('should throw an error if the response is not ok', async () => { + mockFetch([ + { + url: validOptions.tokenIntrospectionEndpointUrl, + method: 'POST', + response: { + ok: false, + statusText: 'Bad Request', + }, + }, + ]); + + await expectAsync( + adapter.requestTokenInfo('invalidAccessToken') + ).toBeRejectedWithError('OAuth2 token introspection request failed.'); + }); + }); + + describe('OAuth2Adapter E2E Tests', () => { + beforeEach(async () => { + // Simulate reconfiguring the server with OAuth2 auth options + await reconfigureServer({ + auth: { + mockOauth: { + tokenIntrospectionEndpointUrl: 'https://provider.com/introspect', + useridField: 'sub', + appidField: 'aud', + appIds: ['valid-app-id'], + authorizationHeader: 'Bearer validAuthToken', + oauth2: true + }, + }, + }); + }); + + it('should validate and authenticate user successfully', async () => { + mockFetch([ + { + url: 'https://provider.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ + active: true, + sub: 'user123', + aud: 'valid-app-id', + }), + }, + }, + ]); + + const authData = { access_token: 'validAccessToken', id: 'user123' }; + const user = await Parse.User.logInWith('mockOauth', { authData }); + + expect(user.id).toBeDefined(); + expect(user.get('authData').mockOauth.id).toEqual('user123'); + }); + + it('should reject authentication for inactive token', async () => { + mockFetch([ + { + url: 'https://provider.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ active: false, aud: ['valid-app-id'] }), + }, + }, + ]); + + const authData = { access_token: 'inactiveToken', id: 'user123' }; + await expectAsync(Parse.User.logInWith('mockOauth', { authData })).toBeRejectedWith( + new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 access token is invalid for this user.') + ); + }); + + it('should reject authentication for mismatched user ID', async () => { + mockFetch([ + { + url: 'https://provider.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ + active: true, + sub: 'different-user', + aud: 'valid-app-id', + }), + }, + }, + ]); + + const authData = { access_token: 'validAccessToken', id: 'user123' }; + await expectAsync(Parse.User.logInWith('mockOauth', { authData })).toBeRejectedWith( + new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 access token is invalid for this user.') + ); + }); + + it('should reject authentication for invalid app ID', async () => { + mockFetch([ + { + url: 'https://provider.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ + active: true, + sub: 'user123', + aud: 'invalid-app-id', + }), + }, + }, + ]); + + const authData = { access_token: 'validAccessToken', id: 'user123' }; + await expectAsync(Parse.User.logInWith('mockOauth', { authData })).toBeRejectedWithError( + 'OAuth2: Invalid app ID.' + ); + }); + + it('should send the correct access token to the introspection endpoint during app ID validation', async () => { + const capturedTokens = []; + const originalFetch = global.fetch; + try { + global.fetch = async (url, options) => { + if (typeof url === 'string' && url === 'https://provider.com/introspect') { + const body = options?.body?.toString() || ''; + const token = new URLSearchParams(body).get('token'); + capturedTokens.push(token); + return { + ok: true, + json: () => Promise.resolve({ + active: true, + sub: 'user123', + aud: 'valid-app-id', + }), + }; + } + return originalFetch(url, options); + }; + + const authData = { access_token: 'myRealAccessToken', id: 'user123' }; + const user = await Parse.User.logInWith('mockOauth', { authData }); + expect(user.id).toBeDefined(); + + // With appidField configured, validateAppId and validateAuthData both call requestTokenInfo. + // Both should receive the actual access token, not 'undefined' from argument mismatch. + expect(capturedTokens.length).toBeGreaterThanOrEqual(2); + for (const token of capturedTokens) { + expect(token).toBe('myRealAccessToken'); + } + } finally { + global.fetch = originalFetch; + } + }); + + it('should reject account takeover when useridField is omitted and attacker uses their own token with victim ID', async () => { + await reconfigureServer({ + auth: { + mockOauth: { + tokenIntrospectionEndpointUrl: 'https://provider.example.com/introspect', + authorizationHeader: 'Bearer validAuthToken', + oauth2: true, + }, + }, + }); + + // Victim signs up with their own valid token + mockFetch([ + { + url: 'https://provider.example.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ + active: true, + sub: 'victim-sub-id', + }), + }, + }, + ]); + + const victimAuthData = { access_token: 'victimToken', id: 'victim-sub-id' }; + const victim = await Parse.User.logInWith('mockOauth', { authData: victimAuthData }); + expect(victim.id).toBeDefined(); + + // Attacker tries to log in with their own valid token but claims victim's ID + mockFetch([ + { + url: 'https://provider.example.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ + active: true, + sub: 'attacker-sub-id', + }), + }, + }, + ]); + + const attackerAuthData = { access_token: 'attackerToken', id: 'victim-sub-id' }; + await expectAsync(Parse.User.logInWith('mockOauth', { authData: attackerAuthData })).toBeRejectedWith( + new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 access token is invalid for this user.') + ); + }); + + it('should handle error when token introspection endpoint is missing', async () => { + await reconfigureServer({ + auth: { + mockOauth: { + tokenIntrospectionEndpointUrl: null, + useridField: 'sub', + appidField: 'aud', + appIds: ['valid-app-id'], + authorizationHeader: 'Bearer validAuthToken', + oauth2: true + }, + }, + }); + + const authData = { access_token: 'validAccessToken', id: 'user123' }; + await expectAsync(Parse.User.logInWith('mockOauth', { authData })).toBeRejectedWith( + new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 token introspection endpoint URL is missing.') + ); + }); + }); + +}); diff --git a/spec/Adapters/Auth/qq.spec.js b/spec/Adapters/Auth/qq.spec.js new file mode 100644 index 0000000000..1e67e18941 --- /dev/null +++ b/spec/Adapters/Auth/qq.spec.js @@ -0,0 +1,252 @@ +const QqAdapter = require('../../../lib/Adapters/Auth/qq').default; + +describe('QqAdapter', () => { + let adapter; + + beforeEach(() => { + adapter = new QqAdapter.constructor(); + }); + + describe('getUserFromAccessToken', () => { + it('should fetch user data successfully', async () => { + const mockResponse = `callback({"client_id":"validAppId","openid":"user123"})`; + + mockFetch([ + { + url: 'https://graph.qq.com/oauth2.0/me', + method: 'GET', + response: { + ok: true, + text: () => Promise.resolve(mockResponse), + }, + }, + ]); + + const result = await adapter.getUserFromAccessToken('validAccessToken'); + + expect(result).toEqual({ client_id: 'validAppId', openid: 'user123' }); + }); + + it('should throw an error if the API request fails', async () => { + mockFetch([ + { + url: 'https://graph.qq.com/oauth2.0/me', + method: 'GET', + response: { + ok: false, + statusText: 'Unauthorized', + }, + }, + ]); + + await expectAsync( + adapter.getUserFromAccessToken('invalidAccessToken') + ).toBeRejectedWithError('qq API request failed.'); + }); + }); + + describe('getAccessTokenFromCode', () => { + it('should fetch access token successfully', async () => { + const mockResponse = `callback({"access_token":"validAccessToken","expires_in":3600,"refresh_token":"refreshToken"})`; + + mockFetch([ + { + url: 'https://graph.qq.com/oauth2.0/token', + method: 'GET', + response: { + ok: true, + text: () => Promise.resolve(mockResponse), + }, + }, + ]); + + const result = await adapter.getAccessTokenFromCode({ + code: 'validCode', + redirect_uri: 'https://your-redirect-uri.com/callback', + }); + + expect(result).toBe('validAccessToken'); + }); + + it('should throw an error if the API request fails', async () => { + mockFetch([ + { + url: 'https://graph.qq.com/oauth2.0/token', + method: 'GET', + response: { + ok: false, + statusText: 'Bad Request', + }, + }, + ]); + + await expectAsync( + adapter.getAccessTokenFromCode({ + code: 'invalidCode', + redirect_uri: 'https://your-redirect-uri.com/callback', + }) + ).toBeRejectedWithError('qq API request failed.'); + }); + }); + + describe('parseResponseData', () => { + it('should parse valid callback response data', () => { + const response = `callback({"key":"value"})`; + const result = adapter.parseResponseData(response); + + expect(result).toEqual({ key: 'value' }); + }); + + it('should throw an error if the response data is invalid', () => { + const response = 'invalid response'; + + expect(() => adapter.parseResponseData(response)).toThrowError( + 'qq auth is invalid for this user.' + ); + }); + }); + + describe('QqAdapter E2E Test', () => { + beforeEach(async () => { + await reconfigureServer({ + auth: { + qq: { + clientId: 'validAppId', + clientSecret: 'validAppSecret', + }, + }, + }); + }); + + it('should log in user using Qq adapter successfully', async () => { + mockFetch([ + { + url: 'https://graph.qq.com/oauth2.0/token', + method: 'GET', + response: { + ok: true, + text: () => + Promise.resolve( + `callback({"access_token":"mockAccessToken","expires_in":3600})` + ), + }, + }, + { + url: 'https://graph.qq.com/oauth2.0/me', + method: 'GET', + response: { + ok: true, + text: () => + Promise.resolve( + `callback({"client_id":"validAppId","openid":"user123"})` + ), + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'https://your-redirect-uri.com/callback' }; + const user = await Parse.User.logInWith('qq', { authData }); + + expect(user.id).toBeDefined(); + }); + + it('should handle error when Qq returns invalid code', async () => { + mockFetch([ + { + url: 'https://graph.qq.com/oauth2.0/token', + method: 'GET', + response: { + ok: false, + statusText: 'Invalid code', + }, + }, + ]); + + const authData = { code: 'invalidCode', redirect_uri: 'https://your-redirect-uri.com/callback' }; + + await expectAsync(Parse.User.logInWith('qq', { authData })).toBeRejectedWithError( + 'qq API request failed.' + ); + }); + + it('should handle error when Qq returns invalid user data', async () => { + mockFetch([ + { + url: 'https://graph.qq.com/oauth2.0/token', + method: 'GET', + response: { + ok: true, + text: () => + Promise.resolve( + `callback({"access_token":"mockAccessToken","expires_in":3600})` + ), + }, + }, + { + url: 'https://graph.qq.com/oauth2.0/me', + method: 'GET', + response: { + ok: false, + statusText: 'Unauthorized', + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'https://your-redirect-uri.com/callback' }; + + await expectAsync(Parse.User.logInWith('qq', { authData })).toBeRejectedWithError( + 'qq API request failed.' + ); + }); + + it('e2e secure does not support insecure payload', async () => { + mockFetch(); + const authData = { id: 'mockUserId', access_token: 'mockAccessToken' }; + await expectAsync(Parse.User.logInWith('qq', { authData })).toBeRejectedWithError( + 'qq code is required.' + ); + }); + + it('e2e insecure does support secure payload', async () => { + await reconfigureServer({ + auth: { + qq: { + appId: 'validAppId', + appSecret: 'validAppSecret', + enableInsecureAuth: true, + }, + }, + }); + + mockFetch([ + { + url: 'https://graph.qq.com/oauth2.0/token', + method: 'GET', + response: { + ok: true, + text: () => + Promise.resolve( + `callback({"access_token":"mockAccessToken","expires_in":3600})` + ), + }, + }, + { + url: 'https://graph.qq.com/oauth2.0/me', + method: 'GET', + response: { + ok: true, + text: () => + Promise.resolve( + `callback({"client_id":"validAppId","openid":"user123"})` + ), + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'https://your-redirect-uri.com/callback' }; + const user = await Parse.User.logInWith('qq', { authData }); + + expect(user.id).toBeDefined(); + }); + }); +}); diff --git a/spec/Adapters/Auth/spotify.spec.js b/spec/Adapters/Auth/spotify.spec.js new file mode 100644 index 0000000000..b3c6a5ef6f --- /dev/null +++ b/spec/Adapters/Auth/spotify.spec.js @@ -0,0 +1,113 @@ +const SpotifyAdapter = require('../../../lib/Adapters/Auth/spotify').default; + +describe('SpotifyAdapter', () => { + let adapter; + + beforeEach(() => { + adapter = new SpotifyAdapter.constructor(); + }); + + describe('getUserFromAccessToken', () => { + it('should fetch user data successfully', async () => { + const mockResponse = { + id: 'spotifyUser123', + }; + + mockFetch([ + { + url: 'https://api.spotify.com/v1/me', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve(mockResponse), + }, + }, + ]); + + const result = await adapter.getUserFromAccessToken('validAccessToken'); + + expect(result).toEqual({ id: 'spotifyUser123' }); + }); + + it('should throw an error if the API request fails', async () => { + mockFetch([ + { + url: 'https://api.spotify.com/v1/me', + method: 'GET', + response: { + ok: false, + statusText: 'Unauthorized', + }, + }, + ]); + + await expectAsync(adapter.getUserFromAccessToken('invalidAccessToken')).toBeRejectedWithError( + 'Spotify API request failed.' + ); + }); + }); + + describe('getAccessTokenFromCode', () => { + it('should fetch access token successfully', async () => { + const mockResponse = { + access_token: 'validAccessToken', + expires_in: 3600, + refresh_token: 'refreshToken', + }; + + mockFetch([ + { + url: 'https://accounts.spotify.com/api/token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve(mockResponse), + }, + }, + ]); + + const authData = { + code: 'validCode', + redirect_uri: 'https://your-redirect-uri.com/callback', + code_verifier: 'validCodeVerifier', + }; + + const result = await adapter.getAccessTokenFromCode(authData); + + expect(result).toEqual(mockResponse); + }); + + it('should throw an error if authData is missing required fields', async () => { + const authData = { + redirect_uri: 'https://your-redirect-uri.com/callback', + }; + + await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError( + 'Spotify auth configuration authData.code and/or authData.redirect_uri and/or authData.code_verifier.' + ); + }); + + it('should throw an error if the API request fails', async () => { + mockFetch([ + { + url: 'https://accounts.spotify.com/api/token', + method: 'POST', + response: { + ok: false, + statusText: 'Bad Request', + }, + }, + ]); + + const authData = { + code: 'invalidCode', + redirect_uri: 'https://your-redirect-uri.com/callback', + code_verifier: 'invalidCodeVerifier', + }; + + await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError( + 'Spotify API request failed.' + ); + }); + }); +}); diff --git a/spec/Adapters/Auth/twitter.spec.js b/spec/Adapters/Auth/twitter.spec.js new file mode 100644 index 0000000000..2869ff4121 --- /dev/null +++ b/spec/Adapters/Auth/twitter.spec.js @@ -0,0 +1,120 @@ +const TwitterAuthAdapter = require('../../../lib/Adapters/Auth/twitter').default; + +describe('TwitterAuthAdapter', function () { + let adapter; + const validOptions = { + consumer_key: 'validConsumerKey', + consumer_secret: 'validConsumerSecret', + }; + + beforeEach(function () { + adapter = new TwitterAuthAdapter.constructor(); + }); + + describe('Test configuration errors', function () { + it('should throw an error when options are missing', function () { + expect(() => adapter.validateOptions()).toThrowError('Twitter auth options are required.'); + }); + + it('should throw an error when consumer_key and consumer_secret are missing for secure auth', function () { + const options = { enableInsecureAuth: false }; + expect(() => adapter.validateOptions(options)).toThrowError( + 'Consumer key and secret are required for secure Twitter auth.' + ); + }); + + it('should not throw an error when valid options are provided', function () { + expect(() => adapter.validateOptions(validOptions)).not.toThrow(); + }); + }); + + describe('Validate Insecure Auth', function () { + it('should throw an error if oauth_token or oauth_token_secret are missing', async function () { + const authData = { oauth_token: 'validToken' }; // Missing oauth_token_secret + await expectAsync(adapter.validateInsecureAuth(authData, validOptions)).toBeRejectedWithError( + 'Twitter insecure auth requires oauth_token and oauth_token_secret.' + ); + }); + + it('should validate insecure auth successfully when data matches', async function () { + spyOn(adapter, 'request').and.returnValue( + Promise.resolve({ + json: () => Promise.resolve({ id: 'validUserId' }), + }) + ); + + const authData = { + id: 'validUserId', + oauth_token: 'validToken', + oauth_token_secret: 'validSecret', + }; + await expectAsync(adapter.validateInsecureAuth(authData, validOptions)).toBeResolved(); + }); + + it('should throw an error when user ID does not match', async function () { + spyOn(adapter, 'request').and.returnValue( + Promise.resolve({ + json: () => Promise.resolve({ id: 'invalidUserId' }), + }) + ); + + const authData = { + id: 'validUserId', + oauth_token: 'validToken', + oauth_token_secret: 'validSecret', + }; + await expectAsync(adapter.validateInsecureAuth(authData, validOptions)).toBeRejectedWithError( + 'Twitter auth is invalid for this user.' + ); + }); + }); + + describe('End-to-End Tests', function () { + beforeEach(async function () { + await reconfigureServer({ + auth: { + twitter: validOptions, + } + }) + }); + + it('should authenticate user successfully using validateAuthData', async function () { + spyOn(adapter, 'exchangeAccessToken').and.returnValue( + Promise.resolve({ oauth_token: 'validToken', user_id: 'validUserId' }) + ); + + const authData = { + oauth_token: 'validToken', + oauth_verifier: 'validVerifier', + }; + await expectAsync(adapter.validateAuthData(authData, validOptions)).toBeResolved(); + expect(authData.id).toBe('validUserId'); + expect(authData.auth_token).toBe('validToken'); + }); + + it('should handle multiple configurations and validate successfully', async function () { + const authData = { + consumer_key: 'validConsumerKey', + oauth_token: 'validToken', + oauth_token_secret: 'validSecret', + }; + + const optionsArray = [ + { consumer_key: 'invalidKey', consumer_secret: 'invalidSecret' }, + validOptions, + ]; + + const selectedOption = adapter.handleMultipleConfigurations(authData, optionsArray); + expect(selectedOption).toEqual(validOptions); + }); + + it('should throw an error when no matching configuration is found', function () { + const authData = { consumer_key: 'missingKey' }; + const optionsArray = [validOptions]; + + expect(() => adapter.handleMultipleConfigurations(authData, optionsArray)).toThrowError( + 'Twitter auth is invalid for this user.' + ); + }); + }); +}); diff --git a/spec/Adapters/Auth/wechat.spec.js b/spec/Adapters/Auth/wechat.spec.js new file mode 100644 index 0000000000..43518ec0df --- /dev/null +++ b/spec/Adapters/Auth/wechat.spec.js @@ -0,0 +1,236 @@ +const WeChatAdapter = require('../../../lib/Adapters/Auth/wechat').default; + +describe('WeChatAdapter', function () { + let adapter; + + beforeEach(function () { + adapter = new WeChatAdapter.constructor(); + }); + + describe('Test getUserFromAccessToken', function () { + it('should fetch user successfully', async function () { + mockFetch([ + { + url: 'https://api.weixin.qq.com/sns/auth?access_token=validToken&openid=validOpenId', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ errcode: 0, id: 'validUserId' }), + }, + }, + ]); + + const user = await adapter.getUserFromAccessToken('validToken', { id: 'validOpenId' }); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.weixin.qq.com/sns/auth?access_token=validToken&openid=validOpenId', + jasmine.any(Object) + ); + expect(user).toEqual({ errcode: 0, id: 'validUserId' }); + }); + + it('should throw error for invalid response', async function () { + mockFetch([ + { + url: 'https://api.weixin.qq.com/sns/auth?access_token=invalidToken&openid=undefined', + method: 'GET', + response: { + ok: false, + json: () => Promise.resolve({ errcode: 40013, errmsg: 'Invalid token' }), + }, + }, + ]); + + await expectAsync(adapter.getUserFromAccessToken('invalidToken', 'invalidOpenId')).toBeRejectedWith( + jasmine.objectContaining({ message: 'WeChat auth is invalid for this user.' }) + ); + }); + }); + + describe('Test getAccessTokenFromCode', function () { + it('should fetch access token successfully', async function () { + mockFetch([ + { + url: 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=validCode&grant_type=authorization_code', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'validToken', errcode: 0 }), + }, + }, + ]); + + adapter.validateOptions({ clientId: 'validAppId', clientSecret: 'validAppSecret' }); + const authData = { code: 'validCode' }; + const token = await adapter.getAccessTokenFromCode(authData); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=validCode&grant_type=authorization_code', + jasmine.any(Object) + ); + expect(token).toEqual('validToken'); + }); + + it('should throw error for invalid response', async function () { + mockFetch([ + { + url: 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=invalidCode&grant_type=authorization_code', + method: 'GET', + response: { + ok: false, + json: () => Promise.resolve({ errcode: 40029, errmsg: 'Invalid code' }), + }, + }, + ]); + adapter.validateOptions({ clientId: 'validAppId', clientSecret: 'validAppSecret' }); + + const authData = { code: 'invalidCode' }; + + await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWith( + jasmine.objectContaining({ message: 'WeChat auth is invalid for this user.' }) + ); + }); + }); + + describe('WeChatAdapter E2E Tests', function () { + beforeEach(async () => { + await reconfigureServer({ + auth: { + wechat: { + clientId: 'validAppId', + clientSecret: 'validAppSecret', + enableInsecureAuth: false, + }, + }, + }); + }); + + it('should authenticate user successfully using WeChatAdapter', async function () { + mockFetch([ + { + url: 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=validCode&grant_type=authorization_code', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'validAccessToken', openid: 'user123', errcode: 0 }), + }, + }, + { + url: 'https://api.weixin.qq.com/sns/auth?access_token=validAccessToken&openid=user123', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ errcode: 0, id: 'user123' }), + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' }; + const user = await Parse.User.logInWith('wechat', { authData }); + + expect(user.id).toBeDefined(); + }); + + it('should handle invalid code error gracefully', async function () { + mockFetch([ + { + url: 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=invalidCode&grant_type=authorization_code', + method: 'GET', + response: { + ok: false, + json: () => Promise.resolve({ errcode: 40029, errmsg: 'Invalid code' }), + }, + }, + ]); + + const authData = { code: 'invalidCode', redirect_uri: 'http://example.com/callback' }; + + await expectAsync(Parse.User.logInWith('wechat', { authData })).toBeRejectedWith( + jasmine.objectContaining({ message: 'WeChat auth is invalid for this user.' }) + ); + }); + + it('should handle error when fetching user data fails', async function () { + mockFetch([ + { + url: 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=validCode&grant_type=authorization_code', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'validAccessToken', openid: 'user123', errcode: 0 }), + }, + }, + { + url: 'https://api.weixin.qq.com/sns/auth?access_token=validAccessToken&openid=user123', + method: 'GET', + response: { + ok: false, + json: () => Promise.resolve({ errcode: 40013, errmsg: 'Invalid token' }), + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' }; + + await expectAsync(Parse.User.logInWith('wechat', { authData })).toBeRejectedWith( + jasmine.objectContaining({ message: 'WeChat auth is invalid for this user.' }) + ); + }); + + it('should allow insecure auth when enabled', async function () { + mockFetch([ + { + url: 'https://api.weixin.qq.com/sns/auth?access_token=validAccessToken&openid=user123', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ errcode: 0, id: 'user123' }), + }, + }, + ]); + + await reconfigureServer({ + auth: { + wechat: { + appId: 'validAppId', + appSecret: 'validAppSecret', + enableInsecureAuth: true, + }, + }, + }); + + const authData = { access_token: 'validAccessToken', id: 'user123' }; + const user = await Parse.User.logInWith('wechat', { authData }); + + expect(user.id).toBeDefined(); + }); + + it('should reject insecure auth when user id does not match', async function () { + mockFetch([ + { + url: 'https://api.weixin.qq.com/sns/auth?access_token=validAccessToken&openid=incorrectUserId', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ errcode: 0, id: 'incorrectUser' }), + }, + }, + ]); + + await reconfigureServer({ + auth: { + wechat: { + appId: 'validAppId', + appSecret: 'validAppSecret', + enableInsecureAuth: true, + }, + }, + }); + + const authData = { access_token: 'validAccessToken', id: 'incorrectUserId' }; + await expectAsync(Parse.User.logInWith('wechat', { authData })).toBeRejectedWith( + jasmine.objectContaining({ message: 'WeChat auth is invalid for this user.' }) + ); + }); + }); +}); diff --git a/spec/Adapters/Auth/weibo.spec.js b/spec/Adapters/Auth/weibo.spec.js new file mode 100644 index 0000000000..685739e663 --- /dev/null +++ b/spec/Adapters/Auth/weibo.spec.js @@ -0,0 +1,204 @@ +const WeiboAdapter = require('../../../lib/Adapters/Auth/weibo').default; + +describe('WeiboAdapter', function () { + let adapter; + + beforeEach(function () { + adapter = new WeiboAdapter.constructor(); + }); + + describe('Test configuration errors', function () { + it('should throw error if code or redirect_uri is missing', async function () { + const invalidAuthData = [ + {}, + { code: 'validCode' }, + { redirect_uri: 'http://example.com/callback' }, + ]; + + for (const authData of invalidAuthData) { + await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWith( + jasmine.objectContaining({ + message: 'Weibo auth requires code and redirect_uri to be sent.', + }) + ); + } + }); + }); + + describe('Test getUserFromAccessToken', function () { + it('should fetch user successfully', async function () { + mockFetch([ + { + url: 'https://api.weibo.com/oauth2/get_token_info', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ uid: 'validUserId' }), + }, + }, + ]); + + const authData = { id: 'validUserId' }; + const user = await adapter.getUserFromAccessToken('validToken', authData); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.weibo.com/oauth2/get_token_info', + jasmine.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + ); + expect(user).toEqual({ id: 'validUserId' }); + }); + + it('should throw error for invalid response', async function () { + mockFetch([ + { + url: 'https://api.weibo.com/oauth2/get_token_info', + method: 'POST', + response: { + ok: false, + json: () => Promise.resolve({}), + }, + }, + ]); + + const authData = { id: 'invalidUserId' }; + await expectAsync(adapter.getUserFromAccessToken('invalidToken', authData)).toBeRejectedWith( + jasmine.objectContaining({ + message: 'Weibo auth is invalid for this user.', + }) + ); + }); + }); + + describe('Test getAccessTokenFromCode', function () { + it('should fetch access token successfully', async function () { + mockFetch([ + { + url: 'https://api.weibo.com/oauth2/access_token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'validToken', uid: 'validUserId' }), + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' }; + const token = await adapter.getAccessTokenFromCode(authData); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.weibo.com/oauth2/access_token', + jasmine.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + ); + expect(token).toEqual('validToken'); + }); + + it('should throw error for invalid response', async function () { + mockFetch([ + { + url: 'https://api.weibo.com/oauth2/access_token', + method: 'POST', + response: { + ok: false, + json: () => Promise.resolve({ errcode: 40029 }), + }, + }, + ]); + + const authData = { code: 'invalidCode', redirect_uri: 'http://example.com/callback' }; + await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWith( + jasmine.objectContaining({ + message: 'Weibo auth is invalid for this user.', + }) + ); + }); + }); + + describe('WeiboAdapter E2E Tests', function () { + beforeEach(async () => { + await reconfigureServer({ + auth: { + weibo: { + clientId: 'validAppId', + clientSecret: 'validAppSecret', + }, + } + }); + }); + + it('should authenticate user successfully using WeiboAdapter', async function () { + mockFetch([ + { + url: 'https://api.weibo.com/oauth2/access_token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'validAccessToken', uid: 'user123' }), + }, + }, + { + url: 'https://api.weibo.com/oauth2/get_token_info', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ uid: 'user123' }), + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' }; + const user = await Parse.User.logInWith('weibo', { authData }); + + expect(user.id).toBeDefined(); + }); + + it('should handle invalid code error gracefully', async function () { + mockFetch([ + { + url: 'https://api.weibo.com/oauth2/access_token', + method: 'POST', + response: { + ok: false, + json: () => Promise.resolve({ errcode: 40029 }), + }, + }, + ]); + + const authData = { code: 'invalidCode', redirect_uri: 'http://example.com/callback' }; + await expectAsync(Parse.User.logInWith('weibo', { authData })).toBeRejectedWith( + jasmine.objectContaining({ message: 'Weibo auth is invalid for this user.' }) + ); + }); + + it('should handle error when fetching user data fails', async function () { + mockFetch([ + { + url: 'https://api.weibo.com/oauth2/access_token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'validAccessToken', uid: 'user123' }), + }, + }, + { + url: 'https://api.weibo.com/oauth2/get_token_info', + method: 'POST', + response: { + ok: false, + json: () => Promise.resolve({}), + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' }; + await expectAsync(Parse.User.logInWith('weibo', { authData })).toBeRejectedWith( + jasmine.objectContaining({ message: 'Weibo auth is invalid for this user.' }) + ); + }); + }); +}); diff --git a/spec/AggregateRouter.spec.js b/spec/AggregateRouter.spec.js new file mode 100644 index 0000000000..96aedcc313 --- /dev/null +++ b/spec/AggregateRouter.spec.js @@ -0,0 +1,174 @@ +const AggregateRouter = require('../lib/Routers/AggregateRouter').AggregateRouter; + +describe('AggregateRouter', () => { + it('get pipeline from Array', () => { + const body = [ + { + $group: { _id: {} }, + }, + ]; + const expected = [{ $group: { _id: {} } }]; + const result = AggregateRouter.getPipeline(body); + expect(result).toEqual(expected); + }); + + it('get pipeline from Object', () => { + const body = { + $group: { _id: {} }, + }; + const expected = [{ $group: { _id: {} } }]; + const result = AggregateRouter.getPipeline(body); + expect(result).toEqual(expected); + }); + + it('get pipeline from Pipeline Operator (Array)', () => { + const body = { + pipeline: [ + { + $group: { _id: {} }, + }, + ], + }; + const expected = [{ $group: { _id: {} } }]; + const result = AggregateRouter.getPipeline(body); + expect(result).toEqual(expected); + }); + + it('get pipeline from Pipeline Operator (Object)', () => { + const body = { + pipeline: { + $group: { _id: {} }, + }, + }; + const expected = [{ $group: { _id: {} } }]; + const result = AggregateRouter.getPipeline(body); + expect(result).toEqual(expected); + }); + + it('get pipeline fails multiple keys in Array stage ', () => { + const body = [ + { + $group: { _id: {} }, + $match: { name: 'Test' }, + }, + ]; + expect(() => AggregateRouter.getPipeline(body)).toThrow( + new Parse.Error( + Parse.Error.INVALID_QUERY, + 'Pipeline stages should only have one key but found $group, $match.' + ) + ); + }); + + it('get pipeline fails multiple keys in Pipeline Operator Array stage ', () => { + const body = { + pipeline: [ + { + $group: { _id: {} }, + $match: { name: 'Test' }, + }, + ], + }; + expect(() => AggregateRouter.getPipeline(body)).toThrow( + new Parse.Error( + Parse.Error.INVALID_QUERY, + 'Pipeline stages should only have one key but found $group, $match.' + ) + ); + }); + + it('get search pipeline from Pipeline Operator (Array)', () => { + const body = { + pipeline: { + $search: {}, + }, + }; + const expected = [{ $search: {} }]; + const result = AggregateRouter.getPipeline(body); + expect(result).toEqual(expected); + }); + + it('support stage name starting with `$`', () => { + const body = { + $match: { someKey: 'whatever' }, + }; + const expected = [{ $match: { someKey: 'whatever' } }]; + const result = AggregateRouter.getPipeline(body); + expect(result).toEqual(expected); + }); + + it('support nested stage names starting with `$`', () => { + const body = [ + { + $lookup: { + from: 'ACollection', + let: { id: '_id' }, + as: 'results', + pipeline: [ + { + $match: { + $expr: { + $eq: ['$_id', '$$id'], + }, + }, + }, + ], + }, + }, + ]; + const expected = [ + { + $lookup: { + from: 'ACollection', + let: { id: '_id' }, + as: 'results', + pipeline: [ + { + $match: { + $expr: { + $eq: ['$_id', '$$id'], + }, + }, + }, + ], + }, + }, + ]; + const result = AggregateRouter.getPipeline(body); + expect(result).toEqual(expected); + }); + + it('support the use of `_id` in stages', () => { + const body = [ + { $match: { _id: 'randomId' } }, + { $sort: { _id: -1 } }, + { $addFields: { _id: 1 } }, + { $group: { _id: {} } }, + { $project: { _id: 0 } }, + ]; + const expected = [ + { $match: { _id: 'randomId' } }, + { $sort: { _id: -1 } }, + { $addFields: { _id: 1 } }, + { $group: { _id: {} } }, + { $project: { _id: 0 } }, + ]; + const result = AggregateRouter.getPipeline(body); + expect(result).toEqual(expected); + }); + + it('should throw with invalid stage', () => { + expect(() => AggregateRouter.getPipeline([{ foo: 'bar' }])).toThrow( + new Parse.Error(Parse.Error.INVALID_QUERY, `Invalid aggregate stage 'foo'.`) + ); + }); + + it('should throw with invalid group', () => { + expect(() => AggregateRouter.getPipeline([{ $group: { objectId: 'bar' } }])).toThrow( + new Parse.Error( + Parse.Error.INVALID_QUERY, + `Cannot use 'objectId' in aggregation stage $group.` + ) + ); + }); +}); diff --git a/spec/Analytics.spec.js b/spec/Analytics.spec.js new file mode 100644 index 0000000000..049a2795c8 --- /dev/null +++ b/spec/Analytics.spec.js @@ -0,0 +1,69 @@ +const analyticsAdapter = { + appOpened: function () {}, + trackEvent: function () {}, +}; + +describe('AnalyticsController', () => { + it('should track a simple event', done => { + spyOn(analyticsAdapter, 'trackEvent').and.callThrough(); + reconfigureServer({ + analyticsAdapter, + }) + .then(() => { + return Parse.Analytics.track('MyEvent', { + key: 'value', + count: '0', + }); + }) + .then( + () => { + expect(analyticsAdapter.trackEvent).toHaveBeenCalled(); + const lastCall = analyticsAdapter.trackEvent.calls.first(); + const args = lastCall.args; + expect(args[0]).toEqual('MyEvent'); + expect(args[1]).toEqual({ + dimensions: { + key: 'value', + count: '0', + }, + }); + done(); + }, + err => { + fail(JSON.stringify(err)); + done(); + } + ); + }); + + it('should track a app opened event', done => { + spyOn(analyticsAdapter, 'appOpened').and.callThrough(); + reconfigureServer({ + analyticsAdapter, + }) + .then(() => { + return Parse.Analytics.track('AppOpened', { + key: 'value', + count: '0', + }); + }) + .then( + () => { + expect(analyticsAdapter.appOpened).toHaveBeenCalled(); + const lastCall = analyticsAdapter.appOpened.calls.first(); + const args = lastCall.args; + expect(args[0]).toEqual({ + dimensions: { + key: 'value', + count: '0', + }, + }); + done(); + }, + err => { + fail(JSON.stringify(err)); + done(); + } + ); + }); +}); diff --git a/spec/AudienceRouter.spec.js b/spec/AudienceRouter.spec.js new file mode 100644 index 0000000000..96b8f2459f --- /dev/null +++ b/spec/AudienceRouter.spec.js @@ -0,0 +1,444 @@ +const auth = require('../lib/Auth'); +const Config = require('../lib/Config'); +const rest = require('../lib/rest'); +const request = require('../lib/request'); +const AudiencesRouter = require('../lib/Routers/AudiencesRouter').AudiencesRouter; + +describe('AudiencesRouter', () => { + let loggerErrorSpy; + + beforeEach(() => { + const logger = require('../lib/logger').default; + loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + }); + + it('uses find condition from request.body', done => { + const config = Config.get('test'); + const androidAudienceRequest = { + name: 'Android Users', + query: '{ "test": "android" }', + }; + const iosAudienceRequest = { + name: 'Iphone Users', + query: '{ "test": "ios" }', + }; + const request = { + config: config, + auth: auth.master(config), + body: { + where: { + query: '{ "test": "android" }', + }, + }, + query: {}, + info: {}, + }; + + const router = new AudiencesRouter(); + rest + .create(config, auth.master(config), '_Audience', androidAudienceRequest) + .then(() => { + return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest); + }) + .then(() => { + return router.handleFind(request); + }) + .then(res => { + const results = res.response.results; + expect(results.length).toEqual(1); + done(); + }) + .catch(err => { + fail(JSON.stringify(err)); + done(); + }); + }); + + it('uses find condition from request.query', done => { + const config = Config.get('test'); + const androidAudienceRequest = { + name: 'Android Users', + query: '{ "test": "android" }', + }; + const iosAudienceRequest = { + name: 'Iphone Users', + query: '{ "test": "ios" }', + }; + const request = { + config: config, + auth: auth.master(config), + body: {}, + query: { + where: { + query: '{ "test": "android" }', + }, + }, + info: {}, + }; + + const router = new AudiencesRouter(); + rest + .create(config, auth.master(config), '_Audience', androidAudienceRequest) + .then(() => { + return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest); + }) + .then(() => { + return router.handleFind(request); + }) + .then(res => { + const results = res.response.results; + expect(results.length).toEqual(1); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + + it('query installations with limit = 0', done => { + const config = Config.get('test'); + const androidAudienceRequest = { + name: 'Android Users', + query: '{ "test": "android" }', + }; + const iosAudienceRequest = { + name: 'Iphone Users', + query: '{ "test": "ios" }', + }; + const request = { + config: config, + auth: auth.master(config), + body: {}, + query: { + limit: 0, + }, + info: {}, + }; + + Config.get('test'); + const router = new AudiencesRouter(); + rest + .create(config, auth.master(config), '_Audience', androidAudienceRequest) + .then(() => { + return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest); + }) + .then(() => { + return router.handleFind(request); + }) + .then(res => { + const response = res.response; + expect(response.results.length).toEqual(0); + done(); + }) + .catch(err => { + fail(JSON.stringify(err)); + done(); + }); + }); + + it_exclude_dbs(['postgres'])('query installations with count = 1', done => { + const config = Config.get('test'); + const androidAudienceRequest = { + name: 'Android Users', + query: '{ "test": "android" }', + }; + const iosAudienceRequest = { + name: 'Iphone Users', + query: '{ "test": "ios" }', + }; + const request = { + config: config, + auth: auth.master(config), + body: {}, + query: { + count: 1, + }, + info: {}, + }; + + const router = new AudiencesRouter(); + rest + .create(config, auth.master(config), '_Audience', androidAudienceRequest) + .then(() => rest.create(config, auth.master(config), '_Audience', iosAudienceRequest)) + .then(() => router.handleFind(request)) + .then(res => { + const response = res.response; + expect(response.results.length).toEqual(2); + expect(response.count).toEqual(2); + done(); + }) + .catch(error => { + fail(JSON.stringify(error)); + done(); + }); + }); + + it_exclude_dbs(['postgres'])('query installations with limit = 0 and count = 1', done => { + const config = Config.get('test'); + const androidAudienceRequest = { + name: 'Android Users', + query: '{ "test": "android" }', + }; + const iosAudienceRequest = { + name: 'Iphone Users', + query: '{ "test": "ios" }', + }; + const request = { + config: config, + auth: auth.master(config), + body: {}, + query: { + limit: 0, + count: 1, + }, + info: {}, + }; + + const router = new AudiencesRouter(); + rest + .create(config, auth.master(config), '_Audience', androidAudienceRequest) + .then(() => { + return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest); + }) + .then(() => { + return router.handleFind(request); + }) + .then(res => { + const response = res.response; + expect(response.results.length).toEqual(0); + expect(response.count).toEqual(2); + done(); + }) + .catch(err => { + fail(JSON.stringify(err)); + done(); + }); + }); + + it('should create, read, update and delete audiences throw api', done => { + Parse._request( + 'POST', + 'push_audiences', + { name: 'My Audience', query: JSON.stringify({ deviceType: 'ios' }) }, + { useMasterKey: true } + ).then(() => { + Parse._request('GET', 'push_audiences', {}, { useMasterKey: true }).then(results => { + expect(results.results.length).toEqual(1); + expect(results.results[0].name).toEqual('My Audience'); + expect(results.results[0].query.deviceType).toEqual('ios'); + Parse._request( + 'GET', + `push_audiences/${results.results[0].objectId}`, + {}, + { useMasterKey: true } + ).then(results => { + expect(results.name).toEqual('My Audience'); + expect(results.query.deviceType).toEqual('ios'); + Parse._request( + 'PUT', + `push_audiences/${results.objectId}`, + { name: 'My Audience 2' }, + { useMasterKey: true } + ).then(() => { + Parse._request( + 'GET', + `push_audiences/${results.objectId}`, + {}, + { useMasterKey: true } + ).then(results => { + expect(results.name).toEqual('My Audience 2'); + expect(results.query.deviceType).toEqual('ios'); + Parse._request( + 'DELETE', + `push_audiences/${results.objectId}`, + {}, + { useMasterKey: true } + ).then(() => { + Parse._request('GET', 'push_audiences', {}, { useMasterKey: true }).then( + results => { + expect(results.results.length).toEqual(0); + done(); + } + ); + }); + }); + }); + }); + }); + }); + }); + + it('should only create with master key', done => { + loggerErrorSpy.calls.reset(); + Parse._request('POST', 'push_audiences', { + name: 'My Audience', + query: JSON.stringify({ deviceType: 'ios' }), + }).then( + () => {}, + error => { + expect(error.message).toEqual('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('unauthorized: master key is required')); + done(); + } + ); + }); + + it('should only find with master key', done => { + loggerErrorSpy.calls.reset(); + Parse._request('GET', 'push_audiences', {}).then( + () => {}, + error => { + expect(error.message).toEqual('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('unauthorized: master key is required')); + done(); + } + ); + }); + + it('should only get with master key', done => { + loggerErrorSpy.calls.reset(); + Parse._request('GET', `push_audiences/someId`, {}).then( + () => {}, + error => { + expect(error.message).toEqual('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('unauthorized: master key is required')); + done(); + } + ); + }); + + it('should only update with master key', done => { + loggerErrorSpy.calls.reset(); + Parse._request('PUT', `push_audiences/someId`, { + name: 'My Audience 2', + }).then( + () => {}, + error => { + expect(error.message).toEqual('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('unauthorized: master key is required')); + done(); + } + ); + }); + + it('should only delete with master key', done => { + loggerErrorSpy.calls.reset(); + Parse._request('DELETE', `push_audiences/someId`, {}).then( + () => {}, + error => { + expect(error.message).toEqual('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('unauthorized: master key is required')); + done(); + } + ); + }); + + it_id('af1111b5-3251-4b40-8f06-fb0fc624fa91')(it_exclude_dbs(['postgres']))('should support legacy parse.com audience fields', done => { + const database = Config.get(Parse.applicationId).database.adapter.database; + const now = new Date(); + Parse._request( + 'POST', + 'push_audiences', + { name: 'My Audience', query: JSON.stringify({ deviceType: 'ios' }) }, + { useMasterKey: true } + ).then(audience => { + database + .collection('test__Audience') + .updateOne( + { _id: audience.objectId }, + { + $set: { + times_used: 1, + _last_used: now, + }, + } + ) + .then(result => { + expect(result).toBeTruthy(); + + database + .collection('test__Audience') + .find({ _id: audience.objectId }) + .toArray() + .then(rows => { + expect(rows[0]['times_used']).toEqual(1); + expect(rows[0]['_last_used']).toEqual(now); + Parse._request( + 'GET', + 'push_audiences/' + audience.objectId, + {}, + { useMasterKey: true } + ) + .then(audience => { + expect(audience.name).toEqual('My Audience'); + expect(audience.query.deviceType).toEqual('ios'); + expect(audience.timesUsed).toEqual(1); + expect(audience.lastUsed).toEqual(now.toISOString()); + done(); + }) + .catch(error => { + done.fail(error); + }); + }) + .catch(error => { + done.fail(error); + }); + }); + }); + }); + + it('should be able to search on audiences', done => { + Parse._request( + 'POST', + 'push_audiences', + { name: 'neverUsed', query: JSON.stringify({ deviceType: 'ios' }) }, + { useMasterKey: true } + ).then(() => { + const query = { + timesUsed: { $exists: false }, + lastUsed: { $exists: false }, + }; + Parse._request( + 'GET', + 'push_audiences?order=-createdAt&limit=1', + { where: query }, + { useMasterKey: true } + ) + .then(results => { + expect(results.results.length).toEqual(1); + const audience = results.results[0]; + expect(audience.name).toEqual('neverUsed'); + done(); + }) + .catch(error => { + done.fail(error); + }); + }); + }); + + it('should handle _Audience invalid fields via rest', async () => { + await reconfigureServer({ + appId: 'test', + restAPIKey: 'test', + masterKey: 'test', + publicServerURL: 'http://localhost:8378/1', + }); + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/_Audience', + body: { lorem: 'ipsum', _method: 'POST' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', + }, + }); + expect(true).toBeFalsy(); + } catch (e) { + expect(e.data.code).toBe(107); + expect(e.data.error).toBe('Could not add field lorem'); + } + }); +}); diff --git a/spec/Auth.spec.js b/spec/Auth.spec.js index 0b19f4ca3a..a055cda5bc 100644 --- a/spec/Auth.spec.js +++ b/spec/Auth.spec.js @@ -1,12 +1,13 @@ -describe('Auth', () => { - var Auth = require('../src/Auth.js').Auth; +'use strict'; +describe('Auth', () => { + const { Auth, getAuthForSessionToken } = require('../lib/Auth.js'); + const Config = require('../lib/Config'); describe('getUserRoles', () => { - var auth; - var config; - var cacheController; - var currentRoles = null; - var currentUserId = 'userId'; + let auth; + let config; + let currentRoles = null; + const currentUserId = 'userId'; beforeEach(() => { currentRoles = ['role:userId']; @@ -15,69 +16,241 @@ describe('Auth', () => { cacheController: { role: { get: () => Promise.resolve(currentRoles), - set: jasmine.createSpy('set') - } - } - } + set: jasmine.createSpy('set'), + }, + }, + }; spyOn(config.cacheController.role, 'get').and.callThrough(); auth = new Auth({ config: config, isMaster: false, user: { - id: currentUserId + id: currentUserId, }, - installationId: 'installationId' + installationId: 'installationId', }); }); - it('should get user roles from the cache', (done) => { - auth.getUserRoles() - .then((roles) => { - var firstSet = config.cacheController.role.set.calls.first(); - expect(firstSet).toEqual(undefined); + it('should get user roles from the cache', done => { + auth.getUserRoles().then(roles => { + const firstSet = config.cacheController.role.set.calls.first(); + expect(firstSet).toEqual(undefined); - var firstGet = config.cacheController.role.get.calls.first(); - expect(firstGet.args[0]).toEqual(currentUserId); - expect(roles).toEqual(currentRoles); - done(); - }); + const firstGet = config.cacheController.role.get.calls.first(); + expect(firstGet.args[0]).toEqual(currentUserId); + expect(roles).toEqual(currentRoles); + done(); + }); }); - it('should only query the roles once', (done) => { - var loadRolesSpy = spyOn(auth, '_loadRoles').and.callThrough(); - auth.getUserRoles() - .then((roles) => { + it('should only query the roles once', done => { + const loadRolesSpy = spyOn(auth, '_loadRoles').and.callThrough(); + auth + .getUserRoles() + .then(roles => { expect(roles).toEqual(currentRoles); - return auth.getUserRoles() + return auth.getUserRoles(); }) - .then((roles) => auth.getUserRoles()) - .then((roles) => auth.getUserRoles()) - .then((roles) => { + .then(() => auth.getUserRoles()) + .then(() => auth.getUserRoles()) + .then(roles => { // Should only call the cache adapter once. expect(config.cacheController.role.get.calls.count()).toEqual(1); expect(loadRolesSpy.calls.count()).toEqual(1); - var firstGet = config.cacheController.role.get.calls.first(); + const firstGet = config.cacheController.role.get.calls.first(); expect(firstGet.args[0]).toEqual(currentUserId); expect(roles).toEqual(currentRoles); done(); }); }); - it('should not have any roles with no user', (done) => { - auth.user = null - auth.getUserRoles() - .then((roles) => expect(roles).toEqual([])) + it('should not have any roles with no user', done => { + auth.user = null; + auth + .getUserRoles() + .then(roles => expect(roles).toEqual([])) .then(() => done()); }); - it('should not have any user roles with master', (done) => { - auth.isMaster = true - auth.getUserRoles() - .then((roles) => expect(roles).toEqual([])) + it('should not have any user roles with master', done => { + auth.isMaster = true; + auth + .getUserRoles() + .then(roles => expect(roles).toEqual([])) .then(() => done()); - }) + }); + }); + + it('can use extendSessionOnUse', async () => { + await reconfigureServer({ + extendSessionOnUse: true, + }); + + const user = new Parse.User(); + await user.signUp({ + username: 'hello', + password: 'password', + }); + const session = await new Parse.Query(Parse.Session).first(); + const updatedAt = new Date('2010'); + const expiry = new Date(); + expiry.setHours(expiry.getHours() + 1); + + await Parse.Server.database.update( + '_Session', + { objectId: session.id }, + { + expiresAt: { __type: 'Date', iso: expiry.toISOString() }, + updatedAt: updatedAt.toISOString(), + } + ); + Parse.Server.cacheController.clear(); + await new Promise(resolve => setTimeout(resolve, 1000)); + await session.fetch(); + await new Promise(resolve => setTimeout(resolve, 1000)); + await session.fetch(); + expect(session.get('expiresAt') > expiry).toBeTrue(); + }); + + it('should load auth without a config', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'hello', + password: 'password', + }); + expect(user.getSessionToken()).not.toBeUndefined(); + const userAuth = await getAuthForSessionToken({ + sessionToken: user.getSessionToken(), + }); + expect(userAuth.user instanceof Parse.User).toBe(true); + expect(userAuth.user.id).toBe(user.id); + }); + + it('should load auth with a config', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'hello', + password: 'password', + }); + expect(user.getSessionToken()).not.toBeUndefined(); + const userAuth = await getAuthForSessionToken({ + sessionToken: user.getSessionToken(), + config: Config.get('test'), + }); + expect(userAuth.user instanceof Parse.User).toBe(true); + expect(userAuth.user.id).toBe(user.id); + }); + + describe('getRolesForUser', () => { + const rolesNumber = 100; + + it('should load all roles without config', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'hello', + password: 'password', + }); + expect(user.getSessionToken()).not.toBeUndefined(); + const userAuth = await getAuthForSessionToken({ + sessionToken: user.getSessionToken(), + }); + const roles = []; + for (let i = 0; i < rolesNumber; i++) { + const acl = new Parse.ACL(); + const role = new Parse.Role('roleloadtest' + i, acl); + role.getUsers().add([user]); + roles.push(role); + } + const savedRoles = await Parse.Object.saveAll(roles); + expect(savedRoles.length).toBe(rolesNumber); + const cloudRoles = await userAuth.getRolesForUser(); + expect(cloudRoles.length).toBe(rolesNumber); + }); + + it('should load all roles with config', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'hello', + password: 'password', + }); + expect(user.getSessionToken()).not.toBeUndefined(); + const userAuth = await getAuthForSessionToken({ + sessionToken: user.getSessionToken(), + config: Config.get('test'), + }); + const roles = []; + for (let i = 0; i < rolesNumber; i++) { + const acl = new Parse.ACL(); + const role = new Parse.Role('roleloadtest' + i, acl); + role.getUsers().add([user]); + roles.push(role); + } + const savedRoles = await Parse.Object.saveAll(roles); + expect(savedRoles.length).toBe(rolesNumber); + const cloudRoles = await userAuth.getRolesForUser(); + expect(cloudRoles.length).toBe(rolesNumber); + }); + + it('should load all roles for different users with config', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'hello', + password: 'password', + }); + const user2 = new Parse.User(); + await user2.signUp({ + username: 'world', + password: '1234', + }); + expect(user.getSessionToken()).not.toBeUndefined(); + const userAuth = await getAuthForSessionToken({ + sessionToken: user.getSessionToken(), + config: Config.get('test'), + }); + const user2Auth = await getAuthForSessionToken({ + sessionToken: user2.getSessionToken(), + config: Config.get('test'), + }); + const roles = []; + for (let i = 0; i < rolesNumber; i += 1) { + const acl = new Parse.ACL(); + const acl2 = new Parse.ACL(); + const role = new Parse.Role('roleloadtest' + i, acl); + const role2 = new Parse.Role('role2loadtest' + i, acl2); + role.getUsers().add([user]); + role2.getUsers().add([user2]); + roles.push(role); + roles.push(role2); + } + const savedRoles = await Parse.Object.saveAll(roles); + expect(savedRoles.length).toBe(rolesNumber * 2); + const cloudRoles = await userAuth.getRolesForUser(); + const cloudRoles2 = await user2Auth.getRolesForUser(); + expect(cloudRoles.length).toBe(rolesNumber); + expect(cloudRoles2.length).toBe(rolesNumber); + }); + }); +}); + +describe('extendSessionOnUse', () => { + it(`shouldUpdateSessionExpiry()`, async () => { + const { shouldUpdateSessionExpiry } = require('../lib/Auth'); + let update = new Date(Date.now() - 86410 * 1000); + + const res = shouldUpdateSessionExpiry( + { sessionLength: 86460 }, + { updatedAt: update } + ); + + update = new Date(Date.now() - 43210 * 1000); + const res2 = shouldUpdateSessionExpiry( + { sessionLength: 86460 }, + { updatedAt: update } + ); + expect(res).toBe(true); + expect(res2).toBe(false); }); }); diff --git a/spec/AuthDataUniqueIndex.spec.js b/spec/AuthDataUniqueIndex.spec.js new file mode 100644 index 0000000000..d975a42209 --- /dev/null +++ b/spec/AuthDataUniqueIndex.spec.js @@ -0,0 +1,210 @@ +'use strict'; + +const request = require('../lib/request'); +const Config = require('../lib/Config'); + +describe('AuthData Unique Index', () => { + const fakeAuthProvider = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; + + beforeEach(async () => { + await reconfigureServer({ auth: { fakeAuthProvider } }); + }); + + it('should prevent concurrent signups with the same authData from creating duplicate users', async () => { + const authData = { fakeAuthProvider: { id: 'duplicate-test-id', token: 'token1' } }; + + // Fire multiple concurrent signup requests with the same authData + const concurrentRequests = Array.from({ length: 5 }, () => + request({ + method: 'POST', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + url: 'http://localhost:8378/1/users', + body: { authData }, + }).then( + response => ({ success: true, data: response.data }), + error => ({ success: false, error: error.data || error.message }) + ) + ); + + const results = await Promise.all(concurrentRequests); + const successes = results.filter(r => r.success); + const failures = results.filter(r => !r.success); + + // All should either succeed (returning the same user) or fail with "this auth is already used" + // The key invariant: only ONE unique objectId should exist + const uniqueObjectIds = new Set(successes.map(r => r.data.objectId)); + expect(uniqueObjectIds.size).toBe(1); + + // Failures should be "this auth is already used" errors + for (const failure of failures) { + expect(failure.error.code).toBe(208); + expect(failure.error.error).toBe('this auth is already used'); + } + + // Verify only one user exists in the database with this authData + const query = new Parse.Query('_User'); + query.equalTo('authData.fakeAuthProvider.id', 'duplicate-test-id'); + const users = await query.find({ useMasterKey: true }); + expect(users.length).toBe(1); + }); + + it('should prevent concurrent signups via batch endpoint with same authData', async () => { + const authData = { fakeAuthProvider: { id: 'batch-race-test-id', token: 'token1' } }; + + const response = await request({ + method: 'POST', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + url: 'http://localhost:8378/1/batch', + body: { + requests: Array.from({ length: 3 }, () => ({ + method: 'POST', + path: '/1/users', + body: { authData }, + })), + }, + }); + + const results = response.data; + const successes = results.filter(r => r.success); + const failures = results.filter(r => r.error); + + // All successes should reference the same user + const uniqueObjectIds = new Set(successes.map(r => r.success.objectId)); + expect(uniqueObjectIds.size).toBe(1); + + // Failures should be "this auth is already used" errors + for (const failure of failures) { + expect(failure.error.code).toBe(208); + expect(failure.error.error).toBe('this auth is already used'); + } + + // Verify only one user exists in the database with this authData + const query = new Parse.Query('_User'); + query.equalTo('authData.fakeAuthProvider.id', 'batch-race-test-id'); + const users = await query.find({ useMasterKey: true }); + expect(users.length).toBe(1); + }); + + it('should allow sequential signups with different authData IDs', async () => { + const user1 = await Parse.User.logInWith('fakeAuthProvider', { + authData: { id: 'user-id-1', token: 'token1' }, + }); + const user2 = await Parse.User.logInWith('fakeAuthProvider', { + authData: { id: 'user-id-2', token: 'token2' }, + }); + + expect(user1.id).toBeDefined(); + expect(user2.id).toBeDefined(); + expect(user1.id).not.toBe(user2.id); + }); + + it('should still allow login with authData after successful signup', async () => { + const authPayload = { authData: { id: 'login-test-id', token: 'token1' } }; + + // Signup + const user1 = await Parse.User.logInWith('fakeAuthProvider', authPayload); + expect(user1.id).toBeDefined(); + + // Login again with same authData — should return same user + const user2 = await Parse.User.logInWith('fakeAuthProvider', authPayload); + expect(user2.id).toBe(user1.id); + }); + + it('should skip startup index creation when createIndexAuthDataUniqueness is false', async () => { + const config = Config.get('test'); + const adapter = config.database.adapter; + const spy = spyOn(adapter, 'ensureAuthDataUniqueness').and.callThrough(); + + // Temporarily set the option to false + const originalOptions = config.database.options.databaseOptions; + config.database.options.databaseOptions = { createIndexAuthDataUniqueness: false }; + + await config.database.performInitialization(); + expect(spy).not.toHaveBeenCalled(); + + // Restore original options + config.database.options.databaseOptions = originalOptions; + }); + + it('should handle calling ensureAuthDataUniqueness multiple times (idempotent)', async () => { + const config = Config.get('test'); + const adapter = config.database.adapter; + + // Both calls should succeed (index creation is idempotent) + await adapter.ensureAuthDataUniqueness('fakeAuthProvider'); + await adapter.ensureAuthDataUniqueness('fakeAuthProvider'); + }); + + it('should log warning when index creation fails due to existing duplicates', async () => { + const config = Config.get('test'); + const adapter = config.database.adapter; + + // Spy on the adapter to simulate a duplicate value error + spyOn(adapter, 'ensureAuthDataUniqueness').and.callFake(() => { + return Promise.reject( + new Parse.Error(Parse.Error.DUPLICATE_VALUE, 'duplicates exist') + ); + }); + + const logSpy = spyOn(require('../lib/logger').logger, 'warn'); + + // Re-run performInitialization — should warn but not throw + await config.database.performInitialization(); + expect(logSpy).toHaveBeenCalledWith( + jasmine.stringContaining('Unable to ensure uniqueness for auth data provider'), + jasmine.anything() + ); + }); + + it('should prevent concurrent signups with same anonymous authData', async () => { + const anonymousId = 'anon-race-test-id'; + const authData = { anonymous: { id: anonymousId } }; + + const concurrentRequests = Array.from({ length: 5 }, () => + request({ + method: 'POST', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + url: 'http://localhost:8378/1/users', + body: { authData }, + }).then( + response => ({ success: true, data: response.data }), + error => ({ success: false, error: error.data || error.message }) + ) + ); + + const results = await Promise.all(concurrentRequests); + const successes = results.filter(r => r.success); + const failures = results.filter(r => !r.success); + + // All successes should reference the same user + const uniqueObjectIds = new Set(successes.map(r => r.data.objectId)); + expect(uniqueObjectIds.size).toBe(1); + + // Failures should be "this auth is already used" errors + for (const failure of failures) { + expect(failure.error.code).toBe(208); + expect(failure.error.error).toBe('this auth is already used'); + } + + // Verify only one user exists in the database with this authData + const query = new Parse.Query('_User'); + query.equalTo('authData.anonymous.id', anonymousId); + const users = await query.find({ useMasterKey: true }); + expect(users.length).toBe(1); + }); +}); diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js new file mode 100644 index 0000000000..f975914bd7 --- /dev/null +++ b/spec/AuthenticationAdapters.spec.js @@ -0,0 +1,2222 @@ +const request = require('../lib/request'); +const Config = require('../lib/Config'); +const defaultColumns = require('../lib/Controllers/SchemaController').defaultColumns; +const authenticationLoader = require('../lib/Adapters/Auth'); +const path = require('path'); + +describe('AuthenticationProviders', function () { + const getMockMyOauthProvider = function () { + return { + authData: { + id: '12345', + access_token: '12345', + expiration_date: new Date().toJSON(), + }, + shouldError: false, + loggedOut: false, + synchronizedUserId: null, + synchronizedAuthToken: null, + synchronizedExpiration: null, + + authenticate: function (options) { + if (this.shouldError) { + options.error(this, 'An error occurred'); + } else if (this.shouldCancel) { + options.error(this, null); + } else { + options.success(this, this.authData); + } + }, + restoreAuthentication: function (authData) { + if (!authData) { + this.synchronizedUserId = null; + this.synchronizedAuthToken = null; + this.synchronizedExpiration = null; + return true; + } + this.synchronizedUserId = authData.id; + this.synchronizedAuthToken = authData.access_token; + this.synchronizedExpiration = authData.expiration_date; + return true; + }, + getAuthType: function () { + return 'myoauth'; + }, + deauthenticate: function () { + this.loggedOut = true; + this.restoreAuthentication(null); + }, + }; + }; + + Parse.User.extend({ + extended: function () { + return true; + }, + }); + + const createOAuthUser = function (callback) { + return createOAuthUserWithSessionToken(undefined, callback); + }; + + const createOAuthUserWithSessionToken = function (token, callback) { + const jsonBody = { + authData: { + myoauth: getMockMyOauthProvider().authData, + }, + }; + + const options = { + method: 'POST', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Installation-Id': 'yolo', + 'X-Parse-Session-Token': token, + 'Content-Type': 'application/json', + }, + url: 'http://localhost:8378/1/users', + body: jsonBody, + }; + return request(options) + .then(response => { + if (callback) { + callback(null, response, response.data); + } + return { + res: response, + body: response.data, + }; + }) + .catch(error => { + if (callback) { + callback(error); + } + throw error; + }); + }; + + it('should create user with REST API', done => { + createOAuthUser((error, response, body) => { + expect(error).toBe(null); + const b = body; + ok(b.sessionToken); + expect(b.objectId).not.toBeNull(); + expect(b.objectId).not.toBeUndefined(); + const sessionToken = b.sessionToken; + const q = new Parse.Query('_Session'); + q.equalTo('sessionToken', sessionToken); + q.first({ useMasterKey: true }) + .then(res => { + if (!res) { + fail('should not fail fetching the session'); + done(); + return; + } + expect(res.get('installationId')).toEqual('yolo'); + done(); + }) + .catch(() => { + fail('should not fail fetching the session'); + done(); + }); + }); + }); + + it('should only create a single user with REST API', done => { + let objectId; + createOAuthUser((error, response, body) => { + expect(error).toBe(null); + const b = body; + expect(b.objectId).not.toBeNull(); + expect(b.objectId).not.toBeUndefined(); + objectId = b.objectId; + + createOAuthUser((error, response, body) => { + expect(error).toBe(null); + const b = body; + expect(b.objectId).not.toBeNull(); + expect(b.objectId).not.toBeUndefined(); + expect(b.objectId).toBe(objectId); + done(); + }); + }); + }); + + it("should fail to link if session token don't match user", done => { + Parse.User.signUp('myUser', 'password') + .then(user => { + return createOAuthUserWithSessionToken(user.getSessionToken()); + }) + .then(() => { + return Parse.User.logOut(); + }) + .then(() => { + return Parse.User.signUp('myUser2', 'password'); + }) + .then(user => { + return createOAuthUserWithSessionToken(user.getSessionToken()); + }) + .then(fail, ({ data }) => { + expect(data.code).toBe(208); + expect(data.error).toBe('this auth is already used'); + done(); + }) + .catch(done.fail); + }); + + it('should support loginWith with session token and with/without mutated authData', async () => { + const fakeAuthProvider = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; + const payload = { authData: { id: 'user1', token: 'fakeToken' } }; + const payload2 = { authData: { id: 'user1', token: 'fakeToken2' } }; + await reconfigureServer({ auth: { fakeAuthProvider } }); + const user = await Parse.User.logInWith('fakeAuthProvider', payload); + const user2 = await Parse.User.logInWith('fakeAuthProvider', payload, { + sessionToken: user.getSessionToken(), + }); + const user3 = await Parse.User.logInWith('fakeAuthProvider', payload2, { + sessionToken: user2.getSessionToken(), + }); + expect(user.id).toEqual(user2.id); + expect(user.id).toEqual(user3.id); + }); + + it('should support sync/async validateAppId', async () => { + const syncProvider = { + validateAppId: () => true, + appIds: 'test', + validateAuthData: () => Promise.resolve(), + }; + const asyncProvider = { + appIds: 'test', + validateAppId: () => Promise.resolve(true), + validateAuthData: () => Promise.resolve(), + }; + const payload = { authData: { id: 'user1', token: 'fakeToken' } }; + const syncSpy = spyOn(syncProvider, 'validateAppId'); + const asyncSpy = spyOn(asyncProvider, 'validateAppId'); + + await reconfigureServer({ auth: { asyncProvider, syncProvider } }); + const user = await Parse.User.logInWith('asyncProvider', payload); + const user2 = await Parse.User.logInWith('syncProvider', payload); + expect(user.getSessionToken()).toBeDefined(); + expect(user2.getSessionToken()).toBeDefined(); + expect(syncSpy).toHaveBeenCalledTimes(1); + expect(asyncSpy).toHaveBeenCalledTimes(1); + }); + + it('unlink and link with custom provider', async () => { + const provider = getMockMyOauthProvider(); + Parse.User._registerAuthenticationProvider(provider); + const model = await Parse.User._logInWith('myoauth'); + ok(model instanceof Parse.User, 'Model should be a Parse.User'); + strictEqual(Parse.User.current(), model); + ok(model.extended(), 'Should have used the subclass.'); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); + ok(model._isLinked('myoauth'), 'User should be linked to myoauth'); + + await model._unlinkFrom('myoauth'); + ok(!model._isLinked('myoauth'), 'User should not be linked to myoauth'); + ok(!provider.synchronizedUserId, 'User id should be cleared'); + ok(!provider.synchronizedAuthToken, 'Auth token should be cleared'); + ok(!provider.synchronizedExpiration, 'Expiration should be cleared'); + // make sure the auth data is properly deleted + const config = Config.get(Parse.applicationId); + const res = await config.database.adapter.find( + '_User', + { + fields: Object.assign({}, defaultColumns._Default, defaultColumns._Installation), + }, + { objectId: model.id }, + {} + ); + expect(res.length).toBe(1); + expect(res[0]._auth_data_myoauth).toBeUndefined(); + expect(res[0]._auth_data_myoauth).not.toBeNull(); + + await model._linkWith('myoauth'); + + ok(provider.synchronizedUserId, 'User id should have a value'); + ok(provider.synchronizedAuthToken, 'Auth token should have a value'); + ok(provider.synchronizedExpiration, 'Expiration should have a value'); + ok(model._isLinked('myoauth'), 'User should be linked to myoauth'); + }); + + function validateValidator(validator) { + expect(typeof validator).toBe('function'); + } + + function validateAuthenticationHandler(authenticationHandler) { + expect(authenticationHandler).not.toBeUndefined(); + expect(typeof authenticationHandler.getValidatorForProvider).toBe('function'); + expect(typeof authenticationHandler.getValidatorForProvider).toBe('function'); + } + + function validateAuthenticationAdapter(authAdapter) { + expect(authAdapter).not.toBeUndefined(); + if (!authAdapter) { + return; + } + expect(typeof authAdapter.validateAuthData).toBe('function'); + expect(typeof authAdapter.validateAppId).toBe('function'); + } + + it('properly loads custom adapter', done => { + const validAuthData = { + id: 'hello', + token: 'world', + }; + const adapter = { + validateAppId: function () { + return Promise.resolve(); + }, + validateAuthData: function (authData) { + if (authData.id == validAuthData.id && authData.token == validAuthData.token) { + return Promise.resolve(); + } + return Promise.reject(); + }, + }; + + const authDataSpy = spyOn(adapter, 'validateAuthData').and.callThrough(); + const appIdSpy = spyOn(adapter, 'validateAppId').and.callThrough(); + + const authenticationHandler = authenticationLoader({ + customAuthentication: adapter, + }); + + validateAuthenticationHandler(authenticationHandler); + const { validator } = authenticationHandler.getValidatorForProvider('customAuthentication'); + validateValidator(validator); + + validator(validAuthData, {}, {}).then( + () => { + expect(authDataSpy).toHaveBeenCalled(); + // AppIds are not provided in the adapter, should not be called + expect(appIdSpy).not.toHaveBeenCalled(); + done(); + }, + err => { + jfail(err); + done(); + } + ); + }); + + it('properly loads custom adapter module object', done => { + const authenticationHandler = authenticationLoader({ + customAuthentication: path.resolve('./spec/support/CustomAuth.js'), + }); + + validateAuthenticationHandler(authenticationHandler); + const { validator } = authenticationHandler.getValidatorForProvider('customAuthentication'); + validateValidator(validator); + validator( + { + token: 'my-token', + }, + {}, + {} + ).then( + () => { + done(); + }, + err => { + jfail(err); + done(); + } + ); + }); + + it('properly loads custom adapter module object (again)', done => { + const authenticationHandler = authenticationLoader({ + customAuthentication: { + module: path.resolve('./spec/support/CustomAuthFunction.js'), + options: { token: 'valid-token' }, + }, + }); + + validateAuthenticationHandler(authenticationHandler); + const { validator } = authenticationHandler.getValidatorForProvider('customAuthentication'); + validateValidator(validator); + + validator( + { + token: 'valid-token', + }, + {}, + {} + ).then( + () => { + done(); + }, + err => { + jfail(err); + done(); + } + ); + }); + + it('properly loads a default adapter with options', () => { + const options = { + facebook: { + appIds: ['a', 'b'], + appSecret: 'secret', + }, + }; + const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter( + 'facebook', + options + ); + validateAuthenticationAdapter(adapter); + expect(appIds).toEqual(['a', 'b']); + expect(providerOptions).toEqual(options.facebook); + }); + + it('should handle Facebook appSecret for validating appIds', async () => { + const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); + spyOn(httpsRequest, 'get').and.callFake(() => { + return Promise.resolve({ id: 'a' }); + }); + const options = { + facebook: { + appIds: ['a', 'b'], + appSecret: 'secret_sauce', + }, + }; + const authData = { + access_token: 'badtoken', + }; + const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter( + 'facebook', + options + ); + await adapter.validateAppId(appIds, authData, providerOptions); + expect(httpsRequest.get.calls.first().args[0].includes('appsecret_proof')).toBe(true); + }); + + it('should throw error when Facebook request appId is wrong data type', async () => { + const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); + spyOn(httpsRequest, 'get').and.callFake(() => { + return Promise.resolve({ id: 'a' }); + }); + const options = { + facebook: { + appIds: 'abcd', + appSecret: 'secret_sauce', + }, + }; + const authData = { + access_token: 'badtoken', + }; + const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter( + 'facebook', + options + ); + await expectAsync(adapter.validateAppId(appIds, authData, providerOptions)).toBeRejectedWith( + new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'appIds must be an array.') + ); + }); + + it('should handle Facebook appSecret for validating auth data', async () => { + const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); + spyOn(httpsRequest, 'get').and.callFake(() => { + return Promise.resolve(); + }); + const options = { + facebook: { + appIds: ['a', 'b'], + appSecret: 'secret_sauce', + }, + }; + const authData = { + id: 'test', + access_token: 'test', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('facebook', options); + await adapter.validateAuthData(authData, providerOptions); + expect(httpsRequest.get.calls.first().args[0].includes('appsecret_proof')).toBe(true); + }); + + it('properly loads a custom adapter with options', () => { + const options = { + custom: { + validateAppId: () => {}, + validateAuthData: () => {}, + appIds: ['a', 'b'], + }, + }; + const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter( + 'custom', + options + ); + validateAuthenticationAdapter(adapter); + expect(appIds).toEqual(['a', 'b']); + expect(providerOptions).toEqual(options.custom); + }); + + it('can disable provider', async () => { + await reconfigureServer({ + auth: { + myoauth: { + enabled: false, + module: path.resolve(__dirname, 'support/myoauth'), // relative path as it's run from src + }, + }, + }); + const provider = getMockMyOauthProvider(); + Parse.User._registerAuthenticationProvider(provider); + await expectAsync(Parse.User._logInWith('myoauth')).toBeRejectedWith( + new Parse.Error(Parse.Error.UNSUPPORTED_SERVICE, 'This authentication method is unsupported.') + ); + }); +}); + +describe('google auth adapter', () => { + const google = require('../lib/Adapters/Auth/google'); + const jwt = require('jsonwebtoken'); + const authUtils = require('../lib/Adapters/Auth/utils'); + + it('should throw error with missing id_token', async () => { + try { + await google.validateAuthData({}, { clientId: 'secret' }); + fail(); + } catch (e) { + expect(e.message).toBe('id token is invalid for this user.'); + } + }); + + it('should not decode invalid id_token', async () => { + try { + await google.validateAuthData({ id: 'the_user_id', id_token: 'the_token' }, { clientId: 'secret' }); + fail(); + } catch (e) { + expect(e.message).toBe('provided token does not decode as JWT'); + } + }); + + it('should reject forged alg:none JWT from advisory PoC (GHSA-4q3h-vp4r-prv2)', async () => { + const header = Buffer.from('{"alg":"none","kid":"nonexistent-key","typ":"JWT"}').toString('base64url'); + const payload = Buffer.from('{"sub":"the_user_id","iss":"accounts.google.com","aud":"secret","exp":9999999999}').toString('base64url'); + const forgedToken = `${header}.${payload}.`; + + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + + try { + await google.validateAuthData( + { id: 'the_user_id', id_token: forgedToken }, + { clientId: 'secret' } + ); + fail('should have rejected forged token'); + } catch (e) { + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + } + }); + + it('should pass hardcoded RS256 algorithm to jwt.verify, not the JWT header alg', async () => { + const fakeClaim = { + iss: 'https://accounts.google.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { kid: '123', alg: 'ES256' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + await google.validateAuthData( + { id: 'the_user_id', id_token: 'the_token' }, + { clientId: 'secret' } + ); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']); + }); + + it('should throw error if Google signing key is not found', async () => { + const fakeDecodedToken = { kid: '789', alg: 'RS256' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.rejectWith(new Error('key not found')); + + try { + await google.validateAuthData( + { id: 'the_user_id', id_token: 'the_token' }, + { clientId: 'secret' } + ); + fail('should have thrown'); + } catch (e) { + expect(e.message).toBe('Unable to find matching key for Key ID: 789'); + } + }); + + it('(using client id as string) should verify id_token (google.com)', async () => { + const fakeClaim = { + iss: 'https://accounts.google.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { kid: '123', alg: 'RS256' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const result = await google.validateAuthData( + { id: 'the_user_id', id_token: 'the_token' }, + { clientId: 'secret' } + ); + expect(result).toEqual(fakeClaim); + }); + + it('(using client id as string) should throw error with with invalid jwt issuer (google.com)', async () => { + const fakeClaim = { + iss: 'https://not.google.com', + sub: 'the_user_id', + }; + const fakeDecodedToken = { kid: '123', alg: 'RS256' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + try { + await google.validateAuthData( + { id: 'the_user_id', id_token: 'the_token' }, + { clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe( + 'id token not issued by correct provider - expected: accounts.google.com or https://accounts.google.com | from: https://not.google.com' + ); + } + }); + + xit('(using client id as string) should throw error with invalid jwt client_id', async () => { + const fakeClaim = { + iss: 'https://accounts.google.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { kid: '123', alg: 'RS256' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + try { + await google.validateAuthData( + { id: 'INSERT ID HERE', token: 'INSERT APPLE TOKEN HERE' }, + { clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe('jwt audience invalid. expected: secret'); + } + }); + + xit('should throw error with invalid user id', async () => { + const fakeClaim = { + iss: 'https://accounts.google.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { kid: '123', alg: 'RS256' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + try { + await google.validateAuthData( + { id: 'invalid user', token: 'INSERT APPLE TOKEN HERE' }, + { clientId: 'INSERT CLIENT ID HERE' } + ); + fail(); + } catch (e) { + expect(e.message).toBe('auth data is invalid for this user.'); + } + }); + + it('should throw error when clientId is not configured', async () => { + try { + await google.validateAuthData({ id: 'the_user_id', id_token: 'the_token' }, {}); + fail('should have thrown'); + } catch (e) { + expect(e.message).toBe('Google auth is not configured.'); + } + }); +}); + +describe('keycloak auth adapter', () => { + const keycloak = require('../lib/Adapters/Auth/keycloak'); + const jwt = require('jsonwebtoken'); + const authUtils = require('../lib/Adapters/Auth/utils'); + + it('validateAuthData should fail without access token', async () => { + const authData = { + id: 'fakeid', + }; + try { + await keycloak.validateAuthData(authData); + fail(); + } catch (e) { + expect(e.message).toBe('Missing access token and/or User id'); + } + }); + + it('validateAuthData should fail without user id', async () => { + const authData = { + access_token: 'sometoken', + }; + try { + await keycloak.validateAuthData(authData); + fail(); + } catch (e) { + expect(e.message).toBe('Missing access token and/or User id'); + } + }); + + it('validateAuthData should fail without config', async () => { + const options = { + keycloak: { + config: null, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + try { + await adapter.validateAuthData(authData, providerOptions); + fail(); + } catch (e) { + expect(e.message).toBe('Missing keycloak configuration'); + } + }); + + it('validateAuthData should fail without client-id', async () => { + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + }, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + try { + await adapter.validateAuthData(authData, providerOptions); + fail(); + } catch (e) { + expect(e.message).toBe('Keycloak auth is not configured. Missing client-id.'); + } + }); + + it('validateAuthData should fail with invalid JWT token', async () => { + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', + }, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'not-a-jwt', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + try { + await adapter.validateAuthData(authData, providerOptions); + fail(); + } catch (e) { + expect(e.message).toBe('provided token does not decode as JWT'); + } + }); + + it('validateAuthData should fail with wrong issuer', async () => { + const fakeClaim = { + iss: 'https://evil.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'fakeid', + exp: Math.floor(Date.now() / 1000) + 3600, + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', + }, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'fake.jwt.token', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + try { + await adapter.validateAuthData(authData, providerOptions); + fail(); + } catch (e) { + expect(e.message).toBe( + 'access token not issued by correct provider - expected: https://auth.example.com/realms/my-realm | from: https://evil.example.com/realms/my-realm' + ); + } + }); + + it('validateAuthData should fail with wrong azp (audience)', async () => { + const fakeClaim = { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'other-app', + sub: 'fakeid', + exp: Math.floor(Date.now() / 1000) + 3600, + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', + }, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'fake.jwt.token', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + try { + await adapter.validateAuthData(authData, providerOptions); + fail(); + } catch (e) { + expect(e.message).toBe( + 'access token is not authorized for this client - expected: parse-app | from: other-app' + ); + } + }); + + it('validateAuthData should fail with wrong sub', async () => { + const fakeClaim = { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'wrong-id', + exp: Math.floor(Date.now() / 1000) + 3600, + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', + }, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'fake.jwt.token', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + try { + await adapter.validateAuthData(authData, providerOptions); + fail(); + } catch (e) { + expect(e.message).toBe('auth data is invalid for this user.'); + } + }); + + it('validateAuthData should fail with invalid roles (JWT validation)', async () => { + const fakeClaim = { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'fakeid', + exp: Math.floor(Date.now() / 1000) + 3600, + roles: ['role1'], + groups: ['group1'], + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', + }, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'fake.jwt.token', + roles: ['wrong-role'], + groups: ['group1'], + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + try { + await adapter.validateAuthData(authData, providerOptions); + fail(); + } catch (e) { + expect(e.message).toBe('Invalid authentication'); + } + }); + + it('validateAuthData should fail with invalid groups (JWT validation)', async () => { + const fakeClaim = { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'fakeid', + exp: Math.floor(Date.now() / 1000) + 3600, + roles: ['role1'], + groups: ['group1'], + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', + }, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'fake.jwt.token', + roles: ['role1'], + groups: ['wrong-group'], + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + try { + await adapter.validateAuthData(authData, providerOptions); + fail(); + } catch (e) { + expect(e.message).toBe('Invalid authentication'); + } + }); + + it('validateAuthData should handle successful authentication', async () => { + const fakeClaim = { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'fakeid', + exp: Math.floor(Date.now() / 1000) + 3600, + roles: ['role1'], + groups: ['group1'], + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', + }, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'fake.jwt.token', + roles: ['role1'], + groups: ['group1'], + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + await adapter.validateAuthData(authData, providerOptions); + expect(jwt.verify).toHaveBeenCalled(); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']); + }); + + it('validateAuthData should handle successful authentication without roles and groups', async () => { + const fakeClaim = { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'fakeid', + exp: Math.floor(Date.now() / 1000) + 3600, + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', + }, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'fake.jwt.token', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + await adapter.validateAuthData(authData, providerOptions); + expect(jwt.verify).toHaveBeenCalled(); + }); + + it('validateAuthData should use hardcoded RS256 algorithm, not JWT header alg', async () => { + const fakeClaim = { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'fakeid', + exp: Math.floor(Date.now() / 1000) + 3600, + }; + const fakeDecodedToken = { kid: '123', alg: 'none' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', + }, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'fake.jwt.token', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + await adapter.validateAuthData(authData, providerOptions); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']); + }); + + it('validateAuthData should verify a real signed JWT end-to-end', async () => { + const crypto = require('crypto'); + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + + const token = jwt.sign( + { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'user123', + roles: ['admin'], + groups: ['staff'], + }, + privateKey, + { algorithm: 'RS256', keyid: 'test-key-1', expiresIn: '1h' } + ); + + // Only mock the JWKS key fetch — jwt.verify runs for real + spyOn(authUtils, 'getSigningKey').and.resolveTo({ + kid: 'test-key-1', + publicKey: publicKey, + }); + + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', + }, + }, + }; + const authData = { + id: 'user123', + access_token: token, + roles: ['admin'], + groups: ['staff'], + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + const result = await adapter.validateAuthData(authData, providerOptions); + expect(result.sub).toBe('user123'); + expect(result.azp).toBe('parse-app'); + expect(result.iss).toBe('https://auth.example.com/realms/my-realm'); + }); + + it('validateAuthData should reject a JWT signed with a different key', async () => { + const crypto = require('crypto'); + const { privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + const { publicKey: differentPublicKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + + const token = jwt.sign( + { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'user123', + }, + privateKey, + { algorithm: 'RS256', keyid: 'test-key-1', expiresIn: '1h' } + ); + + // Return a different public key — signature verification should fail + spyOn(authUtils, 'getSigningKey').and.resolveTo({ + kid: 'test-key-1', + publicKey: differentPublicKey, + }); + + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', + }, + }, + }; + const authData = { + id: 'user123', + access_token: token, + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + try { + await adapter.validateAuthData(authData, providerOptions); + fail(); + } catch (e) { + expect(e.message).toBe('invalid signature'); + } + }); +}); + +describe('apple signin auth adapter', () => { + const apple = require('../lib/Adapters/Auth/apple'); + const jwt = require('jsonwebtoken'); + const authUtils = require('../lib/Adapters/Auth/utils'); + + it('(using client id as string) should throw error with missing id_token', async () => { + try { + await apple.validateAuthData({}, { clientId: 'secret' }); + fail(); + } catch (e) { + expect(e.message).toBe('id token is invalid for this user.'); + } + }); + + it('(using client id as array) should throw error with missing id_token', async () => { + try { + await apple.validateAuthData({}, { clientId: ['secret'] }); + fail(); + } catch (e) { + expect(e.message).toBe('id token is invalid for this user.'); + } + }); + + it('should not decode invalid id_token', async () => { + try { + await apple.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe('provided token does not decode as JWT'); + } + }); + + it('should throw error if public key used to encode token is not available', async () => { + const fakeDecodedToken = { header: { kid: '789', alg: 'RS256' } }; + try { + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + + await apple.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe( + `Unable to find matching key for Key ID: ${fakeDecodedToken.header.kid}` + ); + } + }); + + it('should use algorithm from key header to verify id_token (apple.com)', async () => { + const fakeClaim = { + iss: 'https://appleid.apple.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const result = await apple.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + expect(result).toEqual(fakeClaim); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']); + }); + + it('should pass hardcoded RS256 algorithm to jwt.verify, not the JWT header alg (GHSA-4q3h-vp4r-prv2)', async () => { + const fakeClaim = { + iss: 'https://appleid.apple.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { kid: '123', alg: 'none' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + await apple.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']); + }); + + it('should not verify invalid id_token', async () => { + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + + try { + await apple.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe('jwt malformed'); + } + }); + + it('(using client id as array) should not verify invalid id_token', async () => { + try { + await apple.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: ['secret'] } + ); + fail(); + } catch (e) { + expect(e.message).toBe('provided token does not decode as JWT'); + } + }); + + it('(using client id as string) should verify id_token (apple.com)', async () => { + const fakeClaim = { + iss: 'https://appleid.apple.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const result = await apple.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + expect(result).toEqual(fakeClaim); + }); + + it('(using client id as array) should verify id_token (apple.com)', async () => { + const fakeClaim = { + iss: 'https://appleid.apple.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const result = await apple.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: ['secret'] } + ); + expect(result).toEqual(fakeClaim); + }); + + it('(using client id as array with multiple items) should verify id_token (apple.com)', async () => { + const fakeClaim = { + iss: 'https://appleid.apple.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const result = await apple.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: ['secret', 'secret 123'] } + ); + expect(result).toEqual(fakeClaim); + }); + + it('(using client id as string) should throw error with with invalid jwt issuer (apple.com)', async () => { + const fakeClaim = { + iss: 'https://not.apple.com', + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + try { + await apple.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe( + 'id token not issued by correct OpenID provider - expected: https://appleid.apple.com | from: https://not.apple.com' + ); + } + }); + + // TODO: figure out a way to generate our own apple signed tokens, perhaps with a parse apple account + // and a private key + xit('(using client id as array) should throw error with with invalid jwt issuer', async () => { + const fakeClaim = { + iss: 'https://not.apple.com', + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + try { + await apple.validateAuthData( + { + id: 'INSERT ID HERE', + token: 'INSERT APPLE TOKEN HERE WITH INVALID JWT ISSUER', + }, + { clientId: ['INSERT CLIENT ID HERE'] } + ); + fail(); + } catch (e) { + expect(e.message).toBe( + 'id token not issued by correct OpenID provider - expected: https://appleid.apple.com | from: https://not.apple.com' + ); + } + }); + + it('(using client id as string) should throw error with with invalid jwt issuer with token (apple.com)', async () => { + const fakeClaim = { + iss: 'https://not.apple.com', + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + try { + await apple.validateAuthData( + { + id: 'INSERT ID HERE', + token: 'INSERT APPLE TOKEN HERE WITH INVALID JWT ISSUER', + }, + { clientId: 'INSERT CLIENT ID HERE' } + ); + fail(); + } catch (e) { + expect(e.message).toBe( + 'id token not issued by correct OpenID provider - expected: https://appleid.apple.com | from: https://not.apple.com' + ); + } + }); + + // TODO: figure out a way to generate our own apple signed tokens, perhaps with a parse apple account + // and a private key + xit('(using client id as string) should throw error with invalid jwt clientId', async () => { + try { + await apple.validateAuthData( + { id: 'INSERT ID HERE', token: 'INSERT APPLE TOKEN HERE' }, + { clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe('jwt audience invalid. expected: secret'); + } + }); + + // TODO: figure out a way to generate our own apple signed tokens, perhaps with a parse apple account + // and a private key + xit('(using client id as array) should throw error with invalid jwt clientId', async () => { + try { + await apple.validateAuthData( + { id: 'INSERT ID HERE', token: 'INSERT APPLE TOKEN HERE' }, + { clientId: ['secret'] } + ); + fail(); + } catch (e) { + expect(e.message).toBe('jwt audience invalid. expected: secret'); + } + }); + + // TODO: figure out a way to generate our own apple signed tokens, perhaps with a parse apple account + // and a private key + xit('should throw error with invalid user id', async () => { + try { + await apple.validateAuthData( + { id: 'invalid user', token: 'INSERT APPLE TOKEN HERE' }, + { clientId: 'INSERT CLIENT ID HERE' } + ); + fail(); + } catch (e) { + expect(e.message).toBe('auth data is invalid for this user.'); + } + }); + + it('should throw error with with invalid user id (apple.com)', async () => { + const fakeClaim = { + iss: 'https://appleid.apple.com', + aud: 'invalid_client_id', + sub: 'a_different_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + try { + await apple.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe('auth data is invalid for this user.'); + } + }); + + it('should throw error when clientId is not configured', async () => { + try { + await apple.validateAuthData({ id: 'the_user_id', token: 'the_token' }, {}); + fail('should have thrown'); + } catch (e) { + expect(e.message).toBe('Apple auth is not configured.'); + } + }); +}); + +describe('phant auth adapter', () => { + const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); + + it('validateAuthData should throw for invalid auth', async () => { + await reconfigureServer({ + auth: { + phantauth: { + enableInsecureAuth: true, + } + } + }) + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { adapter } = authenticationLoader.loadAuthAdapter('phantauth', {}); + + spyOn(httpsRequest, 'get').and.callFake(() => Promise.resolve({ sub: 'invalidID' })); + try { + await adapter.validateAuthData(authData); + fail(); + } catch (e) { + expect(e.message).toBe('PhantAuth auth is invalid for this user.'); + } + }); +}); + +describe('facebook limited auth adapter', () => { + const facebook = require('../lib/Adapters/Auth/facebook'); + const jwt = require('jsonwebtoken'); + const authUtils = require('../lib/Adapters/Auth/utils'); + + // TODO: figure out a way to run this test alongside facebook classic tests + xit('should throw error with missing id_token', async () => { + try { + await facebook.validateAuthData({}, { appIds: ['secret'] }); + fail(); + } catch (e) { + expect(e.message).toBe('Facebook auth is not configured.'); + } + }); + + it('should not decode invalid id_token', async () => { + try { + await facebook.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { appIds: ['secret'] } + ); + fail(); + } catch (e) { + expect(e.message).toBe('provided token does not decode as JWT'); + } + }); + + it('should throw error if public key used to encode token is not available', async () => { + const fakeDecodedToken = { + header: { kid: '789', alg: 'RS256' }, + }; + try { + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + + await facebook.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { appIds: ['secret'] } + ); + fail(); + } catch (e) { + expect(e.message).toBe( + `Unable to find matching key for Key ID: ${fakeDecodedToken.header.kid}` + ); + } + }); + + it_id('7bfa55ab-8fd7-4526-992e-6de3df16bf9c')(it)('should use algorithm from key header to verify id_token (facebook.com)', async () => { + const fakeClaim = { + iss: 'https://www.facebook.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const result = await facebook.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { appIds: ['secret'] } + ); + expect(result).toEqual(fakeClaim); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']); + }); + + it('should pass hardcoded RS256 algorithm to jwt.verify, not the JWT header alg (GHSA-4q3h-vp4r-prv2)', async () => { + const fakeClaim = { + iss: 'https://www.facebook.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { kid: '123', alg: 'none' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + await facebook.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { appIds: ['secret'] } + ); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']); + }); + + it('should not verify invalid id_token', async () => { + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + + try { + await facebook.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { appIds: ['secret'] } + ); + fail(); + } catch (e) { + expect(e.message).toBe('jwt malformed'); + } + }); + + it_id('4bcb1a1a-11f8-4e12-a3f6-73f7e25e355a')(it)('should verify id_token (facebook.com)', async () => { + const fakeClaim = { + iss: 'https://www.facebook.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const result = await facebook.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { appIds: ['secret'] } + ); + expect(result).toEqual(fakeClaim); + }); + + it_id('e3f16404-18e9-4a87-a555-4710cfbdac67')(it)('(using multiple appIds) should verify id_token (facebook.com)', async () => { + const fakeClaim = { + iss: 'https://www.facebook.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const result = await facebook.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { appIds: ['secret', 'secret 123'] } + ); + expect(result).toEqual(fakeClaim); + }); + + it_id('549c33a1-3a6b-4732-8cf6-8f010ad4569c')(it)('should throw error with with invalid jwt issuer (facebook.com)', async () => { + const fakeClaim = { + iss: 'https://not.facebook.com', + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + try { + await facebook.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { appIds: ['secret'] } + ); + fail(); + } catch (e) { + expect(e.message).toBe( + 'id token not issued by correct OpenID provider - expected: https://www.facebook.com | from: https://not.facebook.com' + ); + } + }); + + // TODO: figure out a way to generate our own facebook signed tokens, perhaps with a parse facebook account + // and a private key + xit('should throw error with invalid jwt audience', async () => { + try { + await facebook.validateAuthData( + { + id: 'INSERT ID HERE', + token: 'INSERT FACEBOOK TOKEN HERE', + }, + { appIds: ['secret'] } + ); + fail(); + } catch (e) { + expect(e.message).toBe('jwt audience invalid. expected: secret'); + } + }); + + // TODO: figure out a way to generate our own facebook signed tokens, perhaps with a parse facebook account + // and a private key + xit('should throw error with invalid user id', async () => { + try { + await facebook.validateAuthData( + { + id: 'invalid user', + token: 'INSERT FACEBOOK TOKEN HERE', + }, + { appIds: ['INSERT APP ID HERE'] } + ); + fail(); + } catch (e) { + expect(e.message).toBe('auth data is invalid for this user.'); + } + }); + + it_id('c194d902-e697-46c9-a303-82c2d914473c')(it)('should throw error with with invalid user id (facebook.com)', async () => { + const fakeClaim = { + iss: 'https://www.facebook.com', + aud: 'invalid_app_id', + sub: 'a_different_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + try { + await facebook.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { appIds: ['secret'] } + ); + fail(); + } catch (e) { + expect(e.message).toBe('auth data is invalid for this user.'); + } + }); + + it('should throw error when appIds is not configured for Limited Login', async () => { + try { + await facebook.validateAuthData({ id: 'the_user_id', token: 'the_token' }, {}); + fail('should have thrown'); + } catch (e) { + expect(e.message).toBe('Facebook auth is not configured.'); + } + }); + + it('should throw error when appIds is not configured for Standard Login', async () => { + try { + await facebook.validateAuthData({ id: 'the_user_id', access_token: 'the_token' }, {}); + fail('should have thrown'); + } catch (e) { + expect(e.message).toBe('Facebook auth is not configured.'); + } + }); + + it('should throw error when appIds is empty array for Standard Login', async () => { + try { + await facebook.validateAuthData({ id: 'the_user_id', access_token: 'the_token' }, { appIds: [] }); + fail('should have thrown'); + } catch (e) { + expect(e.message).toBe('Facebook auth is not configured.'); + } + }); +}); + +describe('OTP TOTP auth adatper', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + beforeEach(async () => { + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + }); + }); + + it('can enroll', async () => { + const user = await Parse.User.signUp('username', 'password'); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken: user.getSessionToken() } + ); + const response = user.get('authDataResponse'); + expect(response.mfa).toBeDefined(); + expect(response.mfa.recovery).toBeDefined(); + expect(response.mfa.recovery.split(',').length).toEqual(2); + await user.fetch(); + expect(user.get('authData').mfa).toEqual({ status: 'enabled' }); + }); + + it('can login with valid token', async () => { + const user = await Parse.User.signUp('username', 'password'); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken: user.getSessionToken() } + ); + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { + mfa: { + token: totp.generate(), + }, + }, + }), + }).then(res => res.data); + expect(response.objectId).toEqual(user.id); + expect(response.sessionToken).toBeDefined(); + expect(response.authData).toEqual({ mfa: { status: 'enabled' } }); + expect(Object.keys(response).sort()).toEqual( + [ + 'objectId', + 'username', + 'createdAt', + 'updatedAt', + 'authData', + 'ACL', + 'sessionToken', + 'authDataResponse', + ].sort() + ); + }); + + it('can change OTP with valid token', async () => { + const user = await Parse.User.signUp('username', 'password'); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken: user.getSessionToken() } + ); + + const new_secret = new OTPAuth.Secret(); + const new_totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: new_secret, + }); + const new_token = new_totp.generate(); + await user.save( + { + authData: { mfa: { secret: new_secret.base32, token: new_token, old: totp.generate() } }, + }, + { sessionToken: user.getSessionToken() } + ); + await user.fetch({ useMasterKey: true }); + expect(user.get('authData').mfa.secret).toEqual(new_secret.base32); + }); + + it('cannot change OTP with invalid token', async () => { + const user = await Parse.User.signUp('username', 'password'); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken: user.getSessionToken() } + ); + + const new_secret = new OTPAuth.Secret(); + const new_totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: new_secret, + }); + const new_token = new_totp.generate(); + await expectAsync( + user.save( + { + authData: { mfa: { secret: new_secret.base32, token: new_token, old: '123' } }, + }, + { sessionToken: user.getSessionToken() } + ) + ).toBeRejectedWith(new Parse.Error(Parse.Error.OTHER_CAUSE, 'Invalid MFA token')); + await user.fetch({ useMasterKey: true }); + expect(user.get('authData').mfa.secret).toEqual(secret.base32); + }); + + it('future logins require TOTP token', async () => { + const user = await Parse.User.signUp('username', 'password'); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken: user.getSessionToken() } + ); + await expectAsync(Parse.User.logIn('username', 'password')).toBeRejectedWith( + new Parse.Error(Parse.Error.OTHER_CAUSE, 'Missing additional authData mfa') + ); + }); + + it('consumes recovery code after use', async () => { + const user = await Parse.User.signUp('username', 'password'); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken: user.getSessionToken() } + ); + // Get recovery codes from stored auth data + await user.fetch({ useMasterKey: true }); + const recoveryCode = user.get('authData').mfa.recovery[0]; + // First login with recovery code should succeed + await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { + mfa: { + token: recoveryCode, + }, + }, + }), + }); + // Second login with same recovery code should fail (code consumed) + await expectAsync( + request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { + mfa: { + token: recoveryCode, + }, + }, + }), + }).catch(e => { + throw e.data; + }) + ).toBeRejectedWith({ code: Parse.Error.SCRIPT_FAILED, error: 'Invalid MFA token' }); + }); + + it('future logins reject incorrect TOTP token', async () => { + const user = await Parse.User.signUp('username', 'password'); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken: user.getSessionToken() } + ); + await expectAsync( + request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { + mfa: { + token: 'abcd', + }, + }, + }), + }).catch(e => { + throw e.data; + }) + ).toBeRejectedWith({ code: Parse.Error.SCRIPT_FAILED, error: 'Invalid MFA token' }); + }); + + it('allows unlinking MFA without TOTP verification (by design)', async () => { + const user = await Parse.User.signUp('username', 'password'); + const sessionToken = user.getSessionToken(); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + // Enable MFA + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken } + ); + await user.fetch({ useMasterKey: true }); + expect(user.get('authData').mfa.secret).toBeDefined(); + // Unlink MFA without providing TOTP + await user.save( + { authData: { mfa: null } }, + { sessionToken } + ); + // MFA should be removed + await user.fetch({ useMasterKey: true }); + expect(user.get('authData')).toBeUndefined(); + // Login should succeed without MFA + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + }), + }); + expect(response.data.sessionToken).toBeDefined(); + }); + + it('allows blocking MFA unlink via beforeSave trigger', async () => { + Parse.Cloud.beforeSave('_User', request => { + const authData = request.object.get('authData'); + if (authData?.mfa === null) { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, 'Cannot disable MFA without verification'); + } + }); + const user = await Parse.User.signUp('username', 'password'); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + // Enable MFA + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken: user.getSessionToken() } + ); + // Attempt to unlink MFA — should be blocked by beforeSave trigger + await expectAsync( + user.save( + { authData: { mfa: null } }, + { sessionToken: user.getSessionToken() } + ) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.VALIDATION_ERROR, 'Cannot disable MFA without verification') + ); + // MFA should still be enabled + await user.fetch({ useMasterKey: true }); + expect(user.get('authData').mfa.secret).toBeDefined(); + }); +}); + +describe('OTP SMS auth adatper', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + let code; + let mobile; + const mfa = { + enabled: true, + options: ['SMS'], + sendSMS(smsCode, number) { + expect(smsCode).toBeDefined(); + expect(number).toBeDefined(); + expect(smsCode.length).toEqual(6); + code = smsCode; + mobile = number; + }, + digits: 6, + period: 30, + }; + beforeEach(async () => { + code = ''; + mobile = ''; + await reconfigureServer({ + auth: { + mfa, + }, + }); + }); + + it('can enroll', async () => { + const user = await Parse.User.signUp('username', 'password'); + const sessionToken = user.getSessionToken(); + const spy = spyOn(mfa, 'sendSMS').and.callThrough(); + await user.save({ authData: { mfa: { mobile: '+11111111111' } } }, { sessionToken }); + await user.fetch({ sessionToken }); + expect(user.get('authData')).toEqual({ mfa: { status: 'disabled' } }); + expect(spy).toHaveBeenCalledWith(code, '+11111111111'); + await user.fetch({ useMasterKey: true }); + const authData = user.get('authData').mfa?.pending; + expect(authData).toBeDefined(); + expect(authData['+11111111111']).toBeDefined(); + expect(Object.keys(authData['+11111111111'])).toEqual(['token', 'expiry']); + + await user.save({ authData: { mfa: { mobile, token: code } } }, { sessionToken }); + await user.fetch({ sessionToken }); + expect(user.get('authData')).toEqual({ mfa: { status: 'enabled' } }); + }); + + it('future logins require SMS code', async () => { + const user = await Parse.User.signUp('username', 'password'); + const spy = spyOn(mfa, 'sendSMS').and.callThrough(); + await user.save( + { authData: { mfa: { mobile: '+11111111111' } } }, + { sessionToken: user.getSessionToken() } + ); + + await user.save( + { authData: { mfa: { mobile, token: code } } }, + { sessionToken: user.getSessionToken() } + ); + + spy.calls.reset(); + + await expectAsync(Parse.User.logIn('username', 'password')).toBeRejectedWith( + new Parse.Error(Parse.Error.OTHER_CAUSE, 'Missing additional authData mfa') + ); + const res = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { + mfa: { + token: 'request', + }, + }, + }), + }).catch(e => e.data); + expect(res).toEqual({ code: Parse.Error.SCRIPT_FAILED, error: 'Please enter the token' }); + expect(spy).toHaveBeenCalledWith(code, '+11111111111'); + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { + mfa: { + token: code, + }, + }, + }), + }).then(res => res.data); + expect(response.objectId).toEqual(user.id); + expect(response.sessionToken).toBeDefined(); + expect(response.authData).toEqual({ mfa: { status: 'enabled' } }); + expect(Object.keys(response).sort()).toEqual( + [ + 'objectId', + 'username', + 'createdAt', + 'updatedAt', + 'authData', + 'ACL', + 'sessionToken', + 'authDataResponse', + ].sort() + ); + }); + + it('partially enrolled users can still login', async () => { + const user = await Parse.User.signUp('username', 'password'); + await user.save({ authData: { mfa: { mobile: '+11111111111' } } }); + const spy = spyOn(mfa, 'sendSMS').and.callThrough(); + await Parse.User.logIn('username', 'password'); + expect(spy).not.toHaveBeenCalled(); + }); +}); diff --git a/spec/AuthenticationAdaptersV2.spec.js b/spec/AuthenticationAdaptersV2.spec.js new file mode 100644 index 0000000000..cafb309f0b --- /dev/null +++ b/spec/AuthenticationAdaptersV2.spec.js @@ -0,0 +1,1705 @@ +const request = require('../lib/request'); +const Auth = require('../lib/Auth'); +const Config = require('../lib/Config'); +const requestWithExpectedError = async params => { + try { + return await request(params); + } catch (e) { + throw new Error(e.data.error); + } +}; +describe('Auth Adapter features', () => { + const baseAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; + const baseAdapter2 = { + validateAppId: appIds => (appIds[0] === 'test' ? Promise.resolve() : Promise.reject()), + validateAuthData: () => Promise.resolve(), + appIds: ['test'], + options: { anOption: true }, + }; + + const doNotSaveAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve({ doNotSave: true }), + }; + + const additionalAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + policy: 'additional', + }; + + const soloAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + policy: 'solo', + }; + + const challengeAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + challenge: () => Promise.resolve({ token: 'test' }), + options: { + anOption: true, + }, + }; + + const modernAdapter = { + validateAppId: () => Promise.resolve(), + validateSetUp: () => Promise.resolve(), + validateUpdate: () => Promise.resolve(), + validateLogin: () => Promise.resolve(), + }; + + const modernAdapter2 = { + validateAppId: () => Promise.resolve(), + validateSetUp: () => Promise.resolve(), + validateUpdate: () => Promise.resolve(), + validateLogin: () => Promise.resolve(), + }; + + const modernAdapter3 = { + validateAppId: () => Promise.resolve(), + validateSetUp: () => Promise.resolve(), + validateUpdate: () => Promise.resolve(), + validateLogin: () => Promise.resolve(), + validateOptions: () => Promise.resolve(), + afterFind() { + return { + foo: 'bar', + }; + }, + }; + + const wrongAdapter = { + validateAppId: () => Promise.resolve(), + }; + + // Code-based adapter that requires 'code' field (like gpgames) + const codeBasedAdapter = { + validateAppId: () => Promise.resolve(), + validateSetUp: authData => { + if (!authData.code) { + throw new Error('code is required.'); + } + return Promise.resolve({ save: { id: authData.id } }); + }, + validateUpdate: authData => { + if (!authData.code) { + throw new Error('code is required.'); + } + return Promise.resolve({ save: { id: authData.id } }); + }, + validateLogin: authData => { + if (!authData.code) { + throw new Error('code is required.'); + } + return Promise.resolve({ save: { id: authData.id } }); + }, + afterFind: authData => { + // Strip sensitive 'code' field when returning to client + return { id: authData.id }; + }, + }; + + // Simple adapter that doesn't require code + const simpleAdapter = { + validateAppId: () => Promise.resolve(), + validateSetUp: () => Promise.resolve(), + validateUpdate: () => Promise.resolve(), + validateLogin: () => Promise.resolve(), + }; + + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('should ensure no duplicate auth data id after before save', async () => { + await reconfigureServer({ + auth: { baseAdapter }, + cloud: () => { + Parse.Cloud.beforeSave('_User', async request => { + request.object.set('authData', { baseAdapter: { id: 'test' } }); + }); + }, + }); + + const user = new Parse.User(); + await user.save({ authData: { baseAdapter: { id: 'another' } } }); + await user.fetch({ useMasterKey: true }); + expect(user.get('authData')).toEqual({ baseAdapter: { id: 'test' } }); + + const user2 = new Parse.User(); + await expectAsync( + user2.save({ authData: { baseAdapter: { id: 'another' } } }) + ).toBeRejectedWithError('this auth is already used'); + }); + + it('should ensure no duplicate auth data id after before save in case of more than one result', async () => { + await reconfigureServer({ + auth: { baseAdapter }, + cloud: () => { + Parse.Cloud.beforeSave('_User', async request => { + request.object.set('authData', { baseAdapter: { id: 'test' } }); + }); + }, + }); + + const user = new Parse.User(); + await user.save({ authData: { baseAdapter: { id: 'another' } } }); + await user.fetch({ useMasterKey: true }); + expect(user.get('authData')).toEqual({ baseAdapter: { id: 'test' } }); + + let i = 0; + const originalFn = Auth.findUsersWithAuthData; + spyOn(Auth, 'findUsersWithAuthData').and.callFake((...params) => { + // First call is triggered during authData validation + if (i === 0) { + i++; + return originalFn(...params); + } + // Second call is triggered after beforeSave. A developer can modify authData during beforeSave. + // To perform a determinist login, the uniqueness of `auth.id` needs to be ensured. + // A developer with a direct access to the database could break something and duplicate authData.id. + // In this case, if 2 matching users are detected for a single authData.id, then the login/register will be canceled. + // Promise.resolve([true, true]) simulates this case with 2 matching users. + return Promise.resolve([true, true]); + }); + const user2 = new Parse.User(); + await expectAsync( + user2.save({ authData: { baseAdapter: { id: 'another' } } }) + ).toBeRejectedWithError('this auth is already used'); + }); + + it('should ensure no duplicate auth data id during authData validation in case of more than one result', async () => { + await reconfigureServer({ + auth: { baseAdapter }, + cloud: () => { + Parse.Cloud.beforeSave('_User', async request => { + request.object.set('authData', { baseAdapter: { id: 'test' } }); + }); + }, + }); + + spyOn(Auth, 'findUsersWithAuthData').and.resolveTo([true, true]); + + const user = new Parse.User(); + await expectAsync( + user.save({ authData: { baseAdapter: { id: 'another' } } }) + ).toBeRejectedWithError('this auth is already used'); + }); + + it('should pass authData, options, request to validateAuthData', async () => { + spyOn(baseAdapter, 'validateAuthData').and.resolveTo({}); + await reconfigureServer({ auth: { baseAdapter } }); + const user = new Parse.User(); + const payload = { someData: true }; + + await user.save({ + username: 'test', + password: 'password', + authData: { baseAdapter: payload }, + }); + + expect(user.getSessionToken()).toBeDefined(); + const firstCall = baseAdapter.validateAuthData.calls.argsFor(0); + expect(firstCall[0]).toEqual(payload); + expect(firstCall[1]).toEqual(baseAdapter); + expect(firstCall[2].object).toBeDefined(); + expect(firstCall[2].original).toBeUndefined(); + expect(firstCall[2].user).toBeUndefined(); + expect(firstCall[2].isChallenge).toBeUndefined(); + expect(firstCall.length).toEqual(3); + + await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'test', + password: 'password', + authData: { baseAdapter: payload }, + }), + }); + const secondCall = baseAdapter.validateAuthData.calls.argsFor(1); + expect(secondCall[0]).toEqual(payload); + expect(secondCall[1]).toEqual(baseAdapter); + expect(secondCall[2].original).toBeDefined(); + expect(secondCall[2].original instanceof Parse.User).toBeTruthy(); + expect(secondCall[2].original.id).toEqual(user.id); + expect(secondCall[2].object).toBeDefined(); + expect(secondCall[2].object instanceof Parse.User).toBeTruthy(); + expect(secondCall[2].object.id).toEqual(user.id); + expect(secondCall[2].isChallenge).toBeUndefined(); + expect(secondCall[2].user).toBeUndefined(); + expect(secondCall.length).toEqual(3); + }); + + it('should trigger correctly validateSetUp', async () => { + spyOn(modernAdapter, 'validateSetUp').and.resolveTo({}); + spyOn(modernAdapter, 'validateUpdate').and.resolveTo({}); + spyOn(modernAdapter, 'validateLogin').and.resolveTo({}); + spyOn(modernAdapter2, 'validateSetUp').and.resolveTo({}); + spyOn(modernAdapter2, 'validateUpdate').and.resolveTo({}); + spyOn(modernAdapter2, 'validateLogin').and.resolveTo({}); + + await reconfigureServer({ auth: { modernAdapter, modernAdapter2 } }); + const user = new Parse.User(); + + await user.save({ authData: { modernAdapter: { id: 'modernAdapter' } } }); + + expect(modernAdapter.validateUpdate).toHaveBeenCalledTimes(0); + expect(modernAdapter.validateLogin).toHaveBeenCalledTimes(0); + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + const call = modernAdapter.validateSetUp.calls.argsFor(0); + expect(call[0]).toEqual({ id: 'modernAdapter' }); + expect(call[1]).toEqual(modernAdapter); + expect(call[2].isChallenge).toBeUndefined(); + expect(call[2].master).toBeDefined(); + expect(call[2].object instanceof Parse.User).toBeTruthy(); + expect(call[2].user).toBeUndefined(); + expect(call[2].original).toBeUndefined(); + expect(call[2].triggerName).toBe('validateSetUp'); + expect(call.length).toEqual(3); + expect(user.getSessionToken()).toBeDefined(); + + await user.save( + { authData: { modernAdapter2: { id: 'modernAdapter2' } } }, + { sessionToken: user.getSessionToken() } + ); + + expect(modernAdapter2.validateUpdate).toHaveBeenCalledTimes(0); + expect(modernAdapter2.validateLogin).toHaveBeenCalledTimes(0); + expect(modernAdapter2.validateSetUp).toHaveBeenCalledTimes(1); + const call2 = modernAdapter2.validateSetUp.calls.argsFor(0); + expect(call2[0]).toEqual({ id: 'modernAdapter2' }); + expect(call2[1]).toEqual(modernAdapter2); + expect(call2[2].isChallenge).toBeUndefined(); + expect(call2[2].master).toBeDefined(); + expect(call2[2].object instanceof Parse.User).toBeTruthy(); + expect(call2[2].original instanceof Parse.User).toBeTruthy(); + expect(call2[2].user instanceof Parse.User).toBeTruthy(); + expect(call2[2].original.id).toEqual(call2[2].object.id); + expect(call2[2].user.id).toEqual(call2[2].object.id); + expect(call2[2].object.id).toEqual(user.id); + expect(call2[2].triggerName).toBe('validateSetUp'); + expect(call2.length).toEqual(3); + + const user2 = new Parse.User(); + user2.id = user.id; + await user2.fetch({ useMasterKey: true }); + expect(user2.get('authData')).toEqual({ + modernAdapter: { id: 'modernAdapter' }, + modernAdapter2: { id: 'modernAdapter2' }, + }); + }); + + it('should trigger correctly validateLogin', async () => { + spyOn(modernAdapter, 'validateSetUp').and.resolveTo({}); + spyOn(modernAdapter, 'validateUpdate').and.resolveTo({}); + spyOn(modernAdapter, 'validateLogin').and.resolveTo({}); + + await reconfigureServer({ auth: { modernAdapter }, allowExpiredAuthDataToken: false }); + const user = new Parse.User(); + + await user.save({ authData: { modernAdapter: { id: 'modernAdapter' } } }); + + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + const user2 = new Parse.User(); + await user2.save({ authData: { modernAdapter: { id: 'modernAdapter' } } }); + + expect(modernAdapter.validateUpdate).toHaveBeenCalledTimes(0); + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + expect(modernAdapter.validateLogin).toHaveBeenCalledTimes(1); + const call = modernAdapter.validateLogin.calls.argsFor(0); + expect(call[0]).toEqual({ id: 'modernAdapter' }); + expect(call[1]).toEqual(modernAdapter); + expect(call[2].object instanceof Parse.User).toBeTruthy(); + expect(call[2].original instanceof Parse.User).toBeTruthy(); + expect(call[2].isChallenge).toBeUndefined(); + expect(call[2].master).toBeDefined(); + expect(call[2].user).toBeUndefined(); + expect(call[2].original.id).toEqual(user2.id); + expect(call[2].object.id).toEqual(user2.id); + expect(call[2].object.id).toEqual(user.id); + expect(call.length).toEqual(3); + expect(user2.getSessionToken()).toBeDefined(); + }); + + it('should trigger correctly validateUpdate', async () => { + spyOn(modernAdapter, 'validateSetUp').and.resolveTo({}); + spyOn(modernAdapter, 'validateUpdate').and.resolveTo({}); + spyOn(modernAdapter, 'validateLogin').and.resolveTo({}); + + await reconfigureServer({ auth: { modernAdapter } }); + const user = new Parse.User(); + + await user.save({ authData: { modernAdapter: { id: 'modernAdapter' } } }); + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + + // Save same data + await user.save( + { authData: { modernAdapter: { id: 'modernAdapter' } } }, + { sessionToken: user.getSessionToken() } + ); + + // Save same data with master key + await user.save( + { authData: { modernAdapter: { id: 'modernAdapter' } } }, + { useMasterKey: true } + ); + + expect(modernAdapter.validateUpdate).toHaveBeenCalledTimes(0); + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + expect(modernAdapter.validateLogin).toHaveBeenCalledTimes(0); + + // Change authData + await user.save( + { authData: { modernAdapter: { id: 'modernAdapter2' } } }, + { sessionToken: user.getSessionToken() } + ); + + expect(modernAdapter.validateUpdate).toHaveBeenCalledTimes(1); + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + expect(modernAdapter.validateLogin).toHaveBeenCalledTimes(0); + const call = modernAdapter.validateUpdate.calls.argsFor(0); + expect(call[0]).toEqual({ id: 'modernAdapter2' }); + expect(call[1]).toEqual(modernAdapter); + expect(call[2].isChallenge).toBeUndefined(); + expect(call[2].master).toBeDefined(); + expect(call[2].object instanceof Parse.User).toBeTruthy(); + expect(call[2].user instanceof Parse.User).toBeTruthy(); + expect(call[2].original instanceof Parse.User).toBeTruthy(); + expect(call[2].object.id).toEqual(user.id); + expect(call[2].original.id).toEqual(user.id); + expect(call[2].user.id).toEqual(user.id); + expect(call.length).toEqual(3); + expect(user.getSessionToken()).toBeDefined(); + }); + + it('should strip out authData if required', async () => { + const spy = spyOn(modernAdapter3, 'validateOptions').and.callThrough(); + const afterSpy = spyOn(modernAdapter3, 'afterFind').and.callThrough(); + await reconfigureServer({ auth: { modernAdapter3 } }); + const user = new Parse.User(); + await user.save({ authData: { modernAdapter3: { id: 'modernAdapter3Data' } } }); + await user.fetch({ sessionToken: user.getSessionToken() }); + const authData = user.get('authData').modernAdapter3; + expect(authData).toEqual({ foo: 'bar' }); + for (const call of afterSpy.calls.all()) { + const args = call.args[2]; + if (args.user) { + user._objCount = args.user._objCount; + break; + } + } + expect(afterSpy).toHaveBeenCalledWith( + { id: 'modernAdapter3Data' }, + undefined, + { ip: '127.0.0.1', user, master: false }, + ); + expect(spy).toHaveBeenCalled(); + }); + + it('should throw if policy does not match one of default/solo/additional', async () => { + const adapterWithBadPolicy = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + policy: 'bad', + }; + await reconfigureServer({ auth: { adapterWithBadPolicy } }); + const user = new Parse.User(); + await expectAsync( + user.save({ authData: { adapterWithBadPolicy: { id: 'adapterWithBadPolicy' } } }) + ).toBeRejectedWithError( + 'AuthAdapter policy is not configured correctly. The value must be either "solo", "additional", "default" or undefined (will be handled as "default")' + ); + }); + + it('should throw if no triggers found', async () => { + await reconfigureServer({ auth: { wrongAdapter } }); + const user = new Parse.User(); + await expectAsync( + user.save({ authData: { wrongAdapter: { id: 'wrongAdapter' } } }) + ).toBeRejectedWithError( + 'Adapter is not configured. Implement either validateAuthData or all of the following: validateSetUp, validateLogin and validateUpdate' + ); + }); + + it('should not update authData if provider return doNotSave', async () => { + spyOn(doNotSaveAdapter, 'validateAuthData').and.resolveTo({ doNotSave: true }); + await reconfigureServer({ + auth: { doNotSaveAdapter, baseAdapter }, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { baseAdapter: { id: 'baseAdapter' }, doNotSaveAdapter: { token: true } }, + }); + + await user.fetch({ useMasterKey: true }); + + expect(user.get('authData')).toEqual({ baseAdapter: { id: 'baseAdapter' } }); + }); + + it('should loginWith user with auth Adapter with do not save option, mutated authData and no additional auth adapter', async () => { + const spy = spyOn(doNotSaveAdapter, 'validateAuthData').and.resolveTo({ doNotSave: false }); + await reconfigureServer({ + auth: { doNotSaveAdapter, baseAdapter }, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { doNotSaveAdapter: { id: 'doNotSaveAdapter' } }, + }); + + await user.fetch({ useMasterKey: true }); + + expect(user.get('authData')).toEqual({ doNotSaveAdapter: { id: 'doNotSaveAdapter' } }); + + spy.and.resolveTo({ doNotSave: true }); + + const user2 = await Parse.User.logInWith('doNotSaveAdapter', { + authData: { id: 'doNotSaveAdapter', example: 'example' }, + }); + expect(user2.getSessionToken()).toBeDefined(); + expect(user2.id).toEqual(user.id); + }); + + it('should perform authData validation only when its required', async () => { + spyOn(baseAdapter2, 'validateAuthData').and.resolveTo({}); + spyOn(baseAdapter2, 'validateAppId').and.resolveTo({}); + spyOn(baseAdapter, 'validateAuthData').and.resolveTo({}); + await reconfigureServer({ + auth: { baseAdapter2, baseAdapter }, + allowExpiredAuthDataToken: false, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { token: true }, + }, + }); + + expect(baseAdapter2.validateAuthData).toHaveBeenCalledTimes(1); + expect(baseAdapter2.validateAppId).toHaveBeenCalledTimes(1); + + const user2 = new Parse.User(); + await user2.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + }, + }); + + expect(baseAdapter2.validateAuthData).toHaveBeenCalledTimes(1); + + const user3 = new Parse.User(); + await user3.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { token: true }, + }, + }); + + expect(baseAdapter2.validateAuthData).toHaveBeenCalledTimes(2); + }); + + it('should not perform authData validation twice when data mutated', async () => { + spyOn(baseAdapter, 'validateAuthData').and.resolveTo({}); + await reconfigureServer({ + auth: { baseAdapter }, + allowExpiredAuthDataToken: false, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { + baseAdapter: { id: 'baseAdapter', token: "sometoken1" }, + }, + }); + + expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(1); + + const user2 = new Parse.User(); + await user2.save({ + authData: { + baseAdapter: { id: 'baseAdapter', token: "sometoken2" }, + }, + }); + + expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(2); + }); + + it('should require additional provider if configured', async () => { + await reconfigureServer({ + auth: { baseAdapter, additionalAdapter }, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + additionalAdapter: { token: true }, + }, + }); + + const user2 = new Parse.User(); + await expectAsync( + user2.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + }, + }) + ).toBeRejectedWithError('Missing additional authData additionalAdapter'); + expect(user2.getSessionToken()).toBeUndefined(); + + await user2.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + additionalAdapter: { token: true }, + }, + }); + + expect(user2.getSessionToken()).toBeDefined(); + }); + + it('should skip additional provider if used provider is solo', async () => { + await reconfigureServer({ + auth: { soloAdapter, additionalAdapter }, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { + soloAdapter: { id: 'soloAdapter' }, + additionalAdapter: { token: true }, + }, + }); + + const user2 = new Parse.User(); + await user2.save({ + authData: { + soloAdapter: { id: 'soloAdapter' }, + }, + }); + expect(user2.getSessionToken()).toBeDefined(); + }); + + it('should return authData response and save some info on non username login', async () => { + spyOn(baseAdapter, 'validateAuthData').and.resolveTo({ + response: { someData: true }, + }); + spyOn(baseAdapter2, 'validateAuthData').and.resolveTo({ + response: { someData2: true }, + save: { otherData: true }, + }); + await reconfigureServer({ + auth: { baseAdapter, baseAdapter2 }, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + + expect(user.get('authDataResponse')).toEqual({ + baseAdapter: { someData: true }, + baseAdapter2: { someData2: true }, + }); + + const user2 = new Parse.User(); + user2.id = user.id; + await user2.save( + { + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }, + { sessionToken: user.getSessionToken() } + ); + + expect(user2.get('authDataResponse')).toEqual({ baseAdapter2: { someData2: true } }); + + const user3 = new Parse.User(); + await user3.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + + // On logIn all authData are revalidated + expect(user3.get('authDataResponse')).toEqual({ + baseAdapter: { someData: true }, + baseAdapter2: { someData2: true }, + }); + + const userViaMasterKey = new Parse.User(); + userViaMasterKey.id = user2.id; + await userViaMasterKey.fetch({ useMasterKey: true }); + expect(userViaMasterKey.get('authData')).toEqual({ + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { otherData: true }, + }); + }); + + it('should return authData response and save some info on username login', async () => { + spyOn(baseAdapter, 'validateAuthData').and.resolveTo({ + response: { someData: true }, + }); + spyOn(baseAdapter2, 'validateAuthData').and.resolveTo({ + response: { someData2: true }, + save: { otherData: true }, + }); + await reconfigureServer({ + auth: { baseAdapter, baseAdapter2 }, + }); + + const user = new Parse.User(); + + await user.save({ + username: 'username', + password: 'password', + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + + expect(user.get('authDataResponse')).toEqual({ + baseAdapter: { someData: true }, + baseAdapter2: { someData2: true }, + }); + + const res = await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { + baseAdapter2: { test: true }, + baseAdapter: { id: 'baseAdapter' }, + }, + }), + }); + const result = res.data; + expect(result.authDataResponse).toEqual({ + baseAdapter2: { someData2: true }, + baseAdapter: { someData: true }, + }); + + await user.fetch({ useMasterKey: true }); + expect(user.get('authData')).toEqual({ + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { otherData: true }, + }); + }); + + describe('should allow update of authData', () => { + beforeEach(async () => { + spyOn(baseAdapter, 'validateAuthData').and.resolveTo({ + response: { someData: true }, + }); + spyOn(baseAdapter2, 'validateAuthData').and.resolveTo({ + response: { someData2: true }, + save: { otherData: true }, + }); + await reconfigureServer({ + auth: { baseAdapter, baseAdapter2 }, + }); + }); + + it('should not re validate the baseAdapter when user is already logged in and authData not changed', async () => { + const user = new Parse.User(); + + await user.save({ + username: 'username', + password: 'password', + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(1); + + expect(user.id).toBeDefined(); + expect(user.getSessionToken()).toBeDefined(); + await user.save( + { + authData: { + baseAdapter2: { test: true }, + baseAdapter: { id: 'baseAdapter' }, + }, + }, + { sessionToken: user.getSessionToken() } + ); + + expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(1); + }); + + it('should not re-validate the baseAdapter when master key is used and authData has not changed', async () => { + const user = new Parse.User(); + await user.save({ + username: 'username', + password: 'password', + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + await user.save( + { + authData: { + baseAdapter2: { test: true }, + baseAdapter: { id: 'baseAdapter' }, + }, + }, + { useMasterKey: true } + ); + + expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(1); + }); + + it('should allow user to change authData', async () => { + const user = new Parse.User(); + await user.save({ + username: 'username', + password: 'password', + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + await user.save( + { + authData: { + baseAdapter2: { test: true }, + baseAdapter: { id: 'baseAdapter2' }, + }, + }, + { sessionToken: user.getSessionToken() } + ); + + expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(2); + }); + + it('should allow master key to change authData', async () => { + const user = new Parse.User(); + await user.save({ + username: 'username', + password: 'password', + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + await user.save( + { + authData: { + baseAdapter2: { test: true }, + baseAdapter: { id: 'baseAdapter3' }, + }, + }, + { useMasterKey: true } + ); + + expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(2); + + await user.fetch({ useMasterKey: true }); + expect(user.get('authData')).toEqual({ + baseAdapter: { id: 'baseAdapter3' }, + baseAdapter2: { otherData: true }, + }); + }); + }); + + it('should pass user to auth adapter on update by matching session', async () => { + spyOn(baseAdapter2, 'validateAuthData').and.resolveTo({}); + await reconfigureServer({ auth: { baseAdapter2 } }); + + const user = new Parse.User(); + + const payload = { someData: true }; + + await user.save({ + username: 'test', + password: 'password', + }); + + expect(user.getSessionToken()).toBeDefined(); + + await user.save( + { authData: { baseAdapter2: payload } }, + { sessionToken: user.getSessionToken() } + ); + + const firstCall = baseAdapter2.validateAuthData.calls.argsFor(0); + expect(firstCall[0]).toEqual(payload); + expect(firstCall[1]).toEqual(baseAdapter2); + expect(firstCall[2].isChallenge).toBeUndefined(); + expect(firstCall[2].master).toBeDefined(); + expect(firstCall[2].object instanceof Parse.User).toBeTruthy(); + expect(firstCall[2].user instanceof Parse.User).toBeTruthy(); + expect(firstCall[2].original instanceof Parse.User).toBeTruthy(); + expect(firstCall[2].object.id).toEqual(user.id); + expect(firstCall[2].original.id).toEqual(user.id); + expect(firstCall[2].user.id).toEqual(user.id); + expect(firstCall.length).toEqual(3); + + await user.save({ authData: { baseAdapter2: payload } }, { useMasterKey: true }); + + const secondCall = baseAdapter2.validateAuthData.calls.argsFor(1); + expect(secondCall[0]).toEqual(payload); + expect(secondCall[1]).toEqual(baseAdapter2); + expect(secondCall[2].isChallenge).toBeUndefined(); + expect(secondCall[2].master).toEqual(true); + expect(secondCall[2].user).toBeUndefined(); + expect(secondCall[2].object instanceof Parse.User).toBeTruthy(); + expect(secondCall[2].original instanceof Parse.User).toBeTruthy(); + expect(secondCall[2].object.id).toEqual(user.id); + expect(secondCall[2].original.id).toEqual(user.id); + expect(secondCall.length).toEqual(3); + }); + + it('should return custom errors', async () => { + const throwInChallengeAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + challenge: () => Promise.reject('Invalid challenge data: yolo'), + options: { + anOption: true, + }, + }; + const throwInSetup = { + validateAppId: () => Promise.resolve(), + validateSetUp: () => Promise.reject('You cannot signup with that setup data.'), + validateUpdate: () => Promise.resolve(), + validateLogin: () => Promise.resolve(), + }; + + const throwInUpdate = { + validateAppId: () => Promise.resolve(), + validateSetUp: () => Promise.resolve(), + validateUpdate: () => Promise.reject('You cannot update with that update data.'), + validateLogin: () => Promise.resolve(), + }; + + const throwInLogin = { + validateAppId: () => Promise.resolve(), + validateSetUp: () => Promise.resolve(), + validateUpdate: () => Promise.resolve(), + validateLogin: () => Promise.reject('You cannot login with that login data.'), + }; + await reconfigureServer({ + auth: { challengeAdapter: throwInChallengeAdapter }, + }); + let logger = require('../lib/logger').logger; + spyOn(logger, 'error').and.callFake(() => {}); + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + }), + }) + ).toBeRejectedWithError('Invalid challenge data: yolo'); + expect(logger.error).toHaveBeenCalledWith( + `Failed running auth step challenge for challengeAdapter for user undefined with Error: {"message":"Invalid challenge data: yolo","code":${Parse.Error.SCRIPT_FAILED}}`, + { + authenticationStep: 'challenge', + error: new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid challenge data: yolo'), + user: undefined, + provider: 'challengeAdapter', + } + ); + + await reconfigureServer({ auth: { modernAdapter: throwInSetup } }); + logger = require('../lib/logger').logger; + spyOn(logger, 'error').and.callFake(() => {}); + let user = new Parse.User(); + await expectAsync( + user.save({ authData: { modernAdapter: { id: 'modernAdapter' } } }) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.SCRIPT_FAILED, 'You cannot signup with that setup data.') + ); + expect(logger.error).toHaveBeenCalledWith( + `Failed running auth step validateSetUp for modernAdapter for user undefined with Error: {"message":"You cannot signup with that setup data.","code":${Parse.Error.SCRIPT_FAILED}}`, + { + authenticationStep: 'validateSetUp', + error: new Parse.Error( + Parse.Error.SCRIPT_FAILED, + 'You cannot signup with that setup data.' + ), + user: undefined, + provider: 'modernAdapter', + } + ); + + await reconfigureServer({ auth: { modernAdapter: throwInUpdate } }); + logger = require('../lib/logger').logger; + spyOn(logger, 'error').and.callFake(() => {}); + user = new Parse.User(); + await user.save({ authData: { modernAdapter: { id: 'updateAdapter' } } }); + await expectAsync( + user.save( + { authData: { modernAdapter: { id: 'updateAdapter2' } } }, + { sessionToken: user.getSessionToken() } + ) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.SCRIPT_FAILED, 'You cannot update with that update data.') + ); + + expect(logger.error).toHaveBeenCalledWith( + `Failed running auth step validateUpdate for modernAdapter for user ${user.id} with Error: {"message":"You cannot update with that update data.","code":${Parse.Error.SCRIPT_FAILED}}`, + { + authenticationStep: 'validateUpdate', + error: new Parse.Error( + Parse.Error.SCRIPT_FAILED, + 'You cannot update with that update data.' + ), + user: user.id, + provider: 'modernAdapter', + } + ); + + await reconfigureServer({ + auth: { modernAdapter: throwInLogin }, + allowExpiredAuthDataToken: false, + }); + logger = require('../lib/logger').logger; + spyOn(logger, 'error').and.callFake(() => {}); + user = new Parse.User(); + await user.save({ authData: { modernAdapter: { id: 'modernAdapter' } } }); + const user2 = new Parse.User(); + await expectAsync( + user2.save({ authData: { modernAdapter: { id: 'modernAdapter' } } }) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.SCRIPT_FAILED, 'You cannot login with that login data.') + ); + expect(logger.error).toHaveBeenCalledWith( + `Failed running auth step validateLogin for modernAdapter for user ${user.id} with Error: {"message":"You cannot login with that login data.","code":${Parse.Error.SCRIPT_FAILED}}`, + { + authenticationStep: 'validateLogin', + error: new Parse.Error(Parse.Error.SCRIPT_FAILED, 'You cannot login with that login data.'), + user: user.id, + provider: 'modernAdapter', + } + ); + }); + + it('should return challenge with no logged user', async () => { + spyOn(challengeAdapter, 'challenge').and.resolveTo({ token: 'test' }); + + await reconfigureServer({ + auth: { challengeAdapter }, + }); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: {}, + }) + ).toBeRejectedWithError('Nothing to challenge.'); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: { challengeData: true }, + }) + ).toBeRejectedWithError('challengeData should be an object.'); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: { challengeData: { data: true }, authData: true }, + }) + ).toBeRejectedWithError('authData should be an object.'); + + const res = await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + }), + }); + + expect(res.data).toEqual({ + challengeData: { + challengeAdapter: { + token: 'test', + }, + }, + }); + const challengeCall = challengeAdapter.challenge.calls.argsFor(0); + expect(challengeAdapter.challenge).toHaveBeenCalledTimes(1); + expect(challengeCall[0]).toEqual({ someData: true }); + expect(challengeCall[1]).toBeUndefined(); + expect(challengeCall[2]).toEqual(challengeAdapter); + expect(challengeCall[3].master).toBeDefined(); + expect(challengeCall[3].headers).toBeDefined(); + expect(challengeCall[3].object).toBeUndefined(); + expect(challengeCall[3].original).toBeUndefined(); + expect(challengeCall[3].user).toBeUndefined(); + expect(challengeCall[3].isChallenge).toBeTruthy(); + expect(challengeCall.length).toEqual(4); + }); + + it('should return empty challenge data response if challenged provider does not exists', async () => { + spyOn(challengeAdapter, 'challenge').and.resolveTo({ token: 'test' }); + + await reconfigureServer({ + auth: { challengeAdapter }, + }); + + const res = await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + nonExistingProvider: { someData: true }, + }, + }), + }); + + expect(res.data).toEqual({ challengeData: {} }); + }); + it('should return challenge with username created user', async () => { + spyOn(challengeAdapter, 'challenge').and.resolveTo({ token: 'test' }); + + await reconfigureServer({ + auth: { challengeAdapter }, + }); + + const user = new Parse.User(); + await user.save({ username: 'username', password: 'password' }); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + username: 'username', + challengeData: { + challengeAdapter: { someData: true }, + }, + }), + }) + ).toBeRejectedWithError('You provided username or email, you need to also provide password.'); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { data: true }, + challengeData: { + challengeAdapter: { someData: true }, + }, + }), + }) + ).toBeRejectedWithError( + 'You cannot provide username/email and authData, only use one identification method.' + ); + + const res = await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + username: 'username', + password: 'password', + challengeData: { + challengeAdapter: { someData: true }, + }, + }), + }); + + expect(res.data).toEqual({ + challengeData: { + challengeAdapter: { + token: 'test', + }, + }, + }); + + const challengeCall = challengeAdapter.challenge.calls.argsFor(0); + expect(challengeAdapter.challenge).toHaveBeenCalledTimes(1); + expect(challengeCall[0]).toEqual({ someData: true }); + expect(challengeCall[1]).toEqual(undefined); + expect(challengeCall[2]).toEqual(challengeAdapter); + expect(challengeCall[3].master).toBeDefined(); + expect(challengeCall[3].isChallenge).toBeTruthy(); + expect(challengeCall[3].user).toBeUndefined(); + expect(challengeCall[3].object instanceof Parse.User).toBeTruthy(); + expect(challengeCall[3].original instanceof Parse.User).toBeTruthy(); + expect(challengeCall[3].object.id).toEqual(user.id); + expect(challengeCall[3].original.id).toEqual(user.id); + expect(challengeCall.length).toEqual(4); + }); + + it('should return challenge with authData created user', async () => { + spyOn(challengeAdapter, 'challenge').and.resolveTo({ token: 'test' }); + spyOn(challengeAdapter, 'validateAuthData').and.callThrough(); + + await reconfigureServer({ + auth: { challengeAdapter, soloAdapter }, + }); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + authData: { + challengeAdapter: { id: 'challengeAdapter' }, + }, + }), + }) + ).toBeRejectedWithError('User not found.'); + + const user = new Parse.User(); + await user.save({ authData: { challengeAdapter: { id: 'challengeAdapter' } } }); + + const user2 = new Parse.User(); + await user2.save({ authData: { soloAdapter: { id: 'soloAdapter' } } }); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + authData: { + challengeAdapter: { id: 'challengeAdapter' }, + soloAdapter: { id: 'soloAdapter' }, + }, + }), + }) + ).toBeRejectedWithError('You cannot provide more than one authData provider with an id.'); + + const res = await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + authData: { + challengeAdapter: { id: 'challengeAdapter' }, + }, + }), + }); + + expect(res.data).toEqual({ + challengeData: { + challengeAdapter: { + token: 'test', + }, + }, + }); + + const validateCall = challengeAdapter.validateAuthData.calls.argsFor(1); + expect(validateCall[2].isChallenge).toBeTruthy(); + + const challengeCall = challengeAdapter.challenge.calls.argsFor(0); + expect(challengeAdapter.challenge).toHaveBeenCalledTimes(1); + expect(challengeCall[0]).toEqual({ someData: true }); + expect(challengeCall[1]).toEqual({ id: 'challengeAdapter' }); + expect(challengeCall[2]).toEqual(challengeAdapter); + expect(challengeCall[3].master).toBeDefined(); + expect(challengeCall[3].isChallenge).toBeTruthy(); + expect(challengeCall[3].object instanceof Parse.User).toBeTruthy(); + expect(challengeCall[3].original instanceof Parse.User).toBeTruthy(); + expect(challengeCall[3].object.id).toEqual(user.id); + expect(challengeCall[3].original.id).toEqual(user.id); + expect(challengeCall.length).toEqual(4); + }); + + it('should validate provided authData and prevent guess id attack', async () => { + spyOn(challengeAdapter, 'challenge').and.resolveTo({ token: 'test' }); + + await reconfigureServer({ + auth: { challengeAdapter, soloAdapter }, + }); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + authData: { + challengeAdapter: { id: 'challengeAdapter' }, + }, + }), + }) + ).toBeRejectedWithError('User not found.'); + + const user = new Parse.User(); + await user.save({ authData: { challengeAdapter: { id: 'challengeAdapter' } } }); + + spyOn(challengeAdapter, 'validateAuthData').and.rejectWith({}); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + authData: { + challengeAdapter: { id: 'challengeAdapter' }, + }, + }), + }) + ).toBeRejectedWithError('User not found.'); + + const validateCall = challengeAdapter.validateAuthData.calls.argsFor(0); + expect(challengeAdapter.validateAuthData).toHaveBeenCalledTimes(1); + expect(validateCall[0]).toEqual({ id: 'challengeAdapter' }); + expect(validateCall[1]).toEqual(challengeAdapter); + expect(validateCall[2].isChallenge).toBeTruthy(); + expect(validateCall[2].master).toBeDefined(); + expect(validateCall[2].object instanceof Parse.User).toBeTruthy(); + expect(validateCall[2].original instanceof Parse.User).toBeTruthy(); + expect(validateCall[2].object.id).toEqual(user.id); + expect(validateCall[2].original.id).toEqual(user.id); + expect(validateCall.length).toEqual(3); + }); + + it('should work with multiple adapters', async () => { + const adapterA = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; + const adapterB = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; + await reconfigureServer({ auth: { adapterA, adapterB } }); + const user = new Parse.User(); + await user.signUp({ + username: 'test', + password: 'password', + }); + await user.save({ authData: { adapterA: { id: 'testA' } } }); + expect(user.get('authData')).toEqual({ adapterA: { id: 'testA' } }); + await user.save({ authData: { adapterA: null, adapterB: { id: 'test' } } }); + await user.fetch({ useMasterKey: true }); + expect(user.get('authData')).toEqual({ adapterB: { id: 'test' } }); + }); + + it('should unlink a code-based auth provider without triggering adapter validation', async () => { + const mockUserId = 'gpgamesUser123'; + const mockAccessToken = 'mockAccessToken'; + + const otherAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; + + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: mockAccessToken }), + }, + }, + { + url: `https://www.googleapis.com/games/v1/players/${mockUserId}`, + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ playerId: mockUserId }), + }, + }, + ]); + + await reconfigureServer({ + auth: { + gpgames: { + clientId: 'testClientId', + clientSecret: 'testClientSecret', + }, + otherAdapter, + }, + }); + + // Sign up with username/password, then link providers + const user = new Parse.User(); + await user.signUp({ username: 'gpgamesTestUser', password: 'password123' }); + + // Link gpgames code-based provider + await user.save({ + authData: { + gpgames: { id: mockUserId, code: 'authCode123', redirect_uri: 'https://example.com/callback' }, + }, + }); + + // Link a second provider + await user.save({ authData: { otherAdapter: { id: 'other1' } } }); + + // Reset fetch spy to track calls during unlink + global.fetch.calls.reset(); + + // Unlink gpgames by setting authData to null; should not call beforeFind / external APIs + const sessionToken = user.getSessionToken(); + await user.save({ authData: { gpgames: null } }, { sessionToken }); + + // No external HTTP calls should have been made during unlink + expect(global.fetch.calls.count()).toBe(0); + + // Verify gpgames was removed while the other provider remains + await user.fetch({ useMasterKey: true }); + const authData = user.get('authData'); + expect(authData).toBeDefined(); + expect(authData.gpgames).toBeUndefined(); + expect(authData.otherAdapter).toEqual({ id: 'other1' }); + }); + + it('should unlink one code-based provider while echoing back another unchanged', async () => { + const gpgamesUserId = 'gpgamesUser1'; + const instagramUserId = 'igUser1'; + + // Mock gpgames API for initial login + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'gpgamesToken' }), + }, + }, + { + url: `https://www.googleapis.com/games/v1/players/${gpgamesUserId}`, + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ playerId: gpgamesUserId }), + }, + }, + ]); + + await reconfigureServer({ + auth: { + gpgames: { + clientId: 'testClientId', + clientSecret: 'testClientSecret', + }, + instagram: { + clientId: 'testClientId', + clientSecret: 'testClientSecret', + redirectUri: 'https://example.com/callback', + }, + }, + }); + + // Login with gpgames + const user = await Parse.User.logInWith('gpgames', { + authData: { id: gpgamesUserId, code: 'gpCode1', redirect_uri: 'https://example.com/callback' }, + }); + const sessionToken = user.getSessionToken(); + + // Mock instagram API for linking + mockFetch([ + { + url: 'https://api.instagram.com/oauth/access_token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'igToken' }), + }, + }, + { + url: `https://graph.instagram.com/me?fields=id&access_token=igToken`, + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ id: instagramUserId }), + }, + }, + ]); + + // Link instagram as second provider + await user.save( + { authData: { instagram: { id: instagramUserId, code: 'igCode1' } } }, + { sessionToken } + ); + + // Fetch to get current authData (afterFind strips credentials, leaving only { id }) + await user.fetch({ sessionToken }); + const currentAuthData = user.get('authData'); + expect(currentAuthData.gpgames).toBeDefined(); + expect(currentAuthData.instagram).toBeDefined(); + + // Reset fetch spy + global.fetch.calls.reset(); + + // Unlink gpgames while echoing back instagram unchanged — the common client pattern: + // fetch current state, spread it, set the one to unlink to null + user.set('authData', { ...currentAuthData, gpgames: null }); + await user.save(null, { sessionToken }); + + // No external HTTP calls during unlink (no code exchange for unchanged instagram) + expect(global.fetch.calls.count()).toBe(0); + + // Verify gpgames removed, instagram preserved + await user.fetch({ useMasterKey: true }); + const finalAuthData = user.get('authData'); + expect(finalAuthData).toBeDefined(); + expect(finalAuthData.gpgames).toBeUndefined(); + expect(finalAuthData.instagram).toBeDefined(); + expect(finalAuthData.instagram.id).toBe(instagramUserId); + }); + + it('should reject changing an existing code-based provider id without credentials', async () => { + const mockUserId = 'gpgamesUser123'; + const mockAccessToken = 'mockAccessToken'; + + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: mockAccessToken }), + }, + }, + { + url: `https://www.googleapis.com/games/v1/players/${mockUserId}`, + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ playerId: mockUserId }), + }, + }, + ]); + + await reconfigureServer({ + auth: { + gpgames: { + clientId: 'testClientId', + clientSecret: 'testClientSecret', + }, + }, + }); + + // Sign up and link gpgames with valid credentials + const user = new Parse.User(); + await user.save({ + authData: { + gpgames: { id: mockUserId, code: 'authCode123', redirect_uri: 'https://example.com/callback' }, + }, + }); + const sessionToken = user.getSessionToken(); + + // Attempt to change gpgames id without credentials (no code or access_token) + await expectAsync( + user.save({ authData: { gpgames: { id: 'differentUserId' } } }, { sessionToken }) + ).toBeRejectedWith( + jasmine.objectContaining({ message: jasmine.stringContaining('code is required') }) + ); + }); + + it('should reject linking a new code-based provider with only an id and no credentials', async () => { + await reconfigureServer({ + auth: { + gpgames: { + clientId: 'testClientId', + clientSecret: 'testClientSecret', + }, + }, + }); + + // Sign up with username/password (no gpgames linked) + const user = new Parse.User(); + await user.signUp({ username: 'linkTestUser', password: 'password123' }); + const sessionToken = user.getSessionToken(); + + // Attempt to link gpgames with only { id } — no code or access_token + await expectAsync( + user.save({ authData: { gpgames: { id: 'victimUserId' } } }, { sessionToken }) + ).toBeRejectedWith( + jasmine.objectContaining({ message: jasmine.stringContaining('code is required') }) + ); + }); + + it('should handle multiple providers: add one while another remains unchanged (code-based)', async () => { + await reconfigureServer({ + auth: { + codeBasedAdapter, + simpleAdapter, + }, + }); + + // Login with code-based provider + const user = new Parse.User(); + await user.save({ authData: { codeBasedAdapter: { id: 'user1', code: 'code1' } } }); + const sessionToken = user.getSessionToken(); + await user.fetch({ sessionToken }); + + // At this point, authData.codeBasedAdapter only has {id: 'user1'} due to afterFind + const current = user.get('authData') || {}; + expect(current.codeBasedAdapter).toEqual({ id: 'user1' }); + + // Add a second provider while keeping the first unchanged + user.set('authData', { + ...current, + simpleAdapter: { id: 'simple1' }, + // codeBasedAdapter is NOT modified (no new code provided) + }); + + // This should succeed without requiring 'code' for codeBasedAdapter + await user.save(null, { sessionToken }); + + // Verify both providers are present + const reloaded = await new Parse.Query(Parse.User).get(user.id, { + useMasterKey: true, + }); + + const authData = reloaded.get('authData') || {}; + expect(authData.simpleAdapter && authData.simpleAdapter.id).toBe('simple1'); + expect(authData.codeBasedAdapter && authData.codeBasedAdapter.id).toBe('user1'); + }); + + describe('authData dot-notation injection and login crash', () => { + it('rejects dotted update key that targets authData sub-field', async () => { + const user = new Parse.User(); + user.setUsername('dotuser'); + user.setPassword('pass1234'); + await user.signUp(); + + const res = await request({ + method: 'PUT', + url: `http://localhost:8378/1/users/${user.id}`, + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ 'authData.anonymous".id': 'injected' }), + }).catch(e => e); + expect(res.status).toBe(400); + }); + + it('login does not crash when stored authData has unknown provider', async () => { + const user = new Parse.User(); + user.setUsername('dotuser2'); + user.setPassword('pass1234'); + await user.signUp(); + await Parse.User.logOut(); + + // Inject unknown provider directly in database to simulate corrupted data + const config = Config.get('test'); + await config.database.update( + '_User', + { objectId: user.id }, + { authData: { unknown_provider: { id: 'bad' } } } + ); + + // Login should not crash with 500 + const login = await request({ + method: 'GET', + url: `http://localhost:8378/1/login?username=dotuser2&password=pass1234`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }).catch(e => e); + expect(login.status).toBe(200); + expect(login.data.sessionToken).toBeDefined(); + }); + }); + + describe('challenge endpoint authData provider value validation', () => { + it('rejects challenge request with null provider value without 500', async () => { + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/challenge', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + authData: { anonymous: null }, + challengeData: { anonymous: { token: '123456' } }, + }), + }).catch(e => e); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); + + it('rejects challenge request with non-object provider value without 500', async () => { + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/challenge', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + authData: { anonymous: 'string_value' }, + challengeData: { anonymous: { token: '123456' } }, + }), + }).catch(e => e); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); + }); +}); diff --git a/spec/CLI.spec.js b/spec/CLI.spec.js index 733e1e3a00..39061b8083 100644 --- a/spec/CLI.spec.js +++ b/spec/CLI.spec.js @@ -1,30 +1,40 @@ 'use strict'; -var commander = require("../src/cli/utils/commander").default; +let commander; +const definitions = require('../lib/cli/definitions/parse-server').default; +const liveQueryDefinitions = require('../lib/cli/definitions/parse-live-query-server').default; +const path = require('path'); +const { spawn } = require('child_process'); -var definitions = { - "arg0": "PROGRAM_ARG_0", - "arg1": { - env: "PROGRAM_ARG_1", - required: true +const testDefinitions = { + arg0: 'PROGRAM_ARG_0', + arg1: { + env: 'PROGRAM_ARG_1', + required: true, }, - "arg2": { - env: "PROGRAM_ARG_2", - action: function(value) { - var value = parseInt(value); - if (!Number.isInteger(value)) { - throw "arg2 is invalid"; + arg2: { + env: 'PROGRAM_ARG_2', + action: function (value) { + const intValue = parseInt(value); + if (!Number.isInteger(intValue)) { + throw 'arg2 is invalid'; } - return value; - } + return intValue; + }, }, - "arg3": {}, - "arg4": { - default: "arg4Value" - } -} + arg3: {}, + arg4: { + default: 'arg4Value', + }, +}; -describe("commander additions", () => { - afterEach((done) => { +describe('commander additions', () => { + beforeEach(() => { + const command = require('../lib/cli/utils/commander').default; + commander = new command.constructor(); + commander.storeOptionsAsProperties(); + commander.allowExcessArguments(); + }); + afterEach(done => { commander.options = []; delete commander.arg0; delete commander.arg1; @@ -32,107 +42,267 @@ describe("commander additions", () => { delete commander.arg3; delete commander.arg4; done(); - }) + }); - it("should load properly definitions from args", (done) => { - commander.loadDefinitions(definitions); - commander.parse(["node","./CLI.spec.js","--arg0", "arg0Value", "--arg1", "arg1Value", "--arg2", "2", "--arg3", "some"]); - expect(commander.arg0).toEqual("arg0Value"); - expect(commander.arg1).toEqual("arg1Value"); + it('should load properly definitions from args', done => { + commander.loadDefinitions(testDefinitions); + commander.parse([ + 'node', + './CLI.spec.js', + '--arg0', + 'arg0Value', + '--arg1', + 'arg1Value', + '--arg2', + '2', + '--arg3', + 'some', + ]); + expect(commander.arg0).toEqual('arg0Value'); + expect(commander.arg1).toEqual('arg1Value'); expect(commander.arg2).toEqual(2); - expect(commander.arg3).toEqual("some"); - expect(commander.arg4).toEqual("arg4Value"); + expect(commander.arg3).toEqual('some'); + expect(commander.arg4).toEqual('arg4Value'); done(); }); - it("should load properly definitions from env", (done) => { - commander.loadDefinitions(definitions); + it('should load properly definitions from env', done => { + commander.loadDefinitions(testDefinitions); commander.parse([], { - "PROGRAM_ARG_0": "arg0ENVValue", - "PROGRAM_ARG_1": "arg1ENVValue", - "PROGRAM_ARG_2": "3", + PROGRAM_ARG_0: 'arg0ENVValue', + PROGRAM_ARG_1: 'arg1ENVValue', + PROGRAM_ARG_2: '3', }); - expect(commander.arg0).toEqual("arg0ENVValue"); - expect(commander.arg1).toEqual("arg1ENVValue"); + expect(commander.arg0).toEqual('arg0ENVValue'); + expect(commander.arg1).toEqual('arg1ENVValue'); expect(commander.arg2).toEqual(3); - expect(commander.arg4).toEqual("arg4Value"); + expect(commander.arg4).toEqual('arg4Value'); done(); }); - it("should load properly use args over env", (done) => { - commander.loadDefinitions(definitions); - commander.parse(["node","./CLI.spec.js","--arg0", "arg0Value", "--arg4", "anotherArg4"], { - "PROGRAM_ARG_0": "arg0ENVValue", - "PROGRAM_ARG_1": "arg1ENVValue", - "PROGRAM_ARG_2": "4", + it('should load properly use args over env', () => { + commander.loadDefinitions(testDefinitions); + commander.parse(['node', './CLI.spec.js', '--arg0', 'arg0Value', '--arg4', ''], { + PROGRAM_ARG_0: 'arg0ENVValue', + PROGRAM_ARG_1: 'arg1ENVValue', + PROGRAM_ARG_2: '4', + PROGRAM_ARG_4: 'arg4ENVValue', }); - expect(commander.arg0).toEqual("arg0Value"); - expect(commander.arg1).toEqual("arg1ENVValue"); + expect(commander.arg0).toEqual('arg0Value'); + expect(commander.arg1).toEqual('arg1ENVValue'); expect(commander.arg2).toEqual(4); - expect(commander.arg4).toEqual("anotherArg4"); - done(); + expect(commander.arg4).toEqual(''); }); - it("should fail in action as port is invalid", (done) => { - commander.loadDefinitions(definitions); - expect(()=> { - commander.parse(["node","./CLI.spec.js","--arg0", "arg0Value"], { - "PROGRAM_ARG_0": "arg0ENVValue", - "PROGRAM_ARG_1": "arg1ENVValue", - "PROGRAM_ARG_2": "hello", + it('should fail in action as port is invalid', done => { + commander.loadDefinitions(testDefinitions); + expect(() => { + commander.parse(['node', './CLI.spec.js', '--arg0', 'arg0Value'], { + PROGRAM_ARG_0: 'arg0ENVValue', + PROGRAM_ARG_1: 'arg1ENVValue', + PROGRAM_ARG_2: 'hello', }); - }).toThrow("arg2 is invalid"); + }).toThrow('arg2 is invalid'); done(); }); - it("should not override config.json", (done) => { - commander.loadDefinitions(definitions); - commander.parse(["node","./CLI.spec.js","--arg0", "arg0Value", "./spec/configs/CLIConfig.json"], { - "PROGRAM_ARG_0": "arg0ENVValue", - "PROGRAM_ARG_1": "arg1ENVValue", - }); - let options = commander.getOptions(); + it('should not override config.json', done => { + spyOn(console, 'log').and.callFake(() => {}); + commander.loadDefinitions(testDefinitions); + commander.parse( + ['node', './CLI.spec.js', '--arg0', 'arg0Value', './spec/configs/CLIConfig.json'], + { + PROGRAM_ARG_0: 'arg0ENVValue', + PROGRAM_ARG_1: 'arg1ENVValue', + } + ); + const options = commander.getOptions(); expect(options.arg2).toBe(8888); - expect(options.arg3).toBe("hello"); //config value + expect(options.arg3).toBe('hello'); //config value expect(options.arg4).toBe('/1'); done(); }); - it("should fail with invalid values in JSON", (done) => { - commander.loadDefinitions(definitions); + it('should fail with invalid values in JSON', done => { + commander.loadDefinitions(testDefinitions); expect(() => { - commander.parse(["node","./CLI.spec.js","--arg0", "arg0Value", "./spec/configs/CLIConfigFail.json"], { - "PROGRAM_ARG_0": "arg0ENVValue", - "PROGRAM_ARG_1": "arg1ENVValue", - }); - }).toThrow("arg2 is invalid") + commander.parse( + ['node', './CLI.spec.js', '--arg0', 'arg0Value', './spec/configs/CLIConfigFail.json'], + { + PROGRAM_ARG_0: 'arg0ENVValue', + PROGRAM_ARG_1: 'arg1ENVValue', + } + ); + }).toThrow('arg2 is invalid'); done(); }); - it("should fail when too many apps are set", (done) => { - commander.loadDefinitions(definitions); + it('should fail when too many apps are set', done => { + commander.loadDefinitions(testDefinitions); expect(() => { - commander.parse(["node","./CLI.spec.js","./spec/configs/CLIConfigFailTooManyApps.json"]); - }).toThrow("Multiple apps are not supported") + commander.parse(['node', './CLI.spec.js', './spec/configs/CLIConfigFailTooManyApps.json']); + }).toThrow('Multiple apps are not supported'); done(); }); - it("should load config from apps", (done) => { - commander.loadDefinitions(definitions); - commander.parse(["node", "./CLI.spec.js", "./spec/configs/CLIConfigApps.json"]); - let options = commander.getOptions(); - expect(options.arg1).toBe("my_app"); + it('should load config from apps', done => { + spyOn(console, 'log').and.callFake(() => {}); + commander.loadDefinitions(testDefinitions); + commander.parse(['node', './CLI.spec.js', './spec/configs/CLIConfigApps.json']); + const options = commander.getOptions(); + expect(options.arg1).toBe('my_app'); expect(options.arg2).toBe(8888); - expect(options.arg3).toBe("hello"); //config value + expect(options.arg3).toBe('hello'); //config value expect(options.arg4).toBe('/1'); done(); }); - it("should fail when passing an invalid arguement", (done) => { - commander.loadDefinitions(definitions); + it('should fail when passing an invalid arguement', done => { + commander.loadDefinitions(testDefinitions); expect(() => { - commander.parse(["node", "./CLI.spec.js", "./spec/configs/CLIConfigUnknownArg.json"]); - }).toThrow('error: unknown option myArg') + commander.parse(['node', './CLI.spec.js', './spec/configs/CLIConfigUnknownArg.json']); + }).toThrow('error: unknown option myArg'); done(); }); }); + +describe('definitions', () => { + it('should have valid types', () => { + for (const key in definitions) { + const definition = definitions[key]; + expect(typeof definition).toBe('object'); + if (typeof definition.env !== 'undefined') { + expect(typeof definition.env).toBe('string'); + } + expect(typeof definition.help).toBe('string'); + if (typeof definition.required !== 'undefined') { + expect(typeof definition.required).toBe('boolean'); + } + if (typeof definition.action !== 'undefined') { + expect(typeof definition.action).toBe('function'); + } + } + }); + + it('should throw when using deprecated facebookAppIds', () => { + expect(() => { + definitions.facebookAppIds.action(); + }).toThrow(); + }); +}); + +describe('LiveQuery definitions', () => { + it('should have valid types', () => { + for (const key in liveQueryDefinitions) { + const definition = liveQueryDefinitions[key]; + expect(typeof definition).toBe('object'); + if (typeof definition.env !== 'undefined') { + expect(typeof definition.env).toBe('string'); + } + expect(typeof definition.help).toBe('string', `help for ${key} should be a string`); + if (typeof definition.required !== 'undefined') { + expect(typeof definition.required).toBe('boolean'); + } + if (typeof definition.action !== 'undefined') { + expect(typeof definition.action).toBe('function'); + } + } + }); +}); + +describe('execution', () => { + const binPath = path.resolve(__dirname, '../bin/parse-server'); + let childProcess; + + function waitForStartup(cp, requiredOutput) { + return new Promise((resolve, reject) => { + const aggregated = []; + cp.stdout.on('data', data => { + aggregated.push(data.toString()); + if (requiredOutput.every(r => aggregated.some(a => a.includes(r)))) { + resolve(); + } + }); + cp.on('error', reject); + }); + } + + afterEach(done => { + if (childProcess) { + childProcess.on('close', () => { + childProcess = undefined; + done(); + }); + childProcess.kill(); + } + }); + + it_id('a0ab74b4-f805-4e03-b31d-b5cd59e64495')(it)('should start Parse Server', async () => { + const env = { ...process.env }; + env.NODE_OPTIONS = '--dns-result-order=ipv4first --trace-deprecation'; + childProcess = spawn( + binPath, + ['--appId', 'test', '--masterKey', 'test', '--databaseURI', databaseURI, '--port', '1339'], + { env } + ); + await waitForStartup(childProcess, ['parse-server running on']); + }); + + it_id('d7165081-b133-4cba-901b-19128ce41301')(it)('should start Parse Server with GraphQL', async () => { + const env = { ...process.env }; + env.NODE_OPTIONS = '--dns-result-order=ipv4first --trace-deprecation'; + childProcess = spawn( + binPath, + [ + '--appId', + 'test', + '--masterKey', + 'test', + '--databaseURI', + databaseURI, + '--port', + '1340', + '--mountGraphQL', + ], + { env } + ); + await waitForStartup(childProcess, ['parse-server running on', 'GraphQL running on']); + }); + + it_id('2769cdb4-ce8a-484d-8a91-635b5894ba7e')(it)('should start Parse Server with GraphQL and Playground', async () => { + const env = { ...process.env }; + env.NODE_OPTIONS = '--dns-result-order=ipv4first --trace-deprecation'; + childProcess = spawn( + binPath, + [ + '--appId', + 'test', + '--masterKey', + 'test', + '--databaseURI', + databaseURI, + '--port', + '1341', + '--mountGraphQL', + '--mountPlayground', + ], + { env } + ); + await waitForStartup(childProcess, [ + 'parse-server running on', + 'Playground running on', + 'GraphQL running on', + ]); + }); + + it_id('23caddd7-bfea-4869-8bd4-0f2cd283c8bd')(it)('can start Parse Server with auth via CLI', async () => { + const env = { ...process.env }; + env.NODE_OPTIONS = '--dns-result-order=ipv4first --trace-deprecation'; + childProcess = spawn( + binPath, + ['--databaseURI', databaseURI, './spec/configs/CLIConfigAuth.json'], + { env } + ); + await waitForStartup(childProcess, ['parse-server running on']); + }); +}); diff --git a/spec/CacheController.spec.js b/spec/CacheController.spec.js index 1e02d59e2f..de07126214 100644 --- a/spec/CacheController.spec.js +++ b/spec/CacheController.spec.js @@ -1,24 +1,23 @@ -var CacheController = require('../src/Controllers/CacheController.js').default; +const CacheController = require('../lib/Controllers/CacheController.js').default; -describe('CacheController', function() { - var FakeCacheAdapter; - var FakeAppID = 'foo'; - var KEY = 'hello'; +describe('CacheController', function () { + let FakeCacheAdapter; + const FakeAppID = 'foo'; + const KEY = 'hello'; beforeEach(() => { FakeCacheAdapter = { get: () => Promise.resolve(null), put: jasmine.createSpy('put'), del: jasmine.createSpy('del'), - clear: jasmine.createSpy('clear') - } + clear: jasmine.createSpy('clear'), + }; spyOn(FakeCacheAdapter, 'get').and.callThrough(); }); - - it('should expose role and user caches', (done) => { - var cache = new CacheController(FakeCacheAdapter, FakeAppID); + it('should expose role and user caches', done => { + const cache = new CacheController(FakeCacheAdapter, FakeAppID); expect(cache.role).not.toEqual(null); expect(cache.role.get).not.toEqual(null); @@ -28,27 +27,26 @@ describe('CacheController', function() { done(); }); - - ['role', 'user'].forEach((cacheName) => { + ['role', 'user'].forEach(cacheName => { it('should prefix ' + cacheName + ' cache', () => { - var cache = new CacheController(FakeCacheAdapter, FakeAppID)[cacheName]; + const cache = new CacheController(FakeCacheAdapter, FakeAppID)[cacheName]; cache.put(KEY, 'world'); - var firstPut = FakeCacheAdapter.put.calls.first(); + const firstPut = FakeCacheAdapter.put.calls.first(); expect(firstPut.args[0]).toEqual([FakeAppID, cacheName, KEY].join(':')); cache.get(KEY); - var firstGet = FakeCacheAdapter.get.calls.first(); + const firstGet = FakeCacheAdapter.get.calls.first(); expect(firstGet.args[0]).toEqual([FakeAppID, cacheName, KEY].join(':')); cache.del(KEY); - var firstDel = FakeCacheAdapter.del.calls.first(); + const firstDel = FakeCacheAdapter.del.calls.first(); expect(firstDel.args[0]).toEqual([FakeAppID, cacheName, KEY].join(':')); }); }); it('should clear the entire cache', () => { - var cache = new CacheController(FakeCacheAdapter, FakeAppID); + const cache = new CacheController(FakeCacheAdapter, FakeAppID); cache.clear(); expect(FakeCacheAdapter.clear.calls.count()).toEqual(1); @@ -60,15 +58,13 @@ describe('CacheController', function() { expect(FakeCacheAdapter.clear.calls.count()).toEqual(3); }); - it('should handle cache rejections', (done) => { - - FakeCacheAdapter.get = () => Promise.reject(); + it('should handle cache rejections', done => { + FakeCacheAdapter.get = () => Promise.reject(); - var cache = new CacheController(FakeCacheAdapter, FakeAppID); + const cache = new CacheController(FakeCacheAdapter, FakeAppID); - cache.get('foo').then(done, () => { - fail('Promise should not be rejected.'); - }); + cache.get('foo').then(done, () => { + fail('Promise should not be rejected.'); + }); }); - }); diff --git a/spec/Client.spec.js b/spec/Client.spec.js index 14b1795529..0de226204a 100644 --- a/spec/Client.spec.js +++ b/spec/Client.spec.js @@ -1,121 +1,120 @@ -var Client = require('../src/LiveQuery/Client').Client; -var ParseWebSocket = require('../src/LiveQuery/ParseWebSocketServer').ParseWebSocket; +const Client = require('../lib/LiveQuery/Client').Client; +const ParseWebSocket = require('../lib/LiveQuery/ParseWebSocketServer').ParseWebSocket; -describe('Client', function() { - it('can be initialized', function() { - var parseWebSocket = new ParseWebSocket({}); - var client = new Client(1, parseWebSocket); +describe('Client', function () { + it('can be initialized', function () { + const parseWebSocket = new ParseWebSocket({}); + const client = new Client(1, parseWebSocket); expect(client.id).toBe(1); expect(client.parseWebSocket).toBe(parseWebSocket); expect(client.subscriptionInfos.size).toBe(0); }); - it('can push response', function() { - var parseWebSocket = { - send: jasmine.createSpy('send') + it('can push response', function () { + const parseWebSocket = { + send: jasmine.createSpy('send'), }; Client.pushResponse(parseWebSocket, 'message'); expect(parseWebSocket.send).toHaveBeenCalledWith('message'); }); - it('can push error', function() { - var parseWebSocket = { - send: jasmine.createSpy('send') + it('can push error', function () { + const parseWebSocket = { + send: jasmine.createSpy('send'), }; Client.pushError(parseWebSocket, 1, 'error', true); - var lastCall = parseWebSocket.send.calls.first(); - var messageJSON = JSON.parse(lastCall.args[0]); + const lastCall = parseWebSocket.send.calls.first(); + const messageJSON = JSON.parse(lastCall.args[0]); expect(messageJSON.op).toBe('error'); expect(messageJSON.error).toBe('error'); expect(messageJSON.code).toBe(1); expect(messageJSON.reconnect).toBe(true); }); - it('can add subscription information', function() { - var subscription = {}; - var fields = ['test']; - var subscriptionInfo = { + it('can add subscription information', function () { + const subscription = {}; + const fields = ['test']; + const subscriptionInfo = { subscription: subscription, - fields: fields - } - var client = new Client(1, {}); + fields: fields, + }; + const client = new Client(1, {}); client.addSubscriptionInfo(1, subscriptionInfo); expect(client.subscriptionInfos.size).toBe(1); expect(client.subscriptionInfos.get(1)).toBe(subscriptionInfo); }); - it('can get subscription information', function() { - var subscription = {}; - var fields = ['test']; - var subscriptionInfo = { + it('can get subscription information', function () { + const subscription = {}; + const fields = ['test']; + const subscriptionInfo = { subscription: subscription, - fields: fields - } - var client = new Client(1, {}); + fields: fields, + }; + const client = new Client(1, {}); client.addSubscriptionInfo(1, subscriptionInfo); - var subscriptionInfoAgain = client.getSubscriptionInfo(1); + const subscriptionInfoAgain = client.getSubscriptionInfo(1); expect(subscriptionInfoAgain).toBe(subscriptionInfo); }); - it('can delete subscription information', function() { - var subscription = {}; - var fields = ['test']; - var subscriptionInfo = { + it('can delete subscription information', function () { + const subscription = {}; + const fields = ['test']; + const subscriptionInfo = { subscription: subscription, - fields: fields - } - var client = new Client(1, {}); + fields: fields, + }; + const client = new Client(1, {}); client.addSubscriptionInfo(1, subscriptionInfo); client.deleteSubscriptionInfo(1); expect(client.subscriptionInfos.size).toBe(0); }); - - it('can generate ParseObject JSON with null selected field', function() { - var parseObjectJSON = { - key : 'value', + it('can generate ParseObject JSON with null selected field', function () { + const parseObjectJSON = { + key: 'value', className: 'test', objectId: 'test', updatedAt: '2015-12-07T21:27:13.746Z', createdAt: '2015-12-07T21:27:13.746Z', ACL: 'test', }; - var client = new Client(1, {}); + const client = new Client(1, {}); expect(client._toJSONWithFields(parseObjectJSON, null)).toBe(parseObjectJSON); }); - it('can generate ParseObject JSON with undefined selected field', function() { - var parseObjectJSON = { - key : 'value', + it('can generate ParseObject JSON with undefined selected field', function () { + const parseObjectJSON = { + key: 'value', className: 'test', objectId: 'test', updatedAt: '2015-12-07T21:27:13.746Z', createdAt: '2015-12-07T21:27:13.746Z', ACL: 'test', }; - var client = new Client(1, {}); + const client = new Client(1, {}); expect(client._toJSONWithFields(parseObjectJSON, undefined)).toBe(parseObjectJSON); }); - it('can generate ParseObject JSON with selected fields', function() { - var parseObjectJSON = { - key : 'value', + it('can generate ParseObject JSON with selected fields', function () { + const parseObjectJSON = { + key: 'value', className: 'test', objectId: 'test', updatedAt: '2015-12-07T21:27:13.746Z', createdAt: '2015-12-07T21:27:13.746Z', ACL: 'test', - test: 'test' + test: 'test', }; - var client = new Client(1, {}); + const client = new Client(1, {}); expect(client._toJSONWithFields(parseObjectJSON, ['test'])).toEqual({ className: 'test', @@ -123,22 +122,22 @@ describe('Client', function() { updatedAt: '2015-12-07T21:27:13.746Z', createdAt: '2015-12-07T21:27:13.746Z', ACL: 'test', - test: 'test' + test: 'test', }); }); - it('can generate ParseObject JSON with nonexistent selected fields', function() { - var parseObjectJSON = { - key : 'value', + it('can generate ParseObject JSON with nonexistent selected fields', function () { + const parseObjectJSON = { + key: 'value', className: 'test', objectId: 'test', updatedAt: '2015-12-07T21:27:13.746Z', createdAt: '2015-12-07T21:27:13.746Z', ACL: 'test', - test: 'test' + test: 'test', }; - var client = new Client(1, {}); - var limitedParseObject = client._toJSONWithFields(parseObjectJSON, ['name']); + const client = new Client(1, {}); + const limitedParseObject = client._toJSONWithFields(parseObjectJSON, ['name']); expect(limitedParseObject).toEqual({ className: 'test', @@ -150,137 +149,137 @@ describe('Client', function() { expect('name' in limitedParseObject).toBe(false); }); - it('can push connect response', function() { - var parseWebSocket = { - send: jasmine.createSpy('send') + it('can push connect response', function () { + const parseWebSocket = { + send: jasmine.createSpy('send'), }; - var client = new Client(1, parseWebSocket); + const client = new Client(1, parseWebSocket); client.pushConnect(); - var lastCall = parseWebSocket.send.calls.first(); - var messageJSON = JSON.parse(lastCall.args[0]); + const lastCall = parseWebSocket.send.calls.first(); + const messageJSON = JSON.parse(lastCall.args[0]); expect(messageJSON.op).toBe('connected'); expect(messageJSON.clientId).toBe(1); }); - it('can push subscribe response', function() { - var parseWebSocket = { - send: jasmine.createSpy('send') + it('can push subscribe response', function () { + const parseWebSocket = { + send: jasmine.createSpy('send'), }; - var client = new Client(1, parseWebSocket); + const client = new Client(1, parseWebSocket); client.pushSubscribe(2); - var lastCall = parseWebSocket.send.calls.first(); - var messageJSON = JSON.parse(lastCall.args[0]); + const lastCall = parseWebSocket.send.calls.first(); + const messageJSON = JSON.parse(lastCall.args[0]); expect(messageJSON.op).toBe('subscribed'); expect(messageJSON.clientId).toBe(1); expect(messageJSON.requestId).toBe(2); }); - it('can push unsubscribe response', function() { - var parseWebSocket = { - send: jasmine.createSpy('send') + it('can push unsubscribe response', function () { + const parseWebSocket = { + send: jasmine.createSpy('send'), }; - var client = new Client(1, parseWebSocket); + const client = new Client(1, parseWebSocket); client.pushUnsubscribe(2); - var lastCall = parseWebSocket.send.calls.first(); - var messageJSON = JSON.parse(lastCall.args[0]); + const lastCall = parseWebSocket.send.calls.first(); + const messageJSON = JSON.parse(lastCall.args[0]); expect(messageJSON.op).toBe('unsubscribed'); expect(messageJSON.clientId).toBe(1); expect(messageJSON.requestId).toBe(2); }); - it('can push create response', function() { - var parseObjectJSON = { - key : 'value', + it('can push create response', function () { + const parseObjectJSON = { + key: 'value', className: 'test', objectId: 'test', updatedAt: '2015-12-07T21:27:13.746Z', createdAt: '2015-12-07T21:27:13.746Z', ACL: 'test', - test: 'test' + test: 'test', }; - var parseWebSocket = { - send: jasmine.createSpy('send') + const parseWebSocket = { + send: jasmine.createSpy('send'), }; - var client = new Client(1, parseWebSocket); + const client = new Client(1, parseWebSocket); client.pushCreate(2, parseObjectJSON); - var lastCall = parseWebSocket.send.calls.first(); - var messageJSON = JSON.parse(lastCall.args[0]); + const lastCall = parseWebSocket.send.calls.first(); + const messageJSON = JSON.parse(lastCall.args[0]); expect(messageJSON.op).toBe('create'); expect(messageJSON.clientId).toBe(1); expect(messageJSON.requestId).toBe(2); expect(messageJSON.object).toEqual(parseObjectJSON); }); - it('can push enter response', function() { - var parseObjectJSON = { - key : 'value', + it('can push enter response', function () { + const parseObjectJSON = { + key: 'value', className: 'test', objectId: 'test', updatedAt: '2015-12-07T21:27:13.746Z', createdAt: '2015-12-07T21:27:13.746Z', ACL: 'test', - test: 'test' + test: 'test', }; - var parseWebSocket = { - send: jasmine.createSpy('send') + const parseWebSocket = { + send: jasmine.createSpy('send'), }; - var client = new Client(1, parseWebSocket); + const client = new Client(1, parseWebSocket); client.pushEnter(2, parseObjectJSON); - var lastCall = parseWebSocket.send.calls.first(); - var messageJSON = JSON.parse(lastCall.args[0]); + const lastCall = parseWebSocket.send.calls.first(); + const messageJSON = JSON.parse(lastCall.args[0]); expect(messageJSON.op).toBe('enter'); expect(messageJSON.clientId).toBe(1); expect(messageJSON.requestId).toBe(2); expect(messageJSON.object).toEqual(parseObjectJSON); }); - it('can push update response', function() { - var parseObjectJSON = { - key : 'value', + it('can push update response', function () { + const parseObjectJSON = { + key: 'value', className: 'test', objectId: 'test', updatedAt: '2015-12-07T21:27:13.746Z', createdAt: '2015-12-07T21:27:13.746Z', ACL: 'test', - test: 'test' + test: 'test', }; - var parseWebSocket = { - send: jasmine.createSpy('send') + const parseWebSocket = { + send: jasmine.createSpy('send'), }; - var client = new Client(1, parseWebSocket); + const client = new Client(1, parseWebSocket); client.pushUpdate(2, parseObjectJSON); - var lastCall = parseWebSocket.send.calls.first(); - var messageJSON = JSON.parse(lastCall.args[0]); + const lastCall = parseWebSocket.send.calls.first(); + const messageJSON = JSON.parse(lastCall.args[0]); expect(messageJSON.op).toBe('update'); expect(messageJSON.clientId).toBe(1); expect(messageJSON.requestId).toBe(2); expect(messageJSON.object).toEqual(parseObjectJSON); }); - it('can push leave response', function() { - var parseObjectJSON = { - key : 'value', + it('can push leave response', function () { + const parseObjectJSON = { + key: 'value', className: 'test', objectId: 'test', updatedAt: '2015-12-07T21:27:13.746Z', createdAt: '2015-12-07T21:27:13.746Z', ACL: 'test', - test: 'test' + test: 'test', }; - var parseWebSocket = { - send: jasmine.createSpy('send') + const parseWebSocket = { + send: jasmine.createSpy('send'), }; - var client = new Client(1, parseWebSocket); + const client = new Client(1, parseWebSocket); client.pushLeave(2, parseObjectJSON); - var lastCall = parseWebSocket.send.calls.first(); - var messageJSON = JSON.parse(lastCall.args[0]); + const lastCall = parseWebSocket.send.calls.first(); + const messageJSON = JSON.parse(lastCall.args[0]); expect(messageJSON.op).toBe('leave'); expect(messageJSON.clientId).toBe(1); expect(messageJSON.requestId).toBe(2); diff --git a/spec/ClientSDK.spec.js b/spec/ClientSDK.spec.js index e714818159..987770833c 100644 --- a/spec/ClientSDK.spec.js +++ b/spec/ClientSDK.spec.js @@ -1,41 +1,49 @@ -var ClientSDK = require('../src/ClientSDK'); +const ClientSDK = require('../lib/ClientSDK'); -describe('ClientSDK', () => { - it('should properly parse the SDK versions', () => { - let clientSDKFromVersion = ClientSDK.fromString; - expect(clientSDKFromVersion('i1.1.1')).toEqual({ - sdk: 'i', - version: '1.1.1' - }); - expect(clientSDKFromVersion('i1')).toEqual({ - sdk: 'i', - version: '1' - }); - expect(clientSDKFromVersion('apple-tv1.13.0')).toEqual({ - sdk: 'apple-tv', - version: '1.13.0' - }); - expect(clientSDKFromVersion('js1.9.0')).toEqual({ - sdk: 'js', - version: '1.9.0' - }); +describe('ClientSDK', () => { + it('should properly parse the SDK versions', () => { + const clientSDKFromVersion = ClientSDK.fromString; + expect(clientSDKFromVersion('i1.1.1')).toEqual({ + sdk: 'i', + version: '1.1.1', + }); + expect(clientSDKFromVersion('i1')).toEqual({ + sdk: 'i', + version: '1', + }); + expect(clientSDKFromVersion('apple-tv1.13.0')).toEqual({ + sdk: 'apple-tv', + version: '1.13.0', + }); + expect(clientSDKFromVersion('js1.9.0')).toEqual({ + sdk: 'js', + version: '1.9.0', + }); }); - - it('should properly sastisfy', () => { - expect(ClientSDK.compatible({ - js: '>=1.9.0' - })("js1.9.0")).toBe(true); - - expect(ClientSDK.compatible({ - js: '>=1.9.0' - })("js2.0.0")).toBe(true); - expect(ClientSDK.compatible({ - js: '>=1.9.0' - })("js1.8.0")).toBe(false); + it('should properly sastisfy', () => { + expect( + ClientSDK.compatible({ + js: '>=1.9.0', + })('js1.9.0') + ).toBe(true); - expect(ClientSDK.compatible({ - js: '>=1.9.0' - })(undefined)).toBe(true); - }) -}) \ No newline at end of file + expect( + ClientSDK.compatible({ + js: '>=1.9.0', + })('js2.0.0') + ).toBe(true); + + expect( + ClientSDK.compatible({ + js: '>=1.9.0', + })('js1.8.0') + ).toBe(false); + + expect( + ClientSDK.compatible({ + js: '>=1.9.0', + })(undefined) + ).toBe(true); + }); +}); diff --git a/spec/CloudCode.Validator.spec.js b/spec/CloudCode.Validator.spec.js new file mode 100644 index 0000000000..11ccc82766 --- /dev/null +++ b/spec/CloudCode.Validator.spec.js @@ -0,0 +1,1811 @@ +'use strict'; +const Parse = require('parse/node'); +const validatorFail = () => { + throw 'you are not authorized'; +}; +const validatorSuccess = () => { + return true; +}; +function testConfig() { + return Parse.Config.save({ internal: 'i', string: 's', number: 12 }, { internal: true }); +} + +describe('cloud validator', () => { + it('complete validator', async done => { + Parse.Cloud.define( + 'myFunction', + () => { + return 'myFunc'; + }, + () => {} + ); + try { + const result = await Parse.Cloud.run('myFunction', {}); + expect(result).toBe('myFunc'); + done(); + } catch (e) { + fail('should not have thrown error'); + } + }); + + it('Throw from validator', async done => { + Parse.Cloud.define( + 'myFunction', + () => { + return 'myFunc'; + }, + () => { + throw 'error'; + } + ); + try { + await Parse.Cloud.run('myFunction'); + fail('cloud function should have failed.'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + done(); + } + }); + + it('validator can throw parse error', async done => { + Parse.Cloud.define( + 'myFunction', + () => { + return 'myFunc'; + }, + () => { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'It should fail'); + } + ); + try { + await Parse.Cloud.run('myFunction'); + fail('should have validation error'); + } catch (e) { + expect(e.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(e.message).toBe('It should fail'); + done(); + } + }); + + it('validator can throw parse error with no message', async done => { + Parse.Cloud.define( + 'myFunction', + () => { + return 'myFunc'; + }, + () => { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED); + } + ); + try { + await Parse.Cloud.run('myFunction'); + fail('should have validation error'); + } catch (e) { + expect(e.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(e.message).toBeUndefined(); + done(); + } + }); + + it('async validator', async done => { + Parse.Cloud.define( + 'myFunction', + () => { + return 'myFunc'; + }, + async () => { + await new Promise(resolve => { + setTimeout(resolve, 1000); + }); + throw 'async error'; + } + ); + try { + await Parse.Cloud.run('myFunction'); + fail('should have validation error'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + expect(e.message).toBe('async error'); + done(); + } + }); + + it('pass function to validator', async done => { + const validator = request => { + expect(request).toBeDefined(); + expect(request.params).toBeDefined(); + expect(request.master).toBe(false); + expect(request.user).toBeUndefined(); + expect(request.installationId).toBeDefined(); + expect(request.log).toBeDefined(); + expect(request.headers).toBeDefined(); + expect(request.functionName).toBeDefined(); + expect(request.context).toBeDefined(); + done(); + }; + Parse.Cloud.define( + 'myFunction', + () => { + return 'myFunc'; + }, + validator + ); + await Parse.Cloud.run('myFunction'); + }); + + it('require user on cloud functions', async done => { + Parse.Cloud.define( + 'hello1', + () => { + return 'Hello world!'; + }, + { + requireUser: true, + } + ); + try { + await Parse.Cloud.run('hello1', {}); + fail('function should have failed.'); + } catch (error) { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Validation failed. Please login to continue.'); + done(); + } + }); + + it('require master on cloud functions', done => { + Parse.Cloud.define( + 'hello2', + () => { + return 'Hello world!'; + }, + { + requireMaster: true, + } + ); + Parse.Cloud.run('hello2', {}) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual( + 'Validation failed. Master key is required to complete this request.' + ); + done(); + }); + }); + + it('set params on cloud functions', done => { + Parse.Cloud.define( + 'hello', + () => { + return 'Hello world!'; + }, + { + fields: ['a'], + } + ); + Parse.Cloud.run('hello', {}) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Validation failed. Please specify data for a.'); + done(); + }); + }); + + it('allow params on cloud functions', done => { + Parse.Cloud.define( + 'hello', + req => { + expect(req.params.a).toEqual('yolo'); + return 'Hello world!'; + }, + { + fields: ['a'], + } + ); + Parse.Cloud.run('hello', { a: 'yolo' }) + .then(() => { + done(); + }) + .catch(() => { + fail('Error should not have been called.'); + }); + }); + + it('set params type array', done => { + Parse.Cloud.define( + 'hello', + () => { + return 'Hello world!'; + }, + { + fields: { + data: { + type: Array, + }, + }, + } + ); + Parse.Cloud.run('hello', { data: '' }) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Validation failed. Invalid type for data. Expected: array'); + done(); + }); + }); + + it('set params type allow array', async () => { + Parse.Cloud.define( + 'hello', + () => { + return 'Hello world!'; + }, + { + fields: { + data: { + type: Array, + }, + }, + } + ); + const result = await Parse.Cloud.run('hello', { data: [{ foo: 'bar' }] }); + expect(result).toBe('Hello world!'); + }); + + it('set params type', done => { + Parse.Cloud.define( + 'hello', + () => { + return 'Hello world!'; + }, + { + fields: { + data: { + type: String, + }, + }, + } + ); + Parse.Cloud.run('hello', { data: [] }) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Validation failed. Invalid type for data. Expected: string'); + done(); + }); + }); + + it('set params default', done => { + Parse.Cloud.define( + 'hello', + req => { + expect(req.params.data).toBe('yolo'); + return 'Hello world!'; + }, + { + fields: { + data: { + type: String, + default: 'yolo', + }, + }, + } + ); + Parse.Cloud.run('hello') + .then(() => { + done(); + }) + .catch(() => { + fail('function should not have failed.'); + }); + }); + + it('set params required', done => { + Parse.Cloud.define( + 'hello', + req => { + expect(req.params.data).toBe('yolo'); + return 'Hello world!'; + }, + { + fields: { + data: { + type: String, + required: true, + }, + }, + } + ); + Parse.Cloud.run('hello', {}) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Validation failed. Please specify data for data.'); + done(); + }); + }); + + it('set params not-required options data', done => { + Parse.Cloud.define( + 'hello', + req => { + expect(req.params.data).toBe('abc'); + return 'Hello world!'; + }, + { + fields: { + data: { + type: String, + required: false, + options: s => { + return s.length >= 4 && s.length <= 50; + }, + error: 'Validation failed. Expected length of data to be between 4 and 50.', + }, + }, + } + ); + Parse.Cloud.run('hello', { data: 'abc' }) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual( + 'Validation failed. Expected length of data to be between 4 and 50.' + ); + done(); + }); + }); + + it('set params not-required type', done => { + Parse.Cloud.define( + 'hello', + req => { + expect(req.params.data).toBe(null); + return 'Hello world!'; + }, + { + fields: { + data: { + type: String, + required: false, + }, + }, + } + ); + Parse.Cloud.run('hello', { data: null }) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Validation failed. Invalid type for data. Expected: string'); + done(); + }); + }); + + it('set params not-required options', done => { + Parse.Cloud.define( + 'hello', + () => { + return 'Hello world!'; + }, + { + fields: { + data: { + type: String, + required: false, + options: s => { + return s.length >= 4 && s.length <= 50; + }, + }, + }, + } + ); + Parse.Cloud.run('hello', {}) + .then(() => { + done(); + }) + .catch(() => { + fail('function should not have failed.'); + }); + }); + + it('set params not-required no-options', done => { + Parse.Cloud.define( + 'hello', + () => { + return 'Hello world!'; + }, + { + fields: { + data: { + type: String, + required: false, + }, + }, + } + ); + Parse.Cloud.run('hello', {}) + .then(() => { + done(); + }) + .catch(() => { + fail('function should not have failed.'); + }); + }); + + it('set params option', done => { + Parse.Cloud.define( + 'hello', + req => { + expect(req.params.data).toBe('yolo'); + return 'Hello world!'; + }, + { + fields: { + data: { + type: String, + required: true, + options: 'a', + }, + }, + } + ); + Parse.Cloud.run('hello', { data: 'f' }) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Validation failed. Invalid option for data. Expected: a'); + done(); + }); + }); + + it('set params options', done => { + Parse.Cloud.define( + 'hello', + req => { + expect(req.params.data).toBe('yolo'); + return 'Hello world!'; + }, + { + fields: { + data: { + type: String, + required: true, + options: ['a', 'b'], + }, + }, + } + ); + Parse.Cloud.run('hello', { data: 'f' }) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Validation failed. Invalid option for data. Expected: a, b'); + done(); + }); + }); + + it('set params options function', done => { + Parse.Cloud.define( + 'hello', + () => { + fail('cloud function should not run.'); + return 'Hello world!'; + }, + { + fields: { + data: { + type: Number, + required: true, + options: val => { + return val > 1 && val < 5; + }, + error: 'Validation failed. Expected data to be between 1 and 5.', + }, + }, + } + ); + Parse.Cloud.run('hello', { data: 7 }) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Validation failed. Expected data to be between 1 and 5.'); + done(); + }); + }); + + it('can run params function on null', done => { + Parse.Cloud.define( + 'hello', + () => { + fail('cloud function should not run.'); + return 'Hello world!'; + }, + { + fields: { + data: { + options: val => { + return val.length > 5; + }, + error: 'Validation failed. String should be at least 5 characters', + }, + }, + } + ); + Parse.Cloud.run('hello', { data: null }) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Validation failed. String should be at least 5 characters'); + done(); + }); + }); + + it('can throw from options validator', done => { + Parse.Cloud.define( + 'hello', + () => { + fail('cloud function should not run.'); + return 'Hello world!'; + }, + { + fields: { + data: { + options: () => { + throw 'validation failed.'; + }, + }, + }, + } + ); + Parse.Cloud.run('hello', { data: 'a' }) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('validation failed.'); + done(); + }); + }); + + it('can throw null from options validator', done => { + Parse.Cloud.define( + 'hello', + () => { + fail('cloud function should not run.'); + return 'Hello world!'; + }, + { + fields: { + data: { + options: () => { + throw null; + }, + }, + }, + } + ); + Parse.Cloud.run('hello', { data: 'a' }) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Validation failed. Invalid value for data.'); + done(); + }); + }); + + it('can create functions', done => { + Parse.Cloud.define( + 'hello', + () => { + return 'Hello world!'; + }, + { + requireUser: false, + requireMaster: false, + fields: { + data: { + type: String, + }, + data1: { + type: String, + default: 'default', + }, + }, + } + ); + Parse.Cloud.run('hello', { data: 'str' }).then(result => { + expect(result).toEqual('Hello world!'); + done(); + }); + }); + + it('basic beforeSave requireUserKey', async function (done) { + Parse.Cloud.beforeSave('BeforeSaveFail', () => {}, { + requireUser: true, + requireUserKeys: ['name'], + }); + const user = await Parse.User.signUp('testuser', 'p@ssword'); + user.set('name', 'foo'); + await user.save(null, { sessionToken: user.getSessionToken() }); + const obj = new Parse.Object('BeforeSaveFail'); + obj.set('foo', 'bar'); + await obj.save(null, { sessionToken: user.getSessionToken() }); + expect(obj.get('foo')).toBe('bar'); + done(); + }); + + it('basic beforeSave skipWithMasterKey', async function (done) { + Parse.Cloud.beforeSave( + 'BeforeSave', + () => { + throw 'before save should have resolved using masterKey.'; + }, + { + skipWithMasterKey: true, + } + ); + const obj = new Parse.Object('BeforeSave'); + obj.set('foo', 'bar'); + await obj.save(null, { useMasterKey: true }); + expect(obj.get('foo')).toBe('bar'); + done(); + }); + + it('basic beforeFind skipWithMasterKey', async function (done) { + Parse.Cloud.beforeFind( + 'beforeFind', + () => { + throw 'before find should have resolved using masterKey.'; + }, + { + skipWithMasterKey: true, + } + ); + const obj = new Parse.Object('beforeFind'); + obj.set('foo', 'bar'); + await obj.save(); + expect(obj.get('foo')).toBe('bar'); + + const query = new Parse.Query('beforeFind'); + const first = await query.first({ useMasterKey: true }); + expect(first).toBeDefined(); + expect(first.id).toBe(obj.id); + done(); + }); + + it('basic beforeDelete skipWithMasterKey', async function (done) { + Parse.Cloud.beforeDelete( + 'beforeFind', + () => { + throw 'before find should have resolved using masterKey.'; + }, + { + skipWithMasterKey: true, + } + ); + const obj = new Parse.Object('beforeFind'); + obj.set('foo', 'bar'); + await obj.save(); + expect(obj.get('foo')).toBe('bar'); + await obj.destroy({ useMasterKey: true }); + done(); + }); + + it('basic beforeSaveFile skipWithMasterKey', async done => { + Parse.Cloud.beforeSave( + Parse.File, + () => { + throw 'beforeSaveFile should have resolved using master key.'; + }, + { + skipWithMasterKey: true, + } + ); + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + const result = await file.save({ useMasterKey: true }); + expect(result).toBe(file); + done(); + }); + + it_id('893eec0c-41bd-4adf-8f0a-306087ad8d61')(it)('basic beforeSave Parse.Config skipWithMasterKey', async () => { + Parse.Cloud.beforeSave( + Parse.Config, + () => { + throw 'beforeSaveFile should have resolved using master key.'; + }, + { + skipWithMasterKey: true, + } + ); + const config = await testConfig(); + expect(config.get('internal')).toBe('i'); + expect(config.get('string')).toBe('s'); + expect(config.get('number')).toBe(12); + }); + + it_id('91e739a4-6a38-405c-8f83-f36d48220734')(it)('basic afterSave Parse.Config skipWithMasterKey', async () => { + Parse.Cloud.afterSave( + Parse.Config, + () => { + throw 'beforeSaveFile should have resolved using master key.'; + }, + { + skipWithMasterKey: true, + } + ); + const config = await testConfig(); + expect(config.get('internal')).toBe('i'); + expect(config.get('string')).toBe('s'); + expect(config.get('number')).toBe(12); + }); + + it('beforeSave validateMasterKey and skipWithMasterKey fail', async function (done) { + Parse.Cloud.beforeSave( + 'BeforeSave', + () => { + throw 'beforeSaveFile should have resolved using master key.'; + }, + { + fields: ['foo'], + validateMasterKey: true, + skipWithMasterKey: true, + } + ); + + const obj = new Parse.Object('BeforeSave'); + try { + await obj.save(null, { useMasterKey: true }); + fail('function should have failed.'); + } catch (error) { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Validation failed. Please specify data for foo.'); + done(); + } + }); + + it('beforeSave validateMasterKey and skipWithMasterKey success', async function (done) { + Parse.Cloud.beforeSave( + 'BeforeSave', + () => { + throw 'beforeSaveFile should have resolved using master key.'; + }, + { + fields: ['foo'], + validateMasterKey: true, + skipWithMasterKey: true, + } + ); + + const obj = new Parse.Object('BeforeSave'); + obj.set('foo', 'bar'); + try { + await obj.save(null, { useMasterKey: true }); + done(); + } catch (error) { + fail('error should not have been called.'); + } + }); + + it('basic beforeSave requireUserKey on User Class', async function (done) { + Parse.Cloud.beforeSave(Parse.User, () => {}, { + requireUser: true, + requireUserKeys: ['name'], + }); + const user = new Parse.User(); + user.set('username', 'testuser'); + user.set('password', 'p@ssword'); + user.set('name', 'foo'); + expect(user.get('name')).toBe('foo'); + done(); + }); + + it('basic beforeSave requireUserKey rejection', async function (done) { + Parse.Cloud.beforeSave('BeforeSaveFail', () => {}, { + requireUser: true, + requireUserKeys: ['name'], + }); + const user = await Parse.User.signUp('testuser', 'p@ssword'); + const obj = new Parse.Object('BeforeSaveFail'); + obj.set('foo', 'bar'); + try { + await obj.save(null, { sessionToken: user.getSessionToken() }); + fail('should not have been able to save without userkey'); + } catch (error) { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Validation failed. Please set data for name on your account.'); + done(); + } + }); + + it('basic beforeSave requireUserKey without user', async function (done) { + Parse.Cloud.beforeSave('BeforeSaveFail', () => {}, { + requireUserKeys: ['name'], + }); + const obj = new Parse.Object('BeforeSaveFail'); + obj.set('foo', 'bar'); + try { + await obj.save(); + fail('should not have been able to save without user'); + } catch (error) { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Please login to make this request.'); + done(); + } + }); + + it('basic beforeSave requireUserKey as admin', async function (done) { + Parse.Cloud.beforeSave(Parse.User, () => {}, { + fields: { + admin: { + default: false, + constant: true, + }, + }, + }); + Parse.Cloud.define( + 'secureFunction', + () => { + return "Here's all the secure data!"; + }, + { + requireUserKeys: { + admin: { + options: true, + error: 'Unauthorized.', + }, + }, + } + ); + const user = new Parse.User(); + user.set('username', 'testuser'); + user.set('password', 'p@ssword'); + user.set('admin', true); + await user.signUp(); + expect(user.get('admin')).toBe(false); + try { + await Parse.Cloud.run('secureFunction'); + fail('function should only be available to admin users'); + } catch (error) { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Unauthorized.'); + } + done(); + }); + + it('basic beforeSave requireUserKey as custom function', async function (done) { + Parse.Cloud.beforeSave(Parse.User, () => {}, { + fields: { + accType: { + default: 'normal', + constant: true, + }, + }, + }); + Parse.Cloud.define( + 'secureFunction', + () => { + return "Here's all the secure data!"; + }, + { + requireUserKeys: { + accType: { + options: val => { + return ['admin', 'admin2'].includes(val); + }, + error: 'Unauthorized.', + }, + }, + } + ); + const user = new Parse.User(); + user.set('username', 'testuser'); + user.set('password', 'p@ssword'); + user.set('accType', 'admin'); + await user.signUp(); + expect(user.get('accType')).toBe('normal'); + try { + await Parse.Cloud.run('secureFunction'); + fail('function should only be available to admin users'); + } catch (error) { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Unauthorized.'); + } + done(); + }); + + it('basic beforeSave allow requireUserKey as custom function', async function (done) { + Parse.Cloud.beforeSave(Parse.User, () => {}, { + fields: { + accType: { + default: 'admin', + constant: true, + }, + }, + }); + Parse.Cloud.define( + 'secureFunction', + () => { + return "Here's all the secure data!"; + }, + { + requireUserKeys: { + accType: { + options: val => { + return ['admin', 'admin2'].includes(val); + }, + error: 'Unauthorized.', + }, + }, + } + ); + const user = new Parse.User(); + user.set('username', 'testuser'); + user.set('password', 'p@ssword'); + await user.signUp(); + expect(user.get('accType')).toBe('admin'); + const result = await Parse.Cloud.run('secureFunction'); + expect(result).toBe("Here's all the secure data!"); + done(); + }); + + it('basic beforeSave requireUser', function (done) { + Parse.Cloud.beforeSave('BeforeSaveFail', () => {}, { + requireUser: true, + }); + + const obj = new Parse.Object('BeforeSaveFail'); + obj.set('foo', 'bar'); + obj + .save() + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Validation failed. Please login to continue.'); + done(); + }); + }); + + it('basic validator requireAnyUserRoles', async function (done) { + Parse.Cloud.define( + 'cloudFunction', + () => { + return true; + }, + { + requireUser: true, + requireAnyUserRoles: ['Admin'], + } + ); + const user = await Parse.User.signUp('testuser', 'p@ssword'); + try { + await Parse.Cloud.run('cloudFunction'); + fail('cloud validator should have failed.'); + } catch (e) { + expect(e.message).toBe('Validation failed. User does not match the required roles.'); + } + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + const role = new Parse.Role('Admin', roleACL); + role.getUsers().add(user); + await role.save({ useMasterKey: true }); + await Parse.Cloud.run('cloudFunction'); + done(); + }); + + it('basic validator requireAllUserRoles', async function (done) { + Parse.Cloud.define( + 'cloudFunction', + () => { + return true; + }, + { + requireUser: true, + requireAllUserRoles: ['Admin', 'Admin2'], + } + ); + const user = await Parse.User.signUp('testuser', 'p@ssword'); + try { + await Parse.Cloud.run('cloudFunction'); + fail('cloud validator should have failed.'); + } catch (e) { + expect(e.message).toBe('Validation failed. User does not match all the required roles.'); + } + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + const role = new Parse.Role('Admin', roleACL); + role.getUsers().add(user); + + const role2 = new Parse.Role('Admin2', roleACL); + role2.getUsers().add(user); + await role.save({ useMasterKey: true }); + await role2.save({ useMasterKey: true }); + await Parse.Cloud.run('cloudFunction'); + done(); + }); + + it('allow requireAnyUserRoles to be a function', async function (done) { + Parse.Cloud.define( + 'cloudFunction', + () => { + return true; + }, + { + requireUser: true, + requireAnyUserRoles: () => { + return ['Admin Func']; + }, + } + ); + const user = await Parse.User.signUp('testuser', 'p@ssword'); + try { + await Parse.Cloud.run('cloudFunction'); + fail('cloud validator should have failed.'); + } catch (e) { + expect(e.message).toBe('Validation failed. User does not match the required roles.'); + } + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + const role = new Parse.Role('Admin Func', roleACL); + role.getUsers().add(user); + await role.save({ useMasterKey: true }); + await Parse.Cloud.run('cloudFunction'); + done(); + }); + + it('allow requireAllUserRoles to be a function', async function (done) { + Parse.Cloud.define( + 'cloudFunction', + () => { + return true; + }, + { + requireUser: true, + requireAllUserRoles: () => { + return ['AdminA', 'AdminB']; + }, + } + ); + const user = await Parse.User.signUp('testuser', 'p@ssword'); + try { + await Parse.Cloud.run('cloudFunction'); + fail('cloud validator should have failed.'); + } catch (e) { + expect(e.message).toBe('Validation failed. User does not match all the required roles.'); + } + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + const role = new Parse.Role('AdminA', roleACL); + role.getUsers().add(user); + + const role2 = new Parse.Role('AdminB', roleACL); + role2.getUsers().add(user); + await role.save({ useMasterKey: true }); + await role2.save({ useMasterKey: true }); + await Parse.Cloud.run('cloudFunction'); + done(); + }); + + it('basic requireAllUserRoles but no user', async function (done) { + Parse.Cloud.define( + 'cloudFunction', + () => { + return true; + }, + { + requireAllUserRoles: ['Admin'], + } + ); + try { + await Parse.Cloud.run('cloudFunction'); + fail('cloud validator should have failed.'); + } catch (e) { + expect(e.message).toBe('Validation failed. Please login to continue.'); + } + const user = await Parse.User.signUp('testuser', 'p@ssword'); + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + const role = new Parse.Role('Admin', roleACL); + role.getUsers().add(user); + await role.save({ useMasterKey: true }); + await Parse.Cloud.run('cloudFunction'); + done(); + }); + + it('basic beforeSave requireMaster', function (done) { + Parse.Cloud.beforeSave('BeforeSaveFail', () => {}, { + requireMaster: true, + }); + + const obj = new Parse.Object('BeforeSaveFail'); + obj.set('foo', 'bar'); + obj + .save() + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual( + 'Validation failed. Master key is required to complete this request.' + ); + done(); + }); + }); + + it('basic beforeSave master', async function (done) { + Parse.Cloud.beforeSave('BeforeSaveFail', () => {}, { + requireUser: true, + }); + + const obj = new Parse.Object('BeforeSaveFail'); + obj.set('foo', 'bar'); + await obj.save(null, { useMasterKey: true }); + done(); + }); + + it('basic beforeSave validateMasterKey', function (done) { + Parse.Cloud.beforeSave('BeforeSaveFail', () => {}, { + requireUser: true, + validateMasterKey: true, + }); + + const obj = new Parse.Object('BeforeSaveFail'); + obj.set('foo', 'bar'); + obj + .save(null, { useMasterKey: true }) + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Validation failed. Please login to continue.'); + done(); + }); + }); + + it('basic beforeSave requireKeys', function (done) { + Parse.Cloud.beforeSave('beforeSaveRequire', () => {}, { + fields: { + foo: { + required: true, + }, + bar: { + required: true, + }, + }, + }); + const obj = new Parse.Object('beforeSaveRequire'); + obj.set('foo', 'bar'); + obj + .save() + .then(() => { + fail('function should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Validation failed. Please specify data for bar.'); + done(); + }); + }); + + it('basic beforeSave constantKeys', async function (done) { + Parse.Cloud.beforeSave('BeforeSave', () => {}, { + fields: { + foo: { + constant: true, + default: 'bar', + }, + }, + }); + const obj = new Parse.Object('BeforeSave'); + obj.set('foo', 'far'); + await obj.save(); + expect(obj.get('foo')).toBe('bar'); + obj.set('foo', 'yolo'); + await obj.save(); + expect(obj.get('foo')).toBe('bar'); + done(); + }); + + it('basic beforeSave defaultKeys', async function (done) { + Parse.Cloud.beforeSave('BeforeSave', () => {}, { + fields: { + foo: { + default: 'bar', + }, + }, + }); + const obj = new Parse.Object('BeforeSave'); + await obj.save(); + expect(obj.get('foo')).toBe('bar'); + obj.set('foo', 'yolo'); + await obj.save(); + expect(obj.get('foo')).toBe('yolo'); + done(); + }); + + it('validate beforeSave', async done => { + Parse.Cloud.beforeSave('MyObject', () => {}, validatorSuccess); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + try { + await myObject.save(); + done(); + } catch (e) { + fail('before save should not have failed.'); + } + }); + + it('validate beforeSave fail', async done => { + Parse.Cloud.beforeSave('MyObject', () => {}, validatorFail); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + try { + await myObject.save(); + fail('cloud function should have failed.'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + done(); + } + }); + + it('validate afterSave', async done => { + Parse.Cloud.afterSave( + 'MyObject', + () => { + done(); + }, + validatorSuccess + ); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + try { + await myObject.save(); + } catch (e) { + fail('before save should not have failed.'); + } + }); + + it('validate afterSave fail', async done => { + Parse.Cloud.afterSave( + 'MyObject', + () => { + fail('this should not be called.'); + }, + validatorFail + ); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + setTimeout(() => { + done(); + }, 1000); + }); + + it('validate beforeDelete', async done => { + Parse.Cloud.beforeDelete('MyObject', () => {}, validatorSuccess); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + try { + await myObject.destroy(); + done(); + } catch (e) { + fail('before delete should not have failed.'); + } + }); + + it('validate beforeDelete fail', async done => { + Parse.Cloud.beforeDelete( + 'MyObject', + () => { + fail('this should not be called.'); + }, + validatorFail + ); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + try { + await myObject.destroy(); + fail('cloud function should have failed.'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + done(); + } + }); + + it('validate afterDelete', async done => { + Parse.Cloud.afterDelete( + 'MyObject', + () => { + done(); + }, + validatorSuccess + ); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + try { + await myObject.destroy(); + } catch (e) { + fail('after delete should not have failed.'); + } + }); + + it('validate afterDelete fail', async done => { + Parse.Cloud.afterDelete( + 'MyObject', + () => { + fail('this should not be called.'); + }, + validatorFail + ); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + try { + await myObject.destroy(); + fail('cloud function should have failed.'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + done(); + } + }); + + it('validate beforeFind', async done => { + Parse.Cloud.beforeFind('MyObject', () => {}, validatorSuccess); + try { + const MyObject = Parse.Object.extend('MyObject'); + const myObjectQuery = new Parse.Query(MyObject); + await myObjectQuery.find(); + done(); + } catch (e) { + fail('beforeFind should not have failed.'); + } + }); + it('validate beforeFind fail', async done => { + Parse.Cloud.beforeFind('MyObject', () => {}, validatorFail); + try { + const MyObject = Parse.Object.extend('MyObject'); + const myObjectQuery = new Parse.Query(MyObject); + await myObjectQuery.find(); + fail('cloud function should have failed.'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + done(); + } + }); + + it('validate afterFind', async done => { + Parse.Cloud.afterFind('MyObject', () => {}, validatorSuccess); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + try { + const myObjectQuery = new Parse.Query(MyObject); + await myObjectQuery.find(); + done(); + } catch (e) { + fail('beforeFind should not have failed.'); + } + }); + + it('validate afterFind fail', async done => { + Parse.Cloud.afterFind('MyObject', () => {}, validatorFail); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + try { + const myObjectQuery = new Parse.Query(MyObject); + await myObjectQuery.find(); + fail('cloud function should have failed.'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + done(); + } + }); + + it('validate beforeSaveFile', async done => { + Parse.Cloud.beforeSave(Parse.File, () => {}, validatorSuccess); + + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + const result = await file.save({ useMasterKey: true }); + expect(result).toBe(file); + done(); + }); + + it('validate beforeSaveFile fail', async done => { + Parse.Cloud.beforeSave(Parse.File, () => {}, validatorFail); + try { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + fail('cloud function should have failed.'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + done(); + } + }); + + it('validate afterSaveFile', async done => { + Parse.Cloud.afterSave(Parse.File, () => {}, validatorSuccess); + + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + const result = await file.save({ useMasterKey: true }); + expect(result).toBe(file); + done(); + }); + + it('validate afterSaveFile fail', async done => { + Parse.Cloud.afterSave(Parse.File, () => {}, validatorFail); + try { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + fail('cloud function should have failed.'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + done(); + } + }); + + it('validate beforeDeleteFile', async done => { + Parse.Cloud.beforeDelete(Parse.File, () => {}, validatorSuccess); + + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save(); + await file.destroy(); + done(); + }); + + it('validate beforeDeleteFile fail', async done => { + Parse.Cloud.beforeDelete(Parse.File, () => {}, validatorFail); + try { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save(); + await file.destroy(); + fail('cloud function should have failed.'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + done(); + } + }); + + it('validate afterDeleteFile', async done => { + Parse.Cloud.afterDelete(Parse.File, () => {}, validatorSuccess); + + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save(); + await file.destroy(); + done(); + }); + + it('validate afterDeleteFile fail', async done => { + Parse.Cloud.afterDelete(Parse.File, () => {}, validatorFail); + try { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save(); + await file.destroy(); + fail('cloud function should have failed.'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + done(); + } + }); + + it_id('32ca1a99-7f2b-429d-a7cf-62b6661d0af6')(it)('validate beforeSave Parse.Config', async () => { + Parse.Cloud.beforeSave(Parse.Config, () => {}, validatorSuccess); + const config = await testConfig(); + expect(config.get('internal')).toBe('i'); + expect(config.get('string')).toBe('s'); + expect(config.get('number')).toBe(12); + }); + + it_id('c84d11e7-d09c-4843-ad98-f671511bf612')(it)('validate beforeSave Parse.Config fail', async () => { + Parse.Cloud.beforeSave(Parse.Config, () => {}, validatorFail); + try { + await testConfig(); + fail('cloud function should have failed.'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + } + }); + + it_id('b18b9a6a-0e35-4b60-9771-30f53501df3c')(it)('validate afterSave Parse.Config', async () => { + Parse.Cloud.afterSave(Parse.Config, () => {}, validatorSuccess); + const config = await testConfig(); + expect(config.get('internal')).toBe('i'); + expect(config.get('string')).toBe('s'); + expect(config.get('number')).toBe(12); + }); + + it_id('ef761222-1758-4614-b984-da84d73fc10c')(it)('validate afterSave Parse.Config fail', async () => { + Parse.Cloud.afterSave(Parse.Config, () => {}, validatorFail); + try { + await testConfig(); + fail('cloud function should have failed.'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + } + }); + + it('Should have validator', async done => { + Parse.Cloud.define( + 'myFunction', + () => {}, + () => { + throw 'error'; + } + ); + try { + await Parse.Cloud.run('myFunction'); + } catch (e) { + expect(e.code).toBe(Parse.Error.VALIDATION_ERROR); + done(); + } + }); + + it('does not log on valid config', () => { + Parse.Cloud.define('myFunction', () => {}, { + requireUser: true, + requireMaster: true, + validateMasterKey: false, + skipWithMasterKey: true, + requireUserKeys: { + Acc: { + constant: true, + options: ['A', 'B'], + required: true, + default: 'f', + error: 'a', + type: String, + }, + }, + fields: { + Acc: { + constant: true, + options: ['A', 'B'], + required: true, + default: 'f', + error: 'a', + type: String, + }, + }, + }); + }); + it('Logs on invalid config', () => { + const fields = [ + { + field: 'requiredUser', + value: true, + error: 'requiredUser is not a supported parameter for Cloud Function validations.', + }, + { + field: 'requireUser', + value: [], + error: + 'Invalid type for Cloud Function validation key requireUser. Expected boolean, actual array', + }, + { + field: 'requireMaster', + value: [], + error: + 'Invalid type for Cloud Function validation key requireMaster. Expected boolean, actual array', + }, + { + field: 'validateMasterKey', + value: [], + error: + 'Invalid type for Cloud Function validation key validateMasterKey. Expected boolean, actual array', + }, + { + field: 'skipWithMasterKey', + value: [], + error: + 'Invalid type for Cloud Function validation key skipWithMasterKey. Expected boolean, actual array', + }, + { + field: 'requireAllUserRoles', + value: true, + error: + 'Invalid type for Cloud Function validation key requireAllUserRoles. Expected array|function, actual boolean', + }, + { + field: 'requireAnyUserRoles', + value: true, + error: + 'Invalid type for Cloud Function validation key requireAnyUserRoles. Expected array|function, actual boolean', + }, + { + field: 'fields', + value: true, + error: + 'Invalid type for Cloud Function validation key fields. Expected array|object, actual boolean', + }, + { + field: 'requireUserKeys', + value: true, + error: + 'Invalid type for Cloud Function validation key requireUserKeys. Expected array|object, actual boolean', + }, + ]; + for (const field of fields) { + try { + Parse.Cloud.define('myFunction', () => {}, { + [field.field]: field.value, + }); + fail(`Expected error registering invalid Cloud Function validation ${field.field}.`); + } catch (e) { + expect(e).toBe(field.error); + } + } + }); + + it('Logs on multiple invalid configs', () => { + const fields = [ + { + field: 'otherKey', + value: true, + error: 'otherKey is not a supported parameter for Cloud Function validations.', + }, + { + field: 'constant', + value: [], + error: + 'Invalid type for Cloud Function validation key constant. Expected boolean, actual array', + }, + { + field: 'required', + value: [], + error: + 'Invalid type for Cloud Function validation key required. Expected boolean, actual array', + }, + { + field: 'error', + value: [], + error: + 'Invalid type for Cloud Function validation key error. Expected string, actual array', + }, + ]; + for (const field of fields) { + try { + Parse.Cloud.define('myFunction', () => {}, { + fields: { + name: { + [field.field]: field.value, + }, + }, + }); + fail(`Expected error registering invalid Cloud Function validation ${field.field}.`); + } catch (e) { + expect(e).toBe(field.error); + } + try { + Parse.Cloud.define('myFunction', () => {}, { + requireUserKeys: { + name: { + [field.field]: field.value, + }, + }, + }); + fail(`Expected error registering invalid Cloud Function validation ${field.field}.`); + } catch (e) { + expect(e).toBe(field.error); + } + } + }); + + it('set params options function async', async () => { + Parse.Cloud.define( + 'hello', + () => { + return 'Hello world!'; + }, + { + fields: { + data: { + type: String, + required: true, + options: async val => { + await new Promise(resolve => { + setTimeout(resolve, 500); + }); + return val === 'f'; + }, + error: 'Validation failed.', + }, + }, + } + ); + try { + await Parse.Cloud.run('hello', { data: 'd' }); + fail('validation should have failed'); + } catch (error) { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Validation failed.'); + } + const result = await Parse.Cloud.run('hello', { data: 'f' }); + expect(result).toBe('Hello world!'); + }); + + it('basic beforeSave requireUserKey as custom async function', async () => { + Parse.Cloud.beforeSave(Parse.User, () => {}, { + fields: { + accType: { + default: 'normal', + constant: true, + }, + }, + }); + Parse.Cloud.define( + 'secureFunction', + () => { + return "Here's all the secure data!"; + }, + { + requireUserKeys: { + accType: { + options: async val => { + await new Promise(resolve => { + setTimeout(resolve, 500); + }); + return ['admin', 'admin2'].includes(val); + }, + error: 'Unauthorized.', + }, + }, + } + ); + const user = new Parse.User(); + user.set('username', 'testuser'); + user.set('password', 'p@ssword'); + user.set('accType', 'admin'); + await user.signUp(); + expect(user.get('accType')).toBe('normal'); + try { + await Parse.Cloud.run('secureFunction'); + fail('function should only be available to admin users'); + } catch (error) { + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + expect(error.message).toEqual('Unauthorized.'); + } + }); +}); diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 3e5fdaa029..941d896aae 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -1,796 +1,5110 @@ -"use strict" -const Parse = require("parse/node"); -const request = require('request'); -const rp = require('request-promise'); -const InMemoryCacheAdapter = require('../src/Adapters/Cache/InMemoryCacheAdapter').InMemoryCacheAdapter; +'use strict'; +const Config = require('../lib/Config'); +const Parse = require('parse/node'); +const ParseServer = require('../lib/index').ParseServer; +const request = require('../lib/request'); +const InMemoryCacheAdapter = require('../lib/Adapters/Cache/InMemoryCacheAdapter') + .InMemoryCacheAdapter; +const Utils = require('../lib/Utils'); + +const mockAdapter = { + createFile: async filename => ({ + name: filename, + location: `http://www.somewhere.com/${filename}`, + }), + deleteFile: () => { }, + getFileData: () => { }, + getFileLocation: (config, filename) => `http://www.somewhere.com/${filename}`, + validateFilename: () => { + return null; + }, +}; describe('Cloud Code', () => { it('can load absolute cloud code file', done => { - reconfigureServer({ cloud: __dirname + '/cloud/cloudCodeRelativeFile.js' }) - .then(() => { - Parse.Cloud.run('cloudCodeInFile', {}, result => { + reconfigureServer({ + cloud: __dirname + '/cloud/cloudCodeRelativeFile.js', + }).then(() => { + Parse.Cloud.run('cloudCodeInFile', {}).then(result => { expect(result).toEqual('It is possible to define cloud code in a file.'); done(); }); - }) + }); }); it('can load relative cloud code file', done => { - reconfigureServer({ cloud: './spec/cloud/cloudCodeAbsoluteFile.js' }) - .then(() => { - Parse.Cloud.run('cloudCodeInFile', {}, result => { + reconfigureServer({ cloud: './spec/cloud/cloudCodeAbsoluteFile.js' }).then(() => { + Parse.Cloud.run('cloudCodeInFile', {}).then(result => { expect(result).toEqual('It is possible to define cloud code in a file.'); done(); }); - }) + }); + }); + + it('can load cloud code as a module', async () => { + process.env.npm_package_type = 'module'; + await reconfigureServer({ appId: 'test1', cloud: './spec/cloud/cloudCodeModuleFile.js' }); + const result = await Parse.Cloud.run('cloudCodeInFile'); + expect(result).toEqual('It is possible to define cloud code in a file.'); + delete process.env.npm_package_type; + }); + + it('cloud code must be valid type', async () => { + spyOn(console, 'error').and.callFake(() => { }); + await expectAsync(reconfigureServer({ cloud: true })).toBeRejectedWith( + "argument 'cloud' must either be a string or a function" + ); + }); + + it('should wait for cloud code to load', async () => { + await reconfigureServer({ appId: 'test3' }); + const initiated = new Date(); + const parseServer = await new ParseServer({ + ...defaultConfiguration, + appId: 'test3', + masterKey: 'test', + serverURL: 'http://localhost:12668/parse', + async cloud() { + await new Promise(resolve => setTimeout(resolve, 1000)); + Parse.Cloud.beforeSave('Test', () => { + throw 'Cannot save.'; + }); + }, + }).start(); + const express = require('express'); + const app = express(); + app.use('/parse', parseServer.app); + const server = app.listen(12668); + const now = new Date(); + expect(now.getTime() - initiated.getTime() > 1000).toBeTrue(); + await expectAsync(new Parse.Object('Test').save()).toBeRejectedWith( + new Parse.Error(141, 'Cannot save.') + ); + await new Promise(resolve => server.close(resolve)); }); it('can create functions', done => { - Parse.Cloud.define('hello', (req, res) => { - res.success('Hello world!'); + Parse.Cloud.define('hello', () => { + return 'Hello world!'; }); - Parse.Cloud.run('hello', {}, result => { + Parse.Cloud.run('hello', {}).then(result => { expect(result).toEqual('Hello world!'); done(); }); }); - it('is cleared cleared after the previous test', done => { - Parse.Cloud.run('hello', {}) - .catch(error => { - expect(error.code).toEqual(141); - done(); - }); + it('can get config', () => { + const config = Parse.Server; + let currentConfig = Config.get('test'); + const server = require('../lib/cloud-code/Parse.Server'); + expect(Object.keys(config)).toEqual(Object.keys({ ...currentConfig, ...server })); + config.silent = false; + Parse.Server = config; + currentConfig = Config.get('test'); + expect(currentConfig.silent).toBeFalse(); }); - it('basic beforeSave rejection', function(done) { - Parse.Cloud.beforeSave('BeforeSaveFail', function(req, res) { - res.error('You shall not pass!'); - }); - - var obj = new Parse.Object('BeforeSaveFail'); - obj.set('foo', 'bar'); - obj.save().then(() => { - fail('Should not have been able to save BeforeSaveFailure class.'); - done(); - }, () => { - done(); - }) + it('can get curent version', () => { + const version = require('../package.json').version; + const currentConfig = Config.get('test'); + expect(Parse.Server.version).toBeDefined(); + expect(currentConfig.version).toBeDefined(); + expect(Parse.Server.version).toEqual(version); }); - it('beforeSave rejection with custom error code', function(done) { - Parse.Cloud.beforeSave('BeforeSaveFailWithErrorCode', function (req, res) { - res.error(999, 'Nope'); + it('show warning on duplicate cloud functions', done => { + const logger = require('../lib/logger').logger; + spyOn(logger, 'warn').and.callFake(() => { }); + Parse.Cloud.define('hello', () => { + return 'Hello world!'; }); - - var obj = new Parse.Object('BeforeSaveFailWithErrorCode'); - obj.set('foo', 'bar'); - obj.save().then(function() { - fail('Should not have been able to save BeforeSaveFailWithErrorCode class.'); - done(); - }, function(error) { - expect(error.code).toEqual(999); - expect(error.message).toEqual('Nope'); - done(); + Parse.Cloud.define('hello', () => { + return 'Hello world!'; }); + expect(logger.warn).toHaveBeenCalledWith( + 'Warning: Duplicate cloud functions exist for hello. Only the last one will be used and the others will be ignored.' + ); + done(); }); - it('basic beforeSave rejection via promise', function(done) { - Parse.Cloud.beforeSave('BeforeSaveFailWithPromise', function (req, res) { - var query = new Parse.Query('Yolo'); - query.find().then(() => { - res.error('Nope'); - }, () => { - res.success(); - }); - }); - - var obj = new Parse.Object('BeforeSaveFailWithPromise'); - obj.set('foo', 'bar'); - obj.save().then(function() { - fail('Should not have been able to save BeforeSaveFailure class.'); - done(); - }, function(error) { + it('is cleared cleared after the previous test', done => { + Parse.Cloud.run('hello', {}).catch(error => { expect(error.code).toEqual(Parse.Error.SCRIPT_FAILED); - expect(error.message).toEqual('Nope'); done(); - }) + }); }); - it('test beforeSave changed object success', function(done) { - Parse.Cloud.beforeSave('BeforeSaveChanged', function(req, res) { - req.object.set('foo', 'baz'); - res.success(); + it('basic beforeSave rejection', function (done) { + Parse.Cloud.beforeSave('BeforeSaveFail', function () { + throw new Error('You shall not pass!'); }); - var obj = new Parse.Object('BeforeSaveChanged'); + const obj = new Parse.Object('BeforeSaveFail'); obj.set('foo', 'bar'); - obj.save().then(function() { - var query = new Parse.Query('BeforeSaveChanged'); - query.get(obj.id).then(function(objAgain) { - expect(objAgain.get('foo')).toEqual('baz'); + obj.save().then( + () => { + fail('Should not have been able to save BeforeSaveFailure class.'); done(); - }, function(error) { - fail(error); + }, + () => { done(); - }); - }, function(error) { - fail(error); - done(); - }); + } + ); }); - it('test beforeSave returns value on create and update', (done) => { - Parse.Cloud.beforeSave('BeforeSaveChanged', function(req, res) { - req.object.set('foo', 'baz'); - res.success(); + it('returns an error', done => { + Parse.Cloud.define('cloudCodeWithError', () => { + /* eslint-disable no-undef */ + foo.bar(); + /* eslint-enable no-undef */ + return 'I better throw an error.'; }); - var obj = new Parse.Object('BeforeSaveChanged'); - obj.set('foo', 'bing'); - obj.save().then(() => { - expect(obj.get('foo')).toEqual('baz'); - obj.set('foo', 'bar'); - return obj.save().then(() => { - expect(obj.get('foo')).toEqual('baz'); + Parse.Cloud.run('cloudCodeWithError').then( + () => done.fail('should not succeed'), + e => { + expect(e).toEqual(new Parse.Error(Parse.Error.SCRIPT_FAILED, 'foo is not defined')); done(); - }) - }) + } + ); }); - it('test afterSave ran and created an object', function(done) { - Parse.Cloud.afterSave('AfterSaveTest', function(req) { - var obj = new Parse.Object('AfterSaveProof'); - obj.set('proof', req.object.id); - obj.save(); + it('returns an empty error', done => { + Parse.Cloud.define('cloudCodeWithError', () => { + throw null; }); - var obj = new Parse.Object('AfterSaveTest'); - obj.save(); - - setTimeout(function() { - var query = new Parse.Query('AfterSaveProof'); - query.equalTo('proof', obj.id); - query.find().then(function(results) { - expect(results.length).toEqual(1); + Parse.Cloud.run('cloudCodeWithError').then( + () => done.fail('should not succeed'), + e => { + expect(e.code).toEqual(Parse.Error.SCRIPT_FAILED); + expect(e.message).toEqual('Script failed.'); done(); - }, function(error) { - fail(error); - done(); - }); - }, 500); + } + ); }); - it('test beforeSave happens on update', function(done) { - Parse.Cloud.beforeSave('BeforeSaveChanged', function(req, res) { - req.object.set('foo', 'baz'); - res.success(); + it('beforeFind can throw string', async function (done) { + Parse.Cloud.beforeFind('beforeFind', () => { + throw 'throw beforeFind'; }); - - var obj = new Parse.Object('BeforeSaveChanged'); + const obj = new Parse.Object('beforeFind'); obj.set('foo', 'bar'); - obj.save().then(function() { - obj.set('foo', 'bar'); - return obj.save(); - }).then(function() { - var query = new Parse.Query('BeforeSaveChanged'); - return query.get(obj.id).then(function(objAgain) { - expect(objAgain.get('foo')).toEqual('baz'); - done(); - }); - }, function(error) { - fail(error); + await obj.save(); + expect(obj.get('foo')).toBe('bar'); + try { + const query = new Parse.Query('beforeFind'); + await query.first(); + } catch (e) { + expect(e.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(e.message).toBe('throw beforeFind'); done(); - }); + } }); - it('test beforeDelete failure', function(done) { - Parse.Cloud.beforeDelete('BeforeDeleteFail', function(req, res) { - res.error('Nope'); + describe('beforeFind without DB operations', () => { + let findSpy; + + beforeEach(() => { + const config = Config.get('test'); + const databaseAdapter = config.database.adapter; + findSpy = spyOn(databaseAdapter, 'find').and.callThrough(); }); - var obj = new Parse.Object('BeforeDeleteFail'); - var id; - obj.set('foo', 'bar'); - obj.save().then(() => { - id = obj.id; - return obj.destroy(); - }).then(() => { - fail('obj.destroy() should have failed, but it succeeded'); - done(); - }, (error) => { - expect(error.code).toEqual(Parse.Error.SCRIPT_FAILED); - expect(error.message).toEqual('Nope'); + it('beforeFind can return object without DB operation', async () => { + Parse.Cloud.beforeFind('TestObject', () => { + return new Parse.Object('TestObject', { foo: 'bar' }); + }); + Parse.Cloud.afterFind('TestObject', req => { + expect(req.objects).toBeDefined(); + expect(req.objects[0].get('foo')).toBe('bar'); + }); - var objAgain = new Parse.Object('BeforeDeleteFail', {objectId: id}); - return objAgain.fetch(); - }).then((objAgain) => { - if (objAgain) { - expect(objAgain.get('foo')).toEqual('bar'); - } else { - fail("unable to fetch the object ", id); - } - done(); - }, (error) => { - // We should have been able to fetch the object again - fail(error); + const newObj = await new Parse.Query('TestObject').first(); + expect(newObj.className).toBe('TestObject'); + expect(newObj.toJSON()).toEqual({ foo: 'bar' }); + expect(findSpy).not.toHaveBeenCalled(); + await newObj.save(); }); - }); - it('basic beforeDelete rejection via promise', function(done) { - Parse.Cloud.beforeSave('BeforeDeleteFailWithPromise', function (req, res) { - var query = new Parse.Query('Yolo'); - query.find().then(() => { - res.error('Nope'); - }, () => { - res.success(); + it('beforeFind can return array of objects without DB operation', async () => { + Parse.Cloud.beforeFind('TestObject', () => { + return [new Parse.Object('TestObject', { foo: 'bar' })]; + }); + Parse.Cloud.afterFind('TestObject', req => { + expect(req.objects).toBeDefined(); + expect(req.objects[0].get('foo')).toBe('bar'); }); - }); - var obj = new Parse.Object('BeforeDeleteFailWithPromise'); - obj.set('foo', 'bar'); - obj.save().then(function() { - fail('Should not have been able to save BeforeSaveFailure class.'); - done(); - }, function(error) { - expect(error.code).toEqual(Parse.Error.SCRIPT_FAILED); - expect(error.message).toEqual('Nope'); + const newObj = await new Parse.Query('TestObject').first(); + expect(newObj.className).toBe('TestObject'); + expect(newObj.toJSON()).toEqual({ foo: 'bar' }); + expect(findSpy).not.toHaveBeenCalled(); + await newObj.save(); + }); - done(); - }) - }); + it('beforeFind can return object for get query without DB operation', async () => { + Parse.Cloud.beforeFind('TestObject', () => { + return [new Parse.Object('TestObject', { foo: 'bar' })]; + }); + Parse.Cloud.afterFind('TestObject', req => { + expect(req.objects).toBeDefined(); + expect(req.objects[0].get('foo')).toBe('bar'); + }); - it('test afterDelete ran and created an object', function(done) { - Parse.Cloud.afterDelete('AfterDeleteTest', function(req) { - var obj = new Parse.Object('AfterDeleteProof'); - obj.set('proof', req.object.id); - obj.save(); - }); + const testObj = new Parse.Object('TestObject'); + await testObj.save(); + findSpy.calls.reset(); - var obj = new Parse.Object('AfterDeleteTest'); - obj.save().then(function() { - obj.destroy(); + const newObj = await new Parse.Query('TestObject').get(testObj.id); + expect(newObj.className).toBe('TestObject'); + expect(newObj.toJSON()).toEqual({ foo: 'bar' }); + expect(findSpy).not.toHaveBeenCalled(); + await newObj.save(); }); - setTimeout(function() { - var query = new Parse.Query('AfterDeleteProof'); - query.equalTo('proof', obj.id); - query.find().then(function(results) { - expect(results.length).toEqual(1); - done(); - }, function(error) { - fail(error); - done(); + it('beforeFind can return empty array without DB operation', async () => { + Parse.Cloud.beforeFind('TestObject', () => { + return []; + }); + Parse.Cloud.afterFind('TestObject', req => { + expect(req.objects.length).toBe(0); }); - }, 500); + + const obj = new Parse.Object('TestObject'); + await obj.save(); + findSpy.calls.reset(); + + const newObj = await new Parse.Query('TestObject').first(); + expect(newObj).toBeUndefined(); + expect(findSpy).not.toHaveBeenCalled(); + }); }); - it('test cloud function return types', function(done) { - Parse.Cloud.define('foo', function(req, res) { - res.success({ - object: { - __type: 'Object', - className: 'Foo', - objectId: '123', - x: 2, - relation: { - __type: 'Object', - className: 'Bar', - objectId: '234', - x: 3 - } - }, - array: [{ - __type: 'Object', - className: 'Bar', - objectId: '345', - x: 2 - }], - a: 2 + describe('beforeFind security with returned objects', () => { + let userA; + let userB; + let secret; + + beforeEach(async () => { + userA = new Parse.User(); + userA.setUsername('userA_' + Date.now()); + userA.setPassword('passA'); + await userA.signUp(); + + userB = new Parse.User(); + userB.setUsername('userB_' + Date.now()); + userB.setPassword('passB'); + await userB.signUp(); + + // Create an object readable only by userB + const acl = new Parse.ACL(); + acl.setPublicReadAccess(false); + acl.setPublicWriteAccess(false); + acl.setReadAccess(userB.id, true); + acl.setWriteAccess(userB.id, true); + + secret = new Parse.Object('SecretDoc'); + secret.set('title', 'top'); + secret.set('content', 'classified'); + secret.setACL(acl); + await secret.save(null, { sessionToken: userB.getSessionToken() }); + + Parse.Cloud.beforeFind('SecretDoc', () => { + return [secret]; }); }); - Parse.Cloud.run('foo').then((result) => { - expect(result.object instanceof Parse.Object).toBeTruthy(); - if (!result.object) { - fail("Unable to run foo"); - done(); - return; - } - expect(result.object.className).toEqual('Foo'); - expect(result.object.get('x')).toEqual(2); - var bar = result.object.get('relation'); - expect(bar instanceof Parse.Object).toBeTruthy(); - expect(bar.className).toEqual('Bar'); - expect(bar.get('x')).toEqual(3); - expect(Array.isArray(result.array)).toEqual(true); - expect(result.array[0] instanceof Parse.Object).toBeTruthy(); - expect(result.array[0].get('x')).toEqual(2); - done(); + it('should not expose objects not readable by current user', async () => { + const q = new Parse.Query('SecretDoc'); + const results = await q.find({ sessionToken: userA.getSessionToken() }); + expect(results.length).toBe(0); }); - }); - it('test cloud function request params types', function(done) { - Parse.Cloud.define('params', function(req, res) { - expect(req.params.date instanceof Date).toBe(true); - expect(req.params.date.getTime()).toBe(1463907600000); - expect(req.params.dateList[0] instanceof Date).toBe(true); - expect(req.params.dateList[0].getTime()).toBe(1463907600000); - expect(req.params.complexStructure.date[0] instanceof Date).toBe(true); - expect(req.params.complexStructure.date[0].getTime()).toBe(1463907600000); - expect(req.params.complexStructure.deepDate.date[0] instanceof Date).toBe(true); - expect(req.params.complexStructure.deepDate.date[0].getTime()).toBe(1463907600000); - expect(req.params.complexStructure.deepDate2[0].date instanceof Date).toBe(true); - expect(req.params.complexStructure.deepDate2[0].date.getTime()).toBe(1463907600000); - // Regression for #2294 - expect(req.params.file instanceof Parse.File).toBe(true); - expect(req.params.file.url()).toEqual('https://some.url'); - // Regression for #2204 - expect(req.params.array).toEqual(['a', 'b', 'c']); - expect(Array.isArray(req.params.array)).toBe(true); - expect(req.params.arrayOfArray).toEqual([['a', 'b', 'c'], ['d', 'e','f']]); - expect(Array.isArray(req.params.arrayOfArray)).toBe(true); - expect(Array.isArray(req.params.arrayOfArray[0])).toBe(true); - expect(Array.isArray(req.params.arrayOfArray[1])).toBe(true); - return res.success({}); + it('should allow authorized user to see their objects', async () => { + const q = new Parse.Query('SecretDoc'); + const results = await q.find({ sessionToken: userB.getSessionToken() }); + expect(results.length).toBe(1); + expect(results[0].id).toBe(secret.id); + expect(results[0].get('title')).toBe('top'); + expect(results[0].get('content')).toBe('classified'); }); - let params = { - 'date': { - '__type': 'Date', - 'iso': '2016-05-22T09:00:00.000Z' - }, - 'dateList': [ - { - '__type': 'Date', - 'iso': '2016-05-22T09:00:00.000Z' - } - ], - 'lol': 'hello', - 'complexStructure': { - 'date': [ - { - '__type': 'Date', - 'iso': '2016-05-22T09:00:00.000Z' - } - ], - 'deepDate': { - 'date': [ - { - '__type': 'Date', - 'iso': '2016-05-22T09:00:00.000Z' - } - ] - }, - 'deepDate2': [ - { - 'date': { - '__type': 'Date', - 'iso': '2016-05-22T09:00:00.000Z' - } - } - ] - }, - 'file': Parse.File.fromJSON({ - __type: 'File', - name: 'name', - url: 'https://some.url' - }), - 'array': ['a', 'b', 'c'], - 'arrayOfArray': [['a', 'b', 'c'], ['d', 'e', 'f']] - }; - Parse.Cloud.run('params', params).then((result) => { - done(); + it('should return OBJECT_NOT_FOUND on get() for unauthorized user', async () => { + const q = new Parse.Query('SecretDoc'); + await expectAsync( + q.get(secret.id, { sessionToken: userA.getSessionToken() }) + ).toBeRejectedWith(jasmine.objectContaining({ code: Parse.Error.OBJECT_NOT_FOUND })); }); - }); - it('test cloud function should echo keys', function(done) { - Parse.Cloud.define('echoKeys', function(req, res){ - return res.success({ - applicationId: Parse.applicationId, - masterKey: Parse.masterKey, - javascriptKey: Parse.javascriptKey - }) + it('should allow master key to bypass ACL filtering when returning objects', async () => { + const q = new Parse.Query('SecretDoc'); + const results = await q.find({ useMasterKey: true }); + expect(results.length).toBe(1); + expect(results[0].id).toBe(secret.id); }); - Parse.Cloud.run('echoKeys').then((result) => { - expect(result.applicationId).toEqual(Parse.applicationId); - expect(result.masterKey).toEqual(Parse.masterKey); - expect(result.javascriptKey).toEqual(Parse.javascriptKey); - done(); + it('should apply protectedFields masking after re-filtering', async () => { + // Configure protectedFields for SecretMask: mask `secretField` for everyone + const protectedFields = { SecretMask: { '*': ['secretField'] } }; + await reconfigureServer({ protectedFields }); + + const user = new Parse.User(); + user.setUsername('pfUser'); + user.setPassword('pfPass'); + await user.signUp(); + + // Object is publicly readable but has a protected field + const doc = new Parse.Object('SecretMask'); + doc.set('name', 'visible'); + doc.set('secretField', 'hiddenValue'); + await doc.save(null, { useMasterKey: true }); + + Parse.Cloud.beforeFind('SecretMask', () => { + return [doc]; + }); + + // Query as normal user; after re-filtering, secretField should be removed + const res = await new Parse.Query('SecretMask').first({ sessionToken: user.getSessionToken() }); + expect(res).toBeDefined(); + expect(res.get('name')).toBe('visible'); + expect(res.get('secretField')).toBeUndefined(); + const json = res.toJSON(); + expect(Object.prototype.hasOwnProperty.call(json, 'secretField')).toBeFalse(); }); }); + const { maybeRunAfterFindTrigger } = require('../lib/triggers'); - it('should properly create an object in before save', done => { - Parse.Cloud.beforeSave('BeforeSaveChanged', function(req, res) { - req.object.set('foo', 'baz'); - res.success(); - }); + describe('maybeRunAfterFindTrigger - direct function tests', () => { + const testConfig = { + applicationId: 'test', + logLevels: { triggerBeforeSuccess: 'info', triggerAfter: 'info' }, + }; - Parse.Cloud.define('createBeforeSaveChangedObject', function(req, res){ - var obj = new Parse.Object('BeforeSaveChanged'); - obj.save().then(() => { - res.success(obj); - }) - }) + it('should convert Parse.Object instances to JSON when no trigger defined', async () => { + const className = 'TestParseObjectDirect_' + Date.now(); - Parse.Cloud.run('createBeforeSaveChangedObject').then((res) => { - expect(res.get('foo')).toEqual('baz'); - done(); + const parseObj1 = new Parse.Object(className); + parseObj1.set('name', 'test1'); + parseObj1.id = 'obj1'; + + const parseObj2 = new Parse.Object(className); + parseObj2.set('name', 'test2'); + parseObj2.id = 'obj2'; + + const result = await maybeRunAfterFindTrigger( + 'afterFind', + null, + className, + [parseObj1, parseObj2], + testConfig, + null, + {} + ); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(2); + expect(result[0].name).toBe('test1'); + expect(result[1].name).toBe('test2'); }); - }); - it('dirtyKeys are set on update', done => { - let triggerTime = 0; - // Register a mock beforeSave hook - Parse.Cloud.beforeSave('GameScore', (req, res) => { - var object = req.object; - expect(object instanceof Parse.Object).toBeTruthy(); - expect(object.get('fooAgain')).toEqual('barAgain'); - if (triggerTime == 0) { - // Create - expect(object.get('foo')).toEqual('bar'); - } else if (triggerTime == 1) { - // Update - expect(object.dirtyKeys()).toEqual(['foo']); - expect(object.dirty('foo')).toBeTruthy(); - expect(object.get('foo')).toEqual('baz'); - } else { - res.error(); - } - triggerTime++; - res.success(); + it('should handle null/undefined objectsInput when no trigger', async () => { + const className = 'TestNullDirect_' + Date.now(); + + const resultNull = await maybeRunAfterFindTrigger( + 'afterFind', + null, + className, + null, + testConfig, + null, + {} + ); + expect(resultNull).toEqual([]); + + const resultUndefined = await maybeRunAfterFindTrigger( + 'afterFind', + null, + className, + undefined, + testConfig, + null, + {} + ); + expect(resultUndefined).toEqual([]); + + const resultEmpty = await maybeRunAfterFindTrigger( + 'afterFind', + null, + className, + [], + testConfig, + null, + {} + ); + expect(resultEmpty).toEqual([]); }); - let obj = new Parse.Object('GameScore'); - obj.set('foo', 'bar'); - obj.set('fooAgain', 'barAgain'); - obj.save().then(() => { - // We only update foo - obj.set('foo', 'baz'); - return obj.save(); - }).then(() => { - // Make sure the checking has been triggered - expect(triggerTime).toBe(2); - done(); - }, function(error) { - fail(error); - done(); + it('should handle plain object query with where clause', async () => { + const className = 'TestQueryWhereDirect_' + Date.now(); + let receivedQuery = null; + + Parse.Cloud.afterFind(className, req => { + receivedQuery = req.query; + return req.objects; + }); + + const mockObject = { id: 'test123', className: className, name: 'test' }; + + const result = await maybeRunAfterFindTrigger( + 'afterFind', + null, + className, + [mockObject], + testConfig, + { where: { name: 'test' }, limit: 10 }, + {} + ); + + expect(receivedQuery).toBeInstanceOf(Parse.Query); + expect(result).toBeDefined(); }); - }); - it('test beforeSave unchanged success', function(done) { - Parse.Cloud.beforeSave('BeforeSaveUnchanged', function(req, res) { - res.success(); + it('should handle plain object query without where clause', async () => { + const className = 'TestQueryNoWhereDirect_' + Date.now(); + let receivedQuery = null; + + Parse.Cloud.afterFind(className, req => { + receivedQuery = req.query; + return req.objects; + }); + + const mockObject = { id: 'test456', className: className, name: 'test' }; + const pq = new Parse.Query(className).withJSON({ limit: 5, skip: 1 }); + + const result = await maybeRunAfterFindTrigger( + 'afterFind', + null, + className, + [mockObject], + testConfig, + pq, + {} + ); + + expect(receivedQuery).toBeInstanceOf(Parse.Query); + const qJSON = receivedQuery.toJSON(); + expect(qJSON.limit).toBe(5); + expect(qJSON.skip).toBe(1); + expect(qJSON.where).toEqual({}); + expect(result).toBeDefined(); }); - var obj = new Parse.Object('BeforeSaveUnchanged'); - obj.set('foo', 'bar'); - obj.save().then(function() { - done(); - }, function(error) { - fail(error); - done(); + it('should create default query for invalid query parameter', async () => { + const className = 'TestInvalidQueryDirect_' + Date.now(); + let receivedQuery = null; + + Parse.Cloud.afterFind(className, req => { + receivedQuery = req.query; + return req.objects; + }); + + const mockObject = { id: 'test789', className: className, name: 'test' }; + + await maybeRunAfterFindTrigger( + 'afterFind', + null, + className, + [mockObject], + testConfig, + 'invalid_query_string', + {} + ); + + expect(receivedQuery).toBeInstanceOf(Parse.Query); + expect(receivedQuery.className).toBe(className); + + receivedQuery = null; + + await maybeRunAfterFindTrigger( + 'afterFind', + null, + className, + [mockObject], + testConfig, + null, + {} + ); + + expect(receivedQuery).toBeInstanceOf(Parse.Query); + expect(receivedQuery.className).toBe(className); }); }); - it('test beforeDelete success', function(done) { - Parse.Cloud.beforeDelete('BeforeDeleteTest', function(req, res) { - res.success(); + it('beforeSave rejection with custom error code', function (done) { + Parse.Cloud.beforeSave('BeforeSaveFailWithErrorCode', function () { + throw new Parse.Error(999, 'Nope'); }); - var obj = new Parse.Object('BeforeDeleteTest'); + const obj = new Parse.Object('BeforeSaveFailWithErrorCode'); obj.set('foo', 'bar'); - obj.save().then(function() { - return obj.destroy(); - }).then(function() { - var objAgain = new Parse.Object('BeforeDeleteTest', obj.id); - return objAgain.fetch().then(fail, done); - }, function(error) { - fail(error); - done(); - }); + obj.save().then( + function () { + fail('Should not have been able to save BeforeSaveFailWithErrorCode class.'); + done(); + }, + function (error) { + expect(error.code).toEqual(999); + expect(error.message).toEqual('Nope'); + done(); + } + ); }); - it('test save triggers get user', function(done) { - Parse.Cloud.beforeSave('SaveTriggerUser', function(req, res) { - if (req.user && req.user.id) { - res.success(); - } else { - res.error('No user present on request object for beforeSave.'); - } + it('basic beforeSave rejection via promise', function (done) { + Parse.Cloud.beforeSave('BeforeSaveFailWithPromise', function () { + const query = new Parse.Query('Yolo'); + return query.find().then( + () => { + throw 'Nope'; + }, + () => { + return Promise.response(); + } + ); }); - Parse.Cloud.afterSave('SaveTriggerUser', function(req) { - if (!req.user || !req.user.id) { - console.log('No user present on request object for afterSave.'); + const obj = new Parse.Object('BeforeSaveFailWithPromise'); + obj.set('foo', 'bar'); + obj.save().then( + function () { + fail('Should not have been able to save BeforeSaveFailure class.'); + done(); + }, + function (error) { + expect(error.code).toEqual(Parse.Error.SCRIPT_FAILED); + expect(error.message).toEqual('Nope'); + done(); } - }); + ); + }); - var user = new Parse.User(); - user.set("password", "asdf"); - user.set("email", "asdf@example.com"); - user.set("username", "zxcv"); - user.signUp(null, { - success: function() { - var obj = new Parse.Object('SaveTriggerUser'); - obj.save().then(function() { + it('test beforeSave changed object success', function (done) { + Parse.Cloud.beforeSave('BeforeSaveChanged', function (req) { + req.object.set('foo', 'baz'); + }); + + const obj = new Parse.Object('BeforeSaveChanged'); + obj.set('foo', 'bar'); + obj.save().then( + function () { + const query = new Parse.Query('BeforeSaveChanged'); + query.get(obj.id).then( + function (objAgain) { + expect(objAgain.get('foo')).toEqual('baz'); + done(); + }, + function (error) { + fail(error); + done(); + } + ); + }, + function (error) { + fail(error); + done(); + } + ); + }); + + it('test beforeSave with invalid field', async () => { + Parse.Cloud.beforeSave('BeforeSaveChanged', function (req) { + req.object.set('length', 0); + }); + + const obj = new Parse.Object('BeforeSaveChanged'); + obj.set('foo', 'bar'); + try { + await obj.save(); + fail('should not succeed'); + } catch (e) { + expect(e.message).toBe('Invalid field name: length.'); + } + }); + + it("test beforeSave changed object fail doesn't change object", async function () { + Parse.Cloud.beforeSave('BeforeSaveChanged', function (req) { + if (req.object.has('fail')) { + return Promise.reject(new Error('something went wrong')); + } + + return Promise.resolve(); + }); + + const obj = new Parse.Object('BeforeSaveChanged'); + obj.set('foo', 'bar'); + await obj.save(); + obj.set('foo', 'baz').set('fail', true); + try { + await obj.save(); + } catch (e) { + await obj.fetch(); + expect(obj.get('foo')).toBe('bar'); + } + }); + + it('test beforeSave returns value on create and update', done => { + Parse.Cloud.beforeSave('BeforeSaveChanged', function (req) { + req.object.set('foo', 'baz'); + }); + + const obj = new Parse.Object('BeforeSaveChanged'); + obj.set('foo', 'bing'); + obj.save().then(() => { + expect(obj.get('foo')).toEqual('baz'); + obj.set('foo', 'bar'); + return obj.save().then(() => { + expect(obj.get('foo')).toEqual('baz'); + done(); + }); + }); + }); + + it('test beforeSave applies changes when beforeSave returns true', done => { + Parse.Cloud.beforeSave('Insurance', function (req) { + req.object.set('rate', '$49.99/Month'); + return true; + }); + + const insurance = new Parse.Object('Insurance'); + insurance.set('rate', '$5.00/Month'); + insurance.save().then(insurance => { + expect(insurance.get('rate')).toEqual('$49.99/Month'); + done(); + }); + }); + + it('test beforeSave applies changes and resolves returned promise', done => { + Parse.Cloud.beforeSave('Insurance', function (req) { + req.object.set('rate', '$49.99/Month'); + return new Parse.Query('Pet').get(req.object.get('pet').id).then(pet => { + pet.set('healthy', true); + return pet.save(); + }); + }); + + const pet = new Parse.Object('Pet'); + pet.set('healthy', false); + pet.save().then(pet => { + const insurance = new Parse.Object('Insurance'); + insurance.set('pet', pet); + insurance.set('rate', '$5.00/Month'); + insurance.save().then(insurance => { + expect(insurance.get('rate')).toEqual('$49.99/Month'); + new Parse.Query('Pet').get(insurance.get('pet').id).then(pet => { + expect(pet.get('healthy')).toEqual(true); + done(); + }); + }); + }); + }); + + it('beforeSave should be called only if user fulfills permissions', async () => { + const triggeruser = new Parse.User(); + triggeruser.setUsername('triggeruser'); + triggeruser.setPassword('triggeruser'); + await triggeruser.signUp(); + + const triggeruser2 = new Parse.User(); + triggeruser2.setUsername('triggeruser2'); + triggeruser2.setPassword('triggeruser2'); + await triggeruser2.signUp(); + + const triggeruser3 = new Parse.User(); + triggeruser3.setUsername('triggeruser3'); + triggeruser3.setPassword('triggeruser3'); + await triggeruser3.signUp(); + + const triggeruser4 = new Parse.User(); + triggeruser4.setUsername('triggeruser4'); + triggeruser4.setPassword('triggeruser4'); + await triggeruser4.signUp(); + + const triggeruser5 = new Parse.User(); + triggeruser5.setUsername('triggeruser5'); + triggeruser5.setPassword('triggeruser5'); + await triggeruser5.signUp(); + + const triggerroleacl = new Parse.ACL(); + triggerroleacl.setPublicReadAccess(true); + + const triggerrole = new Parse.Role(); + triggerrole.setName('triggerrole'); + triggerrole.setACL(triggerroleacl); + triggerrole.getUsers().add(triggeruser); + triggerrole.getUsers().add(triggeruser3); + await triggerrole.save(); + + const config = Config.get('test'); + const schema = await config.database.loadSchema(); + await schema.addClassIfNotExists( + 'triggerclass', + { + someField: { type: 'String' }, + pointerToUser: { type: 'Pointer', targetClass: '_User' }, + }, + { + find: { + 'role:triggerrole': true, + [triggeruser.id]: true, + [triggeruser2.id]: true, + }, + create: { + 'role:triggerrole': true, + [triggeruser.id]: true, + [triggeruser2.id]: true, + }, + get: { + 'role:triggerrole': true, + [triggeruser.id]: true, + [triggeruser2.id]: true, + }, + update: { + 'role:triggerrole': true, + [triggeruser.id]: true, + [triggeruser2.id]: true, + }, + addField: { + 'role:triggerrole': true, + [triggeruser.id]: true, + [triggeruser2.id]: true, + }, + delete: { + 'role:triggerrole': true, + [triggeruser.id]: true, + [triggeruser2.id]: true, + }, + readUserFields: ['pointerToUser'], + writeUserFields: ['pointerToUser'], + }, + {} + ); + + let called = 0; + Parse.Cloud.beforeSave('triggerclass', () => { + called++; + }); + + const triggerobject = new Parse.Object('triggerclass'); + triggerobject.set('someField', 'someValue'); + triggerobject.set('someField2', 'someValue'); + const triggerobjectacl = new Parse.ACL(); + triggerobjectacl.setPublicReadAccess(false); + triggerobjectacl.setPublicWriteAccess(false); + triggerobjectacl.setRoleReadAccess(triggerrole, true); + triggerobjectacl.setRoleWriteAccess(triggerrole, true); + triggerobjectacl.setReadAccess(triggeruser.id, true); + triggerobjectacl.setWriteAccess(triggeruser.id, true); + triggerobjectacl.setReadAccess(triggeruser2.id, true); + triggerobjectacl.setWriteAccess(triggeruser2.id, true); + triggerobject.setACL(triggerobjectacl); + + await triggerobject.save(undefined, { + sessionToken: triggeruser.getSessionToken(), + }); + expect(called).toBe(1); + await triggerobject.save(undefined, { + sessionToken: triggeruser.getSessionToken(), + }); + expect(called).toBe(2); + await triggerobject.save(undefined, { + sessionToken: triggeruser2.getSessionToken(), + }); + expect(called).toBe(3); + await triggerobject.save(undefined, { + sessionToken: triggeruser3.getSessionToken(), + }); + expect(called).toBe(4); + + const triggerobject2 = new Parse.Object('triggerclass'); + triggerobject2.set('someField', 'someValue'); + triggerobject2.set('someField22', 'someValue'); + const triggerobjectacl2 = new Parse.ACL(); + triggerobjectacl2.setPublicReadAccess(false); + triggerobjectacl2.setPublicWriteAccess(false); + triggerobjectacl2.setReadAccess(triggeruser.id, true); + triggerobjectacl2.setWriteAccess(triggeruser.id, true); + triggerobjectacl2.setReadAccess(triggeruser2.id, true); + triggerobjectacl2.setWriteAccess(triggeruser2.id, true); + triggerobjectacl2.setReadAccess(triggeruser5.id, true); + triggerobjectacl2.setWriteAccess(triggeruser5.id, true); + triggerobject2.setACL(triggerobjectacl2); + + await triggerobject2.save(undefined, { + sessionToken: triggeruser2.getSessionToken(), + }); + expect(called).toBe(5); + await triggerobject2.save(undefined, { + sessionToken: triggeruser2.getSessionToken(), + }); + expect(called).toBe(6); + await triggerobject2.save(undefined, { + sessionToken: triggeruser.getSessionToken(), + }); + expect(called).toBe(7); + + let catched = false; + try { + await triggerobject2.save(undefined, { + sessionToken: triggeruser3.getSessionToken(), + }); + } catch (e) { + catched = true; + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + } + expect(catched).toBe(true); + expect(called).toBe(7); + + catched = false; + try { + await triggerobject2.save(undefined, { + sessionToken: triggeruser4.getSessionToken(), + }); + } catch (e) { + catched = true; + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + } + expect(catched).toBe(true); + expect(called).toBe(7); + + catched = false; + try { + await triggerobject2.save(undefined, { + sessionToken: triggeruser5.getSessionToken(), + }); + } catch (e) { + catched = true; + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + } + expect(catched).toBe(true); + expect(called).toBe(7); + + const triggerobject3 = new Parse.Object('triggerclass'); + triggerobject3.set('someField', 'someValue'); + triggerobject3.set('someField33', 'someValue'); + + catched = false; + try { + await triggerobject3.save(undefined, { + sessionToken: triggeruser4.getSessionToken(), + }); + } catch (e) { + catched = true; + expect(e.code).toBe(119); + } + expect(catched).toBe(true); + expect(called).toBe(7); + + catched = false; + try { + await triggerobject3.save(undefined, { + sessionToken: triggeruser5.getSessionToken(), + }); + } catch (e) { + catched = true; + expect(e.code).toBe(119); + } + expect(catched).toBe(true); + expect(called).toBe(7); + }); + + it('test afterSave ran and created an object', function (done) { + Parse.Cloud.afterSave('AfterSaveTest', function (req) { + const obj = new Parse.Object('AfterSaveProof'); + obj.set('proof', req.object.id); + obj.save().then(test); + }); + + const obj = new Parse.Object('AfterSaveTest'); + obj.save(); + + function test() { + const query = new Parse.Query('AfterSaveProof'); + query.equalTo('proof', obj.id); + query.find().then( + function (results) { + expect(results.length).toEqual(1); + done(); + }, + function (error) { + fail(error); + done(); + } + ); + } + }); + + it('test afterSave ran on created object and returned a promise', function (done) { + Parse.Cloud.afterSave('AfterSaveTest2', function (req) { + const obj = req.object; + if (!obj.existed()) { + return new Promise(resolve => { + setTimeout(function () { + obj.set('proof', obj.id); + obj.save().then(function () { + resolve(); + }); + }, 1000); + }); + } + }); + + const obj = new Parse.Object('AfterSaveTest2'); + obj.save().then(function () { + const query = new Parse.Query('AfterSaveTest2'); + query.equalTo('proof', obj.id); + query.find().then( + function (results) { + expect(results.length).toEqual(1); + const savedObject = results[0]; + expect(savedObject.get('proof')).toEqual(obj.id); done(); - }, function(error) { + }, + function (error) { fail(error); done(); + } + ); + }); + }); + + // TODO: Fails on CI randomly as racing + xit('test afterSave ignoring promise, object not found', function (done) { + Parse.Cloud.afterSave('AfterSaveTest2', function (req) { + const obj = req.object; + if (!obj.existed()) { + return new Promise(resolve => { + setTimeout(function () { + obj.set('proof', obj.id); + obj.save().then(function () { + resolve(); + }); + }, 1000); }); } }); + + const obj = new Parse.Object('AfterSaveTest2'); + obj.save().then(function () { + done(); + }); + + const query = new Parse.Query('AfterSaveTest2'); + query.equalTo('proof', obj.id); + query.find().then( + function (results) { + expect(results.length).toEqual(0); + }, + function (error) { + fail(error); + } + ); + }); + + it('test afterSave rejecting promise', function (done) { + Parse.Cloud.afterSave('AfterSaveTest2', function () { + return new Promise((resolve, reject) => { + setTimeout(function () { + reject('THIS SHOULD BE IGNORED'); + }, 1000); + }); + }); + + const obj = new Parse.Object('AfterSaveTest2'); + obj.save().then( + function () { + done(); + }, + function (error) { + fail(error); + done(); + } + ); + }); + + it('test afterDelete returning promise, object is deleted when destroy resolves', function (done) { + Parse.Cloud.afterDelete('AfterDeleteTest2', function (req) { + return new Promise(resolve => { + setTimeout(function () { + const obj = new Parse.Object('AfterDeleteTestProof'); + obj.set('proof', req.object.id); + obj.save().then(function () { + resolve(); + }); + }, 1000); + }); + }); + + const errorHandler = function (error) { + fail(error); + done(); + }; + + const obj = new Parse.Object('AfterDeleteTest2'); + obj.save().then(function () { + obj.destroy().then(function () { + const query = new Parse.Query('AfterDeleteTestProof'); + query.equalTo('proof', obj.id); + query.find().then(function (results) { + expect(results.length).toEqual(1); + const deletedObject = results[0]; + expect(deletedObject.get('proof')).toEqual(obj.id); + done(); + }, errorHandler); + }, errorHandler); + }, errorHandler); + }); + + it('test afterDelete ignoring promise, object is not yet deleted', function (done) { + Parse.Cloud.afterDelete('AfterDeleteTest2', function (req) { + return new Promise(resolve => { + setTimeout(function () { + const obj = new Parse.Object('AfterDeleteTestProof'); + obj.set('proof', req.object.id); + obj.save().then(function () { + resolve(); + }); + }, 1000); + }); + }); + + const errorHandler = function (error) { + fail(error); + done(); + }; + + const obj = new Parse.Object('AfterDeleteTest2'); + obj.save().then(function () { + obj.destroy().then(function () { + done(); + }); + + const query = new Parse.Query('AfterDeleteTestProof'); + query.equalTo('proof', obj.id); + query.find().then(function (results) { + expect(results.length).toEqual(0); + }, errorHandler); + }, errorHandler); + }); + + it('test beforeSave happens on update', function (done) { + Parse.Cloud.beforeSave('BeforeSaveChanged', function (req) { + req.object.set('foo', 'baz'); + }); + + const obj = new Parse.Object('BeforeSaveChanged'); + obj.set('foo', 'bar'); + obj + .save() + .then(function () { + obj.set('foo', 'bar'); + return obj.save(); + }) + .then( + function () { + const query = new Parse.Query('BeforeSaveChanged'); + return query.get(obj.id).then(function (objAgain) { + expect(objAgain.get('foo')).toEqual('baz'); + done(); + }); + }, + function (error) { + fail(error); + done(); + } + ); + }); + + it('test beforeDelete failure', function (done) { + Parse.Cloud.beforeDelete('BeforeDeleteFail', function () { + throw 'Nope'; + }); + + const obj = new Parse.Object('BeforeDeleteFail'); + let id; + obj.set('foo', 'bar'); + obj + .save() + .then(() => { + id = obj.id; + return obj.destroy(); + }) + .then( + () => { + fail('obj.destroy() should have failed, but it succeeded'); + done(); + }, + error => { + expect(error.code).toEqual(Parse.Error.SCRIPT_FAILED); + expect(error.message).toEqual('Nope'); + + const objAgain = new Parse.Object('BeforeDeleteFail', { + objectId: id, + }); + return objAgain.fetch(); + } + ) + .then( + objAgain => { + if (objAgain) { + expect(objAgain.get('foo')).toEqual('bar'); + } else { + fail('unable to fetch the object ', id); + } + done(); + }, + error => { + // We should have been able to fetch the object again + fail(error); + } + ); + }); + + it('basic beforeDelete rejection via promise', function (done) { + Parse.Cloud.beforeSave('BeforeDeleteFailWithPromise', function () { + const query = new Parse.Query('Yolo'); + return query.find().then(() => { + throw 'Nope'; + }); + }); + + const obj = new Parse.Object('BeforeDeleteFailWithPromise'); + obj.set('foo', 'bar'); + obj.save().then( + function () { + fail('Should not have been able to save BeforeSaveFailure class.'); + done(); + }, + function (error) { + expect(error.code).toEqual(Parse.Error.SCRIPT_FAILED); + expect(error.message).toEqual('Nope'); + + done(); + } + ); + }); + + it('test afterDelete ran and created an object', function (done) { + Parse.Cloud.afterDelete('AfterDeleteTest', function (req) { + const obj = new Parse.Object('AfterDeleteProof'); + obj.set('proof', req.object.id); + obj.save().then(test); + }); + + const obj = new Parse.Object('AfterDeleteTest'); + obj.save().then(function () { + obj.destroy(); + }); + + function test() { + const query = new Parse.Query('AfterDeleteProof'); + query.equalTo('proof', obj.id); + query.find().then( + function (results) { + expect(results.length).toEqual(1); + done(); + }, + function (error) { + fail(error); + done(); + } + ); + } + }); + + it('test cloud function return types', function (done) { + Parse.Cloud.define('foo', function () { + return { + object: { + __type: 'Object', + className: 'Foo', + objectId: '123', + x: 2, + relation: { + __type: 'Object', + className: 'Bar', + objectId: '234', + x: 3, + }, + }, + array: [ + { + __type: 'Object', + className: 'Bar', + objectId: '345', + x: 2, + }, + ], + a: 2, + }; + }); + + Parse.Cloud.run('foo').then(result => { + expect(result.object instanceof Parse.Object).toBeTruthy(); + if (!result.object) { + fail('Unable to run foo'); + done(); + return; + } + expect(result.object.className).toEqual('Foo'); + expect(result.object.get('x')).toEqual(2); + const bar = result.object.get('relation'); + expect(bar instanceof Parse.Object).toBeTruthy(); + expect(bar.className).toEqual('Bar'); + expect(bar.get('x')).toEqual(3); + expect(Array.isArray(result.array)).toEqual(true); + expect(result.array[0] instanceof Parse.Object).toBeTruthy(); + expect(result.array[0].get('x')).toEqual(2); + done(); + }); + }); + + it('test cloud function request params types', function (done) { + Parse.Cloud.define('params', function (req) { + expect(Utils.isDate(req.params.date)).toBe(true); + expect(req.params.date.getTime()).toBe(1463907600000); + expect(Utils.isDate(req.params.dateList[0])).toBe(true); + expect(req.params.dateList[0].getTime()).toBe(1463907600000); + expect(Utils.isDate(req.params.complexStructure.date[0])).toBe(true); + expect(req.params.complexStructure.date[0].getTime()).toBe(1463907600000); + expect(Utils.isDate(req.params.complexStructure.deepDate.date[0])).toBe(true); + expect(req.params.complexStructure.deepDate.date[0].getTime()).toBe(1463907600000); + expect(Utils.isDate(req.params.complexStructure.deepDate2[0].date)).toBe(true); + expect(req.params.complexStructure.deepDate2[0].date.getTime()).toBe(1463907600000); + // Regression for #2294 + expect(req.params.file instanceof Parse.File).toBe(true); + expect(req.params.file.url()).toEqual('https://some.url'); + // Regression for #2204 + expect(req.params.array).toEqual(['a', 'b', 'c']); + expect(Array.isArray(req.params.array)).toBe(true); + expect(req.params.arrayOfArray).toEqual([ + ['a', 'b', 'c'], + ['d', 'e', 'f'], + ]); + expect(Array.isArray(req.params.arrayOfArray)).toBe(true); + expect(Array.isArray(req.params.arrayOfArray[0])).toBe(true); + expect(Array.isArray(req.params.arrayOfArray[1])).toBe(true); + return {}; + }); + + const params = { + date: { + __type: 'Date', + iso: '2016-05-22T09:00:00.000Z', + }, + dateList: [ + { + __type: 'Date', + iso: '2016-05-22T09:00:00.000Z', + }, + ], + lol: 'hello', + complexStructure: { + date: [ + { + __type: 'Date', + iso: '2016-05-22T09:00:00.000Z', + }, + ], + deepDate: { + date: [ + { + __type: 'Date', + iso: '2016-05-22T09:00:00.000Z', + }, + ], + }, + deepDate2: [ + { + date: { + __type: 'Date', + iso: '2016-05-22T09:00:00.000Z', + }, + }, + ], + }, + file: Parse.File.fromJSON({ + __type: 'File', + name: 'name', + url: 'https://some.url', + }), + array: ['a', 'b', 'c'], + arrayOfArray: [ + ['a', 'b', 'c'], + ['d', 'e', 'f'], + ], + }; + Parse.Cloud.run('params', params).then(() => { + done(); + }); + }); + + it('test cloud function should echo keys', function (done) { + Parse.Cloud.define('echoKeys', function () { + return { + applicationId: Parse.applicationId, + masterKey: Parse.masterKey, + javascriptKey: Parse.javascriptKey, + }; + }); + + Parse.Cloud.run('echoKeys').then(result => { + expect(result.applicationId).toEqual(Parse.applicationId); + expect(result.masterKey).toEqual(Parse.masterKey); + expect(result.javascriptKey).toEqual(Parse.javascriptKey); + done(); + }); + }); + + it('should properly create an object in before save', done => { + Parse.Cloud.beforeSave('BeforeSaveChanged', function (req) { + req.object.set('foo', 'baz'); + }); + + Parse.Cloud.define('createBeforeSaveChangedObject', function () { + const obj = new Parse.Object('BeforeSaveChanged'); + return obj.save().then(() => { + return obj; + }); + }); + + Parse.Cloud.run('createBeforeSaveChangedObject').then(res => { + expect(res.get('foo')).toEqual('baz'); + done(); + }); + }); + + it('dirtyKeys are set on update', done => { + let triggerTime = 0; + // Register a mock beforeSave hook + Parse.Cloud.beforeSave('GameScore', req => { + const object = req.object; + expect(object instanceof Parse.Object).toBeTruthy(); + expect(object.get('fooAgain')).toEqual('barAgain'); + if (triggerTime == 0) { + // Create + expect(object.get('foo')).toEqual('bar'); + } else if (triggerTime == 1) { + // Update + expect(object.dirtyKeys()).toEqual(['foo']); + expect(object.dirty('foo')).toBeTruthy(); + expect(object.get('foo')).toEqual('baz'); + } else { + throw new Error(); + } + triggerTime++; + }); + + const obj = new Parse.Object('GameScore'); + obj.set('foo', 'bar'); + obj.set('fooAgain', 'barAgain'); + obj + .save() + .then(() => { + // We only update foo + obj.set('foo', 'baz'); + return obj.save(); + }) + .then( + () => { + // Make sure the checking has been triggered + expect(triggerTime).toBe(2); + done(); + }, + function (error) { + fail(error); + done(); + } + ); + }); + + it('test beforeSave unchanged success', function (done) { + Parse.Cloud.beforeSave('BeforeSaveUnchanged', function () { + return; + }); + + const obj = new Parse.Object('BeforeSaveUnchanged'); + obj.set('foo', 'bar'); + obj.save().then( + function () { + done(); + }, + function (error) { + fail(error); + done(); + } + ); + }); + + it('test beforeDelete success', function (done) { + Parse.Cloud.beforeDelete('BeforeDeleteTest', function () { + return; + }); + + const obj = new Parse.Object('BeforeDeleteTest'); + obj.set('foo', 'bar'); + obj + .save() + .then(function () { + return obj.destroy(); + }) + .then( + function () { + const objAgain = new Parse.Object('BeforeDeleteTest', obj.id); + return objAgain.fetch().then(fail, () => done()); + }, + function (error) { + fail(error); + done(); + } + ); + }); + + it('test save triggers get user', async done => { + Parse.Cloud.beforeSave('SaveTriggerUser', function (req) { + if (req.user && req.user.id) { + return; + } else { + throw new Error('No user present on request object for beforeSave.'); + } + }); + + Parse.Cloud.afterSave('SaveTriggerUser', function (req) { + if (!req.user || !req.user.id) { + console.log('No user present on request object for afterSave.'); + } + }); + + const user = new Parse.User(); + user.set('password', 'asdf'); + user.set('email', 'asdf@example.com'); + user.set('username', 'zxcv'); + await user.signUp(); + const obj = new Parse.Object('SaveTriggerUser'); + obj.save().then( + function () { + done(); + }, + function (error) { + fail(error); + done(); + } + ); + }); + + it('beforeSave change propagates through the save response', done => { + Parse.Cloud.beforeSave('ChangingObject', function (request) { + request.object.set('foo', 'baz'); + }); + const obj = new Parse.Object('ChangingObject'); + obj.save({ foo: 'bar' }).then( + objAgain => { + expect(objAgain.get('foo')).toEqual('baz'); + done(); + }, + () => { + fail('Should not have failed to save.'); + done(); + } + ); + }); + + it('beforeSave change propagates through the afterSave #1931', done => { + Parse.Cloud.beforeSave('ChangingObject', function (request) { + request.object.unset('file'); + request.object.unset('date'); + }); + + Parse.Cloud.afterSave('ChangingObject', function (request) { + expect(request.object.has('file')).toBe(false); + expect(request.object.has('date')).toBe(false); + expect(request.object.get('file')).toBeUndefined(); + return Promise.resolve(); + }); + const file = new Parse.File('yolo.txt', [1, 2, 3], 'text/plain'); + file + .save() + .then(() => { + const obj = new Parse.Object('ChangingObject'); + return obj.save({ file, date: new Date() }); + }) + .then( + () => { + done(); + }, + () => { + fail(); + done(); + } + ); + }); + + it('test cloud function parameter validation success', done => { + // Register a function with validation + Parse.Cloud.define( + 'functionWithParameterValidation', + () => { + return 'works'; + }, + request => { + return request.params.success === 100; + } + ); + + Parse.Cloud.run('functionWithParameterValidation', { success: 100 }).then( + () => { + done(); + }, + () => { + fail('Validation should not have failed.'); + done(); + } + ); + }); + + it('doesnt receive stale user in cloud code functions after user has been updated with master key (regression test for #1836)', done => { + Parse.Cloud.define('testQuery', function (request) { + return request.user.get('data'); + }); + + Parse.User.signUp('user', 'pass') + .then(user => { + user.set('data', 'AAA'); + return user.save(); + }) + .then(() => Parse.Cloud.run('testQuery')) + .then(result => { + expect(result).toEqual('AAA'); + Parse.User.current().set('data', 'BBB'); + return Parse.User.current().save(null, { useMasterKey: true }); + }) + .then(() => Parse.Cloud.run('testQuery')) + .then(result => { + expect(result).toEqual('BBB'); + done(); + }); + }); + + it('clears out the user cache for all sessions when the user is changed', done => { + let session1; + let session2; + let user; + const cacheAdapter = new InMemoryCacheAdapter({ ttl: 100000000 }); + reconfigureServer({ cacheAdapter }) + .then(() => { + Parse.Cloud.define('checkStaleUser', request => { + return request.user.get('data'); + }); + + user = new Parse.User(); + user.set('username', 'test'); + user.set('password', 'moon-y'); + user.set('data', 'first data'); + return user.signUp(); + }) + .then(user => { + session1 = user.getSessionToken(); + return request({ + url: 'http://localhost:8378/1/login?username=test&password=moon-y', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }); + }) + .then(response => { + session2 = response.data.sessionToken; + //Ensure both session tokens are in the cache + return Parse.Cloud.run('checkStaleUser', { sessionToken: session2 }); + }) + .then(() => + request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/checkStaleUser', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': session2, + }, + }) + ) + .then(() => + Promise.all([ + cacheAdapter.get('test:user:' + session1), + cacheAdapter.get('test:user:' + session2), + ]) + ) + .then(cachedVals => { + expect(cachedVals[0].objectId).toEqual(user.id); + expect(cachedVals[1].objectId).toEqual(user.id); + + //Change with session 1 and then read with session 2. + user.set('data', 'second data'); + return user.save(); + }) + .then(() => + request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/checkStaleUser', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': session2, + }, + }) + ) + .then(response => { + expect(response.data.result).toEqual('second data'); + done(); + }) + .catch(done.fail); + }); + + it('trivial beforeSave should not affect fetched pointers (regression test for #1238)', done => { + Parse.Cloud.beforeSave('BeforeSaveUnchanged', () => { }); + + const TestObject = Parse.Object.extend('TestObject'); + const NoBeforeSaveObject = Parse.Object.extend('NoBeforeSave'); + const BeforeSaveObject = Parse.Object.extend('BeforeSaveUnchanged'); + + const aTestObject = new TestObject(); + aTestObject.set('foo', 'bar'); + aTestObject + .save() + .then(aTestObject => { + const aNoBeforeSaveObj = new NoBeforeSaveObject(); + aNoBeforeSaveObj.set('aTestObject', aTestObject); + expect(aNoBeforeSaveObj.get('aTestObject').get('foo')).toEqual('bar'); + return aNoBeforeSaveObj.save(); + }) + .then(aNoBeforeSaveObj => { + expect(aNoBeforeSaveObj.get('aTestObject').get('foo')).toEqual('bar'); + + const aBeforeSaveObj = new BeforeSaveObject(); + aBeforeSaveObj.set('aTestObject', aTestObject); + expect(aBeforeSaveObj.get('aTestObject').get('foo')).toEqual('bar'); + return aBeforeSaveObj.save(); + }) + .then(aBeforeSaveObj => { + expect(aBeforeSaveObj.get('aTestObject').get('foo')).toEqual('bar'); + done(); + }); + }); + + it('should encode Parse Objects in cloud functions', async () => { + const user = new Parse.User(); + user.setUsername('username'); + user.setPassword('password'); + user.set('deleted', false); + await user.signUp(); + Parse.Cloud.define( + 'deleteAccount', + async req => { + expect(req.params.object instanceof Parse.Object).toBeTrue(); + req.params.object.set('deleted', true); + await req.params.object.save(null, { useMasterKey: true }); + return 'Object deleted'; + }, + { + requireMaster: true, + } + ); + await Parse.Cloud.run('deleteAccount', { object: user.toPointer() }, { useMasterKey: true }); + }); + + it('beforeSave should not affect fetched pointers', done => { + Parse.Cloud.beforeSave('BeforeSaveUnchanged', () => { }); + + Parse.Cloud.beforeSave('BeforeSaveChanged', function (req) { + req.object.set('foo', 'baz'); + }); + + const TestObject = Parse.Object.extend('TestObject'); + const BeforeSaveUnchangedObject = Parse.Object.extend('BeforeSaveUnchanged'); + const BeforeSaveChangedObject = Parse.Object.extend('BeforeSaveChanged'); + + const aTestObject = new TestObject(); + aTestObject.set('foo', 'bar'); + aTestObject + .save() + .then(aTestObject => { + const aBeforeSaveUnchangedObject = new BeforeSaveUnchangedObject(); + aBeforeSaveUnchangedObject.set('aTestObject', aTestObject); + expect(aBeforeSaveUnchangedObject.get('aTestObject').get('foo')).toEqual('bar'); + return aBeforeSaveUnchangedObject.save(); + }) + .then(aBeforeSaveUnchangedObject => { + expect(aBeforeSaveUnchangedObject.get('aTestObject').get('foo')).toEqual('bar'); + + const aBeforeSaveChangedObject = new BeforeSaveChangedObject(); + aBeforeSaveChangedObject.set('aTestObject', aTestObject); + expect(aBeforeSaveChangedObject.get('aTestObject').get('foo')).toEqual('bar'); + return aBeforeSaveChangedObject.save(); + }) + .then(aBeforeSaveChangedObject => { + expect(aBeforeSaveChangedObject.get('aTestObject').get('foo')).toEqual('bar'); + expect(aBeforeSaveChangedObject.get('foo')).toEqual('baz'); + done(); + }); + }); + + it('should fully delete objects when using `unset` with beforeSave (regression test for #1840)', done => { + const TestObject = Parse.Object.extend('TestObject'); + const NoBeforeSaveObject = Parse.Object.extend('NoBeforeSave'); + const BeforeSaveObject = Parse.Object.extend('BeforeSaveChanged'); + + Parse.Cloud.beforeSave('BeforeSaveChanged', req => { + const object = req.object; + object.set('before', 'save'); + }); + + Parse.Cloud.define('removeme', () => { + const testObject = new TestObject(); + return testObject + .save() + .then(testObject => { + const object = new NoBeforeSaveObject({ remove: testObject }); + return object.save(); + }) + .then(object => { + object.unset('remove'); + return object.save(); + }); + }); + + Parse.Cloud.define('removeme2', () => { + const testObject = new TestObject(); + return testObject + .save() + .then(testObject => { + const object = new BeforeSaveObject({ remove: testObject }); + return object.save(); + }) + .then(object => { + object.unset('remove'); + return object.save(); + }); + }); + + Parse.Cloud.run('removeme') + .then(aNoBeforeSaveObj => { + expect(aNoBeforeSaveObj.get('remove')).toEqual(undefined); + + return Parse.Cloud.run('removeme2'); + }) + .then(aBeforeSaveObj => { + expect(aBeforeSaveObj.get('before')).toEqual('save'); + expect(aBeforeSaveObj.get('remove')).toEqual(undefined); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); + }); + + /* + TODO: fix for Postgres + trying to delete a field that doesn't exists doesn't play nice + */ + it_exclude_dbs(['postgres'])( + 'should fully delete objects when using `unset` and `set` with beforeSave (regression test for #1840)', + done => { + const TestObject = Parse.Object.extend('TestObject'); + const BeforeSaveObject = Parse.Object.extend('BeforeSaveChanged'); + + Parse.Cloud.beforeSave('BeforeSaveChanged', req => { + const object = req.object; + object.set('before', 'save'); + object.unset('remove'); + }); + + let object; + const testObject = new TestObject({ key: 'value' }); + testObject + .save() + .then(() => { + object = new BeforeSaveObject(); + return object.save().then(() => { + object.set({ remove: testObject }); + return object.save(); + }); + }) + .then(objectAgain => { + expect(objectAgain.get('remove')).toBeUndefined(); + expect(object.get('remove')).toBeUndefined(); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); + } + ); + + it('should not include relation op (regression test for #1606)', done => { + const TestObject = Parse.Object.extend('TestObject'); + const BeforeSaveObject = Parse.Object.extend('BeforeSaveChanged'); + let testObj; + Parse.Cloud.beforeSave('BeforeSaveChanged', req => { + const object = req.object; + object.set('before', 'save'); + testObj = new TestObject(); + return testObj.save().then(() => { + object.relation('testsRelation').add(testObj); + }); + }); + + const object = new BeforeSaveObject(); + object + .save() + .then(objectAgain => { + // Originally it would throw as it would be a non-relation + expect(() => { + objectAgain.relation('testsRelation'); + }).not.toThrow(); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); + }); + + /** + * Checks that incrementing a value to a zero in a beforeSave hook + * does not result in that key being omitted from the response. + */ + it('before save increment does not return undefined', done => { + Parse.Cloud.define('cloudIncrementClassFunction', function (req) { + const CloudIncrementClass = Parse.Object.extend('CloudIncrementClass'); + const obj = new CloudIncrementClass(); + obj.id = req.params.objectId; + return obj.save(); + }); + + Parse.Cloud.beforeSave('CloudIncrementClass', function (req) { + const obj = req.object; + if (!req.master) { + obj.increment('points', -10); + obj.increment('num', -9); + } + }); + + const CloudIncrementClass = Parse.Object.extend('CloudIncrementClass'); + const obj = new CloudIncrementClass(); + obj.set('points', 10); + obj.set('num', 10); + obj.save(null, { useMasterKey: true }).then(function () { + Parse.Cloud.run('cloudIncrementClassFunction', { objectId: obj.id }).then(function ( + savedObj + ) { + expect(savedObj.get('num')).toEqual(1); + expect(savedObj.get('points')).toEqual(0); + done(); + }); + }); + }); + + it('before save can revert fields', async () => { + Parse.Cloud.beforeSave('TestObject', ({ object }) => { + object.revert('foo'); + return object; + }); + + Parse.Cloud.afterSave('TestObject', ({ object }) => { + expect(object.get('foo')).toBeUndefined(); + return object; + }); + + const obj = new TestObject(); + obj.set('foo', 'bar'); + await obj.save(); + + expect(obj.get('foo')).toBeUndefined(); + await obj.fetch(); + + expect(obj.get('foo')).toBeUndefined(); + }); + + it('before save can revert fields with existing object', async () => { + Parse.Cloud.beforeSave( + 'TestObject', + ({ object }) => { + object.revert('foo'); + return object; + }, + { + skipWithMasterKey: true, + } + ); + + Parse.Cloud.afterSave( + 'TestObject', + ({ object }) => { + expect(object.get('foo')).toBe('bar'); + return object; + }, + { + skipWithMasterKey: true, + } + ); + + const obj = new TestObject(); + obj.set('foo', 'bar'); + await obj.save(null, { useMasterKey: true }); + + expect(obj.get('foo')).toBe('bar'); + obj.set('foo', 'yolo'); + await obj.save(); + expect(obj.get('foo')).toBe('bar'); + }); + + it('create role with name and ACL and a beforeSave', async () => { + Parse.Cloud.beforeSave(Parse.Role, ({ object }) => { + return object; + }); + + const obj = new Parse.Role('TestRole', new Parse.ACL({ '*': { read: true, write: true } })); + await obj.save(); + + expect(obj.getACL()).toEqual(new Parse.ACL({ '*': { read: true, write: true } })); + expect(obj.get('name')).toEqual('TestRole'); + await obj.fetch(); + + expect(obj.getACL()).toEqual(new Parse.ACL({ '*': { read: true, write: true } })); + expect(obj.get('name')).toEqual('TestRole'); + }); + + it('can unset in afterSave', async () => { + Parse.Cloud.beforeSave('TestObject', ({ object }) => { + if (!object.existed()) { + object.set('secret', true); + return object; + } + object.revert('secret'); + }); + + Parse.Cloud.afterSave('TestObject', ({ object }) => { + object.unset('secret'); + }); + + Parse.Cloud.beforeFind( + 'TestObject', + ({ query }) => { + query.exclude('secret'); + }, + { + skipWithMasterKey: true, + } + ); + + const obj = new TestObject(); + await obj.save(); + expect(obj.get('secret')).toBeUndefined(); + await obj.fetch(); + expect(obj.get('secret')).toBeUndefined(); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('secret')).toBe(true); + }); + + it('should revert in beforeSave', async () => { + Parse.Cloud.beforeSave('MyObject', ({ object }) => { + if (!object.existed()) { + object.set('count', 0); + return object; + } + object.revert('count'); + return object; + }); + const obj = await new Parse.Object('MyObject').save(); + expect(obj.get('count')).toBe(0); + obj.set('count', 10); + await obj.save(); + expect(obj.get('count')).toBe(0); + await obj.fetch(); + expect(obj.get('count')).toBe(0); + }); + + it('pointer should not be cleared by triggers', async () => { + Parse.Cloud.afterSave('MyObject', () => { }); + const foo = await new Parse.Object('Test', { foo: 'bar' }).save(); + const obj = await new Parse.Object('MyObject', { foo }).save(); + const foo2 = obj.get('foo'); + expect(foo2.get('foo')).toBe('bar'); + }); + + it('can set a pointer in triggers', async () => { + Parse.Cloud.beforeSave('MyObject', () => { }); + Parse.Cloud.afterSave( + 'MyObject', + async ({ object }) => { + const foo = await new Parse.Object('Test', { foo: 'bar' }).save(); + object.set({ foo }); + await object.save(null, { useMasterKey: true }); + }, + { + skipWithMasterKey: true, + } + ); + const obj = await new Parse.Object('MyObject').save(); + const foo2 = obj.get('foo'); + expect(foo2.get('foo')).toBe('bar'); + }); + + it('beforeSave should not sanitize database', async done => { + const { adapter } = Config.get(Parse.applicationId).database; + const spy = spyOn(adapter, 'findOneAndUpdate').and.callThrough(); + spy.calls.saveArgumentsByValue(); + + let count = 0; + Parse.Cloud.beforeSave('CloudIncrementNested', req => { + count += 1; + req.object.set('foo', 'baz'); + expect(typeof req.object.get('objectField').number).toBe('number'); + }); + + Parse.Cloud.afterSave('CloudIncrementNested', req => { + expect(typeof req.object.get('objectField').number).toBe('number'); + }); + + const obj = new Parse.Object('CloudIncrementNested'); + obj.set('objectField', { number: 5 }); + obj.set('foo', 'bar'); + await obj.save(); + + obj.increment('objectField.number', 10); + await obj.save(); + + const [ + , + , + , + /* className */ /* schema */ /* query */ update, + ] = adapter.findOneAndUpdate.calls.first().args; + expect(update).toEqual({ + 'objectField.number': { __op: 'Increment', amount: 10 }, + foo: 'baz', + updatedAt: obj.updatedAt.toISOString(), + }); + + count === 2 ? done() : fail(); + }); + + /** + * Verifies that an afterSave hook throwing an exception + * will not prevent a successful save response from being returned + */ + it('should succeed on afterSave exception', done => { + Parse.Cloud.afterSave('AfterSaveTestClass', function () { + throw 'Exception'; + }); + const AfterSaveTestClass = Parse.Object.extend('AfterSaveTestClass'); + const obj = new AfterSaveTestClass(); + obj.save().then(done, done.fail); + }); + + describe('cloud jobs', () => { + it('should define a job', done => { + expect(() => { + Parse.Cloud.job('myJob', ({ message }) => { + message('Hello, world!!!'); + }); + }).not.toThrow(); + + request({ + method: 'POST', + url: 'http://localhost:8378/1/jobs/myJob', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + }) + .then(async response => { + const jobStatusId = response.headers['x-parse-job-status-id']; + const checkJobStatus = async () => { + const jobStatus = await getJobStatus(jobStatusId); + return jobStatus.get('finishedAt') && jobStatus.get('message') === 'Hello, world!!!'; + }; + while (!(await checkJobStatus())) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + }) + .then(done) + .catch(done.fail); + }); + + it('should not run without master key', done => { + expect(() => { + Parse.Cloud.job('myJob', () => { }); + }).not.toThrow(); + + request({ + method: 'POST', + url: 'http://localhost:8378/1/jobs/myJob', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + }).then( + () => { + fail('Expected to be unauthorized'); + done(); + }, + err => { + expect(err.status).toBe(403); + done(); + } + ); + }); + + it('should run with master key', done => { + expect(() => { + Parse.Cloud.job('myJob', (req, res) => { + expect(req.functionName).toBeUndefined(); + expect(req.jobName).toBe('myJob'); + expect(typeof req.jobId).toBe('string'); + expect(typeof req.message).toBe('function'); + expect(typeof res).toBe('undefined'); + }); + }).not.toThrow(); + + request({ + method: 'POST', + url: 'http://localhost:8378/1/jobs/myJob', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + }) + .then(async response => { + const jobStatusId = response.headers['x-parse-job-status-id']; + const checkJobStatus = async () => { + const jobStatus = await getJobStatus(jobStatusId); + return jobStatus.get('finishedAt'); + }; + while (!(await checkJobStatus())) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + }) + .then(done) + .catch(done.fail); + }); + + it('should run with master key basic auth', done => { + expect(() => { + Parse.Cloud.job('myJob', (req, res) => { + expect(req.functionName).toBeUndefined(); + expect(req.jobName).toBe('myJob'); + expect(typeof req.jobId).toBe('string'); + expect(typeof req.message).toBe('function'); + expect(typeof res).toBe('undefined'); + }); + }).not.toThrow(); + + request({ + method: 'POST', + url: `http://${Parse.applicationId}:${Parse.masterKey}@localhost:8378/1/jobs/myJob`, + }) + .then(async response => { + const jobStatusId = response.headers['x-parse-job-status-id']; + const checkJobStatus = async () => { + const jobStatus = await getJobStatus(jobStatusId); + return jobStatus.get('finishedAt'); + }; + while (!(await checkJobStatus())) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + }) + .then(done) + .catch(done.fail); + }); + + it('should set the message / success on the job', done => { + Parse.Cloud.job('myJob', req => { + return req + .message('hello') + .then(() => { + return getJobStatus(req.jobId); + }) + .then(jobStatus => { + expect(jobStatus.get('message')).toEqual('hello'); + expect(jobStatus.get('status')).toEqual('running'); + }); + }); + + request({ + method: 'POST', + url: 'http://localhost:8378/1/jobs/myJob', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + }) + .then(async response => { + const jobStatusId = response.headers['x-parse-job-status-id']; + const checkJobStatus = async () => { + const jobStatus = await getJobStatus(jobStatusId); + return ( + jobStatus.get('finishedAt') && + jobStatus.get('message') === 'hello' && + jobStatus.get('status') === 'succeeded' + ); + }; + while (!(await checkJobStatus())) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + }) + .then(done) + .catch(done.fail); + }); + + it('should set the failure on the job', done => { + Parse.Cloud.job('myJob', () => { + return Promise.reject('Something went wrong'); + }); + + request({ + method: 'POST', + url: 'http://localhost:8378/1/jobs/myJob', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + }) + .then(async response => { + const jobStatusId = response.headers['x-parse-job-status-id']; + const checkJobStatus = async () => { + const jobStatus = await getJobStatus(jobStatusId); + return ( + jobStatus.get('finishedAt') && + jobStatus.get('message') === 'Something went wrong' && + jobStatus.get('status') === 'failed' + ); + }; + while (!(await checkJobStatus())) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + }) + .then(done) + .catch(done.fail); + }); + + it('should set the failure message on the job error', async () => { + Parse.Cloud.job('myJobError', () => { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Something went wrong'); + }); + const job = await Parse.Cloud.startJob('myJobError'); + let jobStatus, status; + while (status !== 'failed') { + if (jobStatus) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + jobStatus = await Parse.Cloud.getJobStatus(job); + status = jobStatus.get('status'); + } + expect(jobStatus.get('message')).toEqual('Something went wrong'); + }); + + function getJobStatus(jobId) { + const q = new Parse.Query('_JobStatus'); + return q.get(jobId, { useMasterKey: true }); + } + }); +}); + +describe('cloud functions', () => { + it('Should have request ip', done => { + Parse.Cloud.define('myFunction', req => { + expect(req.ip).toBeDefined(); + return 'success'; + }); + + Parse.Cloud.run('myFunction', {}).then(() => done()); + }); + + it('should have request config', async () => { + Parse.Cloud.define('myConfigFunction', req => { + expect(req.config).toBeDefined(); + return 'success'; + }); + await Parse.Cloud.run('myConfigFunction', {}); + }); +}); + +describe('beforeSave hooks', () => { + it('should have request headers', done => { + Parse.Cloud.beforeSave('MyObject', req => { + expect(req.headers).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + myObject.save().then(() => done()); + }); + + it('should have request ip', done => { + Parse.Cloud.beforeSave('MyObject', req => { + expect(req.ip).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + myObject.save().then(() => done()); + }); + + it('should have request config', async () => { + Parse.Cloud.beforeSave('MyObject', req => { + expect(req.config).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + }); + + it('should respect custom object ids (#6733)', async () => { + Parse.Cloud.beforeSave('TestObject', req => { + expect(req.object.id).toEqual('test_6733'); + }); + + await reconfigureServer({ allowCustomObjectId: true }); + + const req = request({ + // Parse JS SDK does not currently support custom object ids (see #1097), so we do a REST request + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: { + objectId: 'test_6733', + foo: 'bar', + }, + }); + + { + const res = await req; + expect(res.data.objectId).toEqual('test_6733'); + } + + const query = new Parse.Query('TestObject'); + query.equalTo('objectId', 'test_6733'); + const res = await query.find(); + expect(res.length).toEqual(1); + expect(res[0].get('foo')).toEqual('bar'); + }); + + it('should have access to plaintext password on signup for password policy enforcement', async () => { + let receivedPassword; + Parse.Cloud.beforeSave(Parse.User, req => { + receivedPassword = req.object.get('password'); + }); + + const user = new Parse.User(); + user.setUsername('testuser'); + user.setPassword('securePassword123'); + await user.signUp(); + + expect(receivedPassword).toBe('securePassword123'); + }); + + it('should have access to plaintext password on password change for password policy enforcement', async () => { + const user = new Parse.User(); + user.setUsername('testuser'); + user.setPassword('originalPassword'); + await user.signUp(); + + let receivedPassword; + Parse.Cloud.beforeSave(Parse.User, req => { + receivedPassword = req.object.get('password'); + }); + + user.setPassword('newPassword456'); + await user.save(null, { sessionToken: user.getSessionToken() }); + + expect(receivedPassword).toBe('newPassword456'); + }); + + it('should not expose plaintext password in API response', async () => { + Parse.Cloud.beforeSave(Parse.User, () => {}); + + const user = new Parse.User(); + user.setUsername('testuser'); + user.setPassword('securePassword123'); + const result = await user.signUp(); + + expect(result.get('password')).toBeUndefined(); + expect(result.get('_hashed_password')).toBeUndefined(); + }); +}); + +describe('afterSave hooks', () => { + it('should have request headers', done => { + Parse.Cloud.afterSave('MyObject', req => { + expect(req.headers).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + myObject.save().then(() => done()); + }); + + it('should have request ip', done => { + Parse.Cloud.afterSave('MyObject', req => { + expect(req.ip).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + myObject.save().then(() => done()); + }); + + it('should have request config', async () => { + Parse.Cloud.afterSave('MyObject', req => { + expect(req.config).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + }); + + it('should unset in afterSave', async () => { + Parse.Cloud.afterSave( + 'MyObject', + ({ object }) => { + object.unset('secret'); + }, + { + skipWithMasterKey: true, + } + ); + const obj = new Parse.Object('MyObject'); + obj.set('secret', 'bar'); + await obj.save(); + expect(obj.get('secret')).toBeUndefined(); + await obj.fetch(); + expect(obj.get('secret')).toBe('bar'); + }); + + it('should unset', async () => { + Parse.Cloud.beforeSave('MyObject', ({ object }) => { + object.set('secret', 'hidden'); + }); + + Parse.Cloud.afterSave('MyObject', ({ object }) => { + object.unset('secret'); + }); + const obj = await new Parse.Object('MyObject').save(); + expect(obj.get('secret')).toBeUndefined(); + }); +}); + +describe('beforeDelete hooks', () => { + it('should have request headers', done => { + Parse.Cloud.beforeDelete('MyObject', req => { + expect(req.headers).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + myObject + .save() + .then(myObj => myObj.destroy()) + .then(() => done()); + }); + + it('should have request ip', done => { + Parse.Cloud.beforeDelete('MyObject', req => { + expect(req.ip).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + myObject + .save() + .then(myObj => myObj.destroy()) + .then(() => done()); + }); + + it('should have request config', async () => { + Parse.Cloud.beforeDelete('MyObject', req => { + expect(req.config).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + await myObject.destroy(); + }); +}); + +describe('afterDelete hooks', () => { + it('should have request headers', done => { + Parse.Cloud.afterDelete('MyObject', req => { + expect(req.headers).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + myObject + .save() + .then(myObj => myObj.destroy()) + .then(() => done()); + }); + + it('should have request ip', done => { + Parse.Cloud.afterDelete('MyObject', req => { + expect(req.ip).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + myObject + .save() + .then(myObj => myObj.destroy()) + .then(() => done()); + }); + + it('should have request config', async () => { + Parse.Cloud.afterDelete('MyObject', req => { + expect(req.config).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + await myObject.destroy(); + }); +}); + +describe('beforeFind hooks', () => { + it('should add beforeFind trigger', done => { + Parse.Cloud.beforeFind('MyObject', req => { + const q = req.query; + expect(q instanceof Parse.Query).toBe(true); + const jsonQuery = q.toJSON(); + expect(jsonQuery.where.key).toEqual('value'); + expect(jsonQuery.where.some).toEqual({ $gt: 10 }); + expect(jsonQuery.include).toEqual('otherKey,otherValue'); + expect(jsonQuery.excludeKeys).toBe('exclude'); + expect(jsonQuery.limit).toEqual(100); + expect(jsonQuery.skip).toBe(undefined); + expect(jsonQuery.order).toBe('key'); + expect(jsonQuery.keys).toBe('select'); + expect(jsonQuery.readPreference).toBe('PRIMARY'); + expect(jsonQuery.includeReadPreference).toBe('SECONDARY'); + expect(jsonQuery.subqueryReadPreference).toBe('SECONDARY_PREFERRED'); + + expect(req.isGet).toEqual(false); + }); + + const query = new Parse.Query('MyObject'); + query.equalTo('key', 'value'); + query.greaterThan('some', 10); + query.include('otherKey'); + query.include('otherValue'); + query.ascending('key'); + query.select('select'); + query.exclude('exclude'); + query.readPreference('PRIMARY', 'SECONDARY', 'SECONDARY_PREFERRED'); + query.find().then(() => { + done(); + }); + }); + + it('should use modify', done => { + Parse.Cloud.beforeFind('MyObject', req => { + const q = req.query; + q.equalTo('forced', true); + }); + + const obj0 = new Parse.Object('MyObject'); + obj0.set('forced', false); + + const obj1 = new Parse.Object('MyObject'); + obj1.set('forced', true); + Parse.Object.saveAll([obj0, obj1]).then(() => { + const query = new Parse.Query('MyObject'); + query.equalTo('forced', false); + query.find().then(results => { + expect(results.length).toBe(1); + const firstResult = results[0]; + expect(firstResult.get('forced')).toBe(true); + done(); + }); + }); + }); + + it('should use the modified the query', done => { + Parse.Cloud.beforeFind('MyObject', req => { + const q = req.query; + const otherQuery = new Parse.Query('MyObject'); + otherQuery.equalTo('forced', true); + return Parse.Query.or(q, otherQuery); + }); + + const obj0 = new Parse.Object('MyObject'); + obj0.set('forced', false); + + const obj1 = new Parse.Object('MyObject'); + obj1.set('forced', true); + Parse.Object.saveAll([obj0, obj1]).then(() => { + const query = new Parse.Query('MyObject'); + query.equalTo('forced', false); + query.find().then(results => { + expect(results.length).toBe(2); + done(); + }); + }); + }); + + it('should have object found with nested relational data query', async () => { + const obj1 = Parse.Object.extend('TestObject'); + const obj2 = Parse.Object.extend('TestObject2'); + let item2 = new obj2(); + item2 = await item2.save(); + let item1 = new obj1(); + const relation = item1.relation('rel'); + relation.add(item2); + item1 = await item1.save(); + Parse.Cloud.beforeFind('TestObject', req => { + const additionalQ = new Parse.Query('TestObject'); + additionalQ.equalTo('rel', item2); + return Parse.Query.and(req.query, additionalQ); + }); + const q = new Parse.Query('TestObject'); + const res = await q.first(); + expect(res.id).toEqual(item1.id); + }); + + it('should use the modified exclude query', async () => { + Parse.Cloud.beforeFind('MyObject', req => { + const q = req.query; + q.exclude('number'); + }); + + const obj = new Parse.Object('MyObject'); + obj.set('number', 100); + obj.set('string', 'hello'); + await obj.save(); + + const query = new Parse.Query('MyObject'); + query.equalTo('objectId', obj.id); + const results = await query.find(); + expect(results.length).toBe(1); + expect(results[0].get('number')).toBeUndefined(); + expect(results[0].get('string')).toBe('hello'); + }); + + it('should reject queries', done => { + Parse.Cloud.beforeFind('MyObject', () => { + return Promise.reject('Do not run that query'); + }); + + const query = new Parse.Query('MyObject'); + query.find().then( + () => { + fail('should not succeed'); + done(); + }, + err => { + expect(err.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(err.message).toEqual('Do not run that query'); + done(); + } + ); + }); + + it_id('6ef0d226-af30-4dfd-8306-972a1b4becd3')(it)('should handle empty where', done => { + Parse.Cloud.beforeFind('MyObject', req => { + const otherQuery = new Parse.Query('MyObject'); + otherQuery.equalTo('some', true); + return Parse.Query.or(req.query, otherQuery); + }); + + request({ + url: 'http://localhost:8378/1/classes/MyObject', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + }).then( + () => { + done(); + }, + err => { + fail(err); + done(); + } + ); + }); + + it('should handle sorting where', done => { + Parse.Cloud.beforeFind('MyObject', req => { + const query = req.query; + query.ascending('score'); + return query; + }); + + const count = 20; + const objects = []; + while (objects.length != count) { + const object = new Parse.Object('MyObject'); + object.set('score', Math.floor(Math.random() * 100)); + objects.push(object); + } + Parse.Object.saveAll(objects) + .then(() => { + const query = new Parse.Query('MyObject'); + return query.find(); + }) + .then(objects => { + let lastScore = -1; + objects.forEach(element => { + expect(element.get('score') >= lastScore).toBe(true); + lastScore = element.get('score'); + }); + }) + .then(done) + .catch(done.fail); + }); + + it('should add beforeFind trigger using get API', done => { + const hook = { + method: function (req) { + expect(req.isGet).toEqual(true); + return Promise.resolve(); + }, + }; + spyOn(hook, 'method').and.callThrough(); + Parse.Cloud.beforeFind('MyObject', hook.method); + const obj = new Parse.Object('MyObject'); + obj.set('secretField', 'SSID'); + obj.save().then(function () { + request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/MyObject/' + obj.id, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + }).then(response => { + const body = response.data; + expect(body.secretField).toEqual('SSID'); + expect(hook.method).toHaveBeenCalled(); + done(); + }); + }); + }); + + it('sets correct beforeFind trigger isGet parameter for Parse.Object.fetch request', async () => { + const hook = { + method: req => { + expect(req.isGet).toEqual(true); + return Promise.resolve(); + }, + }; + spyOn(hook, 'method').and.callThrough(); + Parse.Cloud.beforeFind('MyObject', hook.method); + const obj = new Parse.Object('MyObject'); + await obj.save(); + const getObj = await obj.fetch(); + expect(getObj).toBeInstanceOf(Parse.Object); + expect(hook.method).toHaveBeenCalledTimes(1); + }); + + it('sets correct beforeFind trigger isGet parameter for Parse.Query.get request', async () => { + const hook = { + method: req => { + expect(req.isGet).toEqual(false); + return Promise.resolve(); + }, + }; + spyOn(hook, 'method').and.callThrough(); + Parse.Cloud.beforeFind('MyObject', hook.method); + const obj = new Parse.Object('MyObject'); + await obj.save(); + const query = new Parse.Query('MyObject'); + const getObj = await query.get(obj.id); + expect(getObj).toBeInstanceOf(Parse.Object); + expect(hook.method).toHaveBeenCalledTimes(1); + }); + + it('sets correct beforeFind trigger isGet parameter for Parse.Query.find request', async () => { + const hook = { + method: req => { + expect(req.isGet).toEqual(false); + return Promise.resolve(); + }, + }; + spyOn(hook, 'method').and.callThrough(); + Parse.Cloud.beforeFind('MyObject', hook.method); + const obj = new Parse.Object('MyObject'); + await obj.save(); + const query = new Parse.Query('MyObject'); + const findObjs = await query.find(); + expect(findObjs?.[0]).toBeInstanceOf(Parse.Object); + expect(hook.method).toHaveBeenCalledTimes(1); + }); + + it('should have request headers', done => { + Parse.Cloud.beforeFind('MyObject', req => { + expect(req.headers).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + myObject + .save() + .then(myObj => { + const query = new Parse.Query('MyObject'); + query.equalTo('objectId', myObj.id); + return Promise.all([query.get(myObj.id), query.first(), query.find()]); + }) + .then(() => done()); + }); + + it('should have request ip', done => { + Parse.Cloud.beforeFind('MyObject', req => { + expect(req.ip).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + myObject + .save() + .then(myObj => { + const query = new Parse.Query('MyObject'); + query.equalTo('objectId', myObj.id); + return Promise.all([query.get(myObj.id), query.first(), query.find()]); + }) + .then(() => done()); + }); + + it('should have request config', async () => { + Parse.Cloud.beforeFind('MyObject', req => { + expect(req.config).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + const query = new Parse.Query('MyObject'); + query.equalTo('objectId', myObject.id); + await Promise.all([query.get(myObject.id), query.first(), query.find()]); + }) + it('should run beforeFind on pointers and array of pointers from an object', async () => { + const obj1 = new Parse.Object('TestObject'); + const obj2 = new Parse.Object('TestObject2'); + const obj3 = new Parse.Object('TestObject'); + obj2.set('aField', 'aFieldValue'); + await obj2.save(); + obj1.set('pointerField', obj2); + obj3.set('pointerFieldArray', [obj2]); + await obj1.save(); + await obj3.save(); + const spy = jasmine.createSpy('beforeFindSpy'); + Parse.Cloud.beforeFind('TestObject2', spy); + const query = new Parse.Query('TestObject'); + await query.get(obj1.id); + // Pointer not included in query so we don't expect beforeFind to be called + expect(spy).not.toHaveBeenCalled(); + const query2 = new Parse.Query('TestObject'); + query2.include('pointerField'); + const res = await query2.get(obj1.id); + expect(res.get('pointerField').get('aField')).toBe('aFieldValue'); + // Pointer included in query so we expect beforeFind to be called + expect(spy).toHaveBeenCalledTimes(1); + const query3 = new Parse.Query('TestObject'); + query3.include('pointerFieldArray'); + const res2 = await query3.get(obj3.id); + expect(res2.get('pointerFieldArray')[0].get('aField')).toBe('aFieldValue'); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('should have access to context in include query in beforeFind hook', async () => { + let beforeFindTestObjectCalled = false; + let beforeFindTestObject2Called = false; + const obj1 = new Parse.Object('TestObject'); + const obj2 = new Parse.Object('TestObject2'); + obj2.set('aField', 'aFieldValue'); + await obj2.save(); + obj1.set('pointerField', obj2); + await obj1.save(); + Parse.Cloud.beforeFind('TestObject', req => { + expect(req.context).toBeDefined(); + expect(req.context.a).toEqual('a'); + beforeFindTestObjectCalled = true; + }); + Parse.Cloud.beforeFind('TestObject2', req => { + expect(req.context).toBeDefined(); + expect(req.context.a).toEqual('a'); + beforeFindTestObject2Called = true; + }); + const query = new Parse.Query('TestObject'); + await query.include('pointerField').find({ context: { a: 'a' } }); + expect(beforeFindTestObjectCalled).toBeTrue(); + expect(beforeFindTestObject2Called).toBeTrue(); + }); +}); + +describe('afterFind hooks', () => { + it('should add afterFind trigger', done => { + Parse.Cloud.afterFind('MyObject', req => { + const q = req.query; + expect(q instanceof Parse.Query).toBe(true); + const jsonQuery = q.toJSON(); + expect(jsonQuery.where.key).toEqual('value'); + expect(jsonQuery.where.some).toEqual({ $gt: 10 }); + expect(jsonQuery.include).toEqual('otherKey,otherValue'); + expect(jsonQuery.excludeKeys).toBe('exclude'); + expect(jsonQuery.limit).toEqual(100); + expect(jsonQuery.skip).toBe(undefined); + expect(jsonQuery.order).toBe('key'); + expect(jsonQuery.keys).toBe('select'); + expect(jsonQuery.readPreference).toBe('PRIMARY'); + expect(jsonQuery.includeReadPreference).toBe('SECONDARY'); + expect(jsonQuery.subqueryReadPreference).toBe('SECONDARY_PREFERRED'); + }); + + const query = new Parse.Query('MyObject'); + query.equalTo('key', 'value'); + query.greaterThan('some', 10); + query.include('otherKey'); + query.include('otherValue'); + query.ascending('key'); + query.select('select'); + query.exclude('exclude'); + query.readPreference('PRIMARY', 'SECONDARY', 'SECONDARY_PREFERRED'); + query.find().then(() => { + done(); + }); + }); + it('should add afterFind trigger using get', done => { + Parse.Cloud.afterFind('MyObject', req => { + for (let i = 0; i < req.objects.length; i++) { + req.objects[i].set('secretField', '###'); + } + return req.objects; + }); + const obj = new Parse.Object('MyObject'); + obj.set('secretField', 'SSID'); + obj.save().then( + function () { + const query = new Parse.Query('MyObject'); + query.get(obj.id).then( + function (result) { + expect(result.get('secretField')).toEqual('###'); + done(); + }, + function (error) { + fail(error); + done(); + } + ); + }, + function (error) { + fail(error); + done(); + } + ); + }); + + it('should add afterFind trigger using find', done => { + Parse.Cloud.afterFind('MyObject', req => { + for (let i = 0; i < req.objects.length; i++) { + req.objects[i].set('secretField', '###'); + } + return req.objects; + }); + const obj = new Parse.Object('MyObject'); + obj.set('secretField', 'SSID'); + obj.save().then( + function () { + const query = new Parse.Query('MyObject'); + query.equalTo('objectId', obj.id); + query.find().then( + function (results) { + expect(results[0].get('secretField')).toEqual('###'); + done(); + }, + function (error) { + fail(error); + done(); + } + ); + }, + function (error) { + fail(error); + done(); + } + ); + }); + + it('should filter out results', done => { + Parse.Cloud.afterFind('MyObject', req => { + const filteredResults = []; + for (let i = 0; i < req.objects.length; i++) { + if (req.objects[i].get('secretField') === 'SSID1') { + filteredResults.push(req.objects[i]); + } + } + return filteredResults; + }); + const obj0 = new Parse.Object('MyObject'); + obj0.set('secretField', 'SSID1'); + const obj1 = new Parse.Object('MyObject'); + obj1.set('secretField', 'SSID2'); + Parse.Object.saveAll([obj0, obj1]).then( + function () { + const query = new Parse.Query('MyObject'); + query.find().then( + function (results) { + expect(results[0].get('secretField')).toEqual('SSID1'); + expect(results.length).toEqual(1); + done(); + }, + function (error) { + fail(error); + done(); + } + ); + }, + function (error) { + fail(error); + done(); + } + ); + }); + + it('should handle failures', done => { + Parse.Cloud.afterFind('MyObject', () => { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'It should fail'); + }); + const obj = new Parse.Object('MyObject'); + obj.set('secretField', 'SSID'); + obj.save().then( + function () { + const query = new Parse.Query('MyObject'); + query.equalTo('objectId', obj.id); + query.find().then( + function () { + fail('AfterFind should handle response failure correctly'); + done(); + }, + function () { + done(); + } + ); + }, + function () { + done(); + } + ); + }); + + it('should also work with promise', done => { + Parse.Cloud.afterFind('MyObject', req => { + return new Promise(resolve => { + setTimeout(function () { + for (let i = 0; i < req.objects.length; i++) { + req.objects[i].set('secretField', '###'); + } + resolve(req.objects); + }, 1000); + }); + }); + const obj = new Parse.Object('MyObject'); + obj.set('secretField', 'SSID'); + obj.save().then( + function () { + const query = new Parse.Query('MyObject'); + query.equalTo('objectId', obj.id); + query.find().then( + function (results) { + expect(results[0].get('secretField')).toEqual('###'); + done(); + }, + function (error) { + fail(error); + } + ); + }, + function (error) { + fail(error); + } + ); + }); + + it('should alter select', done => { + Parse.Cloud.beforeFind('MyObject', req => { + req.query.select('white'); + return req.query; + }); + + const obj0 = new Parse.Object('MyObject').set('white', true).set('black', true); + obj0.save().then(() => { + new Parse.Query('MyObject').first().then(result => { + expect(result.get('white')).toBe(true); + expect(result.get('black')).toBe(undefined); + done(); + }); + }); + }); + + it('should not alter select', done => { + const obj0 = new Parse.Object('MyObject').set('white', true).set('black', true); + obj0.save().then(() => { + new Parse.Query('MyObject').first().then(result => { + expect(result.get('white')).toBe(true); + expect(result.get('black')).toBe(true); + done(); + }); + }); + }); + + it('should set count to true on beforeFind hooks if query is count', done => { + const hook = { + method: function (req) { + expect(req.count).toBe(true); + return Promise.resolve(); + }, + }; + spyOn(hook, 'method').and.callThrough(); + Parse.Cloud.beforeFind('Stuff', hook.method); + new Parse.Query('Stuff').count().then(count => { + expect(count).toBe(0); + expect(hook.method).toHaveBeenCalled(); + done(); + }); + }); + + it('should set count to false on beforeFind hooks if query is not count', done => { + const hook = { + method: function (req) { + expect(req.count).toBe(false); + return Promise.resolve(); + }, + }; + spyOn(hook, 'method').and.callThrough(); + Parse.Cloud.beforeFind('Stuff', hook.method); + new Parse.Query('Stuff').find().then(res => { + expect(res.length).toBe(0); + expect(hook.method).toHaveBeenCalled(); + done(); + }); + }); + + it('can set a pointer object in afterFind', async () => { + const obj = new Parse.Object('MyObject'); + await obj.save(); + Parse.Cloud.afterFind('MyObject', async ({ objects }) => { + const otherObject = new Parse.Object('Test'); + otherObject.set('foo', 'bar'); + await otherObject.save(); + objects[0].set('Pointer', otherObject); + objects[0].set('xyz', 'yolo'); + expect(objects[0].get('Pointer').get('foo')).toBe('bar'); + }); + const query = new Parse.Query('MyObject'); + query.equalTo('objectId', obj.id); + const obj2 = await query.first(); + expect(obj2.get('xyz')).toBe('yolo'); + const pointer = obj2.get('Pointer'); + expect(pointer.get('foo')).toBe('bar'); + }); + + it('can set invalid object in afterFind', async () => { + const obj = new Parse.Object('MyObject'); + await obj.save(); + Parse.Cloud.afterFind('MyObject', () => [{}]); + const query = new Parse.Query('MyObject'); + query.equalTo('objectId', obj.id); + const obj2 = await query.first(); + expect(obj2).toBeDefined(); + expect(obj2.toJSON()).toEqual({}); + expect(obj2.id).toBeUndefined(); + }); + + it('can return a unsaved object in afterFind', async () => { + const obj = new Parse.Object('MyObject'); + await obj.save(); + Parse.Cloud.afterFind('MyObject', async () => { + const otherObject = new Parse.Object('Test'); + otherObject.set('foo', 'bar'); + return [otherObject]; + }); + const query = new Parse.Query('MyObject'); + const obj2 = await query.first(); + expect(obj2.get('foo')).toEqual('bar'); + expect(obj2.id).toBeUndefined(); + await obj2.save(); + expect(obj2.id).toBeDefined(); + }); + + it('should have request headers', done => { + Parse.Cloud.afterFind('MyObject', req => { + expect(req.headers).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + myObject + .save() + .then(myObj => { + const query = new Parse.Query('MyObject'); + query.equalTo('objectId', myObj.id); + return Promise.all([query.get(myObj.id), query.first(), query.find()]); + }) + .then(() => done()); + }); + + it('should have request ip', done => { + Parse.Cloud.afterFind('MyObject', req => { + expect(req.ip).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + myObject + .save() + .then(myObj => { + const query = new Parse.Query('MyObject'); + query.equalTo('objectId', myObj.id); + return Promise.all([query.get(myObj.id), query.first(), query.find()]); + }) + .then(() => done()) + .catch(done.fail); + }); + + it('should have request config', async () => { + Parse.Cloud.afterFind('MyObject', req => { + expect(req.config).toBeDefined(); + }); + + const MyObject = Parse.Object.extend('MyObject'); + const myObject = new MyObject(); + await myObject.save(); + const query = new Parse.Query('MyObject'); + query.equalTo('objectId', myObject.id); + await Promise.all([query.get(myObject.id), query.first(), query.find()]); + }); + + it('should validate triggers correctly', () => { + expect(() => { + Parse.Cloud.beforeSave('_Session', () => { }); + }).toThrow('Only the afterLogout trigger is allowed for the _Session class.'); + expect(() => { + Parse.Cloud.afterSave('_Session', () => { }); + }).toThrow('Only the afterLogout trigger is allowed for the _Session class.'); + expect(() => { + Parse.Cloud.beforeSave('_PushStatus', () => { }); + }).toThrow('Only afterSave is allowed on _PushStatus'); + expect(() => { + Parse.Cloud.afterSave('_PushStatus', () => { }); + }).not.toThrow(); + expect(() => { + Parse.Cloud.beforeLogin(() => { }); + }).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers'); + expect(() => { + Parse.Cloud.beforeLogin('_User', () => { }); + }).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers'); + expect(() => { + Parse.Cloud.beforeLogin(Parse.User, () => { }); + }).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers'); + expect(() => { + Parse.Cloud.beforeLogin('SomeClass', () => { }); + }).toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers'); + expect(() => { + Parse.Cloud.afterLogin(() => { }); + }).not.toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers'); + expect(() => { + Parse.Cloud.afterLogin('_User', () => { }); + }).not.toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers'); + expect(() => { + Parse.Cloud.afterLogin(Parse.User, () => { }); + }).not.toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers'); + expect(() => { + Parse.Cloud.afterLogin('SomeClass', () => { }); + }).toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers'); + expect(() => { + Parse.Cloud.afterLogout(() => { }); + }).not.toThrow(); + expect(() => { + Parse.Cloud.afterLogout('_Session', () => { }); + }).not.toThrow(); + expect(() => { + Parse.Cloud.afterLogout('_User', () => { }); + }).toThrow('Only the _Session class is allowed for the afterLogout trigger.'); + expect(() => { + Parse.Cloud.afterLogout('SomeClass', () => { }); + }).toThrow('Only the _Session class is allowed for the afterLogout trigger.'); + }); + + it_id('c16159b5-e8ee-42d5-8fe3-e2f7c006881d')(it)('should skip afterFind hooks for aggregate', done => { + const hook = { + method: function () { + return Promise.reject(); + }, + }; + spyOn(hook, 'method').and.callThrough(); + Parse.Cloud.afterFind('MyObject', hook.method); + const obj = new Parse.Object('MyObject'); + const pipeline = [ + { + $group: { _id: {} }, + }, + ]; + obj + .save() + .then(() => { + const query = new Parse.Query('MyObject'); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results[0].objectId).toEqual(null); + expect(hook.method).not.toHaveBeenCalled(); + done(); + }); + }); + + it_id('ca55c90d-36db-422c-9060-a30583ce5224')(it)('should skip afterFind hooks for distinct', done => { + const hook = { + method: function () { + return Promise.reject(); + }, + }; + spyOn(hook, 'method').and.callThrough(); + Parse.Cloud.afterFind('MyObject', hook.method); + const obj = new Parse.Object('MyObject'); + obj.set('score', 10); + obj + .save() + .then(() => { + const query = new Parse.Query('MyObject'); + return query.distinct('score'); + }) + .then(results => { + expect(results[0]).toEqual(10); + expect(hook.method).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should throw error if context header is malformed', async () => { + let calledBefore = false; + let calledAfter = false; + Parse.Cloud.beforeSave('TestObject', () => { + calledBefore = true; + }); + Parse.Cloud.afterSave('TestObject', () => { + calledAfter = true; + }); + const req = request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Cloud-Context': 'key', + }, + body: { + foo: 'bar', + }, + }); + try { + await req; + fail('Should have thrown error'); + } catch (e) { + expect(e).toBeDefined(); + expect(e.data.code).toEqual(Parse.Error.INVALID_JSON); + } + expect(calledBefore).toBe(false); + expect(calledAfter).toBe(false); + }); + + it('should throw error if context header is string "1"', async () => { + let calledBefore = false; + let calledAfter = false; + Parse.Cloud.beforeSave('TestObject', () => { + calledBefore = true; + }); + Parse.Cloud.afterSave('TestObject', () => { + calledAfter = true; + }); + const req = request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Cloud-Context': '1', + }, + body: { + foo: 'bar', + }, + }); + try { + await req; + fail('Should have thrown error'); + } catch (e) { + expect(e).toBeDefined(); + expect(e.data.code).toEqual(Parse.Error.INVALID_JSON); + } + expect(calledBefore).toBe(false); + expect(calledAfter).toBe(false); + }); + + it_id('55ef1741-cf72-4a7c-a029-00cb75f53233')(it)('should expose context in beforeSave/afterSave via header', async () => { + let calledBefore = false; + let calledAfter = false; + Parse.Cloud.beforeSave('TestObject', req => { + expect(req.object.get('foo')).toEqual('bar'); + expect(req.context.otherKey).toBe(1); + expect(req.context.key).toBe('value'); + calledBefore = true; + }); + Parse.Cloud.afterSave('TestObject', req => { + expect(req.object.get('foo')).toEqual('bar'); + expect(req.context.otherKey).toBe(1); + expect(req.context.key).toBe('value'); + calledAfter = true; + }); + const req = request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Cloud-Context': '{"key":"value","otherKey":1}', + }, + body: { + foo: 'bar', + }, + }); + await req; + expect(calledBefore).toBe(true); + expect(calledAfter).toBe(true); + }); + + it('should override header context with body context in beforeSave/afterSave', async () => { + let calledBefore = false; + let calledAfter = false; + Parse.Cloud.beforeSave('TestObject', req => { + expect(req.object.get('foo')).toEqual('bar'); + expect(req.context.otherKey).toBe(10); + expect(req.context.key).toBe('hello'); + calledBefore = true; + }); + Parse.Cloud.afterSave('TestObject', req => { + expect(req.object.get('foo')).toEqual('bar'); + expect(req.context.otherKey).toBe(10); + expect(req.context.key).toBe('hello'); + calledAfter = true; + }); + const req = request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + headers: { + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Cloud-Context': '{"key":"value","otherKey":1}', + }, + body: { + foo: 'bar', + _ApplicationId: 'test', + _context: '{"key":"hello","otherKey":10}', + }, + }); + await req; + expect(calledBefore).toBe(true); + expect(calledAfter).toBe(true); + }); + + it('should throw error if context body is malformed', async () => { + let calledBefore = false; + let calledAfter = false; + Parse.Cloud.beforeSave('TestObject', () => { + calledBefore = true; + }); + Parse.Cloud.afterSave('TestObject', () => { + calledAfter = true; + }); + const req = request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + headers: { + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Cloud-Context': '{"key":"value","otherKey":1}', + }, + body: { + foo: 'bar', + _ApplicationId: 'test', + _context: 'key', + }, + }); + try { + await req; + fail('Should have thrown error'); + } catch (e) { + expect(e).toBeDefined(); + expect(e.data.code).toEqual(Parse.Error.INVALID_JSON); + } + expect(calledBefore).toBe(false); + expect(calledAfter).toBe(false); + }); + + it('should throw error if context body is string "true"', async () => { + let calledBefore = false; + let calledAfter = false; + Parse.Cloud.beforeSave('TestObject', () => { + calledBefore = true; + }); + Parse.Cloud.afterSave('TestObject', () => { + calledAfter = true; + }); + const req = request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + headers: { + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Cloud-Context': '{"key":"value","otherKey":1}', + }, + body: { + foo: 'bar', + _ApplicationId: 'test', + _context: 'true', + }, + }); + try { + await req; + fail('Should have thrown error'); + } catch (e) { + expect(e).toBeDefined(); + expect(e.data.code).toEqual(Parse.Error.INVALID_JSON); + } + expect(calledBefore).toBe(false); + expect(calledAfter).toBe(false); + }); + + it('should expose context in before and afterSave', async () => { + let calledBefore = false; + let calledAfter = false; + Parse.Cloud.beforeSave('MyClass', req => { + req.context = { + key: 'value', + otherKey: 1, + }; + calledBefore = true; + }); + Parse.Cloud.afterSave('MyClass', req => { + expect(req.context.otherKey).toBe(1); + expect(req.context.key).toBe('value'); + calledAfter = true; + }); + + const object = new Parse.Object('MyClass'); + await object.save(); + expect(calledBefore).toBe(true); + expect(calledAfter).toBe(true); + }); + + it('should expose context in before and afterSave and let keys be set individually', async () => { + let calledBefore = false; + let calledAfter = false; + Parse.Cloud.beforeSave('MyClass', req => { + req.context.some = 'value'; + req.context.yolo = 1; + calledBefore = true; + }); + Parse.Cloud.afterSave('MyClass', req => { + expect(req.context.yolo).toBe(1); + expect(req.context.some).toBe('value'); + calledAfter = true; + }); + + const object = new Parse.Object('MyClass'); + await object.save(); + expect(calledBefore).toBe(true); + expect(calledAfter).toBe(true); + }); +}); + +describe('beforeLogin hook', () => { + it('should run beforeLogin with correct credentials', async done => { + let hit = 0; + Parse.Cloud.beforeLogin(req => { + hit++; + expect(req.object.get('username')).toEqual('tupac'); + }); + + await Parse.User.signUp('tupac', 'shakur'); + const user = await Parse.User.logIn('tupac', 'shakur'); + expect(hit).toBe(1); + expect(user).toBeDefined(); + expect(user.getUsername()).toBe('tupac'); + expect(user.getSessionToken()).toBeDefined(); + done(); + }); + + it('should be able to block login if an error is thrown', async done => { + let hit = 0; + Parse.Cloud.beforeLogin(req => { + hit++; + if (req.object.get('isBanned')) { + throw new Error('banned account'); + } + }); + + const user = await Parse.User.signUp('tupac', 'shakur'); + await user.save({ isBanned: true }); + + try { + await Parse.User.logIn('tupac', 'shakur'); + throw new Error('should not have been logged in.'); + } catch (e) { + expect(e.message).toBe('banned account'); + } + expect(hit).toBe(1); + done(); + }); + + it('should be able to block login if an error is thrown even if the user has a attached file', async done => { + let hit = 0; + Parse.Cloud.beforeLogin(req => { + hit++; + if (req.object.get('isBanned')) { + throw new Error('banned account'); + } + }); + + const user = await Parse.User.signUp('tupac', 'shakur'); + const base64 = 'V29ya2luZyBhdCBQYXJzZSBpcyBncmVhdCE='; + const file = new Parse.File('myfile.txt', { base64 }); + await file.save(); + await user.save({ isBanned: true, file }); + + try { + await Parse.User.logIn('tupac', 'shakur'); + throw new Error('should not have been logged in.'); + } catch (e) { + expect(e.message).toBe('banned account'); + } + expect(hit).toBe(1); + done(); + }); + + it('should not run beforeLogin with incorrect credentials', async done => { + let hit = 0; + Parse.Cloud.beforeLogin(req => { + hit++; + expect(req.object.get('username')).toEqual('tupac'); + }); + + await Parse.User.signUp('tupac', 'shakur'); + try { + await Parse.User.logIn('tony', 'shakur'); + } catch (e) { + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + } + expect(hit).toBe(0); + done(); + }); + + it('should not run beforeLogin on sign up', async done => { + let hit = 0; + Parse.Cloud.beforeLogin(req => { + hit++; + expect(req.object.get('username')).toEqual('tupac'); + }); + + const user = await Parse.User.signUp('tupac', 'shakur'); + expect(user).toBeDefined(); + expect(hit).toBe(0); + done(); + }); + + it('should trigger afterLogout hook on logout', async done => { + let userId; + Parse.Cloud.afterLogout(req => { + expect(req.object.className).toEqual('_Session'); + expect(req.object.id).toBeDefined(); + const user = req.object.get('user'); + expect(user).toBeDefined(); + userId = user.id; + }); + + const user = await Parse.User.signUp('user', 'pass'); + await Parse.User.logOut(); + expect(user.id).toBe(userId); + done(); + }); + + it('does not crash server when throwing in afterLogin hook', async () => { + const error = new Parse.Error(2000, 'afterLogin error'); + const trigger = { + afterLogin() { + throw error; + }, + }; + const spy = spyOn(trigger, 'afterLogin').and.callThrough(); + Parse.Cloud.afterLogin(trigger.afterLogin); + await Parse.User.signUp('user', 'pass'); + const response = await Parse.User.logIn('user', 'pass').catch(e => e); + expect(spy).toHaveBeenCalled(); + expect(response).toEqual(error); + }); + + it('does not crash server when throwing in afterLogout hook', async () => { + const error = new Parse.Error(2000, 'afterLogout error'); + const trigger = { + afterLogout() { + throw error; + }, + }; + const spy = spyOn(trigger, 'afterLogout').and.callThrough(); + Parse.Cloud.afterLogout(trigger.afterLogout); + await Parse.User.signUp('user', 'pass'); + const response = await Parse.User.logOut().catch(e => e); + expect(spy).toHaveBeenCalled(); + expect(response).toEqual(error); + }); + + it_id('5656d6d7-65ef-43d1-8ca6-6942ae3614d5')(it)('should have expected data in request in beforeLogin', async done => { + Parse.Cloud.beforeLogin(req => { + expect(req.object).toBeDefined(); + expect(req.user).toBeUndefined(); + expect(req.headers).toBeDefined(); + expect(req.ip).toBeDefined(); + expect(req.installationId).toBeDefined(); + expect(req.context).toBeDefined(); + expect(req.config).toBeDefined(); + }); + + await Parse.User.signUp('tupac', 'shakur'); + await Parse.User.logIn('tupac', 'shakur'); + done(); + }); + + it('afterFind should not be triggered when saving an object', async () => { + let beforeSaves = 0; + Parse.Cloud.beforeSave('SavingTest', () => { + beforeSaves++; + }); + + let afterSaves = 0; + Parse.Cloud.afterSave('SavingTest', () => { + afterSaves++; + }); + + let beforeFinds = 0; + Parse.Cloud.beforeFind('SavingTest', () => { + beforeFinds++; + }); + + let afterFinds = 0; + Parse.Cloud.afterFind('SavingTest', () => { + afterFinds++; + }); + + const obj = new Parse.Object('SavingTest'); + obj.set('someField', 'some value 1'); + await obj.save(); + + expect(beforeSaves).toEqual(1); + expect(afterSaves).toEqual(1); + expect(beforeFinds).toEqual(0); + expect(afterFinds).toEqual(0); + + obj.set('someField', 'some value 2'); + await obj.save(); + + expect(beforeSaves).toEqual(2); + expect(afterSaves).toEqual(2); + expect(beforeFinds).toEqual(0); + expect(afterFinds).toEqual(0); + + await obj.fetch(); + + expect(beforeSaves).toEqual(2); + expect(afterSaves).toEqual(2); + expect(beforeFinds).toEqual(1); + expect(afterFinds).toEqual(1); + + obj.set('someField', 'some value 3'); + await obj.save(); + + expect(beforeSaves).toEqual(3); + expect(afterSaves).toEqual(3); + expect(beforeFinds).toEqual(1); + expect(afterFinds).toEqual(1); + }); +}); + +describe('afterLogin hook', () => { + it('should run afterLogin after successful login', async done => { + let hit = 0; + Parse.Cloud.afterLogin(req => { + hit++; + expect(req.object.get('username')).toEqual('testuser'); + }); + + await Parse.User.signUp('testuser', 'p@ssword'); + const user = await Parse.User.logIn('testuser', 'p@ssword'); + expect(hit).toBe(1); + expect(user).toBeDefined(); + expect(user.getUsername()).toBe('testuser'); + expect(user.getSessionToken()).toBeDefined(); + done(); + }); + + it('should not run afterLogin after unsuccessful login', async done => { + let hit = 0; + Parse.Cloud.afterLogin(req => { + hit++; + expect(req.object.get('username')).toEqual('testuser'); + }); + + await Parse.User.signUp('testuser', 'p@ssword'); + try { + await Parse.User.logIn('testuser', 'badpassword'); + } catch (e) { + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + } + expect(hit).toBe(0); + done(); + }); + + it('should not run afterLogin on sign up', async done => { + let hit = 0; + Parse.Cloud.afterLogin(req => { + hit++; + expect(req.object.get('username')).toEqual('testuser'); + }); + + const user = await Parse.User.signUp('testuser', 'p@ssword'); + expect(user).toBeDefined(); + expect(hit).toBe(0); + done(); + }); + + it_id('e86155c4-62e1-4c6e-ab4a-9ac6c87c60f2')(it)('should have expected data in request in afterLogin', async done => { + Parse.Cloud.afterLogin(req => { + expect(req.object).toBeDefined(); + expect(req.user).toBeDefined(); + expect(req.headers).toBeDefined(); + expect(req.ip).toBeDefined(); + expect(req.installationId).toBeDefined(); + expect(req.context).toBeDefined(); + expect(req.config).toBeDefined(); + }); + + await Parse.User.signUp('testuser', 'p@ssword'); + await Parse.User.logIn('testuser', 'p@ssword'); + done(); + }); + + it('context options should override _context object property when saving a new object', async () => { + Parse.Cloud.beforeSave('TestObject', req => { + expect(req.context.a).toEqual('a'); + expect(req.context.hello).not.toBeDefined(); + expect(req._context).not.toBeDefined(); + expect(req.object._context).not.toBeDefined(); + expect(req.object.context).not.toBeDefined(); + }); + Parse.Cloud.afterSave('TestObject', req => { + expect(req.context.a).toEqual('a'); + expect(req.context.hello).not.toBeDefined(); + expect(req._context).not.toBeDefined(); + expect(req.object._context).not.toBeDefined(); + expect(req.object.context).not.toBeDefined(); + }); + await request({ + url: 'http://localhost:8378/1/classes/TestObject', + method: 'POST', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Cloud-Context': '{"a":"a"}', + }, + body: JSON.stringify({ _context: { hello: 'world' } }), + }); + + }); + + it('should have access to context when saving a new object', async () => { + Parse.Cloud.beforeSave('TestObject', req => { + expect(req.context.a).toEqual('a'); + }); + Parse.Cloud.afterSave('TestObject', req => { + expect(req.context.a).toEqual('a'); + }); + const obj = new TestObject(); + await obj.save(null, { context: { a: 'a' } }); + }); + + it('should have access to context when saving an existing object', async () => { + const obj = new TestObject(); + await obj.save(null); + Parse.Cloud.beforeSave('TestObject', req => { + expect(req.context.a).toEqual('a'); + }); + Parse.Cloud.afterSave('TestObject', req => { + expect(req.context.a).toEqual('a'); + }); + await obj.save(null, { context: { a: 'a' } }); + }); + + it('should have access to context when saving a new object in a trigger', async () => { + Parse.Cloud.beforeSave('TestObject', req => { + expect(req.context.a).toEqual('a'); + }); + Parse.Cloud.afterSave('TestObject', req => { + expect(req.context.a).toEqual('a'); + }); + Parse.Cloud.afterSave('TriggerObject', async () => { + const obj = new TestObject(); + await obj.save(null, { context: { a: 'a' } }); + }); + const obj = new Parse.Object('TriggerObject'); + await obj.save(null); + }); + + it('should have access to context when cascade-saving objects', async () => { + Parse.Cloud.beforeSave('TestObject', req => { + expect(req.context.a).toEqual('a'); + }); + Parse.Cloud.afterSave('TestObject', req => { + expect(req.context.a).toEqual('a'); + }); + Parse.Cloud.beforeSave('TestObject2', req => { + expect(req.context.a).toEqual('a'); + }); + Parse.Cloud.afterSave('TestObject2', req => { + expect(req.context.a).toEqual('a'); + }); + const obj = new Parse.Object('TestObject'); + const obj2 = new Parse.Object('TestObject2'); + obj.set('obj2', obj2); + await obj.save(null, { context: { a: 'a' } }); + }); + + it('should have access to context as saveAll argument', async () => { + Parse.Cloud.beforeSave('TestObject', req => { + expect(req.context.a).toEqual('a'); + }); + Parse.Cloud.afterSave('TestObject', req => { + expect(req.context.a).toEqual('a'); + }); + const obj1 = new TestObject(); + const obj2 = new TestObject(); + await Parse.Object.saveAll([obj1, obj2], { context: { a: 'a' } }); + }); + + it('should have access to context as destroyAll argument', async () => { + Parse.Cloud.beforeDelete('TestObject', req => { + expect(req.context.a).toEqual('a'); + }); + Parse.Cloud.afterDelete('TestObject', req => { + expect(req.context.a).toEqual('a'); + }); + const obj1 = new TestObject(); + const obj2 = new TestObject(); + await Parse.Object.saveAll([obj1, obj2]); + await Parse.Object.destroyAll([obj1, obj2], { context: { a: 'a' } }); + }); + + it('should have access to context as destroy a object', async () => { + Parse.Cloud.beforeDelete('TestObject', req => { + expect(req.context.a).toEqual('a'); + }); + Parse.Cloud.afterDelete('TestObject', req => { + expect(req.context.a).toEqual('a'); + }); + const obj = new TestObject(); + await obj.save(); + await obj.destroy({ context: { a: 'a' } }); + }); + + it('should have access to context in beforeFind hook', async () => { + Parse.Cloud.beforeFind('TestObject', req => { + expect(req.context.a).toEqual('a'); + }); + const query = new Parse.Query('TestObject'); + return query.find({ context: { a: 'a' } }); + }); + + it('should have access to context when cloud function is called.', async () => { + Parse.Cloud.define('contextTest', async req => { + expect(req.context.a).toEqual('a'); + return {}; + }); + + await Parse.Cloud.run('contextTest', {}, { context: { a: 'a' } }); + }); + + it('afterFind should have access to context', async () => { + Parse.Cloud.afterFind('TestObject', req => { + expect(req.context.a).toEqual('a'); + }); + const obj = new TestObject(); + await obj.save(); + const query = new Parse.Query(TestObject); + await query.find({ context: { a: 'a' } }); + }); + + it('beforeFind and afterFind should have access to context while making fetch call', async () => { + Parse.Cloud.beforeFind('TestObject', req => { + expect(req.context.a).toEqual('a'); + expect(req.context.b).toBeUndefined(); + req.context.b = 'b'; + }); + Parse.Cloud.afterFind('TestObject', req => { + expect(req.context.a).toEqual('a'); + expect(req.context.b).toEqual('b'); + }); + const obj = new TestObject(); + await obj.save(); + await obj.fetch({ context: { a: 'a' } }); + }); +}); + +describe('saveFile hooks', () => { + it('beforeSave(Parse.File) should return file that is already saved and not save anything to files adapter', async () => { + await reconfigureServer({ filesAdapter: mockAdapter }); + const createFileSpy = spyOn(mockAdapter, 'createFile').and.callThrough(); + Parse.Cloud.beforeSave(Parse.File, () => { + const newFile = new Parse.File('some-file.txt'); + newFile._url = 'http://www.somewhere.com/parse/files/some-app-id/some-file.txt'; + return newFile; + }); + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + const result = await file.save({ useMasterKey: true }); + expect(result).toBe(file); + expect(result._name).toBe('some-file.txt'); + expect(result._url).toBe('http://www.somewhere.com/parse/files/some-app-id/some-file.txt'); + expect(createFileSpy).not.toHaveBeenCalled(); + }); + + it('beforeSave(Parse.File) should throw error', async () => { + await reconfigureServer({ filesAdapter: mockAdapter }); + Parse.Cloud.beforeSave(Parse.File, () => { + throw new Parse.Error(400, 'some-error-message'); + }); + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + try { + await file.save({ useMasterKey: true }); + } catch (error) { + expect(error.message).toBe('some-error-message'); + } + }); + + it('beforeSaveFile should have config', async () => { + await reconfigureServer({ filesAdapter: mockAdapter }); + Parse.Cloud.beforeSave(Parse.File, req => { + expect(req.config).toBeDefined(); + }); + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + }); + + it('beforeSave(Parse.File) should change values of uploaded file by editing fileObject directly', async () => { + await reconfigureServer({ filesAdapter: mockAdapter }); + const createFileSpy = spyOn(mockAdapter, 'createFile').and.callThrough(); + Parse.Cloud.beforeSave(Parse.File, async req => { + expect(req.triggerName).toEqual('beforeSave'); + expect(req.master).toBe(true); + req.file.addMetadata('foo', 'bar'); + req.file.addTag('tagA', 'some-tag'); + }); + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + const result = await file.save({ useMasterKey: true }); + expect(result).toBe(file); + const newData = new Buffer([1, 2, 3]); + const newOptions = { + tags: { + tagA: 'some-tag', + }, + metadata: { + foo: 'bar', + }, + }; + expect(createFileSpy).toHaveBeenCalledWith( + jasmine.any(String), + newData, + 'text/plain', + newOptions + ); + }); + + it('beforeSave(Parse.File) should change values by returning new fileObject', async () => { + await reconfigureServer({ filesAdapter: mockAdapter }); + const createFileSpy = spyOn(mockAdapter, 'createFile').and.callThrough(); + Parse.Cloud.beforeSave(Parse.File, async req => { + expect(req.triggerName).toEqual('beforeSave'); + expect(req.fileSize).toBe(3); + const newFile = new Parse.File('donald_duck.pdf', [4, 5, 6], 'application/pdf'); + newFile.setMetadata({ foo: 'bar' }); + newFile.setTags({ tagA: 'some-tag' }); + return newFile; + }); + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + const result = await file.save({ useMasterKey: true }); + expect(result).toBeInstanceOf(Parse.File); + const newData = new Buffer([4, 5, 6]); + const newContentType = 'application/pdf'; + const newOptions = { + tags: { + tagA: 'some-tag', + }, + metadata: { + foo: 'bar', + }, + }; + expect(createFileSpy).toHaveBeenCalledWith( + jasmine.any(String), + newData, + newContentType, + newOptions + ); + const expectedFileName = 'donald_duck.pdf'; + expect(file._name.indexOf(expectedFileName)).toBe(file._name.length - expectedFileName.length); + }); + + it('beforeSave(Parse.File) should contain metadata and tags saved from client', async () => { + await reconfigureServer({ filesAdapter: mockAdapter }); + const createFileSpy = spyOn(mockAdapter, 'createFile').and.callThrough(); + Parse.Cloud.beforeSave(Parse.File, async req => { + expect(req.triggerName).toEqual('beforeSave'); + expect(req.fileSize).toBe(3); + expect(req.file).toBeInstanceOf(Parse.File); + expect(req.file.name()).toBe('popeye.txt'); + expect(req.file.metadata()).toEqual({ foo: 'bar' }); + expect(req.file.tags()).toEqual({ bar: 'foo' }); + }); + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + file.setMetadata({ foo: 'bar' }); + file.setTags({ bar: 'foo' }); + const result = await file.save({ useMasterKey: true }); + expect(result).toBeInstanceOf(Parse.File); + const options = { + metadata: { foo: 'bar' }, + tags: { bar: 'foo' }, + }; + expect(createFileSpy).toHaveBeenCalledWith( + jasmine.any(String), + jasmine.any(Buffer), + 'text/plain', + options + ); + }); + + it('beforeSave(Parse.File) should return same file data with new file name', async () => { + await reconfigureServer({ filesAdapter: mockAdapter }); + const config = Config.get('test'); + config.filesController.options.preserveFileName = true; + Parse.Cloud.beforeSave(Parse.File, async ({ file }) => { + expect(file.name()).toBe('popeye.txt'); + const fileData = await file.getData(); + const newFile = new Parse.File('2020-04-01.txt', { base64: fileData }); + return newFile; + }); + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + const result = await file.save({ useMasterKey: true }); + expect(result.name()).toBe('2020-04-01.txt'); + }); + + it('afterSave(Parse.File) should set fileSize to null if beforeSave returns an already saved file', async () => { + await reconfigureServer({ filesAdapter: mockAdapter }); + const createFileSpy = spyOn(mockAdapter, 'createFile').and.callThrough(); + Parse.Cloud.beforeSave(Parse.File, req => { + expect(req.fileSize).toBe(3); + const newFile = new Parse.File('some-file.txt'); + newFile._url = 'http://www.somewhere.com/parse/files/some-app-id/some-file.txt'; + return newFile; + }); + Parse.Cloud.afterSave(Parse.File, req => { + expect(req.fileSize).toBe(null); + }); + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + const result = await file.save({ useMasterKey: true }); + expect(result).toBe(result); + expect(result._name).toBe('some-file.txt'); + expect(result._url).toBe('http://www.somewhere.com/parse/files/some-app-id/some-file.txt'); + expect(createFileSpy).not.toHaveBeenCalled(); }); - it('beforeSave change propagates through the save response', (done) => { - Parse.Cloud.beforeSave('ChangingObject', function(request, response) { - request.object.set('foo', 'baz'); - response.success(); + it('afterSave(Parse.File) should throw error', async () => { + await reconfigureServer({ filesAdapter: mockAdapter }); + Parse.Cloud.afterSave(Parse.File, async () => { + throw new Parse.Error(400, 'some-error-message'); }); - let obj = new Parse.Object('ChangingObject'); - obj.save({ foo: 'bar' }).then((objAgain) => { - expect(objAgain.get('foo')).toEqual('baz'); + const filename = 'donald_duck.pdf'; + const file = new Parse.File(filename, [1, 2, 3], 'text/plain'); + try { + await file.save({ useMasterKey: true }); + } catch (error) { + expect(error.message).toBe('some-error-message'); + } + }); + + it('afterSave(Parse.File) should call with fileObject', async done => { + await reconfigureServer({ filesAdapter: mockAdapter }); + Parse.Cloud.beforeSave(Parse.File, async req => { + req.file.setTags({ tagA: 'some-tag' }); + req.file.setMetadata({ foo: 'bar' }); + }); + Parse.Cloud.afterSave(Parse.File, async req => { + expect(req.master).toBe(true); + expect(req.file._tags).toEqual({ tagA: 'some-tag' }); + expect(req.file._metadata).toEqual({ foo: 'bar' }); done(); - }, (e) => { - fail('Should not have failed to save.'); + }); + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + }); + + it('afterSave(Parse.File) should change fileSize when file data changes', async done => { + await reconfigureServer({ filesAdapter: mockAdapter }); + Parse.Cloud.beforeSave(Parse.File, async req => { + expect(req.fileSize).toBe(3); + expect(req.master).toBe(true); + const newFile = new Parse.File('donald_duck.pdf', [4, 5, 6, 7, 8, 9], 'application/pdf'); + return newFile; + }); + Parse.Cloud.afterSave(Parse.File, async req => { + expect(req.fileSize).toBe(6); + expect(req.master).toBe(true); done(); }); + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); }); - it('test cloud function parameter validation success', (done) => { - // Register a function with validation - Parse.Cloud.define('functionWithParameterValidation', (req, res) => { - res.success('works'); - }, (request) => { - return request.params.success === 100; + it('beforeDelete(Parse.File) should call with fileObject', async () => { + await reconfigureServer({ filesAdapter: mockAdapter }); + Parse.Cloud.beforeDelete(Parse.File, req => { + expect(req.file).toBeInstanceOf(Parse.File); + expect(req.file._name).toEqual('popeye.txt'); + expect(req.file._url).toEqual('http://www.somewhere.com/popeye.txt'); + expect(req.fileSize).toBe(null); }); + const file = new Parse.File('popeye.txt'); + await file.destroy({ useMasterKey: true }); + }); - Parse.Cloud.run('functionWithParameterValidation', {"success":100}).then((s) => { + it('beforeDelete(Parse.File) should throw error', async done => { + await reconfigureServer({ filesAdapter: mockAdapter }); + Parse.Cloud.beforeDelete(Parse.File, () => { + throw new Error('some error message'); + }); + const file = new Parse.File('popeye.txt'); + try { + await file.destroy({ useMasterKey: true }); + } catch (error) { + expect(error.message).toBe('some error message'); done(); - }, (e) => { - fail('Validation should not have failed.'); + } + }); + + it('afterDelete(Parse.File) should call with fileObject', async done => { + await reconfigureServer({ filesAdapter: mockAdapter }); + Parse.Cloud.beforeDelete(Parse.File, req => { + expect(req.file).toBeInstanceOf(Parse.File); + expect(req.file._name).toEqual('popeye.txt'); + expect(req.file._url).toEqual('http://www.somewhere.com/popeye.txt'); + }); + Parse.Cloud.afterDelete(Parse.File, req => { + expect(req.file).toBeInstanceOf(Parse.File); + expect(req.file._name).toEqual('popeye.txt'); + expect(req.file._url).toEqual('http://www.somewhere.com/popeye.txt'); done(); }); + const file = new Parse.File('popeye.txt'); + await file.destroy({ useMasterKey: true }); }); - it('doesnt receive stale user in cloud code functions after user has been updated with master key (regression test for #1836)', done => { - Parse.Cloud.define('testQuery', function(request, response) { - response.success(request.user.get('data')); + it('beforeSave(Parse.File) should not change file if nothing is returned', async () => { + await reconfigureServer({ filesAdapter: mockAdapter }); + Parse.Cloud.beforeSave(Parse.File, () => { + return; }); + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + const result = await file.save({ useMasterKey: true }); + expect(result).toBe(file); + }); - Parse.User.signUp('user', 'pass') - .then(user => { - user.set('data', 'AAA'); - return user.save(); - }) - .then(() => Parse.Cloud.run('testQuery')) - .then(result => { - expect(result).toEqual('AAA'); - Parse.User.current().set('data', 'BBB'); - return Parse.User.current().save(null, {useMasterKey: true}); - }) - .then(() => Parse.Cloud.run('testQuery')) - .then(result => { - expect(result).toEqual('BBB'); + it('throw custom error from beforeSave(Parse.File) ', async done => { + Parse.Cloud.beforeSave(Parse.File, () => { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'It should fail'); + }); + try { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + fail('error should have thrown'); + } catch (e) { + expect(e.code).toBe(Parse.Error.SCRIPT_FAILED); done(); + } + }); + + it('throw empty error from beforeSave(Parse.File)', async done => { + Parse.Cloud.beforeSave(Parse.File, () => { + throw null; }); + try { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + fail('error should have thrown'); + } catch (e) { + expect(e.code).toBe(130); + done(); + } }); +}); - it('clears out the user cache for all sessions when the user is changed', done => { - let session1; - let session2; - let user; - const cacheAdapter = new InMemoryCacheAdapter({ ttl: 100000000 }); - reconfigureServer({ cacheAdapter }) - .then(() => { - Parse.Cloud.define('checkStaleUser', (request, response) => { - response.success(request.user.get('data')); - }); - - user = new Parse.User(); - user.set('username', 'test'); - user.set('password', 'moon-y'); - user.set('data', 'first data'); - return user.signUp(); - }) - .then(user => { - session1 = user.getSessionToken(); - return rp({ - uri: 'http://localhost:8378/1/login?username=test&password=moon-y', - json: true, +describe('Parse.File hooks', () => { + it('find hooks should run', async () => { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const user = await Parse.User.signUp('username', 'password'); + const hooks = { + beforeFind(req) { + expect(req).toBeDefined(); + expect(req.file).toBeDefined(); + expect(req.triggerName).toBe('beforeFind'); + expect(req.master).toBeFalse(); + expect(req.log).toBeDefined(); + }, + afterFind(req) { + expect(req).toBeDefined(); + expect(req.file).toBeDefined(); + expect(req.triggerName).toBe('afterFind'); + expect(req.master).toBeFalse(); + expect(req.log).toBeDefined(); + expect(req.forceDownload).toBeFalse(); + }, + }; + for (const hook in hooks) { + spyOn(hooks, hook).and.callThrough(); + Parse.Cloud[hook](Parse.File, hooks[hook]); + } + await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }); + for (const hook in hooks) { + expect(hooks[hook]).toHaveBeenCalled(); + } + }); + + it('beforeFind can throw', async () => { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const user = await Parse.User.signUp('username', 'password'); + const hooks = { + beforeFind() { + throw 'unauthorized'; + }, + afterFind() { }, + }; + for (const hook in hooks) { + spyOn(hooks, hook).and.callThrough(); + Parse.Cloud[hook](Parse.File, hooks[hook]); + } + await expectAsync( + request({ + url: file.url(), headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), }, + }).catch(e => { + throw new Parse.Error(e.data.code, e.data.error); }) - }) - .then(body => { - session2 = body.sessionToken; + ).toBeRejectedWith(new Parse.Error(Parse.Error.SCRIPT_FAILED, 'unauthorized')); - //Ensure both session tokens are in the cache - return Parse.Cloud.run('checkStaleUser') - }) - .then(() => rp({ - method: 'POST', - uri: 'http://localhost:8378/1/functions/checkStaleUser', - json: true, + expect(hooks.beforeFind).toHaveBeenCalled(); + expect(hooks.afterFind).not.toHaveBeenCalled(); + }); + + it('afterFind can throw', async () => { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const user = await Parse.User.signUp('username', 'password'); + const hooks = { + beforeFind() { }, + afterFind() { + throw 'unauthorized'; + }, + }; + for (const hook in hooks) { + spyOn(hooks, hook).and.callThrough(); + Parse.Cloud[hook](Parse.File, hooks[hook]); + } + await expectAsync( + request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }).catch(e => { + throw new Parse.Error(e.data.code, e.data.error); + }) + ).toBeRejectedWith(new Parse.Error(Parse.Error.SCRIPT_FAILED, 'unauthorized')); + for (const hook in hooks) { + expect(hooks[hook]).toHaveBeenCalled(); + } + }); + + it('can force download', async () => { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const user = await Parse.User.signUp('username', 'password'); + Parse.Cloud.afterFind(Parse.File, req => { + req.forceDownload = true; + }); + const response = await request({ + url: file.url(), headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Session-Token': session2, - } - })) - .then(() => Parse.Promise.all([cacheAdapter.get('test:user:' + session1), cacheAdapter.get('test:user:' + session2)])) - .then(cachedVals => { - expect(cachedVals[0].objectId).toEqual(user.id); - expect(cachedVals[1].objectId).toEqual(user.id); - - //Change with session 1 and then read with session 2. - user.set('data', 'second data'); - return user.save() - }) - .then(() => rp({ - method: 'POST', - uri: 'http://localhost:8378/1/functions/checkStaleUser', - json: true, + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }); + expect(response.headers['content-disposition']).toBe(`attachment;filename=${file._name}`); + }); + + it('can set custom response headers in afterFind', async () => { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + Parse.Cloud.afterFind(Parse.File, req => { + req.responseHeaders['X-Custom-Header'] = 'custom-value'; + }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }); + expect(response.headers['x-custom-header']).toBe('custom-value'); + expect(response.headers['x-content-type-options']).toBe('nosniff'); + }); + + it('can override default response headers in afterFind', async () => { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + Parse.Cloud.afterFind(Parse.File, req => { + delete req.responseHeaders['X-Content-Type-Options']; + }); + const response = await request({ + url: file.url(), headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Session-Token': session2, + }, + }); + expect(response.headers['x-content-type-options']).toBeUndefined(); + }); + + it('beforeFind blocks metadata endpoint', async () => { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + Parse.Cloud.beforeFind(Parse.File, () => { + throw 'unauthorized'; + }); + await expectAsync( + request({ + url: `http://localhost:8378/1/files/test/metadata/${file._name}`, + }).catch(e => { + throw new Parse.Error(e.data.code, e.data.error); + }) + ).toBeRejectedWith(new Parse.Error(Parse.Error.SCRIPT_FAILED, 'unauthorized')); + }); +}); + +describe('Cloud Config hooks', () => { + function testConfig() { + return Parse.Config.save({ internal: 'i', string: 's', number: 12 }, { internal: true }); + } + + it_id('997fe20a-96f7-454a-a5b0-c155b8d02f05')(it)('beforeSave(Parse.Config) can run hook with new config', async () => { + let count = 0; + Parse.Cloud.beforeSave(Parse.Config, (req) => { + expect(req.object).toBeDefined(); + expect(req.original).toBeUndefined(); + expect(req.user).toBeUndefined(); + expect(req.headers).toBeDefined(); + expect(req.ip).toBeDefined(); + expect(req.installationId).toBeDefined(); + expect(req.context).toBeDefined(); + const config = req.object; + expect(config.get('internal')).toBe('i'); + expect(config.get('string')).toBe('s'); + expect(config.get('number')).toBe(12); + count += 1; + }); + await testConfig(); + const config = await Parse.Config.get({ useMasterKey: true }); + expect(config.get('internal')).toBe('i'); + expect(config.get('string')).toBe('s'); + expect(config.get('number')).toBe(12); + expect(count).toBe(1); + }); + + it_id('06a9b66c-ffb4-43d1-a025-f7d2192500e7')(it)('beforeSave(Parse.Config) can run hook with existing config', async () => { + let count = 0; + Parse.Cloud.beforeSave(Parse.Config, (req) => { + if (count === 0) { + expect(req.object.get('number')).toBe(12); + expect(req.original).toBeUndefined(); } - })) - .then(body => { - expect(body.result).toEqual('second data'); - done(); - }) - .catch(error => { - fail(JSON.stringify(error)); - done(); + if (count === 1) { + expect(req.object.get('number')).toBe(13); + expect(req.original.get('number')).toBe(12); + } + count += 1; }); + await testConfig(); + await Parse.Config.save({ number: 13 }); + expect(count).toBe(2); }); - it('trivial beforeSave should not affect fetched pointers (regression test for #1238)', done => { - Parse.Cloud.beforeSave('BeforeSaveUnchanged', (req, res) => { - res.success(); - }); - - var TestObject = Parse.Object.extend("TestObject"); - var NoBeforeSaveObject = Parse.Object.extend("NoBeforeSave"); - var BeforeSaveObject = Parse.Object.extend("BeforeSaveUnchanged"); - - var aTestObject = new TestObject(); - aTestObject.set("foo", "bar"); - aTestObject.save() - .then(aTestObject => { - var aNoBeforeSaveObj = new NoBeforeSaveObject(); - aNoBeforeSaveObj.set("aTestObject", aTestObject); - expect(aNoBeforeSaveObj.get("aTestObject").get("foo")).toEqual("bar"); - return aNoBeforeSaveObj.save(); - }) - .then(aNoBeforeSaveObj => { - expect(aNoBeforeSaveObj.get("aTestObject").get("foo")).toEqual("bar"); - - var aBeforeSaveObj = new BeforeSaveObject(); - aBeforeSaveObj.set("aTestObject", aTestObject); - expect(aBeforeSaveObj.get("aTestObject").get("foo")).toEqual("bar"); - return aBeforeSaveObj.save(); - }) - .then(aBeforeSaveObj => { - expect(aBeforeSaveObj.get("aTestObject").get("foo")).toEqual("bar"); - done(); + it_id('ca76de8e-671b-4c2d-9535-bd28a855fa1a')(it)('beforeSave(Parse.Config) should not change config if nothing is returned', async () => { + let count = 0; + Parse.Cloud.beforeSave(Parse.Config, () => { + count += 1; + return; }); + await testConfig(); + const config = await Parse.Config.get({ useMasterKey: true }); + expect(config.get('internal')).toBe('i'); + expect(config.get('string')).toBe('s'); + expect(config.get('number')).toBe(12); + expect(count).toBe(1); }); - it_exclude_dbs(['postgres'])('should fully delete objects when using `unset` with beforeSave (regression test for #1840)', done => { - var TestObject = Parse.Object.extend('TestObject'); - var NoBeforeSaveObject = Parse.Object.extend('NoBeforeSave'); - var BeforeSaveObject = Parse.Object.extend('BeforeSaveChanged'); + it('beforeSave(Parse.Config) throw custom error', async () => { + Parse.Cloud.beforeSave(Parse.Config, () => { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'It should fail'); + }); + try { + await testConfig(); + fail('error should have thrown'); + } catch (e) { + expect(e.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(e.message).toBe('It should fail'); + } + }); - Parse.Cloud.beforeSave('BeforeSaveChanged', (req, res) => { - var object = req.object; - object.set('before', 'save'); - res.success(); + it('beforeSave(Parse.Config) throw string error', async () => { + Parse.Cloud.beforeSave(Parse.Config, () => { + throw 'before save failed'; }); + try { + await testConfig(); + fail('error should have thrown'); + } catch (e) { + expect(e.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(e.message).toBe('before save failed'); + } + }); - Parse.Cloud.define('removeme', (req, res) => { - var testObject = new TestObject(); - testObject.save() - .then(testObject => { - var object = new NoBeforeSaveObject({remove: testObject}); - return object.save(); - }) - .then(object => { - object.unset('remove'); - return object.save(); - }) - .then(object => { - res.success(object); - }); + it('beforeSave(Parse.Config) throw empty error', async () => { + Parse.Cloud.beforeSave(Parse.Config, () => { + throw null; }); + try { + await testConfig(); + fail('error should have thrown'); + } catch (e) { + expect(e.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(e.message).toBe('Script failed. Unknown error.'); + } + }); - Parse.Cloud.define('removeme2', (req, res) => { - var testObject = new TestObject(); - testObject.save() - .then(testObject => { - var object = new BeforeSaveObject({remove: testObject}); - return object.save(); - }) - .then(object => { - object.unset('remove'); - return object.save(); - }) - .then(object => { - res.success(object); - }); + it_id('3e7a75c0-6c2e-4c7e-b042-6eb5f23acf94')(it)('afterSave(Parse.Config) can run hook with new config', async () => { + let count = 0; + Parse.Cloud.afterSave(Parse.Config, (req) => { + expect(req.object).toBeDefined(); + expect(req.original).toBeUndefined(); + expect(req.user).toBeUndefined(); + expect(req.headers).toBeDefined(); + expect(req.ip).toBeDefined(); + expect(req.installationId).toBeDefined(); + expect(req.context).toBeDefined(); + const config = req.object; + expect(config.get('internal')).toBe('i'); + expect(config.get('string')).toBe('s'); + expect(config.get('number')).toBe(12); + count += 1; }); + await testConfig(); + const config = await Parse.Config.get({ useMasterKey: true }); + expect(config.get('internal')).toBe('i'); + expect(config.get('string')).toBe('s'); + expect(config.get('number')).toBe(12); + expect(count).toBe(1); + }); - Parse.Cloud.run('removeme') - .then(aNoBeforeSaveObj => { - expect(aNoBeforeSaveObj.get('remove')).toEqual(undefined); - - return Parse.Cloud.run('removeme2'); - }) - .then(aBeforeSaveObj => { - expect(aBeforeSaveObj.get('before')).toEqual('save'); - expect(aBeforeSaveObj.get('remove')).toEqual(undefined); - done(); + it_id('5cffb28a-2924-4857-84bb-f5778d80372a')(it)('afterSave(Parse.Config) can run hook with existing config', async () => { + let count = 0; + Parse.Cloud.afterSave(Parse.Config, (req) => { + if (count === 0) { + expect(req.object.get('number')).toBe(12); + expect(req.original).toBeUndefined(); + } + if (count === 1) { + expect(req.object.get('number')).toBe(13); + expect(req.original.get('number')).toBe(12); + } + count += 1; }); + await testConfig(); + await Parse.Config.save({ number: 13 }); + expect(count).toBe(2); }); - it_exclude_dbs(['postgres'])('should fully delete objects when using `unset` with beforeSave (regression test for #1840)', done => { - var TestObject = Parse.Object.extend('TestObject'); - var BeforeSaveObject = Parse.Object.extend('BeforeSaveChanged'); + it_id('49883992-ce91-4797-85f9-7cce1f819407')(it)('afterSave(Parse.Config) should throw error', async () => { + Parse.Cloud.afterSave(Parse.Config, () => { + throw new Parse.Error(400, 'It should fail'); + }); + try { + await testConfig(); + fail('error should have thrown'); + } catch (e) { + expect(e.code).toBe(400); + expect(e.message).toBe('It should fail'); + } + }); +}); - Parse.Cloud.beforeSave('BeforeSaveChanged', (req, res) => { - var object = req.object; - object.set('before', 'save'); - object.unset('remove'); - res.success(); +describe('sendEmail', () => { + it('can send email via Parse.Cloud', async done => { + const emailAdapter = { + sendMail: mailData => { + expect(mailData).toBeDefined(); + expect(mailData.to).toBe('test'); + reconfigureServer().then(done, done); + }, + }; + await reconfigureServer({ + emailAdapter: emailAdapter, }); + const mailData = { to: 'test' }; + await Parse.Cloud.sendEmail(mailData); + }); - let object; - let testObject = new TestObject({key: 'value'}); - testObject.save().then(() => { - object = new BeforeSaveObject(); - return object.save().then(() => { - object.set({remove:testObject}) - return object.save(); - }); - }).then((objectAgain) => { - expect(objectAgain.get('remove')).toBeUndefined(); - expect(object.get('remove')).toBeUndefined(); - done(); - }).fail((err) => { - console.error(err); - done(); - }) + it('cannot send email without adapter', async () => { + const logger = require('../lib/logger').logger; + spyOn(logger, 'error').and.callFake(() => { }); + await Parse.Cloud.sendEmail({}); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to send email because no mail adapter is configured for Parse Server.' + ); }); +}); - it_exclude_dbs(['postgres'])('should not include relation op (regression test for #1606)', done => { - var TestObject = Parse.Object.extend('TestObject'); - var BeforeSaveObject = Parse.Object.extend('BeforeSaveChanged'); - let testObj; - Parse.Cloud.beforeSave('BeforeSaveChanged', (req, res) => { - var object = req.object; - object.set('before', 'save'); - testObj = new TestObject(); - testObj.save().then(() => { - object.relation('testsRelation').add(testObj); - res.success(); - }) +describe('beforePasswordResetRequest hook', () => { + it('should run beforePasswordResetRequest with valid user', async () => { + let hit = 0; + let sendPasswordResetEmailCalled = false; + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => { + sendPasswordResetEmailCalled = true; + }, + sendMail: () => {}, + }; + + await reconfigureServer({ + appName: 'test', + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', }); - let object = new BeforeSaveObject(); - object.save().then((objectAgain) => { - // Originally it would throw as it would be a non-relation - expect(() => { objectAgain.relation('testsRelation') }).not.toThrow(); - done(); - }).fail((err) => { - console.error(err); - done(); - }) + Parse.Cloud.beforePasswordResetRequest(req => { + hit++; + expect(req.object).toBeDefined(); + expect(req.object.get('email')).toEqual('test@example.com'); + expect(req.object.get('username')).toEqual('testuser'); + }); + + const user = new Parse.User(); + user.setUsername('testuser'); + user.setPassword('password'); + user.set('email', 'test@example.com'); + await user.signUp(); + + await Parse.User.requestPasswordReset('test@example.com'); + expect(hit).toBe(1); + expect(sendPasswordResetEmailCalled).toBe(true); + }); + + it('should be able to block password reset request if an error is thrown', async () => { + let hit = 0; + let sendPasswordResetEmailCalled = false; + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => { + sendPasswordResetEmailCalled = true; + }, + sendMail: () => {}, + }; + + await reconfigureServer({ + appName: 'test', + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }); + + Parse.Cloud.beforePasswordResetRequest(req => { + hit++; + throw new Error('password reset blocked'); + }); + + const user = new Parse.User(); + user.setUsername('testuser'); + user.setPassword('password'); + user.set('email', 'test@example.com'); + await user.signUp(); + + try { + await Parse.User.requestPasswordReset('test@example.com'); + throw new Error('should not have sent password reset email.'); + } catch (e) { + expect(e.message).toBe('password reset blocked'); + } + expect(hit).toBe(1); + expect(sendPasswordResetEmailCalled).toBe(false); + }); + + it('should not run beforePasswordResetRequest if email does not exist', async () => { + let hit = 0; + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => {}, + sendMail: () => {}, + }; + + await reconfigureServer({ + appName: 'test', + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }); + + Parse.Cloud.beforePasswordResetRequest(req => { + hit++; + }); + + await Parse.User.requestPasswordReset('nonexistent@example.com'); + + expect(hit).toBe(0); + }); + + it('should have expected data in request in beforePasswordResetRequest', async () => { + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => {}, + sendMail: () => {}, + }; + + await reconfigureServer({ + appName: 'test', + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }); + + const base64 = 'V29ya2luZyBhdCBQYXJzZSBpcyBncmVhdCE='; + const file = new Parse.File('myfile.txt', { base64 }); + await file.save(); + + Parse.Cloud.beforePasswordResetRequest(req => { + expect(req.object).toBeDefined(); + expect(req.object.get('email')).toBeDefined(); + expect(req.object.get('email')).toBe('test2@example.com'); + expect(req.object.get('file')).toBeDefined(); + expect(req.object.get('file')).toBeInstanceOf(Parse.File); + expect(req.object.get('file').name()).toContain('myfile.txt'); + expect(req.headers).toBeDefined(); + expect(req.ip).toBeDefined(); + expect(req.installationId).toBeDefined(); + expect(req.context).toBeDefined(); + expect(req.config).toBeDefined(); + }); + + const user = new Parse.User(); + user.setUsername('testuser2'); + user.setPassword('password'); + user.set('email', 'test2@example.com'); + user.set('file', file); + await user.signUp(); + + await Parse.User.requestPasswordReset('test2@example.com'); + }); + + it('should validate that only _User class is allowed for beforePasswordResetRequest', () => { + expect(() => { + Parse.Cloud.beforePasswordResetRequest('SomeClass', () => { }); + }).toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers'); + expect(() => { + Parse.Cloud.beforePasswordResetRequest(() => { }); + }).not.toThrow(); + expect(() => { + Parse.Cloud.beforePasswordResetRequest('_User', () => { }); + }).not.toThrow(); + expect(() => { + Parse.Cloud.beforePasswordResetRequest(Parse.User, () => { }); + }).not.toThrow(); + }); + + describe('Express-style cloud functions with (req, res) parameters', () => { + it('should support express-style cloud function with res.success()', async () => { + Parse.Cloud.define('expressStyleFunction', (req, res) => { + res.success({ message: 'Hello from express style!' }); + }); + + const result = await Parse.Cloud.run('expressStyleFunction', {}); + expect(result.message).toEqual('Hello from express style!'); + }); + + it('should support express-style cloud function with res.error()', async () => { + Parse.Cloud.define('expressStyleError', (req, res) => { + res.error('Custom error message'); + }); + + await expectAsync(Parse.Cloud.run('expressStyleError', {})).toBeRejectedWith( + new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Custom error message') + ); + }); + + it('should support setting custom HTTP status code with res.status().success()', async () => { + Parse.Cloud.define('customStatusCode', (req, res) => { + res.status(201).success({ created: true }); + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/customStatusCode', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + body: {}, + }); + + expect(response.status).toBe(201); + expect(response.data.result.created).toBe(true); + }); + + it('should support 401 unauthorized status code with error', async () => { + Parse.Cloud.define('unauthorizedFunction', (req, res) => { + if (!req.user) { + res.status(401).error('Unauthorized access'); + } else { + res.success({ message: 'Authorized' }); + } + }); + + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/unauthorizedFunction', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + body: {}, + }) + ).toBeRejected(); + }); + + it('should support 404 not found status code with error', async () => { + Parse.Cloud.define('notFoundFunction', (req, res) => { + res.status(404).error('Resource not found'); + }); + + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/notFoundFunction', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + body: {}, + }) + ).toBeRejected(); + }); + + it('should default to 200 status code when not specified', async () => { + Parse.Cloud.define('defaultStatusCode', (req, res) => { + res.success({ message: 'Default status' }); + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/defaultStatusCode', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + body: {}, + }); + + expect(response.status).toBe(200); + expect(response.data.result.message).toBe('Default status'); + }); + + it('should maintain backward compatibility with single-parameter functions', async () => { + Parse.Cloud.define('traditionalFunction', (req) => { + return { message: 'Traditional style works!' }; + }); + + const result = await Parse.Cloud.run('traditionalFunction', {}); + expect(result.message).toEqual('Traditional style works!'); + }); + + it('should maintain backward compatibility with implicit return functions', async () => { + Parse.Cloud.define('implicitReturnFunction', () => 'Implicit return works!'); + + const result = await Parse.Cloud.run('implicitReturnFunction', {}); + expect(result).toEqual('Implicit return works!'); + }); + + it('should support async express-style functions', async () => { + Parse.Cloud.define('asyncExpressStyle', async (req, res) => { + await new Promise(resolve => setTimeout(resolve, 10)); + res.success({ async: true }); + }); + + const result = await Parse.Cloud.run('asyncExpressStyle', {}); + expect(result.async).toBe(true); + }); + + it('should access request parameters in express-style functions', async () => { + Parse.Cloud.define('expressWithParams', (req, res) => { + const { name } = req.params; + res.success({ greeting: `Hello, ${name}!` }); + }); + + const result = await Parse.Cloud.run('expressWithParams', { name: 'World' }); + expect(result.greeting).toEqual('Hello, World!'); + }); + + it('should access user in express-style functions', async () => { + const user = new Parse.User(); + user.set('username', 'testuser'); + user.set('password', 'testpass'); + await user.signUp(); + + Parse.Cloud.define('expressWithUser', (req, res) => { + if (req.user) { + res.success({ username: req.user.get('username') }); + } else { + res.status(401).error('Not authenticated'); + } + }); + + const result = await Parse.Cloud.run('expressWithUser', {}); + expect(result.username).toEqual('testuser'); + + await Parse.User.logOut(); + }); + + it('should support setting custom headers with res.header()', async () => { + Parse.Cloud.define('customHeaderFunction', (req, res) => { + res.header('X-Custom-Header', 'custom-value').success({ message: 'OK' }); + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/customHeaderFunction', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + body: {}, + }); + + expect(response.status).toBe(200); + expect(response.headers['x-custom-header']).toBe('custom-value'); + expect(response.data.result.message).toBe('OK'); + }); + + it('should support setting multiple custom headers', async () => { + Parse.Cloud.define('multipleHeadersFunction', (req, res) => { + res.header('X-Header-One', 'value1') + .header('X-Header-Two', 'value2') + .success({ message: 'Multiple headers' }); + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/multipleHeadersFunction', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + body: {}, + }); + + expect(response.status).toBe(200); + expect(response.headers['x-header-one']).toBe('value1'); + expect(response.headers['x-header-two']).toBe('value2'); + expect(response.data.result.message).toBe('Multiple headers'); + }); + + it('should support combining status code and custom headers', async () => { + Parse.Cloud.define('statusAndHeaderFunction', (req, res) => { + res.status(201) + .header('X-Resource-Id', '12345') + .success({ created: true }); + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/statusAndHeaderFunction', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + body: {}, + }); + + expect(response.status).toBe(201); + expect(response.headers['x-resource-id']).toBe('12345'); + expect(response.data.result.created).toBe(true); + }); }); }); diff --git a/spec/CloudCodeLogger.spec.js b/spec/CloudCodeLogger.spec.js index 145a6155b9..16d9d02950 100644 --- a/spec/CloudCodeLogger.spec.js +++ b/spec/CloudCodeLogger.spec.js @@ -1,67 +1,404 @@ -'use strict'; -var LoggerController = require('../src/Controllers/LoggerController').LoggerController; -var FileLoggerAdapter = require('../src/Adapters/Logger/FileLoggerAdapter').FileLoggerAdapter; - -describe("Cloud Code Logger", () => { - it("should expose log to functions", (done) => { - var logController = new LoggerController(new FileLoggerAdapter()); - - Parse.Cloud.define("loggerTest", (req, res) => { - req.log.info('logTest', 'info log', {info: 'some log' }); - req.log.error('logTest','error log', {error: 'there was an error'}); - res.success({}); - }); - - Parse.Cloud.run('loggerTest').then(() => { - return logController.getLogs({from: Date.now() - 500, size: 1000}); - }).then((res) => { - expect(res.length).not.toBe(0); - let lastLogs = res.slice(0, 3); - let cloudFunctionMessage = lastLogs[0]; - let errorMessage = lastLogs[1]; - let infoMessage = lastLogs[2]; - expect(cloudFunctionMessage.level).toBe('info'); - expect(cloudFunctionMessage.params).toEqual({}); - expect(cloudFunctionMessage.message).toEqual('Ran cloud function loggerTest with:\nInput: {}\nResult: {}'); - expect(cloudFunctionMessage.functionName).toEqual('loggerTest'); - expect(errorMessage.level).toBe('error'); - expect(errorMessage.error).toBe('there was an error'); - expect(errorMessage.message).toBe('logTest error log'); - expect(infoMessage.level).toBe('info'); - expect(infoMessage.info).toBe('some log'); - expect(infoMessage.message).toBe('logTest info log'); - done(); - }); - }); - - it("should expose log to trigger", (done) => { - var logController = new LoggerController(new FileLoggerAdapter()); - - Parse.Cloud.beforeSave("MyObject", (req, res) => { - req.log.info('beforeSave MyObject', 'info log', {info: 'some log' }); - req.log.error('beforeSave MyObject','error log', {error: 'there was an error'}); - res.success({}); - }); - - let obj = new Parse.Object('MyObject'); - obj.save().then(() => { - return logController.getLogs({from: Date.now() - 500, size: 1000}) - }).then((res) => { - expect(res.length).not.toBe(0); - let lastLogs = res.slice(0, 3); - let cloudTriggerMessage = lastLogs[0]; - let errorMessage = lastLogs[1]; - let infoMessage = lastLogs[2]; - expect(cloudTriggerMessage.level).toBe('info'); - expect(cloudTriggerMessage.input).toEqual({}); - expect(cloudTriggerMessage.message).toEqual('beforeSave triggered for MyObject\nInput: {}\nResult: {}'); - expect(errorMessage.level).toBe('error'); - expect(errorMessage.error).toBe('there was an error'); - expect(errorMessage.message).toBe('beforeSave MyObject error log'); - expect(infoMessage.level).toBe('info'); - expect(infoMessage.info).toBe('some log'); - expect(infoMessage.message).toBe('beforeSave MyObject info log'); - done(); - }); +const LoggerController = require('../lib/Controllers/LoggerController').LoggerController; +const WinstonLoggerAdapter = require('../lib/Adapters/Logger/WinstonLoggerAdapter') + .WinstonLoggerAdapter; +const fs = require('fs'); +const Config = require('../lib/Config'); + +const loremFile = __dirname + '/support/lorem.txt'; + +describe('Cloud Code Logger', () => { + let user; + let spy; + beforeEach(async () => { + Parse.User.enableUnsafeCurrentUser(); + return reconfigureServer({ + // useful to flip to false for fine tuning :). + silent: true, + logLevel: undefined, + logLevels: { + cloudFunctionError: 'error', + cloudFunctionSuccess: 'info', + triggerAfter: 'info', + triggerBeforeError: 'error', + triggerBeforeSuccess: 'info', + }, + }) + .then(() => { + return Parse.User.signUp('tester', 'abc') + .catch(() => { }) + .then(loggedInUser => (user = loggedInUser)) + .then(() => Parse.User.logIn(user.get('username'), 'abc')); + }) + .then(() => { + spy = spyOn(Config.get('test').loggerController.adapter, 'log').and.callThrough(); + }); + }); + + // Note that helpers takes care of logout. + // see helpers.js:afterEach + + it_id('02d53b97-3ec7-46fb-abb6-176fd6e85590')(it)('should expose log to functions', () => { + const spy = spyOn(Config.get('test').loggerController, 'log').and.callThrough(); + Parse.Cloud.define('loggerTest', req => { + req.log.info('logTest', 'info log', { info: 'some log' }); + req.log.error('logTest', 'error log', { error: 'there was an error' }); + return {}; + }); + + return Parse.Cloud.run('loggerTest').then(() => { + expect(spy).toHaveBeenCalledTimes(3); + const cloudFunctionMessage = spy.calls.all()[2]; + const errorMessage = spy.calls.all()[1]; + const infoMessage = spy.calls.all()[0]; + expect(cloudFunctionMessage.args[0]).toBe('info'); + expect(cloudFunctionMessage.args[1][1].params).toEqual({}); + expect(cloudFunctionMessage.args[1][0]).toMatch( + /Ran cloud function loggerTest for user [^ ]* with:\n {2}Input: {}\n {2}Result: {}/ + ); + expect(cloudFunctionMessage.args[1][1].functionName).toEqual('loggerTest'); + expect(errorMessage.args[0]).toBe('error'); + expect(errorMessage.args[1][2].error).toBe('there was an error'); + expect(errorMessage.args[1][0]).toBe('logTest'); + expect(errorMessage.args[1][1]).toBe('error log'); + expect(infoMessage.args[0]).toBe('info'); + expect(infoMessage.args[1][2].info).toBe('some log'); + expect(infoMessage.args[1][0]).toBe('logTest'); + expect(infoMessage.args[1][1]).toBe('info log'); + }); + }); + + it_id('768412f5-d32f-4134-89a6-08949781a6c0')(it)('trigger should obfuscate password', done => { + Parse.Cloud.beforeSave(Parse.User, req => { + return req.object; + }); + + Parse.User.signUp('tester123', 'abc') + .then(() => { + const entry = spy.calls.mostRecent().args; + expect(entry[1]).not.toMatch(/password":"abc/); + expect(entry[1]).toMatch(/\*\*\*\*\*\*\*\*/); + done(); + }) + .then(null, e => done.fail(e)); + }); + + it_id('3c394047-272e-4728-9d02-9eaa660d2ed2')(it)('should expose log to trigger', done => { + Parse.Cloud.beforeSave('MyObject', req => { + req.log.info('beforeSave MyObject', 'info log', { info: 'some log' }); + req.log.error('beforeSave MyObject', 'error log', { + error: 'there was an error', + }); + return {}; + }); + + const obj = new Parse.Object('MyObject'); + obj.save().then(() => { + const lastCalls = spy.calls.all().reverse(); + const cloudTriggerMessage = lastCalls[0].args; + const errorMessage = lastCalls[1].args; + const infoMessage = lastCalls[2].args; + expect(cloudTriggerMessage[0]).toBe('info'); + expect(cloudTriggerMessage[2].triggerType).toEqual('beforeSave'); + expect(cloudTriggerMessage[1]).toMatch( + /beforeSave triggered for MyObject for user [^ ]*\n {2}Input: {}\n {2}Result: {"object":{}}/ + ); + expect(cloudTriggerMessage[2].user).toBe(user.id); + expect(errorMessage[0]).toBe('error'); + expect(errorMessage[3].error).toBe('there was an error'); + expect(errorMessage[1] + ' ' + errorMessage[2]).toBe('beforeSave MyObject error log'); + expect(infoMessage[0]).toBe('info'); + expect(infoMessage[3].info).toBe('some log'); + expect(infoMessage[1] + ' ' + infoMessage[2]).toBe('beforeSave MyObject info log'); + done(); + }); + }); + + it('should truncate really long lines when asked to', () => { + const logController = new LoggerController(new WinstonLoggerAdapter()); + const longString = fs.readFileSync(loremFile, 'utf8'); + const truncatedString = logController.truncateLogMessage(longString); + expect(truncatedString.length).toBe(1015); // truncate length + the string '... (truncated)' + }); + + it_id('4a009b1f-9203-49ca-8d48-5b45f4eedbdf')(it)('should truncate input and result of long lines', done => { + const longString = fs.readFileSync(loremFile, 'utf8'); + Parse.Cloud.define('aFunction', req => { + return req.params; + }); + + Parse.Cloud.run('aFunction', { longString }) + .then(() => { + const log = spy.calls.mostRecent().args; + expect(log[0]).toEqual('info'); + expect(log[1]).toMatch( + /Ran cloud function aFunction for user [^ ]* with:\n {2}Input: {.*?\(truncated\)$/m + ); + done(); + }) + .then(null, e => done.fail(e)); + }); + + it_id('9857e15d-bb18-478d-8a67-fdaad3e89565')(it)('should log an afterSave', done => { + Parse.Cloud.afterSave('MyObject', () => { }); + new Parse.Object('MyObject') + .save() + .then(() => { + const log = spy.calls.mostRecent().args; + expect(log[2].triggerType).toEqual('afterSave'); + done(); + }) + // catch errors - not that the error is actually useful :( + .then(null, e => done.fail(e)); + }); + + it_id('ec13a296-f8b1-4fc6-985a-3593462edd9c')(it)('should log a denied beforeSave', done => { + Parse.Cloud.beforeSave('MyObject', () => { + throw 'uh oh!'; + }); + + new Parse.Object('MyObject') + .save() + .then( + () => done.fail('this is not supposed to succeed'), + () => new Promise(resolve => setTimeout(resolve, 100)) + ) + .then(() => { + const logs = spy.calls.all().reverse(); + const log = logs[1].args; // 0 is the 'uh oh!' from rejection... + expect(log[0]).toEqual('error'); + const error = log[2].error; + expect(error instanceof Parse.Error).toBeTruthy(); + expect(error.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(error.message).toBe('uh oh!'); + done(); + }); + }); + + it_id('3e0caa45-60d6-41af-829a-fd389710c132')(it)('should log cloud function success', done => { + Parse.Cloud.define('aFunction', () => { + return 'it worked!'; + }); + + Parse.Cloud.run('aFunction', { foo: 'bar' }).then(() => { + const log = spy.calls.mostRecent().args; + expect(log[0]).toEqual('info'); + expect(log[1]).toMatch( + /Ran cloud function aFunction for user [^ ]* with:\n {2}Input: {"foo":"bar"}\n {2}Result: "it worked!/ + ); + done(); + }); + }); + + it_id('8088de8a-7cba-4035-8b05-4a903307e674')(it)('should log cloud function execution using the custom log level', async () => { + Parse.Cloud.define('aFunction', () => { + return 'it worked!'; + }); + + Parse.Cloud.define('bFunction', () => { + throw new Error('Failed'); + }); + + await Parse.Cloud.run('aFunction', { foo: 'bar' }).then(() => { + const log = spy.calls.allArgs().find(log => log[1].startsWith('Ran cloud function '))?.[0]; + expect(log).toEqual('info'); + }); + + Parse.Cloud._removeAllHooks(); + await reconfigureServer({ + silent: true, + logLevels: { + cloudFunctionSuccess: 'warn', + cloudFunctionError: 'info', + }, + }); + + Parse.Cloud.define('bFunction', () => { + throw new Error('Failed'); + }); + + spy = spyOn(Config.get('test').loggerController.adapter, 'log').and.callThrough(); + + try { + await Parse.Cloud.run('bFunction', { foo: 'bar' }); + throw new Error('bFunction should have failed'); + } catch { + const log = spy.calls + .allArgs() + .find(log => log[1].startsWith('Failed running cloud function bFunction for '))?.[0]; + expect(log).toEqual('info'); + } + }); + + it('should log cloud function triggers using the custom log level', async () => { + const execTest = async (logLevel, triggerBeforeSuccess, triggerAfter) => { + Parse.Cloud._removeAllHooks(); + await reconfigureServer({ + silent: true, + logLevel, + logLevels: { + triggerAfter, + triggerBeforeSuccess, + }, + }); + + let afterSaveResolve; + const afterSavePromise = new Promise(resolve => { afterSaveResolve = resolve; }); + Parse.Cloud.beforeSave('TestClass', () => { }); + Parse.Cloud.afterSave('TestClass', () => { afterSaveResolve(); }); + + spy = spyOn(Config.get('test').loggerController.adapter, 'log').and.callThrough(); + const obj = new Parse.Object('TestClass'); + await obj.save(); + await afterSavePromise; + + return { + beforeSave: spy.calls + .allArgs() + .find(log => log[1].startsWith('beforeSave triggered for TestClass for user '))?.[0], + afterSave: spy.calls + .allArgs() + .find(log => log[1].startsWith('afterSave triggered for TestClass for user '))?.[0], + }; + }; + + let calls = await execTest('silly', 'silly', 'debug'); + expect(calls).toEqual({ beforeSave: 'silly', afterSave: 'debug' }); + + calls = await execTest('info', 'warn', 'debug'); + expect(calls).toEqual({ beforeSave: 'warn', afterSave: undefined }); + }); + + it_id('97e0eafa-cde6-4a9a-9e53-7db98bacbc62')(it)('should log cloud function failure', done => { + Parse.Cloud.define('aFunction', () => { + throw 'it failed!'; + }); + + Parse.Cloud.run('aFunction', { foo: 'bar' }) + .catch(() => { }) + .then(() => { + const logs = spy.calls.all().reverse(); + expect(logs[0].args[1]).toBe('Parse error: '); + expect(logs[0].args[2].message).toBe('it failed!'); + + const log = logs[1].args; + expect(log[0]).toEqual('error'); + expect(log[1]).toMatch( + /Failed running cloud function aFunction for user [^ ]* with:\n {2}Input: {"foo":"bar"}\n {2}Error:/ + ); + const errorString = JSON.stringify( + new Parse.Error(Parse.Error.SCRIPT_FAILED, 'it failed!') + ); + expect(log[1].indexOf(errorString)).toBeGreaterThan(0); + done(); + }) + .catch(done.fail); + }); + + xit('should log a changed beforeSave indicating a change', done => { + pending('needs more work.....'); + const logController = new LoggerController(new WinstonLoggerAdapter()); + + Parse.Cloud.beforeSave('MyObject', req => { + const myObj = req.object; + myObj.set('aChange', true); + return myObj; + }); + + new Parse.Object('MyObject') + .save() + .then(() => logController.getLogs({ from: Date.now() - 500, size: 1000 })) + .then(() => { + // expect the log to indicate that it has changed + /* + Here's what it looks like on parse.com... + + Input: {"original":{"clientVersion":"1","createdAt":"2016-06-02T05:29:08.694Z","image":{"__type":"File","name":"tfss-xxxxxxxx.png","url":"http://files.parsetfss.com/xxxxxxxx.png"},"lastScanDate":{"__type":"Date","iso":"2016-06-02T05:28:58.135Z"},"localIdentifier":"XXXXX","objectId":"OFHMX7ZUcI","status":... (truncated) + Result: Update changed to {"object":{"__type":"Pointer","className":"Emoticode","objectId":"ksrq7z3Ehc"},"imageThumb":{"__type":"File","name":"tfss-xxxxxxx.png","url":"http://files.parsetfss.com/xxxxx.png"},"status":"success"} + */ + done(); + }) + .then(null, e => done.fail(JSON.stringify(e))); + }); + + it_id('b86e8168-8370-4730-a4ba-24ca3016ad66')(it)('cloud function should obfuscate password', done => { + Parse.Cloud.define('testFunction', () => { + return 'verify code success'; + }); + + Parse.Cloud.run('testFunction', { username: 'hawk', password: '123456' }) + .then(() => { + const entry = spy.calls.mostRecent().args; + expect(entry[2].params.password).toMatch(/\*\*\*\*\*\*\*\*/); + done(); + }) + .then(null, e => done.fail(e)); + }); + + it('should only log once for object not found', async () => { + const config = Config.get('test'); + const spy = spyOn(config.loggerController, 'error').and.callThrough(); + try { + const object = new Parse.Object('Object'); + object.id = 'invalid'; + await object.fetch(); + } catch (e) { + /**/ + } + expect(spy).toHaveBeenCalled(); + expect(spy.calls.count()).toBe(1); + const { args } = spy.calls.mostRecent(); + expect(args[0]).toBe('Parse error: '); + expect(args[1].message).toBe('Object not found.'); + }); + + it('should log cloud function execution using the silent log level', async () => { + Parse.Cloud._removeAllHooks(); + await reconfigureServer({ + logLevels: { + cloudFunctionSuccess: 'silent', + cloudFunctionError: 'silent', + }, + }); + Parse.Cloud.define('aFunction', () => { + return 'it worked!'; + }); + Parse.Cloud.define('bFunction', () => { + throw new Error('Failed'); }); + spy = spyOn(Config.get('test').loggerController.adapter, 'log').and.callThrough(); + + await Parse.Cloud.run('aFunction', { foo: 'bar' }); + expect(spy).toHaveBeenCalledTimes(0); + + await expectAsync(Parse.Cloud.run('bFunction', { foo: 'bar' })).toBeRejected(); + // Not "Failed running cloud function message..." + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should log cloud function triggers using the silent log level', async () => { + Parse.Cloud._removeAllHooks(); + await reconfigureServer({ + logLevels: { + triggerAfter: 'silent', + triggerBeforeSuccess: 'silent', + triggerBeforeError: 'silent', + }, + }); + Parse.Cloud.beforeSave('TestClassError', () => { + throw new Error('Failed'); + }); + Parse.Cloud.beforeSave('TestClass', () => { }); + Parse.Cloud.afterSave('TestClass', () => { }); + + spy = spyOn(Config.get('test').loggerController.adapter, 'log').and.callThrough(); + + const obj = new Parse.Object('TestClass'); + await obj.save(); + expect(spy).toHaveBeenCalledTimes(0); + + const objError = new Parse.Object('TestClassError'); + await expectAsync(objError.save()).toBeRejected(); + // Not "beforeSave failed for TestClassError for user ..." + expect(spy).toHaveBeenCalledTimes(1); + }); }); diff --git a/spec/CloudCodeMultipart.spec.js b/spec/CloudCodeMultipart.spec.js new file mode 100644 index 0000000000..b2f60c0761 --- /dev/null +++ b/spec/CloudCodeMultipart.spec.js @@ -0,0 +1,366 @@ +'use strict'; +const http = require('http'); + +function postMultipart(url, headers, body) { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const req = http.request( + { + method: 'POST', + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname, + headers, + }, + res => { + const chunks = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString(); + try { + resolve({ status: res.statusCode, data: JSON.parse(raw) }); + } catch { + resolve({ status: res.statusCode, data: raw }); + } + }); + } + ); + req.on('error', reject); + req.write(body); + req.end(); + }); +} + +function buildMultipartBody(boundary, parts) { + const segments = []; + for (const part of parts) { + segments.push(`--${boundary}\r\n`); + if (part.filename) { + segments.push( + `Content-Disposition: form-data; name="${part.name}"; filename="${part.filename}"\r\n` + ); + segments.push(`Content-Type: ${part.contentType || 'application/octet-stream'}\r\n\r\n`); + segments.push(part.data); + } else { + segments.push(`Content-Disposition: form-data; name="${part.name}"\r\n\r\n`); + segments.push(part.value); + } + segments.push('\r\n'); + } + segments.push(`--${boundary}--\r\n`); + return Buffer.concat(segments.map(s => (typeof s === 'string' ? Buffer.from(s) : s))); +} + +describe('Cloud Code Multipart', () => { + it('should not reject multipart requests at the JSON parser level', async () => { + Parse.Cloud.define('multipartTest', req => { + return { received: true }; + }); + + const boundary = '----TestBoundary123'; + const body = buildMultipartBody(boundary, [ + { name: 'key', value: 'value' }, + ]); + + const result = await postMultipart( + `http://localhost:8378/1/functions/multipartTest`, + { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body + ); + + expect(result.status).not.toBe(400); + }); + + it('should parse text fields from multipart request', async () => { + Parse.Cloud.define('multipartText', req => { + return { userId: req.params.userId, count: req.params.count }; + }); + + const boundary = '----TestBoundary456'; + const body = buildMultipartBody(boundary, [ + { name: 'userId', value: 'abc123' }, + { name: 'count', value: '5' }, + ]); + + const result = await postMultipart( + `http://localhost:8378/1/functions/multipartText`, + { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body + ); + + expect(result.status).toBe(200); + expect(result.data.result.userId).toBe('abc123'); + expect(result.data.result.count).toBe('5'); + }); + + it('should parse file fields from multipart request', async () => { + Parse.Cloud.define('multipartFile', req => { + const file = req.params.avatar; + return { + filename: file.filename, + contentType: file.contentType, + size: file.data.length, + content: file.data.toString('utf8'), + }; + }); + + const boundary = '----TestBoundary789'; + const fileContent = Buffer.from('hello world'); + const body = buildMultipartBody(boundary, [ + { name: 'avatar', filename: 'photo.txt', contentType: 'text/plain', data: fileContent }, + ]); + + const result = await postMultipart( + `http://localhost:8378/1/functions/multipartFile`, + { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body + ); + + expect(result.status).toBe(200); + expect(result.data.result.filename).toBe('photo.txt'); + expect(result.data.result.contentType).toBe('text/plain'); + expect(result.data.result.size).toBe(11); + expect(result.data.result.content).toBe('hello world'); + }); + + it('should parse mixed text and file fields from multipart request', async () => { + Parse.Cloud.define('multipartMixed', req => { + return { + userId: req.params.userId, + hasAvatar: !!req.params.avatar, + avatarFilename: req.params.avatar.filename, + }; + }); + + const boundary = '----TestBoundaryMixed'; + const body = buildMultipartBody(boundary, [ + { name: 'userId', value: 'user42' }, + { name: 'avatar', filename: 'img.jpg', contentType: 'image/jpeg', data: Buffer.from([0xff, 0xd8, 0xff]) }, + ]); + + const result = await postMultipart( + `http://localhost:8378/1/functions/multipartMixed`, + { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body + ); + + expect(result.status).toBe(200); + expect(result.data.result.userId).toBe('user42'); + expect(result.data.result.hasAvatar).toBe(true); + expect(result.data.result.avatarFilename).toBe('img.jpg'); + }); + + it('should parse multiple file fields from multipart request', async () => { + Parse.Cloud.define('multipartMultiFile', req => { + return { + file1Name: req.params.doc1.filename, + file2Name: req.params.doc2.filename, + file1Size: req.params.doc1.data.length, + file2Size: req.params.doc2.data.length, + }; + }); + + const boundary = '----TestBoundaryMulti'; + const body = buildMultipartBody(boundary, [ + { name: 'doc1', filename: 'a.txt', contentType: 'text/plain', data: Buffer.from('aaa') }, + { name: 'doc2', filename: 'b.txt', contentType: 'text/plain', data: Buffer.from('bbbbb') }, + ]); + + const result = await postMultipart( + `http://localhost:8378/1/functions/multipartMultiFile`, + { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body + ); + + expect(result.status).toBe(200); + expect(result.data.result.file1Name).toBe('a.txt'); + expect(result.data.result.file2Name).toBe('b.txt'); + expect(result.data.result.file1Size).toBe(3); + expect(result.data.result.file2Size).toBe(5); + }); + + it('should handle empty file field from multipart request', async () => { + Parse.Cloud.define('multipartEmptyFile', req => { + return { + filename: req.params.empty.filename, + size: req.params.empty.data.length, + }; + }); + + const boundary = '----TestBoundaryEmpty'; + const body = buildMultipartBody(boundary, [ + { name: 'empty', filename: 'empty.bin', contentType: 'application/octet-stream', data: Buffer.alloc(0) }, + ]); + + const result = await postMultipart( + `http://localhost:8378/1/functions/multipartEmptyFile`, + { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body + ); + + expect(result.status).toBe(200); + expect(result.data.result.filename).toBe('empty.bin'); + expect(result.data.result.size).toBe(0); + }); + + it('should still handle JSON requests as before', async () => { + Parse.Cloud.define('jsonTest', req => { + return { name: req.params.name, count: req.params.count }; + }); + + const result = await Parse.Cloud.run('jsonTest', { name: 'hello', count: 42 }); + + expect(result.name).toBe('hello'); + expect(result.count).toBe(42); + }); + + it('should reject multipart request exceeding maxUploadSize', async () => { + await reconfigureServer({ maxUploadSize: '1kb' }); + + Parse.Cloud.define('multipartLarge', req => { + return { ok: true }; + }); + + const boundary = '----TestBoundaryLarge'; + const largeData = Buffer.alloc(2 * 1024, 'x'); + const body = buildMultipartBody(boundary, [ + { name: 'bigfile', filename: 'large.bin', contentType: 'application/octet-stream', data: largeData }, + ]); + + const result = await postMultipart( + `http://localhost:8378/1/functions/multipartLarge`, + { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body + ); + + expect(result.data.code).toBe(Parse.Error.OBJECT_TOO_LARGE); + }); + + it('should reject multipart request exceeding maxUploadSize via file stream', async () => { + await reconfigureServer({ maxUploadSize: '1kb' }); + + Parse.Cloud.define('multipartLargeFile', req => { + return { ok: true }; + }); + + const boundary = '----TestBoundaryLargeFile'; + const body = buildMultipartBody(boundary, [ + { name: 'small', value: 'ok' }, + { name: 'bigfile', filename: 'large.bin', contentType: 'application/octet-stream', data: Buffer.alloc(2 * 1024, 'x') }, + ]); + + const result = await postMultipart( + `http://localhost:8378/1/functions/multipartLargeFile`, + { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body + ); + + expect(result.data.code).toBe(Parse.Error.OBJECT_TOO_LARGE); + }); + + it('should reject malformed multipart body', async () => { + Parse.Cloud.define('multipartMalformed', req => { + return { ok: true }; + }); + + const result = await postMultipart( + `http://localhost:8378/1/functions/multipartMalformed`, + { + 'Content-Type': 'multipart/form-data; boundary=----TestBoundaryBad', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + Buffer.from('this is not valid multipart data') + ); + + expect(result.data.code).toBe(Parse.Error.INVALID_JSON); + }); + + it('should not allow prototype pollution via __proto__ field name', async () => { + Parse.Cloud.define('multipartProto', req => { + const obj = {}; + return { + polluted: obj.polluted !== undefined, + paramsClean: Object.getPrototypeOf(req.params) === Object.prototype, + }; + }); + + const boundary = '----TestBoundaryProto'; + const body = buildMultipartBody(boundary, [ + { name: '__proto__', value: '{"polluted":"yes"}' }, + ]); + + const result = await postMultipart( + `http://localhost:8378/1/functions/multipartProto`, + { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body + ); + + expect(result.status).toBe(200); + expect(result.data.result.polluted).toBe(false); + expect(result.data.result.paramsClean).toBe(true); + }); + + it('should not grant master key access via multipart fields', async () => { + const obj = new Parse.Object('SecretClass'); + await obj.save(null, { useMasterKey: true }); + + Parse.Cloud.define('multipartAuthCheck', req => { + return { isMaster: req.master }; + }); + + const boundary = '----TestBoundaryAuth'; + const body = buildMultipartBody(boundary, [ + { name: '_MasterKey', value: 'test' }, + ]); + + const result = await postMultipart( + `http://localhost:8378/1/functions/multipartAuthCheck`, + { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body + ); + + expect(result.status).toBe(200); + expect(result.data.result.isMaster).toBe(false); + }); +}); diff --git a/spec/DatabaseController.spec.js b/spec/DatabaseController.spec.js new file mode 100644 index 0000000000..dc2f84bc5f --- /dev/null +++ b/spec/DatabaseController.spec.js @@ -0,0 +1,968 @@ +const Config = require('../lib/Config'); +const DatabaseController = require('../lib/Controllers/DatabaseController.js'); +const validateQuery = DatabaseController._validateQuery; + +describe('DatabaseController', function () { + describe('validateQuery', function () { + it('should not restructure simple cases of SERVER-13732', done => { + const query = { + $or: [{ a: 1 }, { a: 2 }], + _rperm: { $in: ['a', 'b'] }, + foo: 3, + }; + validateQuery(query); + expect(query).toEqual({ + $or: [{ a: 1 }, { a: 2 }], + _rperm: { $in: ['a', 'b'] }, + foo: 3, + }); + done(); + }); + + it('should not restructure SERVER-13732 queries with $nears', done => { + let query = { $or: [{ a: 1 }, { b: 1 }], c: { $nearSphere: {} } }; + validateQuery(query); + expect(query).toEqual({ + $or: [{ a: 1 }, { b: 1 }], + c: { $nearSphere: {} }, + }); + query = { $or: [{ a: 1 }, { b: 1 }], c: { $near: {} } }; + validateQuery(query); + expect(query).toEqual({ $or: [{ a: 1 }, { b: 1 }], c: { $near: {} } }); + done(); + }); + + it('should not push refactored keys down a tree for SERVER-13732', done => { + const query = { + a: 1, + $or: [{ $or: [{ b: 1 }, { b: 2 }] }, { $or: [{ c: 1 }, { c: 2 }] }], + }; + validateQuery(query); + expect(query).toEqual({ + a: 1, + $or: [{ $or: [{ b: 1 }, { b: 2 }] }, { $or: [{ c: 1 }, { c: 2 }] }], + }); + + done(); + }); + + it('should reject invalid queries', done => { + expect(() => validateQuery({ $or: { a: 1 } })).toThrow(); + done(); + }); + + it('should accept valid queries', done => { + expect(() => validateQuery({ $or: [{ a: 1 }, { b: 2 }] })).not.toThrow(); + done(); + }); + }); + + describe('addPointerPermissions', function () { + const CLASS_NAME = 'Foo'; + const USER_ID = 'userId'; + const ACL_GROUP = [USER_ID]; + const OPERATION = 'find'; + + const databaseController = new DatabaseController(); + const schemaController = jasmine.createSpyObj('SchemaController', [ + 'testPermissionsForClassName', + 'getClassLevelPermissions', + 'getExpectedType', + ]); + + it('should not decorate query if no pointer CLPs are present', done => { + const clp = buildCLP(); + const query = { a: 'b' }; + + schemaController.testPermissionsForClassName + .withArgs(CLASS_NAME, ACL_GROUP, OPERATION) + .and.returnValue(true); + schemaController.getClassLevelPermissions.withArgs(CLASS_NAME).and.returnValue(clp); + + const output = databaseController.addPointerPermissions( + schemaController, + CLASS_NAME, + OPERATION, + query, + ACL_GROUP + ); + + expect(output).toEqual({ ...query }); + + done(); + }); + + it('should decorate query if a pointer CLP entry is present', done => { + const clp = buildCLP(['user']); + const query = { a: 'b' }; + + schemaController.testPermissionsForClassName + .withArgs(CLASS_NAME, ACL_GROUP, OPERATION) + .and.returnValue(false); + schemaController.getClassLevelPermissions.withArgs(CLASS_NAME).and.returnValue(clp); + schemaController.getExpectedType + .withArgs(CLASS_NAME, 'user') + .and.returnValue({ type: 'Pointer' }); + + const output = databaseController.addPointerPermissions( + schemaController, + CLASS_NAME, + OPERATION, + query, + ACL_GROUP + ); + + expect(output).toEqual({ ...query, user: createUserPointer(USER_ID) }); + + done(); + }); + + it('should decorate query if an array CLP entry is present', done => { + const clp = buildCLP(['users']); + const query = { a: 'b' }; + + schemaController.testPermissionsForClassName + .withArgs(CLASS_NAME, ACL_GROUP, OPERATION) + .and.returnValue(false); + schemaController.getClassLevelPermissions.withArgs(CLASS_NAME).and.returnValue(clp); + schemaController.getExpectedType + .withArgs(CLASS_NAME, 'users') + .and.returnValue({ type: 'Array' }); + + const output = databaseController.addPointerPermissions( + schemaController, + CLASS_NAME, + OPERATION, + query, + ACL_GROUP + ); + + expect(output).toEqual({ + ...query, + users: { $all: [createUserPointer(USER_ID)] }, + }); + + done(); + }); + + it('should decorate query if an object CLP entry is present', done => { + const clp = buildCLP(['user']); + const query = { a: 'b' }; + + schemaController.testPermissionsForClassName + .withArgs(CLASS_NAME, ACL_GROUP, OPERATION) + .and.returnValue(false); + schemaController.getClassLevelPermissions.withArgs(CLASS_NAME).and.returnValue(clp); + schemaController.getExpectedType + .withArgs(CLASS_NAME, 'user') + .and.returnValue({ type: 'Object' }); + + const output = databaseController.addPointerPermissions( + schemaController, + CLASS_NAME, + OPERATION, + query, + ACL_GROUP + ); + + expect(output).toEqual({ + ...query, + user: createUserPointer(USER_ID), + }); + + done(); + }); + + it('should decorate query if a pointer CLP is present and the same field is part of the query', done => { + const clp = buildCLP(['user']); + const query = { a: 'b', user: 'a' }; + + schemaController.testPermissionsForClassName + .withArgs(CLASS_NAME, ACL_GROUP, OPERATION) + .and.returnValue(false); + schemaController.getClassLevelPermissions.withArgs(CLASS_NAME).and.returnValue(clp); + schemaController.getExpectedType + .withArgs(CLASS_NAME, 'user') + .and.returnValue({ type: 'Pointer' }); + + const output = databaseController.addPointerPermissions( + schemaController, + CLASS_NAME, + OPERATION, + query, + ACL_GROUP + ); + + expect(output).toEqual({ + $and: [{ user: createUserPointer(USER_ID) }, { ...query }], + }); + + done(); + }); + + it('should transform the query to an $or query if multiple array/pointer CLPs are present', done => { + const clp = buildCLP(['user', 'users', 'userObject']); + const query = { a: 'b' }; + + schemaController.testPermissionsForClassName + .withArgs(CLASS_NAME, ACL_GROUP, OPERATION) + .and.returnValue(false); + schemaController.getClassLevelPermissions.withArgs(CLASS_NAME).and.returnValue(clp); + schemaController.getExpectedType + .withArgs(CLASS_NAME, 'user') + .and.returnValue({ type: 'Pointer' }); + schemaController.getExpectedType + .withArgs(CLASS_NAME, 'users') + .and.returnValue({ type: 'Array' }); + schemaController.getExpectedType + .withArgs(CLASS_NAME, 'userObject') + .and.returnValue({ type: 'Object' }); + + const output = databaseController.addPointerPermissions( + schemaController, + CLASS_NAME, + OPERATION, + query, + ACL_GROUP + ); + + expect(output).toEqual({ + $or: [ + { ...query, user: createUserPointer(USER_ID) }, + { ...query, users: { $all: [createUserPointer(USER_ID)] } }, + { ...query, userObject: createUserPointer(USER_ID) }, + ], + }); + + done(); + }); + + it('should not return a $or operation if the query involves one of the two fields also used as array/pointer permissions', done => { + const clp = buildCLP(['users', 'user']); + const query = { a: 'b', user: createUserPointer(USER_ID) }; + schemaController.testPermissionsForClassName + .withArgs(CLASS_NAME, ACL_GROUP, OPERATION) + .and.returnValue(false); + schemaController.getClassLevelPermissions.withArgs(CLASS_NAME).and.returnValue(clp); + schemaController.getExpectedType + .withArgs(CLASS_NAME, 'user') + .and.returnValue({ type: 'Pointer' }); + schemaController.getExpectedType + .withArgs(CLASS_NAME, 'users') + .and.returnValue({ type: 'Array' }); + const output = databaseController.addPointerPermissions( + schemaController, + CLASS_NAME, + OPERATION, + query, + ACL_GROUP + ); + expect(output).toEqual({ ...query, user: createUserPointer(USER_ID) }); + done(); + }); + + it('should not return a $or operation if the query involves one of the fields also used as array/pointer permissions', done => { + const clp = buildCLP(['user', 'users', 'userObject']); + const query = { a: 'b', user: createUserPointer(USER_ID) }; + schemaController.testPermissionsForClassName + .withArgs(CLASS_NAME, ACL_GROUP, OPERATION) + .and.returnValue(false); + schemaController.getClassLevelPermissions.withArgs(CLASS_NAME).and.returnValue(clp); + schemaController.getExpectedType + .withArgs(CLASS_NAME, 'user') + .and.returnValue({ type: 'Pointer' }); + schemaController.getExpectedType + .withArgs(CLASS_NAME, 'users') + .and.returnValue({ type: 'Array' }); + schemaController.getExpectedType + .withArgs(CLASS_NAME, 'userObject') + .and.returnValue({ type: 'Object' }); + const output = databaseController.addPointerPermissions( + schemaController, + CLASS_NAME, + OPERATION, + query, + ACL_GROUP + ); + expect(output).toEqual({ ...query, user: createUserPointer(USER_ID) }); + done(); + }); + + it('should throw an error if for some unexpected reason the property specified in the CLP is neither a pointer nor an array', done => { + const clp = buildCLP(['user']); + const query = { a: 'b' }; + + schemaController.testPermissionsForClassName + .withArgs(CLASS_NAME, ACL_GROUP, OPERATION) + .and.returnValue(false); + schemaController.getClassLevelPermissions.withArgs(CLASS_NAME).and.returnValue(clp); + schemaController.getExpectedType + .withArgs(CLASS_NAME, 'user') + .and.returnValue({ type: 'Number' }); + + expect(() => { + databaseController.addPointerPermissions( + schemaController, + CLASS_NAME, + OPERATION, + query, + ACL_GROUP + ); + }).toThrow( + Error( + `An unexpected condition occurred when resolving pointer permissions: ${CLASS_NAME} user` + ) + ); + + done(); + }); + }); + + describe('reduceOperations', function () { + const databaseController = new DatabaseController(); + + it('objectToEntriesStrings', done => { + const output = databaseController.objectToEntriesStrings({ a: 1, b: 2, c: 3 }); + expect(output).toEqual(['"a":1', '"b":2', '"c":3']); + done(); + }); + + it('reduceOrOperation', done => { + expect(databaseController.reduceOrOperation({ a: 1 })).toEqual({ a: 1 }); + expect(databaseController.reduceOrOperation({ $or: [{ a: 1 }, { b: 2 }] })).toEqual({ + $or: [{ a: 1 }, { b: 2 }], + }); + expect(databaseController.reduceOrOperation({ $or: [{ a: 1 }, { a: 2 }] })).toEqual({ + $or: [{ a: 1 }, { a: 2 }], + }); + expect(databaseController.reduceOrOperation({ $or: [{ a: 1 }, { a: 1 }] })).toEqual({ a: 1 }); + expect( + databaseController.reduceOrOperation({ $or: [{ a: 1, b: 2, c: 3 }, { a: 1 }] }) + ).toEqual({ a: 1 }); + expect( + databaseController.reduceOrOperation({ $or: [{ b: 2 }, { a: 1, b: 2, c: 3 }] }) + ).toEqual({ b: 2 }); + done(); + }); + + it('reduceAndOperation', done => { + expect(databaseController.reduceAndOperation({ a: 1 })).toEqual({ a: 1 }); + expect(databaseController.reduceAndOperation({ $and: [{ a: 1 }, { b: 2 }] })).toEqual({ + $and: [{ a: 1 }, { b: 2 }], + }); + expect(databaseController.reduceAndOperation({ $and: [{ a: 1 }, { a: 2 }] })).toEqual({ + $and: [{ a: 1 }, { a: 2 }], + }); + expect(databaseController.reduceAndOperation({ $and: [{ a: 1 }, { a: 1 }] })).toEqual({ + a: 1, + }); + expect( + databaseController.reduceAndOperation({ $and: [{ a: 1, b: 2, c: 3 }, { b: 2 }] }) + ).toEqual({ a: 1, b: 2, c: 3 }); + done(); + }); + }); + + describe('enableCollationCaseComparison', () => { + const dummyStorageAdapter = { + find: () => Promise.resolve([]), + watch: () => Promise.resolve(), + getAllClasses: () => Promise.resolve([]), + }; + + beforeEach(() => { + Config.get(Parse.applicationId).schemaCache.clear(); + }); + + it('should force caseInsensitive to false with enableCollationCaseComparison option', async () => { + const databaseController = new DatabaseController(dummyStorageAdapter, { + enableCollationCaseComparison: true, + }); + const spy = spyOn(dummyStorageAdapter, 'find'); + spy.and.callThrough(); + await databaseController.find('SomeClass', {}, { caseInsensitive: true }); + expect(spy.calls.all()[0].args[3].caseInsensitive).toEqual(false); + }); + + it('should support caseInsensitive without enableCollationCaseComparison option', async () => { + const databaseController = new DatabaseController(dummyStorageAdapter, {}); + const spy = spyOn(dummyStorageAdapter, 'find'); + spy.and.callThrough(); + await databaseController.find('_User', {}, { caseInsensitive: true }); + expect(spy.calls.all()[0].args[3].caseInsensitive).toEqual(true); + }); + + it_only_db('mongo')( + 'should create insensitive indexes without enableCollationCaseComparison', + async () => { + await reconfigureServer({ + databaseURI: 'mongodb://localhost:27017/enableCollationCaseComparisonFalse', + databaseAdapter: undefined, + }); + const user = new Parse.User(); + await user.save({ + username: 'example', + password: 'password', + email: 'example@example.com', + }); + const schemas = await Parse.Schema.all(); + const UserSchema = schemas.find(({ className }) => className === '_User'); + expect(UserSchema.indexes).toEqual({ + _id_: { _id: 1 }, + username_1: { username: 1 }, + case_insensitive_username: { username: 1 }, + case_insensitive_email: { email: 1 }, + email_1: { email: 1 }, + _email_verify_token: { _email_verify_token: 1 }, + _perishable_token: { _perishable_token: 1 }, + _auth_data_custom_id: { '_auth_data_custom.id': 1 }, + _auth_data_facebook_id: { '_auth_data_facebook.id': 1 }, + _auth_data_myoauth_id: { '_auth_data_myoauth.id': 1 }, + _auth_data_shortLivedAuth_id: { '_auth_data_shortLivedAuth.id': 1 }, + _auth_data_anonymous_id: { '_auth_data_anonymous.id': 1 }, + }); + } + ); + + it_only_db('mongo')( + 'should not create insensitive indexes with enableCollationCaseComparison', + async () => { + await reconfigureServer({ + enableCollationCaseComparison: true, + databaseURI: 'mongodb://localhost:27017/enableCollationCaseComparisonTrue', + databaseAdapter: undefined, + }); + const user = new Parse.User(); + await user.save({ + username: 'example', + password: 'password', + email: 'example@example.com', + }); + const schemas = await Parse.Schema.all(); + const UserSchema = schemas.find(({ className }) => className === '_User'); + expect(UserSchema.indexes).toEqual({ + _id_: { _id: 1 }, + username_1: { username: 1 }, + email_1: { email: 1 }, + _email_verify_token: { _email_verify_token: 1 }, + _perishable_token: { _perishable_token: 1 }, + _auth_data_custom_id: { '_auth_data_custom.id': 1 }, + _auth_data_facebook_id: { '_auth_data_facebook.id': 1 }, + _auth_data_myoauth_id: { '_auth_data_myoauth.id': 1 }, + _auth_data_shortLivedAuth_id: { '_auth_data_shortLivedAuth.id': 1 }, + _auth_data_anonymous_id: { '_auth_data_anonymous.id': 1 }, + }); + } + ); + + it_only_db('mongo')( + 'should use _email_verify_token index in email verification', + async () => { + const TestUtils = require('../lib/TestUtils'); + let emailVerificationLink; + const emailSentPromise = TestUtils.resolvingPromise(); + const emailAdapter = { + sendVerificationEmail: options => { + emailVerificationLink = options.link; + emailSentPromise.resolve(); + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + await reconfigureServer({ + databaseURI: 'mongodb://localhost:27017/testEmailVerifyTokenIndexStats', + databaseAdapter: undefined, + appName: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }); + + // Create a user to trigger email verification + const user = new Parse.User(); + user.setUsername('statsuser'); + user.setPassword('password'); + user.set('email', 'stats@example.com'); + await user.signUp(); + await emailSentPromise; + + // Get index stats before the query + const config = Config.get(Parse.applicationId); + const collection = await config.database.adapter._adaptiveCollection('_User'); + const statsBefore = await collection._mongoCollection.aggregate([ + { $indexStats: {} }, + ]).toArray(); + const emailVerifyIndexBefore = statsBefore.find( + stat => stat.name === '_email_verify_token' + ); + const accessesBefore = emailVerifyIndexBefore?.accesses?.ops || 0; + + // Perform email verification (this should use the index) + const request = require('../lib/request'); + await request({ + url: emailVerificationLink, + followRedirects: false, + }); + + // Get index stats after the query + const statsAfter = await collection._mongoCollection.aggregate([ + { $indexStats: {} }, + ]).toArray(); + const emailVerifyIndexAfter = statsAfter.find( + stat => stat.name === '_email_verify_token' + ); + const accessesAfter = emailVerifyIndexAfter?.accesses?.ops || 0; + + // Verify the index was actually used + expect(accessesAfter).toBeGreaterThan(accessesBefore); + expect(emailVerifyIndexAfter).toBeDefined(); + + // Verify email verification succeeded + await user.fetch(); + expect(user.get('emailVerified')).toBe(true); + } + ); + + it_only_db('mongo')( + 'should use _perishable_token index in password reset', + async () => { + const TestUtils = require('../lib/TestUtils'); + let passwordResetLink; + const emailSentPromise = TestUtils.resolvingPromise(); + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + passwordResetLink = options.link; + emailSentPromise.resolve(); + }, + sendMail: () => {}, + }; + await reconfigureServer({ + databaseURI: 'mongodb://localhost:27017/testPerishableTokenIndexStats', + databaseAdapter: undefined, + appName: 'test', + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }); + + // Create a user + const user = new Parse.User(); + user.setUsername('statsuser2'); + user.setPassword('oldpassword'); + user.set('email', 'stats2@example.com'); + await user.signUp(); + + // Request password reset + await Parse.User.requestPasswordReset('stats2@example.com'); + await emailSentPromise; + + const url = new URL(passwordResetLink); + const token = url.searchParams.get('token'); + + // Get index stats before the query + const config = Config.get(Parse.applicationId); + const collection = await config.database.adapter._adaptiveCollection('_User'); + const statsBefore = await collection._mongoCollection.aggregate([ + { $indexStats: {} }, + ]).toArray(); + const perishableTokenIndexBefore = statsBefore.find( + stat => stat.name === '_perishable_token' + ); + const accessesBefore = perishableTokenIndexBefore?.accesses?.ops || 0; + + // Perform password reset (this should use the index) + const request = require('../lib/request'); + await request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: { new_password: 'newpassword', token, username: 'statsuser2' }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + followRedirects: false, + }); + + // Get index stats after the query + const statsAfter = await collection._mongoCollection.aggregate([ + { $indexStats: {} }, + ]).toArray(); + const perishableTokenIndexAfter = statsAfter.find( + stat => stat.name === '_perishable_token' + ); + const accessesAfter = perishableTokenIndexAfter?.accesses?.ops || 0; + + // Verify the index was actually used + expect(accessesAfter).toBeGreaterThan(accessesBefore); + expect(perishableTokenIndexAfter).toBeDefined(); + } + ); + }); + + describe('convertEmailToLowercase', () => { + const dummyStorageAdapter = { + createObject: () => Promise.resolve({ ops: [{}] }), + findOneAndUpdate: () => Promise.resolve({}), + watch: () => Promise.resolve(), + getAllClasses: () => + Promise.resolve([ + { + className: '_User', + fields: { email: 'String' }, + indexes: {}, + classLevelPermissions: { protectedFields: {} }, + }, + ]), + }; + const dates = { + createdAt: { iso: undefined, __type: 'Date' }, + updatedAt: { iso: undefined, __type: 'Date' }, + }; + + it('should not transform email to lower case without convertEmailToLowercase option on create', async () => { + const databaseController = new DatabaseController(dummyStorageAdapter, {}); + const spy = spyOn(dummyStorageAdapter, 'createObject'); + spy.and.callThrough(); + await databaseController.create('_User', { + email: 'EXAMPLE@EXAMPLE.COM', + }); + expect(spy.calls.all()[0].args[2]).toEqual({ + email: 'EXAMPLE@EXAMPLE.COM', + ...dates, + }); + }); + + it('should transform email to lower case with convertEmailToLowercase option on create', async () => { + const databaseController = new DatabaseController(dummyStorageAdapter, { + convertEmailToLowercase: true, + }); + const spy = spyOn(dummyStorageAdapter, 'createObject'); + spy.and.callThrough(); + await databaseController.create('_User', { + email: 'EXAMPLE@EXAMPLE.COM', + }); + expect(spy.calls.all()[0].args[2]).toEqual({ + email: 'example@example.com', + ...dates, + }); + }); + + it('should not transform email to lower case without convertEmailToLowercase option on update', async () => { + const databaseController = new DatabaseController(dummyStorageAdapter, {}); + const spy = spyOn(dummyStorageAdapter, 'findOneAndUpdate'); + spy.and.callThrough(); + await databaseController.update('_User', { id: 'example' }, { email: 'EXAMPLE@EXAMPLE.COM' }); + expect(spy.calls.all()[0].args[3]).toEqual({ + email: 'EXAMPLE@EXAMPLE.COM', + }); + }); + + it('should transform email to lower case with convertEmailToLowercase option on update', async () => { + const databaseController = new DatabaseController(dummyStorageAdapter, { + convertEmailToLowercase: true, + }); + const spy = spyOn(dummyStorageAdapter, 'findOneAndUpdate'); + spy.and.callThrough(); + await databaseController.update('_User', { id: 'example' }, { email: 'EXAMPLE@EXAMPLE.COM' }); + expect(spy.calls.all()[0].args[3]).toEqual({ + email: 'example@example.com', + }); + }); + + it('should not find a case insensitive user by email with convertEmailToLowercase', async () => { + await reconfigureServer({ convertEmailToLowercase: true }); + const user = new Parse.User(); + await user.save({ username: 'EXAMPLE', email: 'EXAMPLE@EXAMPLE.COM', password: 'password' }); + + const query = new Parse.Query(Parse.User); + query.equalTo('email', 'EXAMPLE@EXAMPLE.COM'); + const result = await query.find({ useMasterKey: true }); + expect(result.length).toEqual(0); + + const query2 = new Parse.Query(Parse.User); + query2.equalTo('email', 'example@example.com'); + const result2 = await query2.find({ useMasterKey: true }); + expect(result2.length).toEqual(1); + }); + }); + + describe('convertUsernameToLowercase', () => { + const dummyStorageAdapter = { + createObject: () => Promise.resolve({ ops: [{}] }), + findOneAndUpdate: () => Promise.resolve({}), + watch: () => Promise.resolve(), + getAllClasses: () => + Promise.resolve([ + { + className: '_User', + fields: { username: 'String' }, + indexes: {}, + classLevelPermissions: { protectedFields: {} }, + }, + ]), + }; + const dates = { + createdAt: { iso: undefined, __type: 'Date' }, + updatedAt: { iso: undefined, __type: 'Date' }, + }; + + it('should not transform username to lower case without convertUsernameToLowercase option on create', async () => { + const databaseController = new DatabaseController(dummyStorageAdapter, {}); + const spy = spyOn(dummyStorageAdapter, 'createObject'); + spy.and.callThrough(); + await databaseController.create('_User', { + username: 'EXAMPLE', + }); + expect(spy.calls.all()[0].args[2]).toEqual({ + username: 'EXAMPLE', + ...dates, + }); + }); + + it('should transform username to lower case with convertUsernameToLowercase option on create', async () => { + const databaseController = new DatabaseController(dummyStorageAdapter, { + convertUsernameToLowercase: true, + }); + const spy = spyOn(dummyStorageAdapter, 'createObject'); + spy.and.callThrough(); + await databaseController.create('_User', { + username: 'EXAMPLE', + }); + expect(spy.calls.all()[0].args[2]).toEqual({ + username: 'example', + ...dates, + }); + }); + + it('should not transform username to lower case without convertUsernameToLowercase option on update', async () => { + const databaseController = new DatabaseController(dummyStorageAdapter, {}); + const spy = spyOn(dummyStorageAdapter, 'findOneAndUpdate'); + spy.and.callThrough(); + await databaseController.update('_User', { id: 'example' }, { username: 'EXAMPLE' }); + expect(spy.calls.all()[0].args[3]).toEqual({ + username: 'EXAMPLE', + }); + }); + + it('should transform username to lower case with convertUsernameToLowercase option on update', async () => { + const databaseController = new DatabaseController(dummyStorageAdapter, { + convertUsernameToLowercase: true, + }); + const spy = spyOn(dummyStorageAdapter, 'findOneAndUpdate'); + spy.and.callThrough(); + await databaseController.update('_User', { id: 'example' }, { username: 'EXAMPLE' }); + expect(spy.calls.all()[0].args[3]).toEqual({ + username: 'example', + }); + }); + + it('should not find a case insensitive user by username with convertUsernameToLowercase', async () => { + await reconfigureServer({ convertUsernameToLowercase: true }); + const user = new Parse.User(); + await user.save({ username: 'EXAMPLE', password: 'password' }); + + const query = new Parse.Query(Parse.User); + query.equalTo('username', 'EXAMPLE'); + const result = await query.find({ useMasterKey: true }); + expect(result.length).toEqual(0); + + const query2 = new Parse.Query(Parse.User); + query2.equalTo('username', 'example'); + const result2 = await query2.find({ useMasterKey: true }); + expect(result2.length).toEqual(1); + }); + }); + + describe('update with validateOnly', () => { + const mockStorageAdapter = { + findOneAndUpdate: () => Promise.resolve({}), + find: () => Promise.resolve([{ objectId: 'test123', testField: 'initialValue' }]), + watch: () => Promise.resolve(), + getAllClasses: () => + Promise.resolve([ + { + className: 'TestObject', + fields: { testField: 'String' }, + indexes: {}, + classLevelPermissions: { protectedFields: {} }, + }, + ]), + }; + + it('should use primary readPreference when validateOnly is true', async () => { + const databaseController = new DatabaseController(mockStorageAdapter, {}); + const findSpy = spyOn(mockStorageAdapter, 'find').and.callThrough(); + const findOneAndUpdateSpy = spyOn(mockStorageAdapter, 'findOneAndUpdate').and.callThrough(); + + try { + // Call update with validateOnly: true (same as RestWrite.runBeforeSaveTrigger) + await databaseController.update( + 'TestObject', + { objectId: 'test123' }, + { testField: 'newValue' }, + {}, + true, // skipSanitization: true (matches RestWrite behavior) + true // validateOnly: true + ); + } catch (error) { + // validateOnly may throw, but we're checking the find call options + } + + // Verify that find was called with primary readPreference + expect(findSpy).toHaveBeenCalled(); + const findCall = findSpy.calls.mostRecent(); + expect(findCall.args[3]).toEqual({ readPreference: 'primary' }); // options parameter + + // Verify that findOneAndUpdate was NOT called (only validation, no actual update) + expect(findOneAndUpdateSpy).not.toHaveBeenCalled(); + }); + + it('should not use primary readPreference when validateOnly is false', async () => { + const databaseController = new DatabaseController(mockStorageAdapter, {}); + const findSpy = spyOn(mockStorageAdapter, 'find').and.callThrough(); + const findOneAndUpdateSpy = spyOn(mockStorageAdapter, 'findOneAndUpdate').and.callThrough(); + + try { + // Call update with validateOnly: false + await databaseController.update( + 'TestObject', + { objectId: 'test123' }, + { testField: 'newValue' }, + {}, + false, // skipSanitization + false // validateOnly + ); + } catch (error) { + // May throw for other reasons, but we're checking the call pattern + } + + // When validateOnly is false, find should not be called for validation + // Instead, findOneAndUpdate should be called + expect(findSpy).not.toHaveBeenCalled(); + expect(findOneAndUpdateSpy).toHaveBeenCalled(); + }); + }); + + describe_only_db('mongo')('update with many', () => { + it('should return matchedCount and modifiedCount when multiple docs are updated', async () => { + const config = Config.get(Parse.applicationId); + const obj1 = new Parse.Object('TestObject'); + const obj2 = new Parse.Object('TestObject'); + const obj3 = new Parse.Object('TestObject'); + obj1.set('status', 'pending'); + obj2.set('status', 'pending'); + obj3.set('status', 'pending'); + await Parse.Object.saveAll([obj1, obj2, obj3]); + + const result = await config.database.update( + 'TestObject', + { status: 'pending' }, + { status: 'done' }, + { many: true } + ); + + expect(result.matchedCount).toBe(3); + expect(result.modifiedCount).toBe(3); + }); + + it('should return matchedCount > 0 and modifiedCount 0 when values are already current', async () => { + const config = Config.get(Parse.applicationId); + const obj1 = new Parse.Object('TestObject'); + const obj2 = new Parse.Object('TestObject'); + obj1.set('status', 'done'); + obj2.set('status', 'done'); + await Parse.Object.saveAll([obj1, obj2]); + + const result = await config.database.update( + 'TestObject', + { status: 'done' }, + { status: 'done' }, + { many: true } + ); + + expect(result.matchedCount).toBe(2); + expect(result.modifiedCount).toBe(0); + }); + + it('should return matchedCount 0 and modifiedCount 0 when no docs match', async () => { + const config = Config.get(Parse.applicationId); + const result = await config.database.update( + 'TestObject', + { status: 'nonexistent' }, + { status: 'done' }, + { many: true } + ); + + expect(result.matchedCount).toBe(0); + expect(result.modifiedCount).toBe(0); + }); + + it('should return only matchedCount and modifiedCount for op-based updates', async () => { + const config = Config.get(Parse.applicationId); + const obj1 = new Parse.Object('TestObject'); + const obj2 = new Parse.Object('TestObject'); + obj1.set('score', 1); + obj2.set('score', 1); + await Parse.Object.saveAll([obj1, obj2]); + + const result = await config.database.update( + 'TestObject', + { score: { $exists: true } }, + { score: { __op: 'Increment', amount: 5 } }, + { many: true } + ); + + expect(result.matchedCount).toBe(2); + expect(result.modifiedCount).toBe(2); + expect(Object.keys(result)).toEqual(['matchedCount', 'modifiedCount']); + }); + + it('should return raw adapter result when skipSanitization is true', async () => { + const config = Config.get(Parse.applicationId); + const obj1 = new Parse.Object('TestObject'); + obj1.set('status', 'pending'); + await obj1.save(); + + const result = await config.database.update( + 'TestObject', + { status: 'pending' }, + { status: 'done' }, + { many: true }, + true // skipSanitization + ); + + // skipSanitization returns raw adapter result, which for MongoDB + // includes additional fields beyond matchedCount and modifiedCount + expect(result.matchedCount).toBe(1); + expect(result.modifiedCount).toBe(1); + expect(result.acknowledged).toBe(true); + }); + }); +}); + +function buildCLP(pointerNames) { + const OPERATIONS = ['count', 'find', 'get', 'create', 'update', 'delete', 'addField']; + + const clp = OPERATIONS.reduce((acc, op) => { + acc[op] = {}; + + if (pointerNames && pointerNames.length) { + acc[op].pointerFields = pointerNames; + } + + return acc; + }, {}); + + clp.protectedFields = {}; + clp.writeUserFields = []; + clp.readUserFields = []; + + return clp; +} + +function createUserPointer(userId) { + return { + __type: 'Pointer', + className: '_User', + objectId: userId, + }; +} diff --git a/spec/DefinedSchemas.spec.js b/spec/DefinedSchemas.spec.js new file mode 100644 index 0000000000..b2cae864c1 --- /dev/null +++ b/spec/DefinedSchemas.spec.js @@ -0,0 +1,757 @@ +const { DefinedSchemas } = require('../lib/SchemaMigrations/DefinedSchemas'); +const Config = require('../lib/Config'); + +const cleanUpIndexes = schema => { + if (schema.indexes) { + delete schema.indexes._id_; + if (!Object.keys(schema.indexes).length) { + delete schema.indexes; + } + } +}; + +describe('DefinedSchemas', () => { + let config; + afterEach(async () => { + config = Config.get('test'); + if (config) { + await config.database.adapter.deleteAllClasses(); + } + }); + + describe('Fields', () => { + it('should keep default fields if not provided', async () => { + const server = await reconfigureServer(); + // Will perform create + await new DefinedSchemas({ definitions: [{ className: 'Test' }] }, server.config).execute(); + let schema = await new Parse.Schema('Test').get(); + const expectedFields = { + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + }; + expect(schema.fields).toEqual(expectedFields); + + await server.config.schemaCache.clear(); + // Will perform update + await new DefinedSchemas({ definitions: [{ className: 'Test' }] }, server.config).execute(); + schema = await new Parse.Schema('Test').get(); + expect(schema.fields).toEqual(expectedFields); + }); + it('should protect default fields', async () => { + const server = await reconfigureServer(); + + const schemas = { + definitions: [ + { + className: '_User', + fields: { + email: 'Object', + }, + }, + { + className: '_Role', + fields: { + users: 'Object', + }, + }, + { + className: '_Installation', + fields: { + installationId: 'Object', + }, + }, + { + className: 'Test', + fields: { + createdAt: { type: 'Object' }, + objectId: { type: 'Number' }, + updatedAt: { type: 'String' }, + ACL: { type: 'String' }, + }, + }, + ], + }; + + const expectedFields = { + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + }; + + const expectedUserFields = { + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + username: { type: 'String' }, + password: { type: 'String' }, + email: { type: 'String' }, + emailVerified: { type: 'Boolean' }, + authData: { type: 'Object' }, + }; + + const expectedRoleFields = { + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + name: { type: 'String' }, + users: { type: 'Relation', targetClass: '_User' }, + roles: { type: 'Relation', targetClass: '_Role' }, + }; + + const expectedInstallationFields = { + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + installationId: { type: 'String' }, + deviceToken: { type: 'String' }, + channels: { type: 'Array' }, + deviceType: { type: 'String' }, + pushType: { type: 'String' }, + GCMSenderId: { type: 'String' }, + timeZone: { type: 'String' }, + localeIdentifier: { type: 'String' }, + badge: { type: 'Number' }, + appVersion: { type: 'String' }, + appName: { type: 'String' }, + appIdentifier: { type: 'String' }, + parseVersion: { type: 'String' }, + }; + + // Perform create + await new DefinedSchemas(schemas, server.config).execute(); + let schema = await new Parse.Schema('Test').get(); + expect(schema.fields).toEqual(expectedFields); + + let userSchema = await new Parse.Schema('_User').get(); + expect(userSchema.fields).toEqual(expectedUserFields); + + let roleSchema = await new Parse.Schema('_Role').get(); + expect(roleSchema.fields).toEqual(expectedRoleFields); + + let installationSchema = await new Parse.Schema('_Installation').get(); + expect(installationSchema.fields).toEqual(expectedInstallationFields); + + await server.config.schemaCache.clear(); + // Perform update + await new DefinedSchemas(schemas, server.config).execute(); + schema = await new Parse.Schema('Test').get(); + expect(schema.fields).toEqual(expectedFields); + + userSchema = await new Parse.Schema('_User').get(); + expect(userSchema.fields).toEqual(expectedUserFields); + + roleSchema = await new Parse.Schema('_Role').get(); + expect(roleSchema.fields).toEqual(expectedRoleFields); + + installationSchema = await new Parse.Schema('_Installation').get(); + expect(installationSchema.fields).toEqual(expectedInstallationFields); + }); + it('should create new fields', async () => { + const server = await reconfigureServer(); + const fields = { + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + aString: { type: 'String' }, + aStringWithDefault: { type: 'String', defaultValue: 'Test' }, + aStringWithRequired: { type: 'String', required: true }, + aStringWithRequiredAndDefault: { type: 'String', required: true, defaultValue: 'Test' }, + aBoolean: { type: 'Boolean' }, + aFile: { type: 'File' }, + aNumber: { type: 'Number' }, + aRelation: { type: 'Relation', targetClass: '_User' }, + aPointer: { type: 'Pointer', targetClass: '_Role' }, + aDate: { type: 'Date' }, + aGeoPoint: { type: 'GeoPoint' }, + aPolygon: { type: 'Polygon' }, + aArray: { type: 'Array' }, + aObject: { type: 'Object' }, + }; + const schemas = { + definitions: [ + { + className: 'Test', + fields, + }, + ], + }; + + // Create + await new DefinedSchemas(schemas, server.config).execute(); + let schema = await new Parse.Schema('Test').get(); + expect(schema.fields).toEqual(fields); + + fields.anotherObject = { type: 'Object' }; + // Update + await new DefinedSchemas(schemas, server.config).execute(); + schema = await new Parse.Schema('Test').get(); + expect(schema.fields).toEqual(fields); + }); + it('should not delete removed fields when "deleteExtraFields" is false', async () => { + const server = await reconfigureServer(); + + await new DefinedSchemas( + { definitions: [{ className: 'Test', fields: { aField: { type: 'String' } } }] }, + server.config + ).execute(); + + let schema = await new Parse.Schema('Test').get(); + expect(schema.fields.aField).toBeDefined(); + + await new DefinedSchemas({ definitions: [{ className: 'Test' }] }, server.config).execute(); + + schema = await new Parse.Schema('Test').get(); + expect(schema.fields).toEqual({ + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + aField: { type: 'String' }, + ACL: { type: 'ACL' }, + }); + }); + it('should delete removed fields when "deleteExtraFields" is true', async () => { + const server = await reconfigureServer(); + + await new DefinedSchemas( + { + definitions: [{ className: 'Test', fields: { aField: { type: 'String' } } }], + }, + server.config + ).execute(); + + let schema = await new Parse.Schema('Test').get(); + expect(schema.fields.aField).toBeDefined(); + + await new DefinedSchemas( + { deleteExtraFields: true, definitions: [{ className: 'Test' }] }, + server.config + ).execute(); + + schema = await new Parse.Schema('Test').get(); + expect(schema.fields).toEqual({ + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + }); + }); + it('should re create fields with changed type when "recreateModifiedFields" is true', async () => { + const server = await reconfigureServer(); + + await new DefinedSchemas( + { definitions: [{ className: 'Test', fields: { aField: { type: 'String' } } }] }, + server.config + ).execute(); + + let schema = await new Parse.Schema('Test').get(); + expect(schema.fields.aField).toEqual({ type: 'String' }); + + const object = new Parse.Object('Test'); + await object.save({ aField: 'Hello' }, { useMasterKey: true }); + + await new DefinedSchemas( + { + recreateModifiedFields: true, + definitions: [{ className: 'Test', fields: { aField: { type: 'Number' } } }], + }, + server.config + ).execute(); + + schema = await new Parse.Schema('Test').get(); + expect(schema.fields.aField).toEqual({ type: 'Number' }); + + await object.fetch({ useMasterKey: true }); + expect(object.get('aField')).toBeUndefined(); + }); + it('should not re create fields with changed type when "recreateModifiedFields" is not true', async () => { + const server = await reconfigureServer(); + + await new DefinedSchemas( + { definitions: [{ className: 'Test', fields: { aField: { type: 'String' } } }] }, + server.config + ).execute(); + + let schema = await new Parse.Schema('Test').get(); + expect(schema.fields.aField).toEqual({ type: 'String' }); + + const object = new Parse.Object('Test'); + await object.save({ aField: 'Hello' }, { useMasterKey: true }); + + await new DefinedSchemas( + { definitions: [{ className: 'Test', fields: { aField: { type: 'Number' } } }] }, + server.config + ).execute(); + + schema = await new Parse.Schema('Test').get(); + expect(schema.fields.aField).toEqual({ type: 'String' }); + + await object.fetch({ useMasterKey: true }); + expect(object.get('aField')).toBeDefined(); + }); + it('should just update classic fields with changed params', async () => { + const server = await reconfigureServer(); + + await new DefinedSchemas( + { definitions: [{ className: 'Test', fields: { aField: { type: 'String' } } }] }, + server.config + ).execute(); + + let schema = await new Parse.Schema('Test').get(); + expect(schema.fields.aField).toEqual({ type: 'String' }); + + const object = new Parse.Object('Test'); + await object.save({ aField: 'Hello' }, { useMasterKey: true }); + + await new DefinedSchemas( + { + definitions: [ + { className: 'Test', fields: { aField: { type: 'String', required: true } } }, + ], + }, + server.config + ).execute(); + + schema = await new Parse.Schema('Test').get(); + expect(schema.fields.aField).toEqual({ type: 'String', required: true }); + + await object.fetch({ useMasterKey: true }); + expect(object.get('aField')).toEqual('Hello'); + }); + }); + + describe('Indexes', () => { + it('should create new indexes', async () => { + const server = await reconfigureServer(); + + const indexes = { complex: { createdAt: 1, updatedAt: 1 } }; + + const schemas = { + definitions: [{ className: 'Test', fields: { aField: { type: 'String' } }, indexes }], + }; + await new DefinedSchemas(schemas, server.config).execute(); + + let schema = await new Parse.Schema('Test').get(); + cleanUpIndexes(schema); + expect(schema.indexes).toEqual(indexes); + + indexes.complex2 = { createdAt: 1, aField: 1 }; + await new DefinedSchemas(schemas, server.config).execute(); + schema = await new Parse.Schema('Test').get(); + cleanUpIndexes(schema); + expect(schema.indexes).toEqual(indexes); + }); + it('should re create changed indexes', async () => { + const server = await reconfigureServer(); + + let indexes = { complex: { createdAt: 1, updatedAt: 1 } }; + + let schemas = { definitions: [{ className: 'Test', indexes }] }; + await new DefinedSchemas(schemas, server.config).execute(); + + indexes = { complex: { createdAt: 1 } }; + schemas = { definitions: [{ className: 'Test', indexes }] }; + + // Change indexes + await new DefinedSchemas(schemas, server.config).execute(); + let schema = await new Parse.Schema('Test').get(); + cleanUpIndexes(schema); + expect(schema.indexes).toEqual(indexes); + + // Update + await new DefinedSchemas(schemas, server.config).execute(); + schema = await new Parse.Schema('Test').get(); + cleanUpIndexes(schema); + expect(schema.indexes).toEqual(indexes); + }); + + it('should delete unknown indexes when keepUnknownIndexes is not set', async () => { + const server = await reconfigureServer(); + + let indexes = { complex: { createdAt: 1, updatedAt: 1 } }; + + let schemas = { definitions: [{ className: 'Test', indexes }] }; + await new DefinedSchemas(schemas, server.config).execute(); + + indexes = {}; + schemas = { definitions: [{ className: 'Test', indexes }] }; + // Change indexes + await new DefinedSchemas(schemas, server.config).execute(); + let schema = await new Parse.Schema('Test').get(); + cleanUpIndexes(schema); + expect(schema.indexes).toBeUndefined(); + + // Update + await new DefinedSchemas(schemas, server.config).execute(); + schema = await new Parse.Schema('Test').get(); + cleanUpIndexes(schema); + expect(schema.indexes).toBeUndefined(); + }); + + it('should delete unknown indexes when keepUnknownIndexes is set to false', async () => { + const server = await reconfigureServer(); + + let indexes = { complex: { createdAt: 1, updatedAt: 1 } }; + + let schemas = { definitions: [{ className: 'Test', indexes }], keepUnknownIndexes: false }; + await new DefinedSchemas(schemas, server.config).execute(); + + indexes = {}; + schemas = { definitions: [{ className: 'Test', indexes }], keepUnknownIndexes: false }; + // Change indexes + await new DefinedSchemas(schemas, server.config).execute(); + let schema = await new Parse.Schema('Test').get(); + cleanUpIndexes(schema); + expect(schema.indexes).toBeUndefined(); + + // Update + await new DefinedSchemas(schemas, server.config).execute(); + schema = await new Parse.Schema('Test').get(); + cleanUpIndexes(schema); + expect(schema.indexes).toBeUndefined(); + }); + + it('should not delete unknown indexes when keepUnknownIndexes is set to true', async () => { + const server = await reconfigureServer(); + + const indexes = { complex: { createdAt: 1, updatedAt: 1 } }; + + let schemas = { definitions: [{ className: 'Test', indexes }], keepUnknownIndexes: true }; + await new DefinedSchemas(schemas, server.config).execute(); + + schemas = { definitions: [{ className: 'Test', indexes: {} }], keepUnknownIndexes: true }; + + // Change indexes + await new DefinedSchemas(schemas, server.config).execute(); + let schema = await new Parse.Schema('Test').get(); + cleanUpIndexes(schema); + expect(schema.indexes).toEqual({ complex: { createdAt: 1, updatedAt: 1 } }); + + // Update + await new DefinedSchemas(schemas, server.config).execute(); + schema = await new Parse.Schema('Test').get(); + cleanUpIndexes(schema); + expect(schema.indexes).toEqual(indexes); + }); + + xit('should keep protected indexes', async () => { + const server = await reconfigureServer(); + + const expectedIndexes = { + username_1: { username: 1 }, + case_insensitive_username: { username: 1 }, + email_1: { email: 1 }, + case_insensitive_email: { email: 1 }, + }; + const schemas = { + definitions: [ + { + className: '_User', + indexes: { + case_insensitive_username: { password: true }, + case_insensitive_email: { password: true }, + }, + }, + { className: 'Test' }, + ], + }; + // Create + await new DefinedSchemas(schemas, server.config).execute(); + let userSchema = await new Parse.Schema('_User').get(); + let testSchema = await new Parse.Schema('Test').get(); + cleanUpIndexes(userSchema); + cleanUpIndexes(testSchema); + expect(testSchema.indexes).toBeUndefined(); + expect(userSchema.indexes).toEqual(expectedIndexes); + + // Update + await new DefinedSchemas(schemas, server.config).execute(); + userSchema = await new Parse.Schema('_User').get(); + testSchema = await new Parse.Schema('Test').get(); + cleanUpIndexes(userSchema); + cleanUpIndexes(testSchema); + expect(testSchema.indexes).toBeUndefined(); + expect(userSchema.indexes).toEqual(expectedIndexes); + }); + + it('should detect protected indexes for _User class', () => { + const definedSchema = new DefinedSchemas({}, {}); + const protectedUserIndexes = ['_id_', 'case_insensitive_email', 'username_1', 'email_1']; + protectedUserIndexes.forEach(field => { + expect(definedSchema.isProtectedIndex('_User', field)).toEqual(true); + }); + expect(definedSchema.isProtectedIndex('_User', 'test')).toEqual(false); + }); + + it('should detect protected indexes for _Role class', () => { + const definedSchema = new DefinedSchemas({}, {}); + expect(definedSchema.isProtectedIndex('_Role', 'name_1')).toEqual(true); + expect(definedSchema.isProtectedIndex('_Role', 'test')).toEqual(false); + }); + + it('should detect protected indexes for _Idempotency class', () => { + const definedSchema = new DefinedSchemas({}, {}); + expect(definedSchema.isProtectedIndex('_Idempotency', 'reqId_1')).toEqual(true); + expect(definedSchema.isProtectedIndex('_Idempotency', 'test')).toEqual(false); + }); + + it('should not detect protected indexes on user defined class', () => { + const definedSchema = new DefinedSchemas({}, {}); + const protectedIndexes = [ + 'case_insensitive_email', + 'username_1', + 'email_1', + 'reqId_1', + 'name_1', + ]; + protectedIndexes.forEach(field => { + expect(definedSchema.isProtectedIndex('ExampleClass', field)).toEqual(false); + }); + expect(definedSchema.isProtectedIndex('ExampleClass', '_id_')).toEqual(true); + }); + }); + + describe('ClassLevelPermissions', () => { + it('should use default CLP', async () => { + const server = await reconfigureServer(); + const schemas = { definitions: [{ className: 'Test' }] }; + await new DefinedSchemas(schemas, server.config).execute(); + + const expectedTestCLP = { + find: {}, + count: {}, + get: {}, + create: {}, + update: {}, + delete: {}, + addField: {}, + protectedFields: {}, + }; + let testSchema = await new Parse.Schema('Test').get(); + expect(testSchema.classLevelPermissions).toEqual(expectedTestCLP); + + await new DefinedSchemas(schemas, server.config).execute(); + testSchema = await new Parse.Schema('Test').get(); + expect(testSchema.classLevelPermissions).toEqual(expectedTestCLP); + }); + it('should save CLP', async () => { + const server = await reconfigureServer(); + + const expectedTestCLP = { + find: {}, + count: { requiresAuthentication: true }, + get: { 'role:Admin': true }, + create: { 'role:ARole': true, requiresAuthentication: true }, + update: { requiresAuthentication: true }, + delete: { requiresAuthentication: true }, + addField: {}, + protectedFields: { '*': ['aField'], 'role:Admin': ['anotherField'] }, + }; + const schemas = { + definitions: [ + { + className: 'Test', + fields: { aField: { type: 'String' }, anotherField: { type: 'Object' } }, + classLevelPermissions: expectedTestCLP, + }, + ], + }; + await new DefinedSchemas(schemas, server.config).execute(); + + let testSchema = await new Parse.Schema('Test').get(); + expect(testSchema.classLevelPermissions).toEqual(expectedTestCLP); + + expectedTestCLP.update = {}; + expectedTestCLP.create = { requiresAuthentication: true }; + + await new DefinedSchemas(schemas, server.config).execute(); + testSchema = await new Parse.Schema('Test').get(); + expect(testSchema.classLevelPermissions).toEqual(expectedTestCLP); + }); + it('should force addField to empty', async () => { + const server = await reconfigureServer(); + const schemas = { + definitions: [{ className: 'Test', classLevelPermissions: { addField: { '*': true } } }], + }; + await new DefinedSchemas(schemas, server.config).execute(); + + const expectedTestCLP = { + find: {}, + count: {}, + get: {}, + create: {}, + update: {}, + delete: {}, + addField: {}, + protectedFields: {}, + }; + + let testSchema = await new Parse.Schema('Test').get(); + expect(testSchema.classLevelPermissions).toEqual(expectedTestCLP); + + await new DefinedSchemas(schemas, server.config).execute(); + testSchema = await new Parse.Schema('Test').get(); + expect(testSchema.classLevelPermissions).toEqual(expectedTestCLP); + }); + }); + + it('should not delete classes automatically', async () => { + await reconfigureServer({ + schema: { definitions: [{ className: '_User' }, { className: 'Test' }] }, + }); + + await reconfigureServer({ schema: { definitions: [{ className: '_User' }] } }); + + const schema = await new Parse.Schema('Test').get(); + expect(schema.className).toEqual('Test'); + }); + + it('should disable class PUT/POST endpoint when lockSchemas provided to avoid dual source of truth', async () => { + await reconfigureServer({ + schema: { + lockSchemas: true, + definitions: [{ className: '_User' }, { className: 'Test' }], + }, + }); + + const schema = await new Parse.Schema('Test').get(); + expect(schema.className).toEqual('Test'); + + const schemas = await Parse.Schema.all(); + // Role could be flaky since all system classes are not ensured + // at start up by the DefinedSchema system + expect(schemas.filter(({ className }) => className !== '_Role').length).toEqual(3); + + await expectAsync(new Parse.Schema('TheNewTest').save()).toBeRejectedWithError( + 'Cannot perform this operation when schemas options is used.' + ); + + await expectAsync(new Parse.Schema('_User').update()).toBeRejectedWithError( + 'Cannot perform this operation when schemas options is used.' + ); + }); + it('should only enable delete class endpoint since', async () => { + await reconfigureServer({ + schema: { definitions: [{ className: '_User' }, { className: 'Test' }] }, + }); + await reconfigureServer({ schema: { definitions: [{ className: '_User' }] } }); + + let schemas = await Parse.Schema.all(); + expect(schemas.length).toEqual(4); + + await new Parse.Schema('_User').delete(); + schemas = await Parse.Schema.all(); + expect(schemas.length).toEqual(3); + }); + it('should run beforeMigration before execution of DefinedSchemas', async () => { + const config = { + schema: { + definitions: [{ className: '_User' }, { className: 'Test' }], + beforeMigration: async () => {}, + }, + }; + const spy = spyOn(config.schema, 'beforeMigration'); + await reconfigureServer(config); + expect(spy).toHaveBeenCalledTimes(1); + }); + it('should run afterMigration after execution of DefinedSchemas', async () => { + const config = { + schema: { + definitions: [{ className: '_User' }, { className: 'Test' }], + afterMigration: async () => {}, + }, + }; + const spy = spyOn(config.schema, 'afterMigration'); + await reconfigureServer(config); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should use logger in case of error', async () => { + const server = await reconfigureServer({ schema: { definitions: [{ className: '_User' }] } }); + const error = new Error('A test error'); + const logger = require('../lib/logger').logger; + spyOn(DefinedSchemas.prototype, 'wait').and.resolveTo(); + spyOn(logger, 'error').and.callThrough(); + spyOn(DefinedSchemas.prototype, 'createDeleteSession').and.callFake(() => { + throw error; + }); + + await new DefinedSchemas( + { definitions: [{ className: 'Test', fields: { aField: { type: 'String' } } }] }, + server.config + ).execute(); + + expect(logger.error).toHaveBeenCalledWith(`Failed to run migrations: ${error.toString()}`); + }); + + it_id('a18bf4f2-25c8-4de3-b986-19cb1ab163b8')(it)('should perform migration in parallel without failing', async () => { + const server = await reconfigureServer(); + const logger = require('../lib/logger').logger; + spyOn(logger, 'error').and.callThrough(); + const migrationOptions = { + definitions: [ + { + className: 'Test', + fields: { aField: { type: 'String' } }, + indexes: { aField: { aField: 1 } }, + classLevelPermissions: { + create: { requiresAuthentication: true }, + }, + }, + ], + }; + + // Simulate parallel deployment + await Promise.all([ + new DefinedSchemas(migrationOptions, server.config).execute(), + new DefinedSchemas(migrationOptions, server.config).execute(), + new DefinedSchemas(migrationOptions, server.config).execute(), + new DefinedSchemas(migrationOptions, server.config).execute(), + new DefinedSchemas(migrationOptions, server.config).execute(), + ]); + + const testSchema = (await Parse.Schema.all()).find( + ({ className }) => className === migrationOptions.definitions[0].className + ); + + expect(testSchema.indexes.aField).toEqual({ aField: 1 }); + expect(testSchema.fields.aField).toEqual({ type: 'String' }); + expect(testSchema.classLevelPermissions.create).toEqual({ requiresAuthentication: true }); + expect(logger.error).toHaveBeenCalledTimes(0); + }); + + it('should not affect cacheAdapter', async () => { + const server = await reconfigureServer(); + const logger = require('../lib/logger').logger; + spyOn(logger, 'error').and.callThrough(); + const migrationOptions = { + definitions: [ + { + className: 'Test', + fields: { aField: { type: 'String' } }, + indexes: { aField: { aField: 1 } }, + classLevelPermissions: { + create: { requiresAuthentication: true }, + }, + }, + ], + }; + + const cacheAdapter = { + get: () => Promise.resolve(null), + put: () => {}, + del: () => {}, + clear: () => {}, + connect: jasmine.createSpy('clear'), + }; + server.config.cacheAdapter = cacheAdapter; + await new DefinedSchemas(migrationOptions, server.config).execute(); + expect(cacheAdapter.connect).not.toHaveBeenCalled(); + }); +}); diff --git a/spec/Deprecator.spec.js b/spec/Deprecator.spec.js new file mode 100644 index 0000000000..993d18682f --- /dev/null +++ b/spec/Deprecator.spec.js @@ -0,0 +1,272 @@ +'use strict'; + +const Deprecator = require('../lib/Deprecator/Deprecator'); + +describe('Deprecator', () => { + let deprecations = []; + + beforeEach(async () => { + deprecations = [{ optionKey: 'exampleKey', changeNewDefault: 'exampleNewDefault' }]; + }); + + it('deprecations are an array', async () => { + expect(Deprecator._getDeprecations()).toBeInstanceOf(Array); + }); + + it('logs deprecation for new default', async () => { + deprecations = [{ optionKey: 'exampleKey', changeNewDefault: 'exampleNewDefault' }]; + + spyOn(Deprecator, '_getDeprecations').and.callFake(() => deprecations); + const logger = require('../lib/logger').logger; + const logSpy = spyOn(logger, 'warn').and.callFake(() => {}); + + await reconfigureServer(); + expect(logSpy.calls.all()[0].args[0]).toEqual( + `DeprecationWarning: The Parse Server option '${deprecations[0].optionKey}' default will change to '${deprecations[0].changeNewDefault}' in a future version.` + ); + }); + + it('does not log deprecation for new default if option is set manually', async () => { + deprecations = [{ optionKey: 'exampleKey', changeNewDefault: 'exampleNewDefault' }]; + + spyOn(Deprecator, '_getDeprecations').and.callFake(() => deprecations); + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + await reconfigureServer({ [deprecations[0].optionKey]: 'manuallySet' }); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('logs runtime deprecation', async () => { + const logger = require('../lib/logger').logger; + const logSpy = spyOn(logger, 'warn').and.callFake(() => {}); + const options = { usage: 'Doing this', solution: 'Do that instead.' }; + + Deprecator.logRuntimeDeprecation(options); + expect(logSpy.calls.all()[0].args[0]).toEqual( + `DeprecationWarning: ${options.usage} is deprecated and will be removed in a future version. ${options.solution}` + ); + }); + + it('logs deprecation for nested option key with dot notation', async () => { + deprecations = [{ optionKey: 'databaseOptions.testOption', changeNewDefault: 'false' }]; + + spyOn(Deprecator, '_getDeprecations').and.callFake(() => deprecations); + const logger = require('../lib/logger').logger; + const logSpy = spyOn(logger, 'warn').and.callFake(() => {}); + + await reconfigureServer(); + expect(logSpy.calls.all()[0].args[0]).toEqual( + `DeprecationWarning: The Parse Server option '${deprecations[0].optionKey}' default will change to '${deprecations[0].changeNewDefault}' in a future version.` + ); + }); + + it('does not log deprecation for nested option key if option is set manually', async () => { + deprecations = [{ optionKey: 'databaseOptions.testOption', changeNewDefault: 'false' }]; + + spyOn(Deprecator, '_getDeprecations').and.callFake(() => deprecations); + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + const Config = require('../lib/Config'); + const config = Config.get('test'); + // Directly test scanParseServerOptions with nested option set + Deprecator.scanParseServerOptions({ databaseOptions: { testOption: true } }); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('logs deprecation for allowedFileUrlDomains when not set', async () => { + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + // Pass a fresh fileUpload object without allowedFileUrlDomains to avoid + // inheriting the mutated default from a previous reconfigureServer() call. + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + enableForAnonymousUser: true, + enableForAuthenticatedUser: true, + }, + }); + expect(logSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + optionKey: 'fileUpload.allowedFileUrlDomains', + changeNewDefault: '[]', + }) + ); + }); + + it('does not log deprecation for allowedFileUrlDomains when explicitly set', async () => { + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + await reconfigureServer({ + fileUpload: { allowedFileUrlDomains: ['*'] }, + }); + expect(logSpy).not.toHaveBeenCalledWith( + jasmine.objectContaining({ + optionKey: 'fileUpload.allowedFileUrlDomains', + }) + ); + }); + + it('logs deprecation for removed key when option is set', async () => { + deprecations = [{ optionKey: 'exampleKey', changeNewKey: '', solution: 'Use something else.' }]; + + spyOn(Deprecator, '_getDeprecations').and.callFake(() => deprecations); + const logger = require('../lib/logger').logger; + const logSpy = spyOn(logger, 'warn').and.callFake(() => {}); + + await reconfigureServer({ exampleKey: true }); + expect(logSpy).toHaveBeenCalledWith( + `DeprecationWarning: The Parse Server option '${deprecations[0].optionKey}' is deprecated and will be removed in a future version. ${deprecations[0].solution}` + ); + }); + + it('does not log deprecation for removed key when option is not set', async () => { + deprecations = [{ optionKey: 'exampleKey', changeNewKey: '', solution: 'Use something else.' }]; + + spyOn(Deprecator, '_getDeprecations').and.callFake(() => deprecations); + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + await reconfigureServer(); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('logs deprecation for mountPlayground when set', async () => { + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + await reconfigureServer({ mountPlayground: true, mountGraphQL: true }); + expect(logSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + optionKey: 'mountPlayground', + changeNewKey: '', + }) + ); + }); + + it('does not log deprecation for mountPlayground when not set', async () => { + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + await reconfigureServer(); + expect(logSpy).not.toHaveBeenCalledWith( + jasmine.objectContaining({ + optionKey: 'mountPlayground', + }) + ); + }); + + it('logs deprecation for requestComplexity limits when not set', async () => { + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + await reconfigureServer(); + const keys = [ + 'requestComplexity.includeDepth', + 'requestComplexity.includeCount', + 'requestComplexity.subqueryDepth', + 'requestComplexity.queryDepth', + 'requestComplexity.graphQLDepth', + 'requestComplexity.graphQLFields', + ]; + for (const key of keys) { + expect(logSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + optionKey: key, + }) + ); + } + }); + + it('logs deprecation for enableProductPurchaseLegacyApi when set', async () => { + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + await reconfigureServer({ enableProductPurchaseLegacyApi: true }); + expect(logSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + optionKey: 'enableProductPurchaseLegacyApi', + changeNewKey: '', + }) + ); + }); + + it('does not log deprecation for enableProductPurchaseLegacyApi when not set', async () => { + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + await reconfigureServer(); + expect(logSpy).not.toHaveBeenCalledWith( + jasmine.objectContaining({ + optionKey: 'enableProductPurchaseLegacyApi', + }) + ); + }); + + it('does not log deprecation for enableProductPurchaseLegacyApi when set to false', async () => { + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + await reconfigureServer({ enableProductPurchaseLegacyApi: false }); + expect(logSpy).not.toHaveBeenCalledWith( + jasmine.objectContaining({ + optionKey: 'enableProductPurchaseLegacyApi', + }) + ); + }); + + it('does not log deprecation for requestComplexity limits when explicitly set', async () => { + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + await reconfigureServer({ + requestComplexity: { + includeDepth: 10, + includeCount: 100, + subqueryDepth: 10, + queryDepth: 10, + graphQLDepth: 20, + graphQLFields: 200, + }, + }); + const keys = [ + 'requestComplexity.includeDepth', + 'requestComplexity.includeCount', + 'requestComplexity.subqueryDepth', + 'requestComplexity.queryDepth', + 'requestComplexity.graphQLDepth', + 'requestComplexity.graphQLFields', + ]; + for (const key of keys) { + expect(logSpy).not.toHaveBeenCalledWith( + jasmine.objectContaining({ + optionKey: key, + }) + ); + } + }); + + it('registers a deprecation entry for installation.duplicateDeviceTokenActionEnforceAuth', () => { + const Deprecations = require('../lib/Deprecator/Deprecations'); + const entry = Deprecations.find( + d => d.optionKey === 'installation.duplicateDeviceTokenActionEnforceAuth' + ); + expect(entry).toBeDefined(); + expect(entry.changeNewDefault).toBe('true'); + expect(entry.solution).toContain('duplicateDeviceTokenActionEnforceAuth'); + }); + + it('logs deprecation for installation.duplicateDeviceTokenActionEnforceAuth when not set', async () => { + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + await reconfigureServer(); + expect(logSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + optionKey: 'installation.duplicateDeviceTokenActionEnforceAuth', + changeNewDefault: 'true', + }) + ); + }); + + it('does not log deprecation for installation.duplicateDeviceTokenActionEnforceAuth when explicitly set', async () => { + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + await reconfigureServer({ + installation: { duplicateDeviceTokenActionEnforceAuth: false }, + }); + expect(logSpy).not.toHaveBeenCalledWith( + jasmine.objectContaining({ + optionKey: 'installation.duplicateDeviceTokenActionEnforceAuth', + }) + ); + }); +}); diff --git a/spec/EmailVerificationToken.spec.js b/spec/EmailVerificationToken.spec.js index 93acc2ea82..11a901f399 100644 --- a/spec/EmailVerificationToken.spec.js +++ b/spec/EmailVerificationToken.spec.js @@ -1,515 +1,1303 @@ -"use strict"; +'use strict'; -const MockEmailAdapterWithOptions = require('./MockEmailAdapterWithOptions'); -const request = require('request'); -const MongoClient = require("mongodb").MongoClient; +const Auth = require('../lib/Auth'); +const Config = require('../lib/Config'); +const request = require('../lib/request'); +const { resolvingPromise, sleep } = require('../lib/TestUtils'); +const MockEmailAdapterWithOptions = require('./support/MockEmailAdapterWithOptions'); -describe("Email Verification Token Expiration: ", () => { - - it_exclude_dbs(['postgres'])('show the invalid link page, if the user clicks on the verify email link after the email verify token expires', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { +describe('Email Verification Token Expiration:', () => { + it('show the invalid verification link page, if the user clicks on the verify email link after the email verify token expires', async () => { + const user = new Parse.User(); + let sendEmailOptions; + const sendPromise = resolvingPromise(); + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; + sendPromise.resolve(); }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } - reconfigureServer({ + sendMail: () => {}, + }; + await reconfigureServer({ appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 0.5, // 0.5 second - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - user.setUsername("testEmailVerifyTokenValidity"); - user.setPassword("expiringToken"); - user.set('email', 'user@parse.com'); - return user.signUp(); - }).then(() => { - // wait for 1 second - simulate user behavior to some extent - setTimeout(() => { - expect(sendEmailOptions).not.toBeUndefined(); - - request.get(sendEmailOptions.link, { - followRedirect: false, - }, (error, response, body) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); - done(); - }); - }, 1000); + publicServerURL: 'http://localhost:8378/1', }); + user.setUsername('testEmailVerifyTokenValidity'); + user.setPassword('expiringToken'); + user.set('email', 'user@parse.com'); + await user.signUp(); + await sendPromise; + // wait for 1 second - simulate user behavior to some extent + await sleep(1000); + + expect(sendEmailOptions).not.toBeUndefined(); + + const response = await request({ + url: sendEmailOptions.link, + followRedirects: false, + }); + expect(response.status).toEqual(200); + expect(response.text).toContain('Invalid verification link!'); }); - it_exclude_dbs(['postgres'])('emailVerified should set to false, if the user does not verify their email before the email verify token expires', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { + it('emailVerified should set to false, if the user does not verify their email before the email verify token expires', async () => { + const user = new Parse.User(); + let sendEmailOptions; + const sendPromise = resolvingPromise(); + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; + sendPromise.resolve(); }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } - reconfigureServer({ + sendMail: () => {}, + }; + await reconfigureServer({ appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 0.5, // 0.5 second - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - user.setUsername("testEmailVerifyTokenValidity"); - user.setPassword("expiringToken"); - user.set('email', 'user@parse.com'); - return user.signUp(); - }).then(() => { - // wait for 1 second - simulate user behavior to some extent - setTimeout(() => { - expect(sendEmailOptions).not.toBeUndefined(); - - request.get(sendEmailOptions.link, { - followRedirect: false, - }, (error, response, body) => { - expect(response.statusCode).toEqual(302); - user.fetch() - .then(() => { - expect(user.get('emailVerified')).toEqual(false); - done(); - }) - .catch((err) => { - fail("this should not fail"); - done(); - }); - }); - }, 1000); + publicServerURL: 'http://localhost:8378/1', }); + user.setUsername('testEmailVerifyTokenValidity'); + user.setPassword('expiringToken'); + user.set('email', 'user@parse.com'); + await user.signUp(); + await sendPromise; + // wait for 1 second - simulate user behavior to some extent + await sleep(1000); + + expect(sendEmailOptions).not.toBeUndefined(); + + const response = await request({ + url: sendEmailOptions.link, + followRedirects: false, + }); + expect(response.status).toEqual(200); + await user.fetch(); + expect(user.get('emailVerified')).toEqual(false); }); - it_exclude_dbs(['postgres'])('if user clicks on the email verify link before email verification token expiration then show the verify email success page', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { + it_id('f20dd3c2-87d9-4bc6-a51d-4ea2834acbcc')(it)('if user clicks on the email verify link before email verification token expiration then show the verify email success page', async () => { + const user = new Parse.User(); + let sendEmailOptions; + const sendPromise = resolvingPromise(); + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; + sendPromise.resolve(); }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } - reconfigureServer({ + sendMail: () => {}, + }; + await reconfigureServer({ appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5, // 5 seconds - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - user.setUsername("testEmailVerifyTokenValidity"); - user.setPassword("expiringToken"); - user.set('email', 'user@parse.com'); - return user.signUp(); - }).then(() => { - request.get(sendEmailOptions.link, { - followRedirect: false, - }, (error, response, body) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html?username=testEmailVerifyTokenValidity'); - done(); - }); + publicServerURL: 'http://localhost:8378/1', + }); + user.setUsername('testEmailVerifyTokenValidity'); + user.setPassword('expiringToken'); + user.set('email', 'user@parse.com'); + await user.signUp(); + await sendPromise; + const response = await request({ + url: sendEmailOptions.link, + followRedirects: false, }); + expect(response.status).toEqual(200); + expect(response.text).toContain('Email verified!'); }); - it_exclude_dbs(['postgres'])('if user clicks on the email verify link before email verification token expiration then emailVerified should be true', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { + it_id('94956799-c85e-4297-b879-e2d1f985394c')(it)('if user clicks on the email verify link before email verification token expiration then emailVerified should be true', async () => { + const user = new Parse.User(); + let sendEmailOptions; + const sendPromise = resolvingPromise(); + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; + sendPromise.resolve(); }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } - reconfigureServer({ + sendMail: () => {}, + }; + await reconfigureServer({ appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5, // 5 seconds - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - user.setUsername("testEmailVerifyTokenValidity"); - user.setPassword("expiringToken"); - user.set('email', 'user@parse.com'); - return user.signUp(); - }).then(() => { - request.get(sendEmailOptions.link, { - followRedirect: false, - }, (error, response, body) => { - expect(response.statusCode).toEqual(302); - user.fetch() - .then(() => { - expect(user.get('emailVerified')).toEqual(true); - done(); - }) - .catch((err) => { - fail("this should not fail"); - done(); - }); - }); + publicServerURL: 'http://localhost:8378/1', + }); + user.setUsername('testEmailVerifyTokenValidity'); + user.setPassword('expiringToken'); + user.set('email', 'user@parse.com'); + await user.signUp(); + await sendPromise; + const response = await request({ + url: sendEmailOptions.link, + followRedirects: false, }); + expect(response.status).toEqual(200); + await user.fetch(); + expect(user.get('emailVerified')).toEqual(true); }); - it_exclude_dbs(['postgres'])('if user clicks on the email verify link before email verification token expiration then user should be able to login', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { + it_id('25f3f895-c987-431c-9841-17cb6aaf18b5')(it)('if user clicks on the email verify link before email verification token expiration then user should be able to login', async () => { + const user = new Parse.User(); + let sendEmailOptions; + const sendPromise = resolvingPromise(); + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; + sendPromise.resolve(); }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } - reconfigureServer({ + sendMail: () => {}, + }; + await reconfigureServer({ appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5, // 5 seconds - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - user.setUsername("testEmailVerifyTokenValidity"); - user.setPassword("expiringToken"); - user.set('email', 'user@parse.com'); - return user.signUp(); - }).then(() => { - request.get(sendEmailOptions.link, { - followRedirect: false, - }, (error, response, body) => { - expect(response.statusCode).toEqual(302); - Parse.User.logIn("testEmailVerifyTokenValidity", "expiringToken") - .then(user => { - expect(typeof user).toBe('object'); - expect(user.get('emailVerified')).toBe(true); - done(); - }) - .catch((error) => { - fail('login should have succeeded'); - done(); - }); - }); + publicServerURL: 'http://localhost:8378/1', }); + user.setUsername('testEmailVerifyTokenValidity'); + user.setPassword('expiringToken'); + user.set('email', 'user@parse.com'); + await user.signUp(); + await sendPromise; + const response = await request({ + url: sendEmailOptions.link, + followRedirects: false, + }); + expect(response.status).toEqual(200); + const verifiedUser = await Parse.User.logIn('testEmailVerifyTokenValidity', 'expiringToken'); + expect(typeof verifiedUser).toBe('object'); + expect(verifiedUser.get('emailVerified')).toBe(true); }); - it_exclude_dbs(['postgres'])('sets the _email_verify_token_expires_at and _email_verify_token fields after user SignUp', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { + it_id('c6a3e188-9065-4f50-842d-454d1e82f289')(it)('sets the _email_verify_token_expires_at and _email_verify_token fields after user SignUp', async () => { + const user = new Parse.User(); + let sendEmailOptions; + const sendPromise = resolvingPromise(); + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; + sendPromise.resolve(); }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } - reconfigureServer({ + sendMail: () => {}, + }; + await reconfigureServer({ appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5, // 5 seconds - publicServerURL: 'http://localhost:8378/1' - }) - .then(() => { - user.setUsername('sets_email_verify_token_expires_at'); - user.setPassword('expiringToken'); - user.set('email', 'user@parse.com'); - return user.signUp(); - }) - .then(() => { - const databaseURI = 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase'; - return MongoClient.connect(databaseURI); - }) - .then(database => { - expect(typeof database).toBe('object'); - return database.collection('test__User').findOne({username: 'sets_email_verify_token_expires_at'}); - }) - .then(user => { - expect(typeof user).toBe('object'); - expect(user.emailVerified).toEqual(false); - expect(typeof user._email_verify_token).toBe('string'); - expect(typeof user._email_verify_token_expires_at).toBe('object'); - done(); - }) - .catch(error => { - fail("this should not fail"); - done(); + publicServerURL: 'http://localhost:8378/1', + }); + user.setUsername('sets_email_verify_token_expires_at'); + user.setPassword('expiringToken'); + user.set('email', 'user@parse.com'); + await user.signUp(); + await sendPromise; + const config = Config.get('test'); + const results = await config.database.find( + '_User', + { + username: 'sets_email_verify_token_expires_at', + }, + {}, + Auth.maintenance(config) + ); + expect(results.length).toBe(1); + const verifiedUser = results[0]; + expect(typeof verifiedUser).toBe('object'); + expect(verifiedUser.emailVerified).toEqual(false); + expect(typeof verifiedUser._email_verify_token).toBe('string'); + expect(typeof verifiedUser._email_verify_token_expires_at).toBe('object'); + expect(sendEmailOptions).toBeDefined(); + }); + + it('can resend email using an expired token', async () => { + const user = new Parse.User(); + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + await reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5, // 5 seconds + publicServerURL: 'http://localhost:8378/1', + }); + user.setUsername('test'); + user.setPassword('password'); + user.set('email', 'user@example.com'); + await user.signUp(); + + await Parse.Server.database.update( + '_User', + { objectId: user.id }, + { + _email_verify_token_expires_at: Parse._encode(new Date('2000')), + } + ); + + const obj = await Parse.Server.database.find( + '_User', + { objectId: user.id }, + {}, + Auth.maintenance(Parse.Server) + ); + const token = obj[0]._email_verify_token; + + const res = await request({ + url: `http://localhost:8378/1/apps/test/verify_email?token=${token}`, + method: 'GET', }); + expect(res.text).toContain('Invalid verification link!'); + + const formUrl = `http://localhost:8378/1/apps/test/resend_verification_email`; + const formResponse = await request({ + url: formUrl, + method: 'POST', + body: { + token: token, + }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + followRedirects: false, + }); + expect(formResponse.text).toContain('email_verification_send_success.html'); }); - it_exclude_dbs(['postgres'])('unsets the _email_verify_token_expires_at and _email_verify_token fields in the User class if email verification is successful', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { + it_id('9365c53c-b8b4-41f7-a3c1-77882f76a89c')(it)('can conditionally send emails', async () => { + let sendEmailOptions; + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } - reconfigureServer({ + sendMail: () => {}, + }; + const verifyUserEmails = { + method(req) { + expect(Object.keys(req)).toEqual([ + 'original', + 'object', + 'master', + 'ip', + 'installationId', + 'createdWith', + ]); + expect(req.createdWith).toEqual({ action: 'signup', authProvider: 'password' }); + return false; + }, + }; + const verifySpy = spyOn(verifyUserEmails, 'method').and.callThrough(); + await reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: verifyUserEmails.method, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5, // 5 seconds + publicServerURL: 'http://localhost:8378/1', + }); + const beforeSave = { + method(req) { + req.object.set('emailVerified', true); + }, + }; + const saveSpy = spyOn(beforeSave, 'method').and.callThrough(); + const emailSpy = spyOn(emailAdapter, 'sendVerificationEmail').and.callThrough(); + Parse.Cloud.beforeSave(Parse.User, beforeSave.method); + const user = new Parse.User(); + user.setUsername('sets_email_verify_token_expires_at'); + user.setPassword('expiringToken'); + user.set('email', 'user@example.com'); + await user.signUp(); + + const config = Config.get('test'); + const results = await config.database.find( + '_User', + { + username: 'sets_email_verify_token_expires_at', + }, + {}, + Auth.maintenance(config) + ); + + expect(results.length).toBe(1); + const user_data = results[0]; + expect(typeof user_data).toBe('object'); + expect(user_data.emailVerified).toEqual(true); + expect(user_data._email_verify_token).toBeUndefined(); + expect(user_data._email_verify_token_expires_at).toBeUndefined(); + expect(emailSpy).not.toHaveBeenCalled(); + expect(saveSpy).toHaveBeenCalled(); + expect(sendEmailOptions).toBeUndefined(); + expect(verifySpy).toHaveBeenCalled(); + }); + + it_id('b3549300-bed7-4a5e-bed5-792dbfead960')(it)('can conditionally send emails and allow conditional login', async () => { + let sendEmailOptions; + const sendPromise = resolvingPromise(); + const emailAdapter = { + sendVerificationEmail: options => { + sendEmailOptions = options; + sendPromise.resolve(); + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + const verifyUserEmails = { + method(req) { + expect(Object.keys(req)).toEqual([ + 'original', + 'object', + 'master', + 'ip', + 'installationId', + 'createdWith', + ]); + expect(req.createdWith).toEqual({ action: 'signup', authProvider: 'password' }); + if (req.object.get('username') === 'no_email') { + return false; + } + return true; + }, + }; + const verifySpy = spyOn(verifyUserEmails, 'method').and.callThrough(); + await reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: verifyUserEmails.method, + preventLoginWithUnverifiedEmail: verifyUserEmails.method, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5, // 5 seconds + publicServerURL: 'http://localhost:8378/1', + }); + const user = new Parse.User(); + user.setUsername('no_email'); + user.setPassword('expiringToken'); + user.set('email', 'user@example.com'); + await user.signUp(); + expect(sendEmailOptions).toBeUndefined(); + expect(user.getSessionToken()).toBeDefined(); + expect(verifySpy).toHaveBeenCalledTimes(2); + const user2 = new Parse.User(); + user2.setUsername('email'); + user2.setPassword('expiringToken'); + user2.set('email', 'user2@example.com'); + await user2.signUp(); + await sendPromise; + expect(user2.getSessionToken()).toBeUndefined(); + expect(sendEmailOptions).toBeDefined(); + expect(verifySpy).toHaveBeenCalledTimes(5); + }); + + it('provides createdWith on signup when verification blocks session creation', async () => { + const verifyUserEmails = { + method: params => { + expect(params.object).toBeInstanceOf(Parse.User); + expect(params.createdWith).toEqual({ action: 'signup', authProvider: 'password' }); + return true; + }, + }; + const verifySpy = spyOn(verifyUserEmails, 'method').and.callThrough(); + await reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: verifyUserEmails.method, + preventLoginWithUnverifiedEmail: true, + preventSignupWithUnverifiedEmail: true, + emailAdapter: MockEmailAdapterWithOptions({ + fromAddress: 'parse@example.com', + apiKey: 'k', + domain: 'd', + }), + publicServerURL: 'http://localhost:8378/1', + }); + + const user = new Parse.User(); + user.setUsername('signup_created_with'); + user.setPassword('pass'); + user.setEmail('signup@example.com'); + const res = await user.signUp().catch(e => e); + expect(res.message).toBe('User email is not verified.'); + expect(user.getSessionToken()).toBeUndefined(); + expect(verifySpy).toHaveBeenCalledTimes(2); // before signup completion and on preventLoginWithUnverifiedEmail + }); + + it('provides createdWith with auth provider on login verification', async () => { + const user = new Parse.User(); + user.setUsername('user_created_with_login'); + user.setPassword('pass'); + user.set('email', 'login@example.com'); + await user.signUp(); + + const verifyUserEmails = { + method: async params => { + expect(params.object).toBeInstanceOf(Parse.User); + expect(params.createdWith).toEqual({ action: 'login', authProvider: 'password' }); + return true; + }, + }; + const verifyUserEmailsSpy = spyOn(verifyUserEmails, 'method').and.callThrough(); + await reconfigureServer({ + appName: 'emailVerifyToken', + publicServerURL: 'http://localhost:8378/1', + verifyUserEmails: verifyUserEmails.method, + preventLoginWithUnverifiedEmail: verifyUserEmails.method, + preventSignupWithUnverifiedEmail: true, + emailAdapter: MockEmailAdapterWithOptions({ + fromAddress: 'parse@example.com', + apiKey: 'k', + domain: 'd', + }), + }); + + const res = await Parse.User.logIn('user_created_with_login', 'pass').catch(e => e); + expect(res.code).toBe(205); + expect(verifyUserEmailsSpy).toHaveBeenCalledTimes(2); // before login completion and on preventLoginWithUnverifiedEmail + }); + + it('provides createdWith with auth provider on signup verification', async () => { + const createdWithValues = []; + const verifyUserEmails = { + method: params => { + createdWithValues.push(params.createdWith); + return true; + }, + }; + const verifySpy = spyOn(verifyUserEmails, 'method').and.callThrough(); + await reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: verifyUserEmails.method, + preventLoginWithUnverifiedEmail: true, + preventSignupWithUnverifiedEmail: true, + emailAdapter: MockEmailAdapterWithOptions({ + fromAddress: 'parse@example.com', + apiKey: 'k', + domain: 'd', + }), + publicServerURL: 'http://localhost:8378/1', + }); + + const provider = { + authData: { id: '8675309', access_token: 'jenny' }, + shouldError: false, + authenticate(options) { + options.success(this, this.authData); + }, + restoreAuthentication() { + return true; + }, + getAuthType() { + return 'facebook'; + }, + deauthenticate() {}, + }; + Parse.User._registerAuthenticationProvider(provider); + const res = await Parse.User._logInWith('facebook').catch(e => e); + expect(res.message).toBe('User email is not verified.'); + // Called once in createSessionTokenIfNeeded (no email set, so _validateEmail skips) + expect(verifySpy).toHaveBeenCalledTimes(1); + expect(createdWithValues[0]).toEqual({ action: 'signup', authProvider: 'facebook' }); + }); + + it('provides createdWith for preventLoginWithUnverifiedEmail function', async () => { + const user = new Parse.User(); + user.setUsername('user_prevent_login_fn'); + user.setPassword('pass'); + user.set('email', 'preventlogin@example.com'); + await user.signUp(); + + const preventLoginCreatedWith = []; + await reconfigureServer({ + appName: 'emailVerifyToken', + publicServerURL: 'http://localhost:8378/1', + verifyUserEmails: true, + preventLoginWithUnverifiedEmail: params => { + preventLoginCreatedWith.push(params.createdWith); + return true; + }, + emailAdapter: MockEmailAdapterWithOptions({ + fromAddress: 'parse@example.com', + apiKey: 'k', + domain: 'd', + }), + }); + + const res = await Parse.User.logIn('user_prevent_login_fn', 'pass').catch(e => e); + expect(res.code).toBe(205); + expect(preventLoginCreatedWith.length).toBe(1); + expect(preventLoginCreatedWith[0]).toEqual({ action: 'login', authProvider: 'password' }); + }); + + it_id('d812de87-33d1-495e-a6e8-3485f6dc3589')(it)('can conditionally send user email verification', async () => { + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + const sendVerificationEmail = { + method(req) { + expect(req.user).toBeDefined(); + expect(req.master).toBeDefined(); + return false; + }, + }; + const sendSpy = spyOn(sendVerificationEmail, 'method').and.callThrough(); + await reconfigureServer({ appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5, // 5 seconds - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - user.setUsername("unsets_email_verify_token_expires_at"); - user.setPassword("expiringToken"); - user.set('email', 'user@parse.com'); - return user.signUp(); - }) - .then(() => { - request.get(sendEmailOptions.link, { - followRedirect: false, - }, (error, response, body) => { - expect(response.statusCode).toEqual(302); - - const databaseURI = 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase'; - MongoClient.connect(databaseURI) - .then(database => { - expect(typeof database).toBe('object'); - return database.collection('test__User').findOne({username: 'unsets_email_verify_token_expires_at'}); - }) - .then(user => { - expect(typeof user).toBe('object'); - expect(user.emailVerified).toEqual(true); - expect(typeof user._email_verify_token).toBe('undefined'); - expect(typeof user._email_verify_token_expires_at).toBe('undefined'); - done(); - }) - .catch(error => { - fail("this should not fail"); - done(); - }); - }); - }) - .catch(error => { - fail("this should not fail"); - done(); + publicServerURL: 'http://localhost:8378/1', + sendUserEmailVerification: sendVerificationEmail.method, + }); + const emailSpy = spyOn(emailAdapter, 'sendVerificationEmail').and.callThrough(); + const newUser = new Parse.User(); + newUser.setUsername('unsets_email_verify_token_expires_at'); + newUser.setPassword('expiringToken'); + newUser.set('email', 'user@example.com'); + await newUser.signUp(); + await Parse.User.requestEmailVerification('user@example.com'); + await sleep(100); + expect(sendSpy).toHaveBeenCalledTimes(2); + expect(emailSpy).toHaveBeenCalledTimes(0); + }); + + it_id('d98babc1-feb8-4b5e-916c-57dc0a6ed9fb')(it)('provides full user object in email verification function on email and username change', async () => { + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + const sendVerificationEmail = { + method(req) { + expect(req.user).toBeDefined(); + expect(req.user.id).toBeDefined(); + expect(req.user.get('createdAt')).toBeDefined(); + expect(req.user.get('updatedAt')).toBeDefined(); + expect(req.master).toBeDefined(); + return false; + }, + }; + await reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5, + publicServerURL: 'http://localhost:8378/1', + sendUserEmailVerification: sendVerificationEmail.method, }); + const user = new Parse.User(); + user.setPassword('password'); + user.setUsername('new@example.com'); + user.setEmail('user@example.com'); + await user.save(null, { useMasterKey: true }); + + // Update email and username + user.setUsername('new@example.com'); + user.setEmail('new@example.com'); + await user.save(null, { useMasterKey: true }); }); - it_exclude_dbs(['postgres'])('clicking on the email verify link by an email VERIFIED user that was setup before enabling the expire email verify token should show an invalid link', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { + it_id('a8c1f820-822f-4a37-9d08-a968cac8369d')(it)('beforeSave options do not change existing behaviour', async () => { + let sendEmailOptions; + const sendPromise = resolvingPromise(); + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; + sendPromise.resolve(); }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } - var serverConfig = { + sendMail: () => {}, + }; + await reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5, // 5 seconds + publicServerURL: 'http://localhost:8378/1', + }); + const emailSpy = spyOn(emailAdapter, 'sendVerificationEmail').and.callThrough(); + const newUser = new Parse.User(); + newUser.setUsername('unsets_email_verify_token_expires_at'); + newUser.setPassword('expiringToken'); + newUser.set('email', 'user@parse.com'); + await newUser.signUp(); + await sendPromise; + const response = await request({ + url: sendEmailOptions.link, + followRedirects: false, + }); + expect(response.status).toEqual(200); + const config = Config.get('test'); + const results = await config.database.find('_User', { + username: 'unsets_email_verify_token_expires_at', + }); + + expect(results.length).toBe(1); + const user = results[0]; + expect(typeof user).toBe('object'); + expect(user.emailVerified).toEqual(true); + expect(typeof user._email_verify_token).toBe('undefined'); + expect(typeof user._email_verify_token_expires_at).toBe('undefined'); + expect(emailSpy).toHaveBeenCalled(); + }); + + it_id('36d277eb-ec7c-4a39-9108-435b68228741')(it)('unsets the _email_verify_token_expires_at and _email_verify_token fields in the User class if email verification is successful', async () => { + const user = new Parse.User(); + let sendEmailOptions; + const sendPromise = resolvingPromise(); + const emailAdapter = { + sendVerificationEmail: options => { + sendEmailOptions = options; + sendPromise.resolve(); + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + await reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5, // 5 seconds + publicServerURL: 'http://localhost:8378/1', + }); + user.setUsername('unsets_email_verify_token_expires_at'); + user.setPassword('expiringToken'); + user.set('email', 'user@parse.com'); + await user.signUp(); + await sendPromise; + const response = await request({ + url: sendEmailOptions.link, + followRedirects: false, + }); + expect(response.status).toEqual(200); + const config = Config.get('test'); + const results = await config.database.find('_User', { + username: 'unsets_email_verify_token_expires_at', + }); + expect(results.length).toBe(1); + const verifiedUser = results[0]; + + expect(typeof verifiedUser).toBe('object'); + expect(verifiedUser.emailVerified).toEqual(true); + expect(typeof verifiedUser._email_verify_token).toBe('undefined'); + expect(typeof verifiedUser._email_verify_token_expires_at).toBe('undefined'); + }); + + it_id('4f444704-ec4b-4dff-b947-614b1c6971c4')(it)('clicking on the email verify link by an email VERIFIED user that was setup before enabling the expire email verify token should show email verify email success', async () => { + const user = new Parse.User(); + let sendEmailOptions; + const sendPromise = resolvingPromise(); + const emailAdapter = { + sendVerificationEmail: options => { + sendEmailOptions = options; + sendPromise.resolve(); + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + const serverConfig = { appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }; // setup server WITHOUT enabling the expire email verify token flag - reconfigureServer(serverConfig) - .then(() => { - user.setUsername("testEmailVerifyTokenValidity"); - user.setPassword("expiringToken"); - user.set('email', 'user@parse.com'); - return user.signUp(); - }) - .then(() => { - return new Promise((resolve, reject) => { - request.get(sendEmailOptions.link, { followRedirect: false, }) - .on('error', error => reject(error)) - .on('response', (response) => { - expect(response.statusCode).toEqual(302); - resolve(user.fetch()); - }); - }); - }) - .then(() => { - expect(user.get('emailVerified')).toEqual(true); - // RECONFIGURE the server i.e., ENABLE the expire email verify token flag - serverConfig.emailVerifyTokenValidityDuration = 5; // 5 seconds - return reconfigureServer(serverConfig); - }) - .then(() => { - request.get(sendEmailOptions.link, { - followRedirect: false, - }, (error, response, body) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); - done(); - }); - }) - .catch((err) => { - fail("this should not fail"); - done(); + await reconfigureServer(serverConfig); + user.setUsername('testEmailVerifyTokenValidity'); + user.setPassword('expiringToken'); + user.set('email', 'user@parse.com'); + await user.signUp(); + await sendPromise; + let response = await request({ + url: sendEmailOptions.link, + followRedirects: false, }); + expect(response.status).toEqual(200); + await user.fetch(); + expect(user.get('emailVerified')).toEqual(true); + // RECONFIGURE the server i.e., ENABLE the expire email verify token flag + serverConfig.emailVerifyTokenValidityDuration = 5; // 5 seconds + await reconfigureServer(serverConfig); + + response = await request({ + url: sendEmailOptions.link, + followRedirects: false, + }); + expect(response.status).toEqual(200); + expect(response.text).toContain('Invalid verification link!'); }); - it_exclude_dbs(['postgres'])('clicking on the email verify link by an email UNVERIFIED user that was setup before enabling the expire email verify token should show an invalid link', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { + it('clicking on the email verify link by an email UNVERIFIED user that was setup before enabling the expire email verify token should show invalid verficiation link page', async () => { + const user = new Parse.User(); + let sendEmailOptions; + const sendPromise = resolvingPromise(); + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; + sendPromise.resolve(); }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } - var serverConfig = { + sendMail: () => {}, + }; + const serverConfig = { appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }; // setup server WITHOUT enabling the expire email verify token flag - reconfigureServer(serverConfig) - .then(() => { - user.setUsername("testEmailVerifyTokenValidity"); - user.setPassword("expiringToken"); - user.set('email', 'user@parse.com'); - return user.signUp(); - }) - .then(() => { - // just get the user again - DO NOT email verify the user - return user.fetch(); - }) - .then(() => { - expect(user.get('emailVerified')).toEqual(false); - // RECONFIGURE the server i.e., ENABLE the expire email verify token flag - serverConfig.emailVerifyTokenValidityDuration = 5; // 5 seconds - return reconfigureServer(serverConfig); - }) - .then(() => { - request.get(sendEmailOptions.link, { - followRedirect: false, - }, (error, response, body) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); - done(); - }); - }) - .catch((err) => { - fail("this should not fail"); - done(); + await reconfigureServer(serverConfig); + user.setUsername('testEmailVerifyTokenValidity'); + user.setPassword('expiringToken'); + user.set('email', 'user@parse.com'); + await user.signUp(); + await sendPromise; + // just get the user again - DO NOT email verify the user + await user.fetch(); + + expect(user.get('emailVerified')).toEqual(false); + // RECONFIGURE the server i.e., ENABLE the expire email verify token flag + serverConfig.emailVerifyTokenValidityDuration = 5; // 5 seconds + await reconfigureServer(serverConfig); + + const response = await request({ + url: sendEmailOptions.link, + followRedirects: false, }); + expect(response.status).toEqual(200); + expect(response.text).toContain('Invalid verification link!'); }); - it_exclude_dbs(['postgres'])('setting the email on the user should set a new email verification token and new expiration date for the token when expire email verify token flag is set', done => { + it_id('b6c87f35-d887-477d-bc86-a9217a424f53')(it)('setting the email on the user should set a new email verification token and new expiration date for the token when expire email verify token flag is set', async () => { + const user = new Parse.User(); - const databaseURI = 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase'; - let db; + let sendEmailOptions; + const sendPromise = resolvingPromise(); + const emailAdapter = { + sendVerificationEmail: options => { + sendEmailOptions = options; + sendPromise.resolve(); + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + const serverConfig = { + appName: 'emailVerifyToken', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5, // 5 seconds + publicServerURL: 'http://localhost:8378/1', + }; - let user = new Parse.User(); - let userBeforeEmailReset; + await reconfigureServer(serverConfig); + user.setUsername('newEmailVerifyTokenOnEmailReset'); + user.setPassword('expiringToken'); + user.set('email', 'user@parse.com'); + await user.signUp(); + await sendPromise; + const config = Config.get('test'); + const userFromDb = await config.database + .find('_User', { username: 'newEmailVerifyTokenOnEmailReset' }) + .then(results => { + return results[0]; + }); + expect(typeof userFromDb).toBe('object'); + const userBeforeEmailReset = userFromDb; + + // trigger another token generation by setting the email + user.set('email', 'user@parse.com'); + await new Promise(resolve => { + // wait for half a sec to get a new expiration time + setTimeout(() => resolve(user.save()), 500); + }); + const userAfterEmailReset = await config.database + .find( + '_User', + { username: 'newEmailVerifyTokenOnEmailReset' }, + {}, + Auth.maintenance(config) + ) + .then(results => { + return results[0]; + }); + + expect(typeof userAfterEmailReset).toBe('object'); + expect(userBeforeEmailReset._email_verify_token).not.toEqual( + userAfterEmailReset._email_verify_token + ); + expect(userBeforeEmailReset._email_verify_token_expires_at).not.toEqual( + userAfterEmailReset._email_verify_token_expires_at + ); + expect(sendEmailOptions).toBeDefined(); + }); + it_id('28f2140d-48bd-44ac-a141-ba60ea8d9713')(it)('should send a new verification email when a resend is requested and the user is UNVERIFIED', async () => { + const user = new Parse.User(); let sendEmailOptions; - let emailAdapter = { + let sendVerificationEmailCallCount = 0; + const promise1 = resolvingPromise(); + const promise2 = resolvingPromise(); + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; + sendVerificationEmailCallCount++; + if (sendVerificationEmailCallCount === 1) { + promise1.resolve(); + } else { + promise2.resolve(); + } }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} + sendMail: () => {}, }; - let serverConfig = { + await reconfigureServer({ appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5, // 5 seconds - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', + }); + user.setUsername('resends_verification_token'); + user.setPassword('expiringToken'); + user.set('email', 'user@parse.com'); + await user.signUp(); + await promise1; + const config = Config.get('test'); + const newUser = await config.database + .find('_User', { username: 'resends_verification_token' }) + .then(results => { + return results[0]; + }); + // store this user before we make our email request + const userBeforeRequest = newUser; + + expect(sendVerificationEmailCallCount).toBe(1); + + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { + email: 'user@parse.com', + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(response.status).toBe(200); + await promise2; + expect(sendVerificationEmailCallCount).toBe(2); + expect(sendEmailOptions).toBeDefined(); + + // query for this user again + const userAfterRequest = await config.database + .find('_User', { username: 'resends_verification_token' }, {}, Auth.maintenance(config)) + .then(results => { + return results[0]; + }); + // verify that our token & expiration has been changed for this new request + expect(typeof userAfterRequest).toBe('object'); + expect(userBeforeRequest._email_verify_token).not.toEqual( + userAfterRequest._email_verify_token + ); + expect(userBeforeRequest._email_verify_token_expires_at).not.toEqual( + userAfterRequest._email_verify_token_expires_at + ); + }); + + it('provides function arguments in verifyUserEmails on verificationEmailRequest', async () => { + const user = new Parse.User(); + user.setUsername('user'); + user.setPassword('pass'); + user.set('email', 'test@example.com'); + await user.signUp(); + + const verifyUserEmails = { + method: async (params) => { + expect(params.object).toBeInstanceOf(Parse.User); + expect(params.ip).toBeDefined(); + expect(params.master).toBeDefined(); + expect(params.installationId).toBeDefined(); + expect(params.resendRequest).toBeTrue(); + expect(params.createdWith).toBeUndefined(); + return true; + }, }; + const verifyUserEmailsSpy = spyOn(verifyUserEmails, 'method').and.callThrough(); + await reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:1337/1', + verifyUserEmails: verifyUserEmails.method, + preventLoginWithUnverifiedEmail: verifyUserEmails.method, + preventSignupWithUnverifiedEmail: true, + emailAdapter: MockEmailAdapterWithOptions({ + fromAddress: 'parse@example.com', + apiKey: 'k', + domain: 'd', + }), + }); - reconfigureServer(serverConfig) - .then(() => { - user.setUsername("newEmailVerifyTokenOnEmailReset"); - user.setPassword("expiringToken"); - user.set('email', 'user@parse.com'); - return user.signUp(); - }) - .then(() => { - return MongoClient.connect(databaseURI); - }) - .then(database => { - expect(typeof database).toBe('object'); - db = database; //save the db object for later use - return db.collection('test__User').findOne({username: 'newEmailVerifyTokenOnEmailReset'}); - }) - .then(userFromDb => { - expect(typeof userFromDb).toBe('object'); - userBeforeEmailReset = userFromDb; - - // trigger another token generation by setting the email - user.set('email', 'user@parse.com'); - return new Promise((resolve, reject) => { - // wait for half a sec to get a new expiration time - setTimeout( () => resolve(user.save()), 500 ); + await expectAsync(Parse.User.requestEmailVerification('test@example.com')).toBeResolved(); + expect(verifyUserEmailsSpy).toHaveBeenCalledTimes(1); + }); + + it('should throw with invalid emailVerifyTokenReuseIfValid', async () => { + const sendEmailOptions = []; + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + sendEmailOptions.push(options); + }, + sendMail: () => {}, + }; + try { + await reconfigureServer({ + appName: 'passwordPolicy', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5 * 60, // 5 minutes + emailVerifyTokenReuseIfValid: [], + publicServerURL: 'http://localhost:8378/1', }); - }) - .then(() => { - // get user data after email reset and new token generation - return db.collection('test__User').findOne({username: 'newEmailVerifyTokenOnEmailReset'}); - }) - .then(userAfterEmailReset => { - expect(typeof userAfterEmailReset).toBe('object'); - expect(userBeforeEmailReset._email_verify_token).not.toEqual(userAfterEmailReset._email_verify_token); - expect(userBeforeEmailReset._email_verify_token_expires_at).not.toEqual(userAfterEmailReset.__email_verify_token_expires_at); - done(); - }) - .catch((err) => { - fail("this should not fail"); - done(); + fail('should have thrown.'); + } catch (e) { + expect(e).toBe('emailVerifyTokenReuseIfValid must be a boolean value'); + } + try { + await reconfigureServer({ + appName: 'passwordPolicy', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenReuseIfValid: true, + publicServerURL: 'http://localhost:8378/1', + }); + fail('should have thrown.'); + } catch (e) { + expect(e).toBe( + 'You cannot use emailVerifyTokenReuseIfValid without emailVerifyTokenValidityDuration' + ); + } + }); + + it_id('0e66b7f6-2c07-4117-a8b9-605aa31a1e29')(it)('should match codes with emailVerifyTokenReuseIfValid', async () => { + let sendEmailOptions; + let sendVerificationEmailCallCount = 0; + const promise1 = resolvingPromise(); + const promise2 = resolvingPromise(); + const emailAdapter = { + sendVerificationEmail: options => { + sendEmailOptions = options; + sendVerificationEmailCallCount++; + if (sendVerificationEmailCallCount === 1) { + promise1.resolve(); + } else { + promise2.resolve(); + } + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + await reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5 * 60, // 5 minutes + publicServerURL: 'http://localhost:8378/1', + emailVerifyTokenReuseIfValid: true, + }); + const user = new Parse.User(); + user.setUsername('resends_verification_token'); + user.setPassword('expiringToken'); + user.set('email', 'user@example.com'); + await user.signUp(); + await promise1; + const config = Config.get('test'); + const [userBeforeRequest] = await config.database.find('_User', { + username: 'resends_verification_token', + }, {}, Auth.maintenance(config)); + // store this user before we make our email request + expect(sendVerificationEmailCallCount).toBe(1); + await new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 1000); + }); + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { + email: 'user@example.com', + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, }); + await promise2; + expect(response.status).toBe(200); + expect(sendVerificationEmailCallCount).toBe(2); + expect(sendEmailOptions).toBeDefined(); + + const [userAfterRequest] = await config.database.find('_User', { + username: 'resends_verification_token', + }, {}, Auth.maintenance(config)); + + // Verify that token & expiration haven't been changed for this new request + expect(typeof userAfterRequest).toBe('object'); + expect(userBeforeRequest._email_verify_token).toBeDefined(); + expect(userBeforeRequest._email_verify_token).toEqual(userAfterRequest._email_verify_token); + expect(userBeforeRequest._email_verify_token_expires_at).toBeDefined(); + expect(userBeforeRequest._email_verify_token_expires_at).toEqual(userAfterRequest._email_verify_token_expires_at); }); - it_exclude_dbs(['postgres'])('client should not see the _email_verify_token_expires_at field', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { + it_id('1ed9a6c2-bebc-4813-af30-4f4a212544c2')(it)('should not send a new verification email when a resend is requested and the user is VERIFIED', async () => { + const user = new Parse.User(); + let sendEmailOptions; + let sendVerificationEmailCallCount = 0; + const sendPromise = resolvingPromise(); + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; + sendVerificationEmailCallCount++; + sendPromise.resolve(); }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } - reconfigureServer({ + sendMail: () => {}, + }; + await reconfigureServer({ appName: 'emailVerifyToken', verifyUserEmails: true, emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5, // 5 seconds - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - user.setUsername("testEmailVerifyTokenValidity"); - user.setPassword("expiringToken"); - user.set('email', 'user@parse.com'); - return user.signUp(); + publicServerURL: 'http://localhost:8378/1', + emailVerifySuccessOnInvalidEmail: false, + }); + user.setUsername('no_new_verification_token_once_verified'); + user.setPassword('expiringToken'); + user.set('email', 'user@parse.com'); + await user.signUp(); + await sendPromise; + let response = await request({ + url: sendEmailOptions.link, + followRedirects: false, + }); + expect(response.status).toEqual(200); + expect(sendVerificationEmailCallCount).toBe(1); + + response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { + email: 'user@parse.com', + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).then(fail, res => res); + expect(response.status).toBe(400); + expect(sendVerificationEmailCallCount).toBe(1); + }); + + it('should not send a new verification email if this user does not exist', async () => { + let sendEmailOptions; + let sendVerificationEmailCallCount = 0; + const emailAdapter = { + sendVerificationEmail: options => { + sendEmailOptions = options; + sendVerificationEmailCallCount++; + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + await reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5, // 5 seconds + publicServerURL: 'http://localhost:8378/1', + emailVerifySuccessOnInvalidEmail: false, + }); + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { + email: 'user@parse.com', + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, }) - .then(() => { - - user.fetch() - .then(() => { - expect(user.get('emailVerified')).toEqual(false); - expect(typeof user.get('_email_verify_token_expires_at')).toBe('undefined'); - done(); - }) - .catch(error => { - fail("this should not fail"); - done(); - }); + .then(fail) + .catch(response => response); + + expect(response.status).toBe(400); + expect(sendVerificationEmailCallCount).toBe(0); + expect(sendEmailOptions).not.toBeDefined(); + }); + + it('should fail if no email is supplied', async () => { + let sendEmailOptions; + let sendVerificationEmailCallCount = 0; + const emailAdapter = { + sendVerificationEmail: options => { + sendEmailOptions = options; + sendVerificationEmailCallCount++; + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + await reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5, // 5 seconds + publicServerURL: 'http://localhost:8378/1', + }); + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: {}, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).then(fail, response => response); + expect(response.status).toBe(400); + expect(response.data.code).toBe(Parse.Error.EMAIL_MISSING); + expect(response.data.error).toBe('you must provide an email'); + expect(sendVerificationEmailCallCount).toBe(0); + expect(sendEmailOptions).not.toBeDefined(); + }); + it('should fail if email is not a string', async () => { + let sendEmailOptions; + let sendVerificationEmailCallCount = 0; + const emailAdapter = { + sendVerificationEmail: options => { + sendEmailOptions = options; + sendVerificationEmailCallCount++; + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + await reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5, // 5 seconds + publicServerURL: 'http://localhost:8378/1', }); + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 3 }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).then(fail, res => res); + expect(response.status).toBe(400); + expect(response.data.code).toBe(Parse.Error.INVALID_EMAIL_ADDRESS); + expect(response.data.error).toBe('you must provide a valid email string'); + expect(sendVerificationEmailCallCount).toBe(0); + expect(sendEmailOptions).not.toBeDefined(); }); -}) + it('client should not see the _email_verify_token_expires_at field', async () => { + const user = new Parse.User(); + let sendEmailOptions; + const sendPromise = resolvingPromise(); + const emailAdapter = { + sendVerificationEmail: options => { + sendEmailOptions = options; + sendPromise.resolve(); + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + await reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5, // 5 seconds + publicServerURL: 'http://localhost:8378/1', + }); + user.setUsername('testEmailVerifyTokenValidity'); + user.setPassword('expiringToken'); + user.set('email', 'user@parse.com'); + await user.signUp(); + await sendPromise; + await user.fetch(); + expect(user.get('emailVerified')).toEqual(false); + expect(typeof user.get('_email_verify_token_expires_at')).toBe('undefined'); + expect(sendEmailOptions).toBeDefined(); + }); + + it_id('b082d387-4974-4d45-a0d9-0c85ca2d7cbf')(it)('emailVerified should be set to false after changing from an already verified email', async () => { + let user = new Parse.User(); + let sendEmailOptions; + const sendPromise = resolvingPromise(); + const emailAdapter = { + sendVerificationEmail: options => { + sendEmailOptions = options; + sendPromise.resolve(); + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + await reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5, // 5 seconds + publicServerURL: 'http://localhost:8378/1', + }); + user.setUsername('testEmailVerifyTokenValidity'); + user.setPassword('expiringToken'); + user.set('email', 'user@parse.com'); + await user.signUp(); + await sendPromise; + let response = await request({ + url: sendEmailOptions.link, + followRedirects: false, + }); + expect(response.status).toEqual(200); + user = await Parse.User.logIn('testEmailVerifyTokenValidity', 'expiringToken'); + expect(typeof user).toBe('object'); + expect(user.get('emailVerified')).toBe(true); + + user.set('email', 'newEmail@parse.com'); + await user.save(); + await user.fetch(); + expect(typeof user).toBe('object'); + expect(user.get('email')).toBe('newEmail@parse.com'); + expect(user.get('emailVerified')).toBe(false); + + response = await request({ + url: sendEmailOptions.link, + followRedirects: false, + }); + expect(response.status).toEqual(200); + }); +}); diff --git a/spec/EnableExpressErrorHandler.spec.js b/spec/EnableExpressErrorHandler.spec.js new file mode 100644 index 0000000000..64c250628b --- /dev/null +++ b/spec/EnableExpressErrorHandler.spec.js @@ -0,0 +1,32 @@ +const request = require('../lib/request'); + +describe('Enable express error handler', () => { + it('should call the default handler in case of error, like updating a non existing object', async done => { + spyOn(console, 'error'); + const parseServer = await reconfigureServer({ + enableExpressErrorHandler: true, + }); + parseServer.app.use(function (err, req, res, next) { + expect(err.message).toBe('Object not found.'); + next(err); + }); + + try { + await request({ + method: 'PUT', + url: defaultConfiguration.serverURL + '/classes/AnyClass/nonExistingId', + headers: { + 'X-Parse-Application-Id': defaultConfiguration.appId, + 'X-Parse-Master-Key': defaultConfiguration.masterKey, + 'Content-Type': 'application/json', + }, + body: { someField: 'blablabla' }, + }); + fail('Should throw error'); + } catch (response) { + expect(response).toBeDefined(); + expect(response.status).toEqual(500); + parseServer.server.close(done); + } + }); +}); diff --git a/spec/EventEmitterPubSub.spec.js b/spec/EventEmitterPubSub.spec.js index c457215be8..00358646de 100644 --- a/spec/EventEmitterPubSub.spec.js +++ b/spec/EventEmitterPubSub.spec.js @@ -1,13 +1,13 @@ -var EventEmitterPubSub = require('../src/LiveQuery/EventEmitterPubSub').EventEmitterPubSub; +const EventEmitterPubSub = require('../lib/Adapters/PubSub/EventEmitterPubSub').EventEmitterPubSub; -describe('EventEmitterPubSub', function() { - it('can publish and subscribe', function() { - var publisher = EventEmitterPubSub.createPublisher(); - var subscriber = EventEmitterPubSub.createSubscriber(); +describe('EventEmitterPubSub', function () { + it('can publish and subscribe', function () { + const publisher = EventEmitterPubSub.createPublisher(); + const subscriber = EventEmitterPubSub.createSubscriber(); subscriber.subscribe('testChannel'); // Register mock checked for subscriber - var isChecked = false; - subscriber.on('message', function(channel, message) { + let isChecked = false; + subscriber.on('message', function (channel, message) { isChecked = true; expect(channel).toBe('testChannel'); expect(message).toBe('testMessage'); @@ -18,14 +18,14 @@ describe('EventEmitterPubSub', function() { expect(isChecked).toBe(true); }); - it('can unsubscribe', function() { - var publisher = EventEmitterPubSub.createPublisher(); - var subscriber = EventEmitterPubSub.createSubscriber(); + it('can unsubscribe', function () { + const publisher = EventEmitterPubSub.createPublisher(); + const subscriber = EventEmitterPubSub.createSubscriber(); subscriber.subscribe('testChannel'); subscriber.unsubscribe('testChannel'); // Register mock checked for subscriber - var isCalled = false; - subscriber.on('message', function(channel, message) { + let isCalled = false; + subscriber.on('message', function () { isCalled = true; }); @@ -34,8 +34,8 @@ describe('EventEmitterPubSub', function() { expect(isCalled).toBe(false); }); - it('can unsubscribe not subscribing channel', function() { - var subscriber = EventEmitterPubSub.createSubscriber(); + it('can unsubscribe not subscribing channel', function () { + const subscriber = EventEmitterPubSub.createSubscriber(); // Make sure subscriber does not throw exception subscriber.unsubscribe('testChannel'); diff --git a/spec/FileDownload.spec.js b/spec/FileDownload.spec.js new file mode 100644 index 0000000000..5010f032ee --- /dev/null +++ b/spec/FileDownload.spec.js @@ -0,0 +1,282 @@ +'use strict'; + +describe('fileDownload', () => { + describe('config validation', () => { + it('should default all flags to true when fileDownload is undefined', async () => { + await reconfigureServer({ fileDownload: undefined }); + const Config = require('../lib/Config'); + const config = Config.get(Parse.applicationId); + expect(config.fileDownload.enableForAnonymousUser).toBe(true); + expect(config.fileDownload.enableForAuthenticatedUser).toBe(true); + expect(config.fileDownload.enableForPublic).toBe(true); + }); + + it('should accept valid boolean values', async () => { + await reconfigureServer({ + fileDownload: { + enableForAnonymousUser: false, + enableForAuthenticatedUser: false, + enableForPublic: false, + }, + }); + const Config = require('../lib/Config'); + const config = Config.get(Parse.applicationId); + expect(config.fileDownload.enableForAnonymousUser).toBe(false); + expect(config.fileDownload.enableForAuthenticatedUser).toBe(false); + expect(config.fileDownload.enableForPublic).toBe(false); + }); + + it('should reject non-object values', async () => { + for (const value of ['string', 123, true, []]) { + await expectAsync(reconfigureServer({ fileDownload: value })).toBeRejected(); + } + }); + + it('should reject non-boolean flag values', async () => { + await expectAsync( + reconfigureServer({ fileDownload: { enableForAnonymousUser: 'yes' } }) + ).toBeRejected(); + await expectAsync( + reconfigureServer({ fileDownload: { enableForAuthenticatedUser: 1 } }) + ).toBeRejected(); + await expectAsync( + reconfigureServer({ fileDownload: { enableForPublic: null } }) + ).toBeRejected(); + }); + }); + + describe('permissions', () => { + async function uploadTestFile() { + const request = require('../lib/request'); + const res = await request({ + headers: { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + method: 'POST', + url: 'http://localhost:8378/1/files/test.txt', + body: 'hello world', + }); + return res.data; + } + + it('should allow public download by default', async () => { + await reconfigureServer(); + const file = await uploadTestFile(); + const request = require('../lib/request'); + const res = await request({ + method: 'GET', + url: file.url, + }); + expect(res.status).toBe(200); + }); + + it('should block public download when enableForPublic is false', async () => { + await reconfigureServer({ + fileDownload: { enableForPublic: false }, + }); + const file = await uploadTestFile(); + const request = require('../lib/request'); + try { + await request({ + method: 'GET', + url: file.url, + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it('should allow authenticated user download when enableForAuthenticatedUser is true', async () => { + await reconfigureServer({ + fileDownload: { enableForPublic: false, enableForAuthenticatedUser: true }, + }); + const file = await uploadTestFile(); + const user = new Parse.User(); + user.set('username', 'testuser'); + user.set('password', 'testpass'); + await user.signUp(); + const request = require('../lib/request'); + const res = await request({ + headers: { + 'X-Parse-Session-Token': user.getSessionToken(), + }, + method: 'GET', + url: file.url, + }); + expect(res.status).toBe(200); + }); + + it('should block authenticated user download when enableForAuthenticatedUser is false', async () => { + await reconfigureServer({ + fileDownload: { enableForAuthenticatedUser: false }, + }); + const file = await uploadTestFile(); + const user = new Parse.User(); + user.set('username', 'testuser'); + user.set('password', 'testpass'); + await user.signUp(); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'X-Parse-Session-Token': user.getSessionToken(), + }, + method: 'GET', + url: file.url, + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it('should block anonymous user download when enableForAnonymousUser is false', async () => { + await reconfigureServer({ + fileDownload: { enableForAnonymousUser: false }, + }); + const file = await uploadTestFile(); + const user = await Parse.AnonymousUtils.logIn(); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'X-Parse-Session-Token': user.getSessionToken(), + }, + method: 'GET', + url: file.url, + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it('should allow anonymous user download when enableForAnonymousUser is true', async () => { + await reconfigureServer({ + fileDownload: { enableForAnonymousUser: true, enableForPublic: false }, + }); + const file = await uploadTestFile(); + const user = await Parse.AnonymousUtils.logIn(); + const request = require('../lib/request'); + const res = await request({ + headers: { + 'X-Parse-Session-Token': user.getSessionToken(), + }, + method: 'GET', + url: file.url, + }); + expect(res.status).toBe(200); + }); + + it('should allow master key to bypass all restrictions', async () => { + await reconfigureServer({ + fileDownload: { + enableForAnonymousUser: false, + enableForAuthenticatedUser: false, + enableForPublic: false, + }, + }); + const file = await uploadTestFile(); + const request = require('../lib/request'); + const res = await request({ + headers: { + 'X-Parse-Master-Key': 'test', + }, + method: 'GET', + url: file.url, + }); + expect(res.status).toBe(200); + }); + + it('should block metadata endpoint when download is disabled for public', async () => { + await reconfigureServer({ + fileDownload: { enableForPublic: false }, + }); + const file = await uploadTestFile(); + const request = require('../lib/request'); + // The file URL is like http://localhost:8378/1/files/test/abc_test.txt + // The metadata URL replaces /files/APPID/ with /files/APPID/metadata/ + const url = new URL(file.url); + const pathParts = url.pathname.split('/'); + // pathParts: ['', '1', 'files', 'test', 'abc_test.txt'] + const appIdIndex = pathParts.indexOf('files') + 1; + pathParts.splice(appIdIndex + 1, 0, 'metadata'); + url.pathname = pathParts.join('/'); + try { + await request({ + method: 'GET', + url: url.toString(), + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it('should block all downloads when all flags are false', async () => { + await reconfigureServer({ + fileDownload: { + enableForAnonymousUser: false, + enableForAuthenticatedUser: false, + enableForPublic: false, + }, + }); + const file = await uploadTestFile(); + const request = require('../lib/request'); + try { + await request({ + method: 'GET', + url: file.url, + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it('should allow maintenance key to bypass download restrictions', async () => { + await reconfigureServer({ + fileDownload: { + enableForAnonymousUser: false, + enableForAuthenticatedUser: false, + enableForPublic: false, + }, + }); + const file = await uploadTestFile(); + const request = require('../lib/request'); + const res = await request({ + headers: { + 'X-Parse-Maintenance-Key': 'testing', + }, + method: 'GET', + url: file.url, + }); + expect(res.status).toBe(200); + }); + + it('should allow maintenance key to bypass upload restrictions', async () => { + await reconfigureServer({ + fileUpload: { + enableForAnonymousUser: false, + enableForAuthenticatedUser: false, + enableForPublic: false, + }, + }); + const request = require('../lib/request'); + const res = await request({ + headers: { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Maintenance-Key': 'testing', + }, + method: 'POST', + url: 'http://localhost:8378/1/files/test.txt', + body: 'hello world', + }); + expect(res.data.url).toBeDefined(); + }); + }); +}); diff --git a/spec/FileLoggerAdapter.spec.js b/spec/FileLoggerAdapter.spec.js deleted file mode 100644 index 2816e95c34..0000000000 --- a/spec/FileLoggerAdapter.spec.js +++ /dev/null @@ -1,107 +0,0 @@ -'use strict'; - -var FileLoggerAdapter = require('../src/Adapters/Logger/FileLoggerAdapter').FileLoggerAdapter; -var Parse = require('parse/node').Parse; -var request = require('request'); - -describe('info logs', () => { - it("Verify INFO logs", (done) => { - var fileLoggerAdapter = new FileLoggerAdapter(); - fileLoggerAdapter.info('testing info logs', () => { - fileLoggerAdapter.query({ - from: new Date(Date.now() - 500), - size: 100, - level: 'info' - }, (results) => { - if(results.length == 0) { - fail('The adapter should return non-empty results'); - done(); - } else { - expect(results[0].message).toEqual('testing info logs'); - done(); - } - }); - }); - }); -}); - -describe('error logs', () => { - it("Verify ERROR logs", (done) => { - var fileLoggerAdapter = new FileLoggerAdapter(); - fileLoggerAdapter.error('testing error logs', () => { - fileLoggerAdapter.query({ - from: new Date(Date.now() - 500), - size: 100, - level: 'error' - }, (results) => { - if(results.length == 0) { - fail('The adapter should return non-empty results'); - done(); - } - else { - expect(results[0].message).toEqual('testing error logs'); - done(); - } - }); - }); - }); -}); - -describe('verbose logs', () => { - it("mask sensitive information in _User class", (done) => { - reconfigureServer({ verbose: true }) - .then(() => createTestUser()) - .then(() => { - let fileLoggerAdapter = new FileLoggerAdapter(); - return fileLoggerAdapter.query({ - from: new Date(Date.now() - 500), - size: 100, - level: 'verbose' - }); - }).then((results) => { - let logString = JSON.stringify(results); - expect(logString.match(/\*\*\*\*\*\*\*\*/g).length).not.toBe(0); - expect(logString.match(/moon-y/g)).toBe(null); - - var headers = { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - request.get({ - headers: headers, - url: 'http://localhost:8378/1/login?username=test&password=moon-y' - }, (error, response, body) => { - let fileLoggerAdapter = new FileLoggerAdapter(); - return fileLoggerAdapter.query({ - from: new Date(Date.now() - 500), - size: 100, - level: 'verbose' - }).then((results) => { - let logString = JSON.stringify(results); - expect(logString.match(/\*\*\*\*\*\*\*\*/g).length).not.toBe(0); - expect(logString.match(/moon-y/g)).toBe(null); - done(); - }); - }); - }).catch((err) => { - fail(JSON.stringify(err)); - done(); - }) - }); - - it("should not mask information in non _User class", (done) => { - let obj = new Parse.Object('users'); - obj.set('password', 'pw'); - obj.save().then(() => { - let fileLoggerAdapter = new FileLoggerAdapter(); - return fileLoggerAdapter.query({ - from: new Date(Date.now() - 500), - size: 100, - level: 'verbose' - }); - }).then((results) => { - expect(results[1].body.password).toEqual("pw"); - done(); - }); - }); -}); diff --git a/spec/FileUrlValidator.spec.js b/spec/FileUrlValidator.spec.js new file mode 100644 index 0000000000..886aaf75e3 --- /dev/null +++ b/spec/FileUrlValidator.spec.js @@ -0,0 +1,141 @@ +'use strict'; + +const { validateFileUrl, validateFileUrlsInObject } = require('../src/FileUrlValidator'); + +describe('FileUrlValidator', () => { + describe('validateFileUrl', () => { + it('allows null, undefined, and empty string URLs', () => { + const config = { fileUpload: { allowedFileUrlDomains: [] } }; + expect(() => validateFileUrl(null, config)).not.toThrow(); + expect(() => validateFileUrl(undefined, config)).not.toThrow(); + expect(() => validateFileUrl('', config)).not.toThrow(); + }); + + it('allows any URL when allowedFileUrlDomains contains wildcard', () => { + const config = { fileUpload: { allowedFileUrlDomains: ['*'] } }; + expect(() => validateFileUrl('http://malicious.example.com/file.txt', config)).not.toThrow(); + expect(() => validateFileUrl('http://malicious.example.com/leak', config)).not.toThrow(); + }); + + it('allows any URL when allowedFileUrlDomains is not an array', () => { + expect(() => validateFileUrl('http://example.com/file', {})).not.toThrow(); + expect(() => validateFileUrl('http://example.com/file', { fileUpload: {} })).not.toThrow(); + expect(() => validateFileUrl('http://example.com/file', null)).not.toThrow(); + }); + + it('rejects all URLs when allowedFileUrlDomains is empty', () => { + const config = { fileUpload: { allowedFileUrlDomains: [] } }; + expect(() => validateFileUrl('http://example.com/file', config)).toThrowError( + /not allowed/ + ); + }); + + it('allows URLs matching exact hostname', () => { + const config = { fileUpload: { allowedFileUrlDomains: ['cdn.example.com'] } }; + expect(() => validateFileUrl('https://cdn.example.com/files/test.txt', config)).not.toThrow(); + }); + + it('rejects URLs not matching any allowed hostname', () => { + const config = { fileUpload: { allowedFileUrlDomains: ['cdn.example.com'] } }; + expect(() => validateFileUrl('http://malicious.example.com/file', config)).toThrowError( + /not allowed/ + ); + }); + + it('supports wildcard subdomain matching', () => { + const config = { fileUpload: { allowedFileUrlDomains: ['*.example.com'] } }; + expect(() => validateFileUrl('https://cdn.example.com/file.txt', config)).not.toThrow(); + expect(() => validateFileUrl('https://us-east.cdn.example.com/file.txt', config)).not.toThrow(); + expect(() => validateFileUrl('https://example.net/file.txt', config)).toThrowError( + /not allowed/ + ); + }); + + it('performs case-insensitive hostname matching', () => { + const config = { fileUpload: { allowedFileUrlDomains: ['CDN.Example.COM'] } }; + expect(() => validateFileUrl('https://cdn.example.com/file.txt', config)).not.toThrow(); + }); + + it('throws on invalid URL strings', () => { + const config = { fileUpload: { allowedFileUrlDomains: ['example.com'] } }; + expect(() => validateFileUrl('not-a-url', config)).toThrowError( + /Invalid file URL/ + ); + }); + + it('supports multiple allowed domains', () => { + const config = { fileUpload: { allowedFileUrlDomains: ['cdn1.example.com', 'cdn2.example.com'] } }; + expect(() => validateFileUrl('https://cdn1.example.com/file.txt', config)).not.toThrow(); + expect(() => validateFileUrl('https://cdn2.example.com/file.txt', config)).not.toThrow(); + expect(() => validateFileUrl('https://cdn3.example.com/file.txt', config)).toThrowError( + /not allowed/ + ); + }); + + it('does not allow partial hostname matches', () => { + const config = { fileUpload: { allowedFileUrlDomains: ['example.com'] } }; + expect(() => validateFileUrl('https://notexample.com/file.txt', config)).toThrowError( + /not allowed/ + ); + expect(() => validateFileUrl('https://example.com.malicious.example.com/file.txt', config)).toThrowError( + /not allowed/ + ); + }); + }); + + describe('validateFileUrlsInObject', () => { + const config = { fileUpload: { allowedFileUrlDomains: ['example.com'] } }; + + it('validates file URLs in flat objects', () => { + expect(() => + validateFileUrlsInObject( + { file: { __type: 'File', name: 'test.txt', url: 'http://malicious.example.com/file' } }, + config + ) + ).toThrowError(/not allowed/); + }); + + it('validates file URLs in nested objects', () => { + expect(() => + validateFileUrlsInObject( + { nested: { deep: { file: { __type: 'File', name: 'test.txt', url: 'http://malicious.example.com/file' } } } }, + config + ) + ).toThrowError(/not allowed/); + }); + + it('validates file URLs in arrays', () => { + expect(() => + validateFileUrlsInObject( + [{ __type: 'File', name: 'test.txt', url: 'http://malicious.example.com/file' }], + config + ) + ).toThrowError(/not allowed/); + }); + + it('allows files without URLs', () => { + expect(() => + validateFileUrlsInObject( + { file: { __type: 'File', name: 'test.txt' } }, + config + ) + ).not.toThrow(); + }); + + it('allows files with permitted URLs', () => { + expect(() => + validateFileUrlsInObject( + { file: { __type: 'File', name: 'test.txt', url: 'http://example.com/file.txt' } }, + config + ) + ).not.toThrow(); + }); + + it('handles null, undefined, and primitive values', () => { + expect(() => validateFileUrlsInObject(null, config)).not.toThrow(); + expect(() => validateFileUrlsInObject(undefined, config)).not.toThrow(); + expect(() => validateFileUrlsInObject('string', config)).not.toThrow(); + expect(() => validateFileUrlsInObject(42, config)).not.toThrow(); + }); + }); +}); diff --git a/spec/FilesController.spec.js b/spec/FilesController.spec.js index d6e9e4cf43..30acf7d13c 100644 --- a/spec/FilesController.spec.js +++ b/spec/FilesController.spec.js @@ -1,30 +1,221 @@ -var GridStoreAdapter = require("../src/Adapters/Files/GridStoreAdapter").GridStoreAdapter; -var Config = require("../src/Config"); -var FilesController = require('../src/Controllers/FilesController').default; +const LoggerController = require('../lib/Controllers/LoggerController').LoggerController; +const WinstonLoggerAdapter = require('../lib/Adapters/Logger/WinstonLoggerAdapter') + .WinstonLoggerAdapter; +const GridFSBucketAdapter = require('../lib/Adapters/Files/GridFSBucketAdapter') + .GridFSBucketAdapter; +const Config = require('../lib/Config'); +const FilesController = require('../lib/Controllers/FilesController').default; +const databaseURI = 'mongodb://localhost:27017/parse'; +const mockAdapter = { + createFile: () => { + return Promise.reject(new Error('it failed with xyz')); + }, + deleteFile: () => {}, + getFileData: () => {}, + getFileLocation: () => 'xyz', + validateFilename: () => { + return null; + }, +}; // Small additional tests to improve overall coverage -describe("FilesController",() =>{ - it("should properly expand objects", (done) => { - - var config = new Config(Parse.applicationId); - var gridStoreAdapter = new GridStoreAdapter('mongodb://localhost:27017/parse'); - var filesController = new FilesController(gridStoreAdapter) - var result = filesController.expandFilesInObject(config, function(){}); +describe('FilesController', () => { + it('should properly expand objects with sync getFileLocation', async () => { + const config = Config.get(Parse.applicationId); + const gridFSAdapter = new GridFSBucketAdapter('mongodb://localhost:27017/parse'); + gridFSAdapter.getFileLocation = (config, filename) => { + return config.mount + '/files/' + config.applicationId + '/' + encodeURIComponent(filename); + } + const filesController = new FilesController(gridFSAdapter); + const result = await filesController.expandFilesInObject(config, function () { }); expect(result).toBeUndefined(); - var fullFile = { + const fullFile = { type: '__type', - url: "http://an.url" - } + url: 'http://an.url', + }; - var anObject = { - aFile: fullFile + const anObject = { + aFile: fullFile, + }; + await filesController.expandFilesInObject(config, anObject); + expect(anObject.aFile.url).toEqual('http://an.url'); + }); + + it('should properly expand objects with async getFileLocation', async () => { + const config = Config.get(Parse.applicationId); + const gridFSAdapter = new GridFSBucketAdapter('mongodb://localhost:27017/parse'); + gridFSAdapter.getFileLocation = async (config, filename) => { + await Promise.resolve(); + return config.mount + '/files/' + config.applicationId + '/' + encodeURIComponent(filename); } - filesController.expandFilesInObject(config, anObject); - expect(anObject.aFile.url).toEqual("http://an.url"); + const filesController = new FilesController(gridFSAdapter); + const result = await filesController.expandFilesInObject(config, function () { }); + + expect(result).toBeUndefined(); + + const fullFile = { + type: '__type', + url: 'http://an.url', + }; + + const anObject = { + aFile: fullFile, + }; + await filesController.expandFilesInObject(config, anObject); + expect(anObject.aFile.url).toEqual('http://an.url'); + }); + + it('should call getFileLocation when config.fileKey is undefined', async () => { + const config = {}; + const gridFSAdapter = new GridFSBucketAdapter('mongodb://localhost:27017/parse'); + + const fullFile = { + name: 'mock-name', + __type: 'File', + }; + gridFSAdapter.getFileLocation = jasmine.createSpy('getFileLocation').and.returnValue(Promise.resolve('mock-url')); + const filesController = new FilesController(gridFSAdapter); + + const anObject = { aFile: fullFile }; + await filesController.expandFilesInObject(config, anObject); + expect(gridFSAdapter.getFileLocation).toHaveBeenCalledWith(config, fullFile.name); + expect(anObject.aFile.url).toEqual('mock-url'); + }); + + it('should call getFileLocation when config.fileKey is defined', async () => { + const config = { fileKey: 'mock-key' }; + const gridFSAdapter = new GridFSBucketAdapter('mongodb://localhost:27017/parse'); + + const fullFile = { + name: 'mock-name', + __type: 'File', + }; + gridFSAdapter.getFileLocation = jasmine.createSpy('getFileLocation').and.returnValue(Promise.resolve('mock-url')); + const filesController = new FilesController(gridFSAdapter); + + const anObject = { aFile: fullFile }; + await filesController.expandFilesInObject(config, anObject); + expect(gridFSAdapter.getFileLocation).toHaveBeenCalledWith(config, fullFile.name); + expect(anObject.aFile.url).toEqual('mock-url'); + }); + + + it_only_db('mongo')('should pass databaseOptions to GridFSBucketAdapter', async () => { + await reconfigureServer({ + databaseURI: 'mongodb://localhost:27017/parse', + filesAdapter: null, + databaseAdapter: null, + databaseOptions: { + retryWrites: true, + }, + }); + const config = Config.get(Parse.applicationId); + expect(config.database.adapter._mongoOptions.retryWrites).toBeTrue(); + expect(config.filesController.adapter._mongoOptions.retryWrites).toBeTrue(); + expect(config.filesController.adapter._mongoOptions.enableSchemaHooks).toBeUndefined(); + expect(config.filesController.adapter._mongoOptions.schemaCacheTtl).toBeUndefined(); + }); + + it('should create a server log on failure', done => { + const logController = new LoggerController(new WinstonLoggerAdapter()); + + reconfigureServer({ filesAdapter: mockAdapter }) + .then(() => new Parse.File('yolo.txt', [1, 2, 3], 'text/plain').save()) + .then( + () => done.fail('should not succeed'), + () => setImmediate(() => Promise.resolve('done')) + ) + .then(() => new Promise(resolve => setTimeout(resolve, 200))) + .then(() => logController.getLogs({ from: Date.now() - 1000, size: 1000 })) + .then(logs => { + // we get two logs here: 1. the source of the failure to save the file + // and 2 the message that will be sent back to the client. + + const log1 = logs.find(x => x.message === 'Error creating a file: it failed with xyz'); + expect(log1.level).toBe('error'); + + const log2 = logs.find(x => x.message === 'it failed with xyz'); + expect(log2.level).toBe('error'); + expect(log2.code).toBe(130); + + done(); + }); + }); + + it('should create a parse error when a string is returned', done => { + const mock2 = mockAdapter; + mock2.validateFilename = () => { + return 'Bad file! No biscuit!'; + }; + const filesController = new FilesController(mockAdapter); + const error = filesController.validateFilename(); + expect(typeof error).toBe('object'); + expect(error.message.indexOf('biscuit')).toBe(13); + expect(error.code).toBe(Parse.Error.INVALID_FILE_NAME); + mockAdapter.validateFilename = () => { + return null; + }; + done(); + }); + + it('should add a unique hash to the file name when the preserveFileName option is false', async () => { + const config = Config.get(Parse.applicationId); + const gridFSAdapter = new GridFSBucketAdapter('mongodb://localhost:27017/parse'); + spyOn(gridFSAdapter, 'createFile'); + gridFSAdapter.createFile.and.returnValue(Promise.resolve()); + const fileName = 'randomFileName.pdf'; + const regexEscapedFileName = fileName.replace(/\./g, '\\$&'); + const filesController = new FilesController(gridFSAdapter, null, { + preserveFileName: false, + }); + + await filesController.createFile(config, fileName); + + expect(gridFSAdapter.createFile).toHaveBeenCalledTimes(1); + expect(gridFSAdapter.createFile.calls.mostRecent().args[0]).toMatch( + `^.{32}_${regexEscapedFileName}$` + ); + }); + + it('should not add a unique hash to the file name when the preserveFileName option is true', async () => { + const config = Config.get(Parse.applicationId); + const gridFSAdapter = new GridFSBucketAdapter('mongodb://localhost:27017/parse'); + spyOn(gridFSAdapter, 'createFile'); + gridFSAdapter.createFile.and.returnValue(Promise.resolve()); + const fileName = 'randomFileName.pdf'; + const filesController = new FilesController(gridFSAdapter, null, { + preserveFileName: true, + }); + + await filesController.createFile(config, fileName); + + expect(gridFSAdapter.createFile).toHaveBeenCalledTimes(1); + expect(gridFSAdapter.createFile.calls.mostRecent().args[0]).toEqual(fileName); + }); + + it('should handle adapter without getMetadata', async () => { + const gridFSAdapter = new GridFSBucketAdapter(databaseURI); + gridFSAdapter.getMetadata = null; + const filesController = new FilesController(gridFSAdapter); + + const result = await filesController.getMetadata(); + expect(result).toEqual({}); + }); + + it('should reject slashes in file names', done => { + const gridFSAdapter = new GridFSBucketAdapter('mongodb://localhost:27017/parse'); + const fileName = 'foo/randomFileName.pdf'; + expect(gridFSAdapter.validateFilename(fileName)).not.toBe(null); + done(); + }); + it('should also reject slashes in file names', done => { + const gridFSAdapter = new GridFSBucketAdapter('mongodb://localhost:27017/parse'); + const fileName = 'foo/randomFileName.pdf'; + expect(gridFSAdapter.validateFilename(fileName)).not.toBe(null); done(); - }) + }); }); diff --git a/spec/GraphQLQueryComplexity.spec.js b/spec/GraphQLQueryComplexity.spec.js new file mode 100644 index 0000000000..def95a6b51 --- /dev/null +++ b/spec/GraphQLQueryComplexity.spec.js @@ -0,0 +1,247 @@ +'use strict'; + +const http = require('http'); +const express = require('express'); +const fetch = (...args) => + import('node-fetch').then(({ default: fetch }) => { + const [url, options = {}] = args; + return fetch(url, { agent: new http.Agent({ keepAlive: false }), ...options }); + }); +require('./helper'); +const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer'); + +describe('graphql query complexity', () => { + let httpServer; + let graphQLServer; + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'Content-Type': 'application/json', + }; + + async function setupGraphQL(serverOptions = {}) { + if (httpServer) { + await new Promise(resolve => httpServer.close(resolve)); + } + const server = await reconfigureServer(serverOptions); + const expressApp = express(); + httpServer = http.createServer(expressApp); + expressApp.use('/parse', server.app); + graphQLServer = new ParseGraphQLServer(server, { + graphQLPath: '/graphql', + }); + graphQLServer.applyGraphQL(expressApp); + await new Promise(resolve => httpServer.listen({ port: 13378 }, resolve)); + } + + async function graphqlRequest(query, requestHeaders = headers) { + const response = await fetch('http://localhost:13378/graphql', { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify({ query }), + }); + return response.json(); + } + + // Returns a query with depth 4: users(1) > edges(2) > node(3) > objectId(4) + function buildDeepQuery() { + return '{ users { edges { node { objectId } } } }'; + } + + function buildWideQuery(fieldCount) { + const fields = Array.from({ length: fieldCount }, (_, i) => `field${i}: objectId`).join('\n '); + return `{ users { edges { node { ${fields} } } } }`; + } + + afterEach(async () => { + if (httpServer) { + await new Promise(resolve => httpServer.close(resolve)); + httpServer = null; + } + }); + + describe('depth limit', () => { + it('should reject query exceeding depth limit', async () => { + await setupGraphQL({ + requestComplexity: { graphQLDepth: 3 }, + }); + const result = await graphqlRequest(buildDeepQuery()); + expect(result.errors).toBeDefined(); + expect(result.errors[0].message).toMatch( + /GraphQL query depth of \d+ exceeds maximum allowed depth of 3/ + ); + }); + + it('should allow query within depth limit', async () => { + await setupGraphQL({ + requestComplexity: { graphQLDepth: 10 }, + }); + const result = await graphqlRequest(buildDeepQuery()); + expect(result.errors).toBeUndefined(); + }); + + it('should allow deep query with master key', async () => { + await setupGraphQL({ + requestComplexity: { graphQLDepth: 3 }, + }); + const result = await graphqlRequest(buildDeepQuery(), { + ...headers, + 'X-Parse-Master-Key': 'test', + }); + expect(result.errors).toBeUndefined(); + }); + + it('should allow unlimited depth when graphQLDepth is -1', async () => { + await setupGraphQL({ + requestComplexity: { graphQLDepth: -1 }, + }); + const result = await graphqlRequest(buildDeepQuery()); + expect(result.errors).toBeUndefined(); + }); + }); + + describe('fields limit', () => { + it('should reject query exceeding fields limit', async () => { + await setupGraphQL({ + requestComplexity: { graphQLFields: 5 }, + }); + const result = await graphqlRequest(buildWideQuery(10)); + expect(result.errors).toBeDefined(); + expect(result.errors[0].message).toMatch( + /Number of GraphQL fields \(\d+\) exceeds maximum allowed \(5\)/ + ); + }); + + it('should allow query within fields limit', async () => { + await setupGraphQL({ + requestComplexity: { graphQLFields: 200 }, + }); + const result = await graphqlRequest(buildDeepQuery()); + expect(result.errors).toBeUndefined(); + }); + + it('should allow wide query with master key', async () => { + await setupGraphQL({ + requestComplexity: { graphQLFields: 5 }, + }); + const result = await graphqlRequest(buildWideQuery(10), { + ...headers, + 'X-Parse-Master-Key': 'test', + }); + expect(result.errors).toBeUndefined(); + }); + + it('should count fragment fields at each spread location', async () => { + // With correct counting: 2 aliases (2) + 2×edges (2) + 2×node (2) + 2×objectId from fragment (2) = 8 + // With incorrect counting (fragment once): 2 + 2 + 2 + 1 = 7 + // Set limit to 7 so incorrect counting passes but correct counting rejects + await setupGraphQL({ + requestComplexity: { graphQLFields: 7 }, + }); + const result = await graphqlRequest(` + fragment UserFields on User { objectId } + { + a1: users { edges { node { ...UserFields } } } + a2: users { edges { node { ...UserFields } } } + } + `); + expect(result.errors).toBeDefined(); + expect(result.errors[0].message).toMatch( + /Number of GraphQL fields \(\d+\) exceeds maximum allowed \(7\)/ + ); + }); + + it('should count inline fragment fields toward depth and field limits', async () => { + await setupGraphQL({ + requestComplexity: { graphQLFields: 3 }, + }); + // Inline fragment adds fields without increasing depth: + // users(1) > edges(2) > ... on UserConnection { edges(3) > node(4) } + const result = await graphqlRequest(`{ + users { + edges { + ... on UserEdge { + node { + objectId + } + } + } + } + }`); + expect(result.errors).toBeDefined(); + expect(result.errors[0].message).toMatch( + /Number of GraphQL fields \(\d+\) exceeds maximum allowed \(3\)/ + ); + }); + + it('should allow unlimited fields when graphQLFields is -1', async () => { + await setupGraphQL({ + requestComplexity: { graphQLFields: -1 }, + }); + const result = await graphqlRequest(buildWideQuery(50)); + expect(result.errors).toBeUndefined(); + }); + }); + + describe('fragment fan-out', () => { + it('should reject query with exponential fragment fan-out efficiently', async () => { + await setupGraphQL({ + requestComplexity: { graphQLFields: 100 }, + }); + // Binary fan-out: each fragment spreads the next one twice. + // Without fix: 2^(levels-1) field visits = 2^25 ≈ 33M (hangs event loop). + // With fix (memoization): O(levels) traversal, same field count, instant rejection. + const levels = 26; + let query = 'query Q { ...F0 }\n'; + for (let i = 0; i < levels; i++) { + if (i === levels - 1) { + query += `fragment F${i} on Query { __typename }\n`; + } else { + query += `fragment F${i} on Query { ...F${i + 1} ...F${i + 1} }\n`; + } + } + const start = Date.now(); + const result = await graphqlRequest(query); + const elapsed = Date.now() - start; + // Must complete in under 5 seconds (without fix it would take seconds or hang) + expect(elapsed).toBeLessThan(5000); + // Field count is 2^(levels-1) = 16777216, which exceeds the limit of 100 + expect(result.errors).toBeDefined(); + expect(result.errors[0].message).toMatch(/Number of GraphQL fields .* exceeds maximum allowed/); + }); + }); + + describe('where argument breadth', () => { + it('should enforce depth and field limits regardless of where argument breadth', async () => { + await setupGraphQL({ + requestComplexity: { graphQLDepth: 3, graphQLFields: 200, subqueryDepth: 1 }, + }); + // The GraphQL where argument may contain many OR branches, but the + // complexity check correctly measures the selection set depth/fields, + // not the where variable content. A query exceeding graphQLDepth is + // rejected even when the where argument is simple. + const result = await graphqlRequest(buildDeepQuery()); + expect(result.errors).toBeDefined(); + expect(result.errors[0].message).toMatch( + /GraphQL query depth of \d+ exceeds maximum allowed depth of 3/ + ); + }); + + it('should allow query with wide where argument when selection set is within limits', async () => { + await setupGraphQL({ + requestComplexity: { graphQLDepth: 10, graphQLFields: 200, subqueryDepth: 1 }, + }); + + const obj = new Parse.Object('TestItem'); + obj.set('name', 'test'); + await obj.save(); + + // Wide where with many OR branches — complexity check measures selection + // set depth and field count, not where argument structure + const orBranches = Array.from({ length: 20 }, (_, i) => `{ name: { equalTo: "test${i}" } }`).join(', '); + const query = `{ testItems(where: { OR: [${orBranches}] }) { edges { node { objectId } } } }`; + const result = await graphqlRequest(query); + expect(result.errors).toBeUndefined(); + }); + }); +}); diff --git a/spec/GridFSBucketStorageAdapter.spec.js b/spec/GridFSBucketStorageAdapter.spec.js new file mode 100644 index 0000000000..48fbd09115 --- /dev/null +++ b/spec/GridFSBucketStorageAdapter.spec.js @@ -0,0 +1,535 @@ +const GridFSBucketAdapter = require('../lib/Adapters/Files/GridFSBucketAdapter') + .GridFSBucketAdapter; +const { randomString } = require('../lib/cryptoUtils'); +const databaseURI = 'mongodb://localhost:27017/parse'; +const request = require('../lib/request'); + +async function expectMissingFile(gfsAdapter, name) { + try { + await gfsAdapter.getFileData(name); + fail('should have thrown'); + } catch (e) { + expect(e.message).toEqual('FileNotFound: file myFileName was not found'); + } +} + +describe_only_db('mongo')('GridFSBucket', () => { + beforeEach(async () => { + const gsAdapter = new GridFSBucketAdapter(databaseURI); + const db = await gsAdapter._connect(); + await db.dropDatabase(); + }); + + it('should connect to mongo with the supported database options', async () => { + const databaseURI = 'mongodb://localhost:27017/parse'; + const gfsAdapter = new GridFSBucketAdapter(databaseURI, { + retryWrites: true, + // Parse Server-specific options that should be filtered out before passing to MongoDB client + allowPublicExplain: true, + enableSchemaHooks: true, + schemaCacheTtl: 5000, + maxTimeMS: 30000, + batchSize: 500, + disableIndexFieldValidation: true, + logClientEvents: [{ name: 'commandStarted' }], + createIndexUserUsername: true, + createIndexUserUsernameCaseInsensitive: true, + createIndexUserEmail: true, + createIndexUserEmailCaseInsensitive: true, + createIndexUserEmailVerifyToken: true, + createIndexUserPasswordResetToken: true, + createIndexRoleName: true, + }); + + const db = await gfsAdapter._connect(); + const status = await db.admin().serverStatus(); + expect(status.connections.current > 0).toEqual(true); + expect(db.options?.retryWrites).toEqual(true); + }); + + it('should store batchSize and filter it from MongoClient options', async () => { + const gfsAdapter = new GridFSBucketAdapter(databaseURI, { batchSize: 500 }); + expect(gfsAdapter._batchSize).toEqual(500); + // Verify batchSize is filtered from MongoClient options + expect(gfsAdapter._mongoOptions.batchSize).toBeUndefined(); + }); + + it('should save an encrypted file that can only be decrypted by a GridFS adapter with the encryptionKey', async () => { + const unencryptedAdapter = new GridFSBucketAdapter(databaseURI); + const encryptedAdapter = new GridFSBucketAdapter( + databaseURI, + {}, + '89E4AFF1-DFE4-4603-9574-BFA16BB446FD' + ); + await expectMissingFile(encryptedAdapter, 'myFileName'); + const originalString = 'abcdefghi'; + await encryptedAdapter.createFile('myFileName', originalString); + const unencryptedResult = await unencryptedAdapter.getFileData('myFileName'); + expect(unencryptedResult.toString('utf8')).not.toBe(originalString); + const encryptedResult = await encryptedAdapter.getFileData('myFileName'); + expect(encryptedResult.toString('utf8')).toBe(originalString); + }); + + it('should rotate key of all unencrypted GridFS files to encrypted files', async () => { + const unencryptedAdapter = new GridFSBucketAdapter(databaseURI); + const encryptedAdapter = new GridFSBucketAdapter( + databaseURI, + {}, + '89E4AFF1-DFE4-4603-9574-BFA16BB446FD' + ); + const fileName1 = 'file1.txt'; + const data1 = 'hello world'; + const fileName2 = 'file2.txt'; + const data2 = 'hello new world'; + //Store unecrypted files + await unencryptedAdapter.createFile(fileName1, data1); + const unencryptedResult1 = await unencryptedAdapter.getFileData(fileName1); + expect(unencryptedResult1.toString('utf8')).toBe(data1); + await unencryptedAdapter.createFile(fileName2, data2); + const unencryptedResult2 = await unencryptedAdapter.getFileData(fileName2); + expect(unencryptedResult2.toString('utf8')).toBe(data2); + //Check if encrypted adapter can read data and make sure it's not the same as unEncrypted adapter + const { rotated, notRotated } = await encryptedAdapter.rotateEncryptionKey(); + expect(rotated.length).toEqual(2); + expect( + rotated.filter(function (value) { + return value === fileName1; + }).length + ).toEqual(1); + expect( + rotated.filter(function (value) { + return value === fileName2; + }).length + ).toEqual(1); + expect(notRotated.length).toEqual(0); + let result = await encryptedAdapter.getFileData(fileName1); + expect(Buffer.isBuffer(result)).toBe(true); + expect(result.toString('utf-8')).toEqual(data1); + const encryptedData1 = await unencryptedAdapter.getFileData(fileName1); + expect(encryptedData1.toString('utf-8')).not.toEqual(unencryptedResult1); + result = await encryptedAdapter.getFileData(fileName2); + expect(Buffer.isBuffer(result)).toBe(true); + expect(result.toString('utf-8')).toEqual(data2); + const encryptedData2 = await unencryptedAdapter.getFileData(fileName2); + expect(encryptedData2.toString('utf-8')).not.toEqual(unencryptedResult2); + }); + + it('should rotate key of all old encrypted GridFS files to encrypted files', async () => { + const oldEncryptionKey = 'oldKeyThatILoved'; + const oldEncryptedAdapter = new GridFSBucketAdapter(databaseURI, {}, oldEncryptionKey); + const encryptedAdapter = new GridFSBucketAdapter(databaseURI, {}, 'newKeyThatILove'); + const fileName1 = 'file1.txt'; + const data1 = 'hello world'; + const fileName2 = 'file2.txt'; + const data2 = 'hello new world'; + //Store unecrypted files + await oldEncryptedAdapter.createFile(fileName1, data1); + const oldEncryptedResult1 = await oldEncryptedAdapter.getFileData(fileName1); + expect(oldEncryptedResult1.toString('utf8')).toBe(data1); + await oldEncryptedAdapter.createFile(fileName2, data2); + const oldEncryptedResult2 = await oldEncryptedAdapter.getFileData(fileName2); + expect(oldEncryptedResult2.toString('utf8')).toBe(data2); + //Check if encrypted adapter can read data and make sure it's not the same as unEncrypted adapter + const { rotated, notRotated } = await encryptedAdapter.rotateEncryptionKey({ + oldKey: oldEncryptionKey, + }); + expect(rotated.length).toEqual(2); + expect( + rotated.filter(function (value) { + return value === fileName1; + }).length + ).toEqual(1); + expect( + rotated.filter(function (value) { + return value === fileName2; + }).length + ).toEqual(1); + expect(notRotated.length).toEqual(0); + let result = await encryptedAdapter.getFileData(fileName1); + expect(Buffer.isBuffer(result)).toBe(true); + expect(result.toString('utf-8')).toEqual(data1); + let decryptionError1; + let encryptedData1; + try { + encryptedData1 = await oldEncryptedAdapter.getFileData(fileName1); + } catch (err) { + decryptionError1 = err; + } + expect(decryptionError1).toMatch('Error'); + expect(encryptedData1).toBeUndefined(); + result = await encryptedAdapter.getFileData(fileName2); + expect(Buffer.isBuffer(result)).toBe(true); + expect(result.toString('utf-8')).toEqual(data2); + let decryptionError2; + let encryptedData2; + try { + encryptedData2 = await oldEncryptedAdapter.getFileData(fileName2); + } catch (err) { + decryptionError2 = err; + } + expect(decryptionError2).toMatch('Error'); + expect(encryptedData2).toBeUndefined(); + }); + + it('should rotate key of all old encrypted GridFS files to unencrypted files', async () => { + const oldEncryptionKey = 'oldKeyThatILoved'; + const oldEncryptedAdapter = new GridFSBucketAdapter(databaseURI, {}, oldEncryptionKey); + const unEncryptedAdapter = new GridFSBucketAdapter(databaseURI); + const fileName1 = 'file1.txt'; + const data1 = 'hello world'; + const fileName2 = 'file2.txt'; + const data2 = 'hello new world'; + //Store unecrypted files + await oldEncryptedAdapter.createFile(fileName1, data1); + const oldEncryptedResult1 = await oldEncryptedAdapter.getFileData(fileName1); + expect(oldEncryptedResult1.toString('utf8')).toBe(data1); + await oldEncryptedAdapter.createFile(fileName2, data2); + const oldEncryptedResult2 = await oldEncryptedAdapter.getFileData(fileName2); + expect(oldEncryptedResult2.toString('utf8')).toBe(data2); + //Check if unEncrypted adapter can read data and make sure it's not the same as oldEncrypted adapter + const { rotated, notRotated } = await unEncryptedAdapter.rotateEncryptionKey({ + oldKey: oldEncryptionKey, + }); + expect(rotated.length).toEqual(2); + expect( + rotated.filter(function (value) { + return value === fileName1; + }).length + ).toEqual(1); + expect( + rotated.filter(function (value) { + return value === fileName2; + }).length + ).toEqual(1); + expect(notRotated.length).toEqual(0); + let result = await unEncryptedAdapter.getFileData(fileName1); + expect(Buffer.isBuffer(result)).toBe(true); + expect(result.toString('utf-8')).toEqual(data1); + let decryptionError1; + let encryptedData1; + try { + encryptedData1 = await oldEncryptedAdapter.getFileData(fileName1); + } catch (err) { + decryptionError1 = err; + } + expect(decryptionError1).toMatch('Error'); + expect(encryptedData1).toBeUndefined(); + result = await unEncryptedAdapter.getFileData(fileName2); + expect(Buffer.isBuffer(result)).toBe(true); + expect(result.toString('utf-8')).toEqual(data2); + let decryptionError2; + let encryptedData2; + try { + encryptedData2 = await oldEncryptedAdapter.getFileData(fileName2); + } catch (err) { + decryptionError2 = err; + } + expect(decryptionError2).toMatch('Error'); + expect(encryptedData2).toBeUndefined(); + }); + + it('should only encrypt specified fileNames', async () => { + const oldEncryptionKey = 'oldKeyThatILoved'; + const oldEncryptedAdapter = new GridFSBucketAdapter(databaseURI, {}, oldEncryptionKey); + const encryptedAdapter = new GridFSBucketAdapter(databaseURI, {}, 'newKeyThatILove'); + const unEncryptedAdapter = new GridFSBucketAdapter(databaseURI); + const fileName1 = 'file1.txt'; + const data1 = 'hello world'; + const fileName2 = 'file2.txt'; + const data2 = 'hello new world'; + //Store unecrypted files + await oldEncryptedAdapter.createFile(fileName1, data1); + const oldEncryptedResult1 = await oldEncryptedAdapter.getFileData(fileName1); + expect(oldEncryptedResult1.toString('utf8')).toBe(data1); + await oldEncryptedAdapter.createFile(fileName2, data2); + const oldEncryptedResult2 = await oldEncryptedAdapter.getFileData(fileName2); + expect(oldEncryptedResult2.toString('utf8')).toBe(data2); + //Inject unecrypted file to see if causes an issue + const fileName3 = 'file3.txt'; + const data3 = 'hello past world'; + await unEncryptedAdapter.createFile(fileName3, data3, 'text/utf8'); + //Check if encrypted adapter can read data and make sure it's not the same as unEncrypted adapter + const { rotated, notRotated } = await encryptedAdapter.rotateEncryptionKey({ + oldKey: oldEncryptionKey, + fileNames: [fileName1, fileName2], + }); + expect(rotated.length).toEqual(2); + expect( + rotated.filter(function (value) { + return value === fileName1; + }).length + ).toEqual(1); + expect( + rotated.filter(function (value) { + return value === fileName2; + }).length + ).toEqual(1); + expect(notRotated.length).toEqual(0); + expect( + rotated.filter(function (value) { + return value === fileName3; + }).length + ).toEqual(0); + let result = await encryptedAdapter.getFileData(fileName1); + expect(Buffer.isBuffer(result)).toBe(true); + expect(result.toString('utf-8')).toEqual(data1); + let decryptionError1; + let encryptedData1; + try { + encryptedData1 = await oldEncryptedAdapter.getFileData(fileName1); + } catch (err) { + decryptionError1 = err; + } + expect(decryptionError1).toMatch('Error'); + expect(encryptedData1).toBeUndefined(); + result = await encryptedAdapter.getFileData(fileName2); + expect(Buffer.isBuffer(result)).toBe(true); + expect(result.toString('utf-8')).toEqual(data2); + let decryptionError2; + let encryptedData2; + try { + encryptedData2 = await oldEncryptedAdapter.getFileData(fileName2); + } catch (err) { + decryptionError2 = err; + } + expect(decryptionError2).toMatch('Error'); + expect(encryptedData2).toBeUndefined(); + }); + + it("should return fileNames of those it can't encrypt with the new key", async () => { + const oldEncryptionKey = 'oldKeyThatILoved'; + const oldEncryptedAdapter = new GridFSBucketAdapter(databaseURI, {}, oldEncryptionKey); + const encryptedAdapter = new GridFSBucketAdapter(databaseURI, {}, 'newKeyThatILove'); + const unEncryptedAdapter = new GridFSBucketAdapter(databaseURI); + const fileName1 = 'file1.txt'; + const data1 = 'hello world'; + const fileName2 = 'file2.txt'; + const data2 = 'hello new world'; + //Store unecrypted files + await oldEncryptedAdapter.createFile(fileName1, data1); + const oldEncryptedResult1 = await oldEncryptedAdapter.getFileData(fileName1); + expect(oldEncryptedResult1.toString('utf8')).toBe(data1); + await oldEncryptedAdapter.createFile(fileName2, data2); + const oldEncryptedResult2 = await oldEncryptedAdapter.getFileData(fileName2); + expect(oldEncryptedResult2.toString('utf8')).toBe(data2); + //Inject unecrypted file to see if causes an issue + const fileName3 = 'file3.txt'; + const data3 = 'hello past world'; + await unEncryptedAdapter.createFile(fileName3, data3, 'text/utf8'); + //Check if encrypted adapter can read data and make sure it's not the same as unEncrypted adapter + const { rotated, notRotated } = await encryptedAdapter.rotateEncryptionKey({ + oldKey: oldEncryptionKey, + }); + expect(rotated.length).toEqual(2); + expect( + rotated.filter(function (value) { + return value === fileName1; + }).length + ).toEqual(1); + expect( + rotated.filter(function (value) { + return value === fileName2; + }).length + ).toEqual(1); + expect(notRotated.length).toEqual(1); + expect( + notRotated.filter(function (value) { + return value === fileName3; + }).length + ).toEqual(1); + let result = await encryptedAdapter.getFileData(fileName1); + expect(Buffer.isBuffer(result)).toBe(true); + expect(result.toString('utf-8')).toEqual(data1); + let decryptionError1; + let encryptedData1; + try { + encryptedData1 = await oldEncryptedAdapter.getFileData(fileName1); + } catch (err) { + decryptionError1 = err; + } + expect(decryptionError1).toMatch('Error'); + expect(encryptedData1).toBeUndefined(); + result = await encryptedAdapter.getFileData(fileName2); + expect(Buffer.isBuffer(result)).toBe(true); + expect(result.toString('utf-8')).toEqual(data2); + let decryptionError2; + let encryptedData2; + try { + encryptedData2 = await oldEncryptedAdapter.getFileData(fileName2); + } catch (err) { + decryptionError2 = err; + } + expect(decryptionError2).toMatch('Error'); + expect(encryptedData2).toBeUndefined(); + }); + + it('should save metadata', async () => { + const gfsAdapter = new GridFSBucketAdapter(databaseURI); + const originalString = 'abcdefghi'; + const metadata = { hello: 'world' }; + await gfsAdapter.createFile('myFileName', originalString, null, { + metadata, + }); + const gfsResult = await gfsAdapter.getFileData('myFileName'); + expect(gfsResult.toString('utf8')).toBe(originalString); + let gfsMetadata = await gfsAdapter.getMetadata('myFileName'); + expect(gfsMetadata.metadata).toEqual(metadata); + + // Empty json for file not found + gfsMetadata = await gfsAdapter.getMetadata('myUnknownFile'); + expect(gfsMetadata).toEqual({}); + }); + + it('should save metadata with file', async () => { + const gfsAdapter = new GridFSBucketAdapter(databaseURI); + await reconfigureServer({ filesAdapter: gfsAdapter }); + const str = 'Hello World!'; + const data = []; + for (let i = 0; i < str.length; i++) { + data.push(str.charCodeAt(i)); + } + const metadata = { foo: 'bar' }; + const file = new Parse.File('hello.txt', data, 'text/plain'); + file.addMetadata('foo', 'bar'); + await file.save(); + let fileData = await gfsAdapter.getMetadata(file.name()); + expect(fileData.metadata).toEqual(metadata); + + // Can only add metadata on create + file.addMetadata('hello', 'world'); + await file.save(); + fileData = await gfsAdapter.getMetadata(file.name()); + expect(fileData.metadata).toEqual(metadata); + + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const response = await request({ + method: 'GET', + headers, + url: `http://localhost:8378/1/files/test/metadata/${file.name()}`, + }); + fileData = response.data; + expect(fileData.metadata).toEqual(metadata); + }); + + it('should handle getMetadata error', async () => { + const gfsAdapter = new GridFSBucketAdapter(databaseURI); + await reconfigureServer({ filesAdapter: gfsAdapter }); + gfsAdapter.getMetadata = () => Promise.reject(); + + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const response = await request({ + method: 'GET', + headers, + url: `http://localhost:8378/1/files/test/metadata/filename.txt`, + }); + expect(response.data).toEqual({}); + }); + + it('properly fetches a large file from GridFS', async () => { + const gfsAdapter = new GridFSBucketAdapter(databaseURI); + const twoMegabytesFile = randomString(2048 * 1024); + await gfsAdapter.createFile('myFileName', twoMegabytesFile); + const gfsResult = await gfsAdapter.getFileData('myFileName'); + expect(gfsResult.toString('utf8')).toBe(twoMegabytesFile); + }); + + it('properly upload a file when disableIndexFieldValidation exist in databaseOptions', async () => { + const gfsAdapter = new GridFSBucketAdapter(databaseURI, { disableIndexFieldValidation: true }); + const twoMegabytesFile = randomString(2048 * 1024); + await gfsAdapter.createFile('myFileName', twoMegabytesFile); + const gfsResult = await gfsAdapter.getFileData('myFileName'); + expect(gfsResult.toString('utf8')).toBe(twoMegabytesFile); + }); + + it('properly deletes a file from GridFS', async () => { + const gfsAdapter = new GridFSBucketAdapter(databaseURI); + await gfsAdapter.createFile('myFileName', 'a simple file'); + await gfsAdapter.deleteFile('myFileName'); + await expectMissingFile(gfsAdapter, 'myFileName'); + }, 1000000); + + it('properly overrides files', async () => { + const gfsAdapter = new GridFSBucketAdapter(databaseURI); + await gfsAdapter.createFile('myFileName', 'a simple file'); + await gfsAdapter.createFile('myFileName', 'an overrided simple file'); + const data = await gfsAdapter.getFileData('myFileName'); + expect(data.toString('utf8')).toBe('an overrided simple file'); + const bucket = await gfsAdapter._getBucket(); + const documents = await bucket.find({ filename: 'myFileName' }).toArray(); + expect(documents.length).toBe(2); + await gfsAdapter.deleteFile('myFileName'); + await expectMissingFile(gfsAdapter, 'myFileName'); + }); + + it('handleShutdown, close connection', async () => { + const databaseURI = 'mongodb://localhost:27017/parse'; + const gfsAdapter = new GridFSBucketAdapter(databaseURI); + + const db = await gfsAdapter._connect(); + const status = await db.admin().serverStatus(); + expect(status.connections.current > 0).toEqual(true); + + await gfsAdapter.handleShutdown(); + try { + await db.admin().serverStatus(); + expect(false).toBe(true); + } catch (e) { + expect(e.message).toEqual('Client must be connected before running operations'); + } + }); + + it('reports supportsStreaming as true', () => { + const gfsAdapter = new GridFSBucketAdapter(databaseURI); + expect(gfsAdapter.supportsStreaming).toBe(true); + }); + + it('creates file from Readable stream', async () => { + const { Readable } = require('stream'); + const gfsAdapter = new GridFSBucketAdapter(databaseURI); + const data = Buffer.from('streamed file content'); + const stream = Readable.from(data); + await gfsAdapter.createFile('streamFile.txt', stream); + const result = await gfsAdapter.getFileData('streamFile.txt'); + expect(result.toString('utf8')).toBe('streamed file content'); + }); + + it('creates encrypted file from Readable stream (buffers for encryption)', async () => { + const { Readable } = require('stream'); + const gfsAdapter = new GridFSBucketAdapter(databaseURI, {}, 'test-encryption-key'); + const data = Buffer.from('encrypted streamed content'); + const stream = Readable.from(data); + await gfsAdapter.createFile('encryptedStream.txt', stream); + const result = await gfsAdapter.getFileData('encryptedStream.txt'); + expect(result.toString('utf8')).toBe('encrypted streamed content'); + }); + + describe('MongoDB Client Metadata', () => { + it('should not pass metadata to MongoClient by default', async () => { + const gfsAdapter = new GridFSBucketAdapter(databaseURI); + await gfsAdapter._connect(); + const driverInfo = gfsAdapter._client.s.options.driverInfo; + // Either driverInfo should be undefined, or it should not contain our custom metadata + if (driverInfo) { + expect(driverInfo.name).toBeUndefined(); + } + await gfsAdapter.handleShutdown(); + }); + + it('should pass custom metadata to MongoClient when configured', async () => { + const customMetadata = { name: 'MyParseServer', version: '1.0.0' }; + const gfsAdapter = new GridFSBucketAdapter(databaseURI, { + clientMetadata: customMetadata + }); + await gfsAdapter._connect(); + expect(gfsAdapter._client.s.options.driverInfo.name).toBe(customMetadata.name); + expect(gfsAdapter._client.s.options.driverInfo.version).toBe(customMetadata.version); + await gfsAdapter.handleShutdown(); + }); + }); +}); diff --git a/spec/GridStoreAdapter.js b/spec/GridStoreAdapter.js deleted file mode 100644 index 78c848f33b..0000000000 --- a/spec/GridStoreAdapter.js +++ /dev/null @@ -1,87 +0,0 @@ -var MongoClient = require("mongodb").MongoClient; -var GridStore = require("mongodb").GridStore; - -var GridStoreAdapter = require("../src/Adapters/Files/GridStoreAdapter").GridStoreAdapter; -var Config = require("../src/Config"); -var FilesController = require('../src/Controllers/FilesController').default; - - -// Small additional tests to improve overall coverage -describe("GridStoreAdapter",() =>{ - it("should properly instanciate the GridStore when deleting a file", (done) => { - - var databaseURI = 'mongodb://localhost:27017/parse'; - var config = new Config(Parse.applicationId); - var gridStoreAdapter = new GridStoreAdapter(databaseURI); - var filesController = new FilesController(gridStoreAdapter); - - // save original unlink before redefinition - var originalUnlink = GridStore.prototype.unlink; - - var gridStoreMode; - - // new unlink method that will capture the mode in which GridStore was opened - GridStore.prototype.unlink = function() { - - // restore original unlink during first call - GridStore.prototype.unlink = originalUnlink; - - gridStoreMode = this.mode; - - return originalUnlink.call(this); - }; - - - filesController.createFile(config, 'myFilename.txt', 'my file content', 'text/plain') - .then(myFile => { - - return MongoClient.connect(databaseURI) - .then(database => { - - // Verify the existance of the fs.files document - return database.collection('fs.files').count().then(count => { - expect(count).toEqual(1); - return database; - }); - }) - .then(database => { - - // Verify the existance of the fs.files document - return database.collection('fs.chunks').count().then(count => { - expect(count).toEqual(1); - return database.close(); - }); - }) - .then(() => { - return filesController.deleteFile(config, myFile.name); - }); - }) - .then(() => { - return MongoClient.connect(databaseURI) - .then(database => { - - // Verify the existance of the fs.files document - return database.collection('fs.files').count().then(count => { - expect(count).toEqual(0); - return database; - }); - }) - .then(database => { - - // Verify the existance of the fs.files document - return database.collection('fs.chunks').count().then(count => { - expect(count).toEqual(0); - return database.close(); - }); - }); - }) - .then(() => { - // Verify that gridStore was opened in read only mode - expect(gridStoreMode).toEqual('r'); - - done(); - }) - .catch(fail); - - }) -}); diff --git a/spec/HTTPRequest.spec.js b/spec/HTTPRequest.spec.js index cb34fe70bb..b138a010b0 100644 --- a/spec/HTTPRequest.spec.js +++ b/spec/HTTPRequest.spec.js @@ -1,253 +1,189 @@ 'use strict'; -var httpRequest = require("../src/cloud-code/httpRequest"), - HTTPResponse = require('../src/cloud-code/HTTPResponse').default, - bodyParser = require('body-parser'), - express = require("express"); - -var port = 13371; -var httpRequestServer = "http://localhost:"+port; - -var app = express(); -app.use(bodyParser.json({ 'type': '*/*' })); -app.get("/hello", function(req, res){ - res.json({response: "OK"}); -}); - -app.get("/404", function(req, res){ - res.status(404); - res.send("NO"); -}); +const httpRequest = require('../lib/request'), + HTTPResponse = require('../lib/request').HTTPResponse, + express = require('express'); + +const port = 13371; +const httpRequestServer = `http://localhost:${port}`; + +function startServer(done) { + const app = express(); + app.use(express.json({ type: '*/*' })); + app.get('/hello', function (req, res) { + res.json({ response: 'OK' }); + }); -app.get("/301", function(req, res){ - res.status(301); - res.location("/hello"); - res.send(); -}); + app.get('/404', function (req, res) { + res.status(404); + res.send('NO'); + }); -app.post('/echo', function(req, res){ - res.json(req.body); -}); + app.get('/301', function (req, res) { + res.status(301); + res.location('/hello'); + res.send(); + }); -app.get('/qs', function(req, res){ - res.json(req.query); -}); + app.post('/echo', function (req, res) { + res.json(req.body); + }); -app.listen(13371); + app.get('/qs', function (req, res) { + res.json(req.query); + }); + return app.listen(13371, undefined, done); +} -describe("httpRequest", () => { - it("should do /hello", (done) => { - httpRequest({ - url: httpRequestServer+"/hello" - }).then(function(httpResponse){ - expect(httpResponse.status).toBe(200); - expect(httpResponse.buffer).toEqual(new Buffer('{"response":"OK"}')); - expect(httpResponse.text).toEqual('{"response":"OK"}'); - expect(httpResponse.data.response).toEqual("OK"); - done(); - }, function(){ - fail("should not fail"); +describe('httpRequest', () => { + let server; + beforeEach(done => { + if (!server) { + server = startServer(done); + } else { done(); - }) + } }); - it("should do /hello with callback and promises", (done) => { - var calls = 0; - httpRequest({ - url: httpRequestServer+"/hello", - success: function() { calls++; }, - error: function() { calls++; } - }).then(function(httpResponse){ - expect(calls).toBe(1); - expect(httpResponse.status).toBe(200); - expect(httpResponse.buffer).toEqual(new Buffer('{"response":"OK"}')); - expect(httpResponse.text).toEqual('{"response":"OK"}'); - expect(httpResponse.data.response).toEqual("OK"); - done(); - }, function(){ - fail("should not fail"); - done(); - }) + afterAll(done => { + server.close(done); }); - it("should do not follow redirects by default", (done) => { + it('should do /hello', async () => { + const httpResponse = await httpRequest({ + url: `${httpRequestServer}/hello`, + }); - httpRequest({ - url: httpRequestServer+"/301" - }).then(function(httpResponse){ - expect(httpResponse.status).toBe(301); - done(); - }, function(){ - fail("should not fail"); - done(); - }) + expect(httpResponse.status).toBe(200); + expect(httpResponse.buffer).toEqual(Buffer.from('{"response":"OK"}')); + expect(httpResponse.text).toEqual('{"response":"OK"}'); + expect(httpResponse.data.response).toEqual('OK'); }); - it("should follow redirects when set", (done) => { + it('should do not follow redirects by default', async () => { + const httpResponse = await httpRequest({ + url: `${httpRequestServer}/301`, + }); - httpRequest({ - url: httpRequestServer+"/301", - followRedirects: true - }).then(function(httpResponse){ - expect(httpResponse.status).toBe(200); - expect(httpResponse.buffer).toEqual(new Buffer('{"response":"OK"}')); - expect(httpResponse.text).toEqual('{"response":"OK"}'); - expect(httpResponse.data.response).toEqual("OK"); - done(); - }, function(){ - fail("should not fail"); - done(); - }) + expect(httpResponse.status).toBe(301); }); - it("should fail on 404", (done) => { - var calls = 0; - httpRequest({ - url: httpRequestServer+"/404", - success: function() { - calls++; - fail("should not succeed"); - done(); - }, - error: function(httpResponse) { - calls++; - expect(calls).toBe(1); - expect(httpResponse.status).toBe(404); - expect(httpResponse.buffer).toEqual(new Buffer('NO')); - expect(httpResponse.text).toEqual('NO'); - expect(httpResponse.data).toBe(undefined); - done(); - } + it('should follow redirects when set', async () => { + const httpResponse = await httpRequest({ + url: `${httpRequestServer}/301`, + followRedirects: true, }); - }) - it("should fail on 404", (done) => { - httpRequest({ - url: httpRequestServer+"/404", - }).then(function(httpResponse){ - fail("should not succeed"); - done(); - }, function(httpResponse){ - expect(httpResponse.status).toBe(404); - expect(httpResponse.buffer).toEqual(new Buffer('NO')); - expect(httpResponse.text).toEqual('NO'); - expect(httpResponse.data).toBe(undefined); - done(); - }) - }) - - it("should post on echo", (done) => { - var calls = 0; - httpRequest({ - method: "POST", - url: httpRequestServer+"/echo", + expect(httpResponse.status).toBe(200); + expect(httpResponse.buffer).toEqual(Buffer.from('{"response":"OK"}')); + expect(httpResponse.text).toEqual('{"response":"OK"}'); + expect(httpResponse.data.response).toEqual('OK'); + }); + + it('should fail on 404', async () => { + await expectAsync( + httpRequest({ + url: `${httpRequestServer}/404`, + }) + ).toBeRejectedWith( + jasmine.objectContaining({ + status: 404, + buffer: Buffer.from('NO'), + text: 'NO', + data: undefined, + }) + ); + }); + + it('should post on echo', async () => { + const httpResponse = await httpRequest({ + method: 'POST', + url: `${httpRequestServer}/echo`, body: { - foo: "bar" + foo: 'bar', }, headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', }, - success: function() { calls++; }, - error: function() { calls++; } - }).then(function(httpResponse){ - expect(calls).toBe(1); - expect(httpResponse.status).toBe(200); - expect(httpResponse.data).toEqual({foo: "bar"}); - done(); - }, function(httpResponse){ - fail("should not fail"); - done(); - }) + }); + + expect(httpResponse.status).toBe(200); + expect(httpResponse.data).toEqual({ foo: 'bar' }); }); - it("should encode a query string body by default", (done) => { - let options = { - body: {"foo": "bar"}, - } - let result = httpRequest.encodeBody(options); + it('should encode a query string body by default', () => { + const options = { + body: { foo: 'bar' }, + }; + const result = httpRequest.encodeBody(options); + expect(result.body).toEqual('foo=bar'); expect(result.headers['Content-Type']).toEqual('application/x-www-form-urlencoded'); - done(); + }); - }) + it('should encode a JSON body', () => { + const options = { + body: { foo: 'bar' }, + headers: { 'Content-Type': 'application/json' }, + }; + const result = httpRequest.encodeBody(options); - it("should encode a JSON body", (done) => { - let options = { - body: {"foo": "bar"}, - headers: {'Content-Type': 'application/json'} - } - let result = httpRequest.encodeBody(options); expect(result.body).toEqual('{"foo":"bar"}'); - done(); + }); - }) - it("should encode a www-form body", (done) => { - let options = { - body: {"foo": "bar", "bar": "baz"}, - headers: {'cOntent-tYpe': 'application/x-www-form-urlencoded'} - } - let result = httpRequest.encodeBody(options); - expect(result.body).toEqual("foo=bar&bar=baz"); - done(); + it('should encode a www-form body', () => { + const options = { + body: { foo: 'bar', bar: 'baz' }, + headers: { 'cOntent-tYpe': 'application/x-www-form-urlencoded' }, + }; + const result = httpRequest.encodeBody(options); + + expect(result.body).toEqual('foo=bar&bar=baz'); }); - it("should not encode a wrong content type", (done) => { - let options = { - body:{"foo": "bar", "bar": "baz"}, - headers: {'cOntent-tYpe': 'mime/jpeg'} - } - let result = httpRequest.encodeBody(options); - expect(result.body).toEqual({"foo": "bar", "bar": "baz"}); - done(); + + it('should not encode a wrong content type', () => { + const options = { + body: { foo: 'bar', bar: 'baz' }, + headers: { 'cOntent-tYpe': 'mime/jpeg' }, + }; + const result = httpRequest.encodeBody(options); + + expect(result.body).toEqual({ foo: 'bar', bar: 'baz' }); }); - it("should fail gracefully", (done) => { - httpRequest({ - url: "http://not a good url", - success: function() { - fail("should not succeed"); - done(); - }, - error: function(error) { - expect(error).not.toBeUndefined(); - expect(error).not.toBeNull(); - done(); - } - }); + it('should fail gracefully', async () => { + await expectAsync( + httpRequest({ + url: 'http://not a good url', + }) + ).toBeRejected(); }); - it("should params object to query string", (done) => { - httpRequest({ - url: httpRequestServer+"/qs", + it('should params object to query string', async () => { + const httpResponse = await httpRequest({ + url: `${httpRequestServer}/qs`, params: { - foo: "bar" - } - }).then(function(httpResponse){ - expect(httpResponse.status).toBe(200); - expect(httpResponse.data).toEqual({foo: "bar"}); - done(); - }, function(){ - fail("should not fail"); - done(); - }) + foo: 'bar', + }, + }); + + expect(httpResponse.status).toBe(200); + expect(httpResponse.data).toEqual({ foo: 'bar' }); }); - it("should params string to query string", (done) => { - httpRequest({ - url: httpRequestServer+"/qs", - params: "foo=bar&foo2=bar2" - }).then(function(httpResponse){ - expect(httpResponse.status).toBe(200); - expect(httpResponse.data).toEqual({foo: "bar", foo2: 'bar2'}); - done(); - }, function(){ - fail("should not fail"); - done(); - }) + it('should params string to query string', async () => { + const httpResponse = await httpRequest({ + url: `${httpRequestServer}/qs`, + params: 'foo=bar&foo2=bar2', + }); + + expect(httpResponse.status).toBe(200); + expect(httpResponse.data).toEqual({ foo: 'bar', foo2: 'bar2' }); }); it('should not crash with undefined body', () => { - let httpResponse = new HTTPResponse({}); + const httpResponse = new HTTPResponse({}); expect(httpResponse.body).toBeUndefined(); expect(httpResponse.data).toBeUndefined(); expect(httpResponse.text).toBeUndefined(); @@ -255,72 +191,78 @@ describe("httpRequest", () => { }); it('serialized httpResponse correctly with body string', () => { - let httpResponse = new HTTPResponse({}, 'hello'); + const httpResponse = new HTTPResponse({}, 'hello'); expect(httpResponse.text).toBe('hello'); expect(httpResponse.data).toBe(undefined); expect(httpResponse.body).toBe('hello'); - let serialized = JSON.stringify(httpResponse); - let result = JSON.parse(serialized); + const serialized = JSON.stringify(httpResponse); + const result = JSON.parse(serialized); + expect(result.text).toBe('hello'); expect(result.data).toBe(undefined); expect(result.body).toBe(undefined); }); it('serialized httpResponse correctly with body object', () => { - let httpResponse = new HTTPResponse({}, {foo: "bar"}); - let encodedResponse = Parse._encode(httpResponse); - let serialized = JSON.stringify(httpResponse); - let result = JSON.parse(serialized); - + const httpResponse = new HTTPResponse({}, { foo: 'bar' }); + Parse._encode(httpResponse); + const serialized = JSON.stringify(httpResponse); + const result = JSON.parse(serialized); + expect(httpResponse.text).toEqual('{"foo":"bar"}'); - expect(httpResponse.data).toEqual({foo: 'bar'}); - expect(httpResponse.body).toEqual({foo: 'bar'}); + expect(httpResponse.data).toEqual({ foo: 'bar' }); + expect(httpResponse.body).toEqual({ foo: 'bar' }); expect(result.text).toEqual('{"foo":"bar"}'); - expect(result.data).toEqual({foo: 'bar'}); + expect(result.data).toEqual({ foo: 'bar' }); expect(result.body).toEqual(undefined); }); it('serialized httpResponse correctly with body buffer string', () => { - let httpResponse = new HTTPResponse({}, new Buffer('hello')); + const httpResponse = new HTTPResponse({}, Buffer.from('hello')); expect(httpResponse.text).toBe('hello'); expect(httpResponse.data).toBe(undefined); - let serialized = JSON.stringify(httpResponse); - let result = JSON.parse(serialized); + const serialized = JSON.stringify(httpResponse); + const result = JSON.parse(serialized); + expect(result.text).toBe('hello'); expect(result.data).toBe(undefined); }); it('serialized httpResponse correctly with body buffer JSON Object', () => { - let json = '{"foo":"bar"}'; - let httpResponse = new HTTPResponse({}, new Buffer(json)); - let serialized = JSON.stringify(httpResponse); - let result = JSON.parse(serialized); + const json = '{"foo":"bar"}'; + const httpResponse = new HTTPResponse({}, Buffer.from(json)); + const serialized = JSON.stringify(httpResponse); + const result = JSON.parse(serialized); + expect(result.text).toEqual('{"foo":"bar"}'); - expect(result.data).toEqual({foo: 'bar'}); + expect(result.data).toEqual({ foo: 'bar' }); }); it('serialized httpResponse with Parse._encode should be allright', () => { - let json = '{"foo":"bar"}'; - let httpResponse = new HTTPResponse({}, new Buffer(json)); - let encoded = Parse._encode(httpResponse); - let foundData, foundText, foundBody = false; - for(var key in encoded) { - if (key == 'data') { + const json = '{"foo":"bar"}'; + const httpResponse = new HTTPResponse({}, Buffer.from(json)); + const encoded = Parse._encode(httpResponse); + let foundData, + foundText, + foundBody = false; + + for (const key in encoded) { + if (key === 'data') { foundData = true; } - if (key == 'text') { + if (key === 'text') { foundText = true; } - if (key == 'body') { + if (key === 'body') { foundBody = true; } } + expect(foundData).toBe(true); expect(foundText).toBe(true); expect(foundBody).toBe(false); }); - }); diff --git a/spec/Idempotency.spec.js b/spec/Idempotency.spec.js new file mode 100644 index 0000000000..22c00a5e79 --- /dev/null +++ b/spec/Idempotency.spec.js @@ -0,0 +1,277 @@ +'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 { randomUUID: uuidv4 } = require('crypto'); + +describe('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; + const ttl = 2; + const maxTimeOut = 4000; + + // 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: ttl, + }); + }); + + afterEach(() => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = process.env.PARSE_SERVER_TEST_TIMEOUT || 10000; + }); + + // Tests + it_id('e25955fd-92eb-4b22-b8b7-38980e5cb223')(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(ttl); + 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_id('be2fbe16-8178-485e-9a12-6fb541096480')(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, maxTimeOut)); + } + await expectAsync(request(params)).toBeResolved(); + expect(counter).toBe(2); + }); + + it_only_db('postgres')( + 'should delete request entry when postgress ttl function is called', + async () => { + const client = Config.get(Parse.applicationId).database.adapter._client; + 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(); + await expectAsync(request(params)).toBeRejected(); + await new Promise(resolve => setTimeout(resolve, maxTimeOut)); + await client.one('SELECT idempotency_delete_expired_records()'); + await expectAsync(request(params)).toBeResolved(); + expect(counter).toBe(2); + } + ); + + it_id('e976d0cc-a57f-45d4-9472-b9b052db6490')(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_id('7c84a3d4-e1b6-4a0d-99f1-af3cf1a6b3d8')(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_id('a030f2dd-5d21-46ac-b53d-9d714f35d72a')(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_id('064c469b-091c-4ba9-9043-be461f26a3eb')(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_id('f11670b6-fa9c-4f21-a268-ae4b6bbff7fd')(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': uuidv4(), + }, + }; + return request(params); + }); + await expectAsync(Promise.all(promises)).toBeResolved(); + expect(counter).toBe(100); + }); + + it_id('0ecd2cd2-dafb-4a2b-bb2b-9ad4c9aca777')(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/InMemoryCache.spec.js b/spec/InMemoryCache.spec.js index 3c0fb47bbb..4a474b7fa2 100644 --- a/spec/InMemoryCache.spec.js +++ b/spec/InMemoryCache.spec.js @@ -1,42 +1,40 @@ -const InMemoryCache = require('../src/Adapters/Cache/InMemoryCache').default; +const InMemoryCache = require('../lib/Adapters/Cache/InMemoryCache').default; - -describe('InMemoryCache', function() { - var BASE_TTL = { - ttl: 10 +describe('InMemoryCache', function () { + const BASE_TTL = { + ttl: 100, }; - var NO_EXPIRE_TTL = { - ttl: NaN + const NO_EXPIRE_TTL = { + ttl: NaN, }; - var KEY = 'hello'; - var KEY_2 = KEY + '_2'; - - var VALUE = 'world'; + const KEY = 'hello'; + const KEY_2 = KEY + '_2'; + const VALUE = 'world'; function wait(sleep) { - return new Promise(function(resolve, reject) { + return new Promise(function (resolve) { setTimeout(resolve, sleep); - }) + }); } - it('should destroy a expire items in the cache', (done) => { - var cache = new InMemoryCache(BASE_TTL); + it('should destroy a expire items in the cache', done => { + const cache = new InMemoryCache(BASE_TTL); cache.put(KEY, VALUE); - var value = cache.get(KEY); + let value = cache.get(KEY); expect(value).toEqual(VALUE); - wait(BASE_TTL.ttl * 5).then(() => { - value = cache.get(KEY) + wait(BASE_TTL.ttl * 10).then(() => { + value = cache.get(KEY); expect(value).toEqual(null); done(); }); }); - it('should delete items', (done) => { - var cache = new InMemoryCache(NO_EXPIRE_TTL); + it('should delete items', done => { + const cache = new InMemoryCache(NO_EXPIRE_TTL); cache.put(KEY, VALUE); cache.put(KEY_2, VALUE); expect(cache.get(KEY)).toEqual(VALUE); @@ -52,8 +50,8 @@ describe('InMemoryCache', function() { done(); }); - it('should clear all items', (done) => { - var cache = new InMemoryCache(NO_EXPIRE_TTL); + it('should clear all items', done => { + const cache = new InMemoryCache(NO_EXPIRE_TTL); cache.put(KEY, VALUE); cache.put(KEY_2, VALUE); @@ -67,8 +65,7 @@ describe('InMemoryCache', function() { }); it('should deafult TTL to 5 seconds', () => { - var cache = new InMemoryCache({}); + const cache = new InMemoryCache({}); expect(cache.ttl).toEqual(5 * 1000); }); - }); diff --git a/spec/InMemoryCacheAdapter.spec.js b/spec/InMemoryCacheAdapter.spec.js index 405da6f7ad..add976fbc9 100644 --- a/spec/InMemoryCacheAdapter.spec.js +++ b/spec/InMemoryCacheAdapter.spec.js @@ -1,59 +1,53 @@ -var InMemoryCacheAdapter = require('../src/Adapters/Cache/InMemoryCacheAdapter').default; +const InMemoryCacheAdapter = require('../lib/Adapters/Cache/InMemoryCacheAdapter').default; -describe('InMemoryCacheAdapter', function() { - var KEY = 'hello'; - var VALUE = 'world'; +describe('InMemoryCacheAdapter', function () { + const KEY = 'hello'; + const VALUE = 'world'; function wait(sleep) { - return new Promise(function(resolve, reject) { + return new Promise(function (resolve) { setTimeout(resolve, sleep); - }) + }); } - it('should expose promisifyed methods', (done) => { - var cache = new InMemoryCacheAdapter({ - ttl: NaN + it('should expose promisifyed methods', done => { + const cache = new InMemoryCacheAdapter({ + ttl: NaN, }); - var noop = () => {}; - // Verify all methods return promises. - Promise.all([ - cache.put(KEY, VALUE), - cache.del(KEY), - cache.get(KEY), - cache.clear() - ]).then(() => { + Promise.all([cache.put(KEY, VALUE), cache.del(KEY), cache.get(KEY), cache.clear()]).then(() => { done(); }); }); - it('should get/set/clear', (done) => { - var cache = new InMemoryCacheAdapter({ - ttl: NaN + it('should get/set/clear', done => { + const cache = new InMemoryCacheAdapter({ + ttl: NaN, }); - cache.put(KEY, VALUE) + cache + .put(KEY, VALUE) .then(() => cache.get(KEY)) - .then((value) => expect(value).toEqual(VALUE)) + .then(value => expect(value).toEqual(VALUE)) .then(() => cache.clear()) .then(() => cache.get(KEY)) - .then((value) => expect(value).toEqual(null)) + .then(value => expect(value).toEqual(null)) .then(done); }); - it('should expire after ttl', (done) => { - var cache = new InMemoryCacheAdapter({ - ttl: 10 + it('should expire after ttl', done => { + const cache = new InMemoryCacheAdapter({ + ttl: 10, }); - cache.put(KEY, VALUE) + cache + .put(KEY, VALUE) .then(() => cache.get(KEY)) - .then((value) => expect(value).toEqual(VALUE)) + .then(value => expect(value).toEqual(VALUE)) .then(wait.bind(null, 50)) .then(() => cache.get(KEY)) - .then((value) => expect(value).toEqual(null)) + .then(value => expect(value).toEqual(null)) .then(done); - }) - + }); }); diff --git a/spec/InstallationDedup.spec.js b/spec/InstallationDedup.spec.js new file mode 100644 index 0000000000..c73c574cf6 --- /dev/null +++ b/spec/InstallationDedup.spec.js @@ -0,0 +1,319 @@ +'use strict'; + +const Parse = require('parse/node').Parse; + +describe('InstallationDedup', () => { + let InstallationDedup; + let logger; + let logSpy; + + beforeEach(() => { + InstallationDedup = require('../lib/InstallationDedup'); + logger = require('../lib/logger').logger; + logSpy = { + verbose: spyOn(logger, 'verbose').and.callFake(() => {}), + warn: spyOn(logger, 'warn').and.callFake(() => {}), + error: spyOn(logger, 'error').and.callFake(() => {}), + }; + }); + + describe('removeConflictingDeviceToken', () => { + it('action="delete" with no match resolves silently and logs verbose', async () => { + const database = { + destroy: jasmine + .createSpy('destroy') + .and.returnValue(Promise.reject({ code: Parse.Error.OBJECT_NOT_FOUND })), + }; + await InstallationDedup.removeConflictingDeviceToken({ + database, + query: { deviceToken: 'X' }, + action: 'delete', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + expect(database.destroy).toHaveBeenCalled(); + expect(logSpy.verbose).toHaveBeenCalled(); + expect(logSpy.warn).not.toHaveBeenCalled(); + expect(logSpy.error).not.toHaveBeenCalled(); + }); + + it('action="delete" with matches calls destroy with empty options when enforceAuth=false', async () => { + const database = { + destroy: jasmine.createSpy('destroy').and.returnValue(Promise.resolve()), + }; + await InstallationDedup.removeConflictingDeviceToken({ + database, + query: { deviceToken: 'X' }, + action: 'delete', + enforceAuth: false, + runOptions: { acl: ['*'] }, + validSchemaController: undefined, + }); + expect(database.destroy).toHaveBeenCalledWith( + '_Installation', + { deviceToken: 'X' }, + {}, + undefined + ); + expect(logSpy.verbose).toHaveBeenCalled(); + }); + + it('action="delete" with enforceAuth=true passes runOptions to destroy', async () => { + const database = { + destroy: jasmine.createSpy('destroy').and.returnValue(Promise.resolve()), + }; + const runOptions = { acl: ['*', 'userABC'] }; + await InstallationDedup.removeConflictingDeviceToken({ + database, + query: { deviceToken: 'X' }, + action: 'delete', + enforceAuth: true, + runOptions, + validSchemaController: undefined, + }); + expect(database.destroy).toHaveBeenCalledWith( + '_Installation', + { deviceToken: 'X' }, + runOptions, + undefined + ); + }); + + it('action="update" calls update with deviceToken cleared and many=true in options', async () => { + const database = { + update: jasmine.createSpy('update').and.returnValue(Promise.resolve()), + }; + await InstallationDedup.removeConflictingDeviceToken({ + database, + query: { deviceToken: 'X' }, + action: 'update', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + expect(database.update).toHaveBeenCalledWith( + '_Installation', + { deviceToken: 'X' }, + { deviceToken: { __op: 'Delete' } }, + jasmine.objectContaining({ many: true }), + false, + false, + undefined + ); + expect(logSpy.verbose).toHaveBeenCalled(); + }); + + it('OPERATION_FORBIDDEN error is swallowed and logged as warn', async () => { + const database = { + destroy: jasmine + .createSpy('destroy') + .and.returnValue( + Promise.reject({ code: Parse.Error.OPERATION_FORBIDDEN, message: 'denied' }) + ), + }; + await InstallationDedup.removeConflictingDeviceToken({ + database, + query: { deviceToken: 'X' }, + action: 'delete', + enforceAuth: true, + runOptions: { acl: ['*'] }, + validSchemaController: undefined, + }); + expect(logSpy.warn).toHaveBeenCalled(); + expect(logSpy.error).not.toHaveBeenCalled(); + }); + + it('unexpected error is logged as error and rethrown', async () => { + const database = { + destroy: jasmine + .createSpy('destroy') + .and.returnValue(Promise.reject(new Error('database connection lost'))), + }; + let caught; + try { + await InstallationDedup.removeConflictingDeviceToken({ + database, + query: { deviceToken: 'X' }, + action: 'delete', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + } catch (e) { + caught = e; + } + expect(caught).toBeDefined(); + expect(caught.message).toBe('database connection lost'); + expect(logSpy.error).toHaveBeenCalled(); + }); + }); + + describe('applyDuplicateDeviceTokenMerge', () => { + const idMatch = { objectId: 'A', installationId: 'I' }; + const deviceTokenMatch = { objectId: 'B', deviceToken: 'X' }; + + it('mergePriority="deviceToken" + action="delete" destroys idMatch and returns deviceTokenMatch.objectId', async () => { + const database = { + destroy: jasmine.createSpy('destroy').and.returnValue(Promise.resolve()), + }; + const result = await InstallationDedup.applyDuplicateDeviceTokenMerge({ + database, + idMatch, + deviceTokenMatch, + action: 'delete', + mergePriority: 'deviceToken', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + expect(result).toBe('B'); + expect(database.destroy).toHaveBeenCalledWith( + '_Installation', + { objectId: 'A' }, + {}, + undefined + ); + expect(logSpy.verbose).toHaveBeenCalled(); + }); + + it('mergePriority="deviceToken" + action="update" clears installationId on idMatch and returns deviceTokenMatch.objectId', async () => { + const database = { + update: jasmine.createSpy('update').and.returnValue(Promise.resolve()), + }; + const result = await InstallationDedup.applyDuplicateDeviceTokenMerge({ + database, + idMatch, + deviceTokenMatch, + action: 'update', + mergePriority: 'deviceToken', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + expect(result).toBe('B'); + expect(database.update).toHaveBeenCalledWith( + '_Installation', + { objectId: 'A' }, + { installationId: { __op: 'Delete' } }, + jasmine.objectContaining({ many: false }), + false, + false, + undefined + ); + }); + + it('mergePriority="installationId" + action="delete" destroys deviceTokenMatch and returns idMatch.objectId', async () => { + const database = { + destroy: jasmine.createSpy('destroy').and.returnValue(Promise.resolve()), + }; + const result = await InstallationDedup.applyDuplicateDeviceTokenMerge({ + database, + idMatch, + deviceTokenMatch, + action: 'delete', + mergePriority: 'installationId', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + expect(result).toBe('A'); + expect(database.destroy).toHaveBeenCalledWith( + '_Installation', + { objectId: 'B' }, + {}, + undefined + ); + }); + + it('mergePriority="installationId" + action="update" clears deviceToken on deviceTokenMatch and returns idMatch.objectId', async () => { + const database = { + update: jasmine.createSpy('update').and.returnValue(Promise.resolve()), + }; + const result = await InstallationDedup.applyDuplicateDeviceTokenMerge({ + database, + idMatch, + deviceTokenMatch, + action: 'update', + mergePriority: 'installationId', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + expect(result).toBe('A'); + expect(database.update).toHaveBeenCalledWith( + '_Installation', + { objectId: 'B' }, + { deviceToken: { __op: 'Delete' } }, + jasmine.objectContaining({ many: false }), + false, + false, + undefined + ); + }); + + it('OPERATION_FORBIDDEN on the merge action still returns survivor objectId (silent skip)', async () => { + const database = { + destroy: jasmine + .createSpy('destroy') + .and.returnValue(Promise.reject({ code: Parse.Error.OPERATION_FORBIDDEN })), + }; + const result = await InstallationDedup.applyDuplicateDeviceTokenMerge({ + database, + idMatch, + deviceTokenMatch, + action: 'delete', + mergePriority: 'deviceToken', + enforceAuth: true, + runOptions: { acl: ['*'] }, + validSchemaController: undefined, + }); + expect(result).toBe('B'); + expect(logSpy.warn).toHaveBeenCalled(); + }); + + it('returns the shared objectId without calling destroy/update when idMatch and deviceTokenMatch are the same row', async () => { + const sameRow = { objectId: 'SAME', installationId: 'I', deviceToken: 'X' }; + const database = { + destroy: jasmine.createSpy('destroy').and.returnValue(Promise.resolve()), + update: jasmine.createSpy('update').and.returnValue(Promise.resolve()), + }; + const result = await InstallationDedup.applyDuplicateDeviceTokenMerge({ + database, + idMatch: sameRow, + deviceTokenMatch: sameRow, + action: 'delete', + mergePriority: 'deviceToken', + enforceAuth: false, + runOptions: {}, + validSchemaController: undefined, + }); + expect(result).toBe('SAME'); + expect(database.destroy).not.toHaveBeenCalled(); + expect(database.update).not.toHaveBeenCalled(); + }); + + it('enforceAuth=true passes runOptions to destroy', async () => { + const database = { + destroy: jasmine.createSpy('destroy').and.returnValue(Promise.resolve()), + }; + const runOptions = { acl: ['*', 'userABC'] }; + await InstallationDedup.applyDuplicateDeviceTokenMerge({ + database, + idMatch, + deviceTokenMatch, + action: 'delete', + mergePriority: 'deviceToken', + enforceAuth: true, + runOptions, + validSchemaController: undefined, + }); + expect(database.destroy).toHaveBeenCalledWith( + '_Installation', + { objectId: 'A' }, + runOptions, + undefined + ); + }); + }); +}); diff --git a/spec/InstallationsRouter.spec.js b/spec/InstallationsRouter.spec.js index 60965ff967..1ccad5013d 100644 --- a/spec/InstallationsRouter.spec.js +++ b/spec/InstallationsRouter.spec.js @@ -1,177 +1,247 @@ -var auth = require('../src/Auth'); -var Config = require('../src/Config'); -var rest = require('../src/rest'); -var InstallationsRouter = require('../src/Routers/InstallationsRouter').InstallationsRouter; - -var config = new Config('test'); +const auth = require('../lib/Auth'); +const Config = require('../lib/Config'); +const rest = require('../lib/rest'); +const InstallationsRouter = require('../lib/Routers/InstallationsRouter').InstallationsRouter; describe('InstallationsRouter', () => { - it('uses find condition from request.body', (done) => { - var androidDeviceRequest = { - 'installationId': '12345678-abcd-abcd-abcd-123456789abc', - 'deviceType': 'android' + it('uses find condition from request.body', done => { + const config = Config.get('test'); + const androidDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abc', + deviceType: 'android', }; - var iosDeviceRequest = { - 'installationId': '12345678-abcd-abcd-abcd-123456789abd', - 'deviceType': 'ios' + const iosDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abd', + deviceType: 'ios', }; - var request = { + const request = { config: config, auth: auth.master(config), body: { where: { - deviceType: 'android' - } + deviceType: 'android', + }, }, query: {}, - info: {} + info: {}, }; - var router = new InstallationsRouter(); - rest.create(config, auth.nobody(config), '_Installation', androidDeviceRequest) - .then(() => { - return rest.create(config, auth.nobody(config), '_Installation', iosDeviceRequest); - }).then(() => { - return router.handleFind(request); - }).then((res) => { - var results = res.response.results; - expect(results.length).toEqual(1); - done(); - }); + const router = new InstallationsRouter(); + rest + .create(config, auth.nobody(config), '_Installation', androidDeviceRequest) + .then(() => { + return rest.create(config, auth.nobody(config), '_Installation', iosDeviceRequest); + }) + .then(() => { + return router.handleFind(request); + }) + .then(res => { + const results = res.response.results; + expect(results.length).toEqual(1); + done(); + }) + .catch(err => { + fail(JSON.stringify(err)); + done(); + }); }); - it('uses find condition from request.query', (done) => { - var androidDeviceRequest = { - 'installationId': '12345678-abcd-abcd-abcd-123456789abc', - 'deviceType': 'android' + it('uses find condition from request.query', done => { + const config = Config.get('test'); + const androidDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abc', + deviceType: 'android', }; - var iosDeviceRequest = { - 'installationId': '12345678-abcd-abcd-abcd-123456789abd', - 'deviceType': 'ios' + const iosDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abd', + deviceType: 'ios', }; - var request = { + const request = { config: config, auth: auth.master(config), body: {}, query: { where: { - deviceType: 'android' - } + deviceType: 'android', + }, }, - info: {} + info: {}, }; - var router = new InstallationsRouter(); - rest.create(config, auth.nobody(config), '_Installation', androidDeviceRequest) - .then(() => { - return rest.create(config, auth.nobody(config), '_Installation', iosDeviceRequest); - }).then(() => { - return router.handleFind(request); - }).then((res) => { - var results = res.response.results; - expect(results.length).toEqual(1); - done(); - }); + const router = new InstallationsRouter(); + rest + .create(config, auth.nobody(config), '_Installation', androidDeviceRequest) + .then(() => { + return rest.create(config, auth.nobody(config), '_Installation', iosDeviceRequest); + }) + .then(() => { + return router.handleFind(request); + }) + .then(res => { + const results = res.response.results; + expect(results.length).toEqual(1); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); }); - it('query installations with limit = 0', (done) => { - var androidDeviceRequest = { - 'installationId': '12345678-abcd-abcd-abcd-123456789abc', - 'deviceType': 'android' + it('query installations with limit = 0', done => { + const config = Config.get('test'); + const androidDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abc', + deviceType: 'android', }; - var iosDeviceRequest = { - 'installationId': '12345678-abcd-abcd-abcd-123456789abd', - 'deviceType': 'ios' + const iosDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abd', + deviceType: 'ios', }; - var request = { + const request = { config: config, auth: auth.master(config), body: {}, query: { - limit: 0 + limit: 0, }, - info: {} + info: {}, }; - var router = new InstallationsRouter(); - rest.create(config, auth.nobody(config), '_Installation', androidDeviceRequest) - .then(() => { - return rest.create(config, auth.nobody(config), '_Installation', iosDeviceRequest); - }).then(() => { - return router.handleFind(request); - }).then((res) => { - var response = res.response; - expect(response.results.length).toEqual(0); - done(); - }); + Config.get('test'); + const router = new InstallationsRouter(); + rest + .create(config, auth.nobody(config), '_Installation', androidDeviceRequest) + .then(() => { + return rest.create(config, auth.nobody(config), '_Installation', iosDeviceRequest); + }) + .then(() => { + return router.handleFind(request); + }) + .then(res => { + const response = res.response; + expect(response.results.length).toEqual(0); + done(); + }) + .catch(err => { + fail(JSON.stringify(err)); + done(); + }); }); - it('query installations with count = 1', done => { - var androidDeviceRequest = { - 'installationId': '12345678-abcd-abcd-abcd-123456789abc', - 'deviceType': 'android' + it_exclude_dbs(['postgres'])('query installations with count = 1', done => { + const config = Config.get('test'); + const androidDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abc', + deviceType: 'android', }; - var iosDeviceRequest = { - 'installationId': '12345678-abcd-abcd-abcd-123456789abd', - 'deviceType': 'ios' + const iosDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abd', + deviceType: 'ios', }; - var request = { + const request = { config: config, auth: auth.master(config), body: {}, query: { - count: 1 + count: 1, }, - info: {} + info: {}, + }; + + const router = new InstallationsRouter(); + rest + .create(config, auth.nobody(config), '_Installation', androidDeviceRequest) + .then(() => rest.create(config, auth.nobody(config), '_Installation', iosDeviceRequest)) + .then(() => router.handleFind(request)) + .then(res => { + const response = res.response; + expect(response.results.length).toEqual(2); + expect(response.count).toEqual(2); + done(); + }) + .catch(error => { + fail(JSON.stringify(error)); + done(); + }); + }); + + it_only_db('postgres')('query installations with count = 1 postgres', async () => { + const config = Config.get('test'); + const androidDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abc', + deviceType: 'android', }; + const iosDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abd', + deviceType: 'ios', + }; + const request = { + config: config, + auth: auth.master(config), + body: {}, + query: { + count: 1, + }, + info: {}, + }; + + const router = new InstallationsRouter(); + await rest.create(config, auth.nobody(config), '_Installation', androidDeviceRequest); + await rest.create(config, auth.nobody(config), '_Installation', iosDeviceRequest); + let res = await router.handleFind(request); + let response = res.response; + expect(response.results.length).toEqual(2); + expect(response.count).toEqual(0); // estimate count is zero + + const pgAdapter = config.database.adapter; + await pgAdapter.updateEstimatedCount('_Installation'); - var router = new InstallationsRouter(); - rest.create(config, auth.nobody(config), '_Installation', androidDeviceRequest) - .then(() => rest.create(config, auth.nobody(config), '_Installation', iosDeviceRequest)) - .then(() => router.handleFind(request)) - .then((res) => { - var response = res.response; - expect(response.results.length).toEqual(2); - expect(response.count).toEqual(2); - done(); - }) - .catch(error => { - fail(JSON.stringify(error)); - done(); - }) + res = await router.handleFind(request); + response = res.response; + expect(response.results.length).toEqual(2); + expect(response.count).toEqual(2); }); - it('query installations with limit = 0 and count = 1', (done) => { - var androidDeviceRequest = { - 'installationId': '12345678-abcd-abcd-abcd-123456789abc', - 'deviceType': 'android' + it_exclude_dbs(['postgres'])('query installations with limit = 0 and count = 1', done => { + const config = Config.get('test'); + const androidDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abc', + deviceType: 'android', }; - var iosDeviceRequest = { - 'installationId': '12345678-abcd-abcd-abcd-123456789abd', - 'deviceType': 'ios' + const iosDeviceRequest = { + installationId: '12345678-abcd-abcd-abcd-123456789abd', + deviceType: 'ios', }; - var request = { + const request = { config: config, auth: auth.master(config), body: {}, query: { limit: 0, - count: 1 + count: 1, }, - info: {} + info: {}, }; - var router = new InstallationsRouter(); - rest.create(config, auth.nobody(config), '_Installation', androidDeviceRequest) - .then(() => { - return rest.create(config, auth.nobody(config), '_Installation', iosDeviceRequest); - }).then(() => { - return router.handleFind(request); - }).then((res) => { - var response = res.response; - expect(response.results.length).toEqual(0); - expect(response.count).toEqual(2); - done(); - }); + const router = new InstallationsRouter(); + rest + .create(config, auth.nobody(config), '_Installation', androidDeviceRequest) + .then(() => { + return rest.create(config, auth.nobody(config), '_Installation', iosDeviceRequest); + }) + .then(() => { + return router.handleFind(request); + }) + .then(res => { + const response = res.response; + expect(response.results.length).toEqual(0); + expect(response.count).toEqual(2); + done(); + }) + .catch(err => { + fail(JSON.stringify(err)); + done(); + }); }); }); diff --git a/spec/JobSchedule.spec.js b/spec/JobSchedule.spec.js new file mode 100644 index 0000000000..853eb20143 --- /dev/null +++ b/spec/JobSchedule.spec.js @@ -0,0 +1,272 @@ +const request = require('../lib/request'); + +const defaultHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Rest-API-Key': 'rest', + 'Content-Type': 'application/json', +}; +const masterKeyHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Rest-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', +}; +const defaultOptions = { + headers: defaultHeaders, + json: true, +}; +const masterKeyOptions = { + headers: masterKeyHeaders, + json: true, +}; + +describe('JobSchedule', () => { + it('should create _JobSchedule with masterKey', done => { + const jobSchedule = new Parse.Object('_JobSchedule'); + jobSchedule.set({ + jobName: 'MY Cool Job', + }); + jobSchedule + .save(null, { useMasterKey: true }) + .then(() => { + done(); + }) + .catch(done.fail); + }); + + it('should fail creating _JobSchedule without masterKey', done => { + const jobSchedule = new Parse.Object('_JobSchedule'); + jobSchedule.set({ + jobName: 'SomeJob', + }); + jobSchedule + .save(null) + .then(done.fail) + .catch(() => done()); + }); + + it('should reject access when not using masterKey (/jobs)', done => { + request( + Object.assign({ url: Parse.serverURL + '/cloud_code/jobs' }, defaultOptions) + ).then(done.fail, () => done()); + }); + + it('should reject access when not using masterKey (/jobs/data)', done => { + request( + Object.assign({ url: Parse.serverURL + '/cloud_code/jobs/data' }, defaultOptions) + ).then(done.fail, () => done()); + }); + + it('should reject access when not using masterKey (PUT /jobs/id)', done => { + request( + Object.assign( + { method: 'PUT', url: Parse.serverURL + '/cloud_code/jobs/jobId' }, + defaultOptions + ) + ).then(done.fail, () => done()); + }); + + it('should reject access when not using masterKey (DELETE /jobs/id)', done => { + request( + Object.assign( + { method: 'DELETE', url: Parse.serverURL + '/cloud_code/jobs/jobId' }, + defaultOptions + ) + ).then(done.fail, () => done()); + }); + + it('should allow access when using masterKey (GET /jobs)', done => { + request(Object.assign({ url: Parse.serverURL + '/cloud_code/jobs' }, masterKeyOptions)).then( + done, + done.fail + ); + }); + + it('should create a job schedule', done => { + Parse.Cloud.job('job', () => {}); + const options = Object.assign({}, masterKeyOptions, { + method: 'POST', + url: Parse.serverURL + '/cloud_code/jobs', + body: { + job_schedule: { + jobName: 'job', + }, + }, + }); + request(options) + .then(res => { + expect(res.data.objectId).not.toBeUndefined(); + }) + .then(() => { + return request( + Object.assign({ url: Parse.serverURL + '/cloud_code/jobs' }, masterKeyOptions) + ); + }) + .then(res => { + expect(res.data.length).toBe(1); + }) + .then(done) + .catch(done.fail); + }); + + it('should fail creating a job with an invalid name', done => { + const options = Object.assign({}, masterKeyOptions, { + url: Parse.serverURL + '/cloud_code/jobs', + method: 'POST', + body: { + job_schedule: { + jobName: 'job', + }, + }, + }); + request(options) + .then(done.fail) + .catch(() => done()); + }); + + it('should update a job', done => { + Parse.Cloud.job('job1', () => {}); + Parse.Cloud.job('job2', () => {}); + const options = Object.assign({}, masterKeyOptions, { + method: 'POST', + url: Parse.serverURL + '/cloud_code/jobs', + body: { + job_schedule: { + jobName: 'job1', + }, + }, + }); + request(options) + .then(res => { + expect(res.data.objectId).not.toBeUndefined(); + return request( + Object.assign(options, { + url: Parse.serverURL + '/cloud_code/jobs/' + res.data.objectId, + method: 'PUT', + body: { + job_schedule: { + jobName: 'job2', + }, + }, + }) + ); + }) + .then(() => { + return request( + Object.assign({}, masterKeyOptions, { + url: Parse.serverURL + '/cloud_code/jobs', + }) + ); + }) + .then(res => { + expect(res.data.length).toBe(1); + expect(res.data[0].jobName).toBe('job2'); + }) + .then(done) + .catch(done.fail); + }); + + it('should fail updating a job with an invalid name', done => { + Parse.Cloud.job('job1', () => {}); + const options = Object.assign({}, masterKeyOptions, { + method: 'POST', + url: Parse.serverURL + '/cloud_code/jobs', + body: { + job_schedule: { + jobName: 'job1', + }, + }, + }); + request(options) + .then(res => { + expect(res.data.objectId).not.toBeUndefined(); + return request( + Object.assign(options, { + method: 'PUT', + url: Parse.serverURL + '/cloud_code/jobs/' + res.data.objectId, + body: { + job_schedule: { + jobName: 'job2', + }, + }, + }) + ); + }) + .then(done.fail) + .catch(() => done()); + }); + + it('should destroy a job', done => { + Parse.Cloud.job('job', () => {}); + const options = Object.assign({}, masterKeyOptions, { + method: 'POST', + url: Parse.serverURL + '/cloud_code/jobs', + body: { + job_schedule: { + jobName: 'job', + }, + }, + }); + request(options) + .then(res => { + expect(res.data.objectId).not.toBeUndefined(); + return request( + Object.assign( + { + method: 'DELETE', + url: Parse.serverURL + '/cloud_code/jobs/' + res.data.objectId, + }, + masterKeyOptions + ) + ); + }) + .then(() => { + return request( + Object.assign( + { + url: Parse.serverURL + '/cloud_code/jobs', + }, + masterKeyOptions + ) + ); + }) + .then(res => { + expect(res.data.length).toBe(0); + }) + .then(done) + .catch(done.fail); + }); + + it('should properly return job data', done => { + Parse.Cloud.job('job1', () => {}); + Parse.Cloud.job('job2', () => {}); + const options = Object.assign({}, masterKeyOptions, { + method: 'POST', + url: Parse.serverURL + '/cloud_code/jobs', + body: { + job_schedule: { + jobName: 'job1', + }, + }, + }); + request(options) + .then(response => { + const res = response.data; + expect(res.objectId).not.toBeUndefined(); + }) + .then(() => { + return request( + Object.assign({ url: Parse.serverURL + '/cloud_code/jobs/data' }, masterKeyOptions) + ); + }) + .then(response => { + const res = response.data; + expect(res.in_use).toEqual(['job1']); + expect(res.jobs).toContain('job1'); + expect(res.jobs).toContain('job2'); + expect(res.jobs.length).toBe(2); + }) + .then(done) + .catch(e => done.fail(e.data)); + }); +}); diff --git a/spec/LdapAuth.spec.js b/spec/LdapAuth.spec.js new file mode 100644 index 0000000000..b577defcd9 --- /dev/null +++ b/spec/LdapAuth.spec.js @@ -0,0 +1,384 @@ +const ldap = require('../lib/Adapters/Auth/ldap'); +const mockLdapServer = require('./support/MockLdapServer'); +const fs = require('fs'); +const port = 12345; +const sslport = 12346; + +describe('LDAP Injection Prevention', () => { + describe('escapeDN', () => { + it('should escape comma', () => { + expect(ldap.escapeDN('admin,ou=evil')).toBe('admin\\,ou\\=evil'); + }); + + it('should escape equals sign', () => { + expect(ldap.escapeDN('admin=evil')).toBe('admin\\=evil'); + }); + + it('should escape plus sign', () => { + expect(ldap.escapeDN('admin+evil')).toBe('admin\\+evil'); + }); + + it('should escape less-than and greater-than signs', () => { + expect(ldap.escapeDN('admin')).toBe('admin\\'); + }); + + it('should escape hash at start', () => { + expect(ldap.escapeDN('#admin')).toBe('\\#admin'); + }); + + it('should escape semicolon', () => { + expect(ldap.escapeDN('admin;evil')).toBe('admin\\;evil'); + }); + + it('should escape double quote', () => { + expect(ldap.escapeDN('admin"evil')).toBe('admin\\"evil'); + }); + + it('should escape backslash', () => { + expect(ldap.escapeDN('admin\\evil')).toBe('admin\\\\evil'); + }); + + it('should escape leading space', () => { + expect(ldap.escapeDN(' admin')).toBe('\\ admin'); + }); + + it('should escape trailing space', () => { + expect(ldap.escapeDN('admin ')).toBe('admin\\ '); + }); + + it('should escape multiple special characters', () => { + expect(ldap.escapeDN('admin,ou=evil+cn=x')).toBe('admin\\,ou\\=evil\\+cn\\=x'); + }); + + it('should not modify safe values', () => { + expect(ldap.escapeDN('testuser')).toBe('testuser'); + expect(ldap.escapeDN('john.doe')).toBe('john.doe'); + expect(ldap.escapeDN('user123')).toBe('user123'); + }); + }); + + describe('escapeFilter', () => { + it('should escape asterisk', () => { + expect(ldap.escapeFilter('*')).toBe('\\2a'); + }); + + it('should escape open parenthesis', () => { + expect(ldap.escapeFilter('test(')).toBe('test\\28'); + }); + + it('should escape close parenthesis', () => { + expect(ldap.escapeFilter('test)')).toBe('test\\29'); + }); + + it('should escape backslash', () => { + expect(ldap.escapeFilter('test\\')).toBe('test\\5c'); + }); + + it('should escape null byte', () => { + expect(ldap.escapeFilter('test\x00')).toBe('test\\00'); + }); + + it('should escape multiple special characters', () => { + expect(ldap.escapeFilter('*()\\')).toBe('\\2a\\28\\29\\5c'); + }); + + it('should not modify safe values', () => { + expect(ldap.escapeFilter('testuser')).toBe('testuser'); + expect(ldap.escapeFilter('john.doe')).toBe('john.doe'); + expect(ldap.escapeFilter('user123')).toBe('user123'); + }); + + it('should escape filter injection attempt with wildcard', () => { + expect(ldap.escapeFilter('x)(|(objectClass=*)')).toBe('x\\29\\28|\\28objectClass=\\2a\\29'); + }); + }); + + describe('authData validation', () => { + it('should reject missing authData.id', async done => { + const server = await mockLdapServer(port, 'uid=testuser, o=example'); + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + }; + try { + await ldap.validateAuthData({ password: 'secret' }, options); + fail('Should have rejected missing id'); + } catch (err) { + expect(err.message).toBe('LDAP: Wrong username or password'); + } + server.close(done); + }); + + it('should reject non-string authData.id', async done => { + const server = await mockLdapServer(port, 'uid=testuser, o=example'); + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + }; + try { + await ldap.validateAuthData({ id: 123, password: 'secret' }, options); + fail('Should have rejected non-string id'); + } catch (err) { + expect(err.message).toBe('LDAP: Wrong username or password'); + } + server.close(done); + }); + }); + + describe('DN injection prevention', () => { + it('should prevent DN injection via comma in authData.id', async done => { + // Mock server accepts the DN that would result from an unescaped injection + const server = await mockLdapServer(port, 'uid=admin,ou=admins,o=example'); + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + }; + // Attacker tries to inject additional DN components via comma + // Without escaping: DN = uid=admin,ou=admins, o=example (3 RDNs) → matches mock + // With escaping: DN = uid=admin\,ou=admins, o=example (2 RDNs) → doesn't match + try { + await ldap.validateAuthData({ id: 'admin,ou=admins', password: 'secret' }, options); + fail('Should have rejected DN injection attempt'); + } catch (err) { + expect(err.message).toBe('LDAP: Wrong username or password'); + } + server.close(done); + }); + }); + + describe('Filter injection prevention', () => { + it('should prevent LDAP filter injection via wildcard in authData.id', async done => { + // Mock server accepts uid=*, o=example (the attacker's bind DN) + // The * is not special in DNs so it binds fine regardless of escaping + const server = await mockLdapServer(port, 'uid=*, o=example'); + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + groupCn: 'powerusers', + groupFilter: '(&(uniqueMember=uid={{id}}, o=example)(objectClass=groupOfUniqueNames))', + }; + // Attacker uses * as ID to match any group member via wildcard + // Group has member uid=testuser, not uid=* + // Without escaping: filter uses SubstringFilter, matches testuser → passes + // With escaping: filter uses EqualityFilter with literal \2a, no match → fails + try { + await ldap.validateAuthData({ id: '*', password: 'secret' }, options); + fail('Should have rejected filter injection attempt'); + } catch (err) { + expect(err.message).toBe('LDAP: User not in group'); + } + server.close(done); + }); + }); +}); + +describe('Ldap Auth', () => { + it('Should fail with missing options', done => { + ldap + .validateAuthData({ id: 'testuser', password: 'testpw' }) + .then(done.fail) + .catch(err => { + jequal(err.message, 'LDAP auth configuration missing'); + done(); + }); + }); + + it('Should return a resolved promise when validating the app id', done => { + ldap.validateAppId().then(done).catch(done.fail); + }); + + it('Should succeed with right credentials', async done => { + const server = await mockLdapServer(port, 'uid=testuser, o=example'); + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + }; + await ldap.validateAuthData({ id: 'testuser', password: 'secret' }, options); + server.close(done); + }); + + it('Should succeed with right credentials when LDAPS is used and certifcate is not checked', async done => { + const server = await mockLdapServer(sslport, 'uid=testuser, o=example', false, true); + const options = { + suffix: 'o=example', + url: `ldaps://localhost:${sslport}`, + dn: 'uid={{id}}, o=example', + tlsOptions: { rejectUnauthorized: false }, + }; + await ldap.validateAuthData({ id: 'testuser', password: 'secret' }, options); + server.close(done); + }); + + it('Should succeed when LDAPS is used and the presented certificate is the expected certificate', async done => { + const server = await mockLdapServer(sslport, 'uid=testuser, o=example', false, true); + const options = { + suffix: 'o=example', + url: `ldaps://localhost:${sslport}`, + dn: 'uid={{id}}, o=example', + tlsOptions: { + ca: fs.readFileSync(__dirname + '/support/cert/cert.pem'), + rejectUnauthorized: true, + }, + }; + await ldap.validateAuthData({ id: 'testuser', password: 'secret' }, options); + server.close(done); + }); + + it('Should fail when LDAPS is used and the presented certificate is not the expected certificate', async done => { + const server = await mockLdapServer(sslport, 'uid=testuser, o=example', false, true); + const options = { + suffix: 'o=example', + url: `ldaps://localhost:${sslport}`, + dn: 'uid={{id}}, o=example', + tlsOptions: { + ca: fs.readFileSync(__dirname + '/support/cert/anothercert.pem'), + rejectUnauthorized: true, + }, + }; + try { + await ldap.validateAuthData({ id: 'testuser', password: 'secret' }, options); + fail(); + } catch (err) { + expect(err.message).toBe('LDAPS: Certificate mismatch'); + } + server.close(done); + }); + + it('Should fail when LDAPS is used certifcate matches but credentials are wrong', async done => { + const server = await mockLdapServer(sslport, 'uid=testuser, o=example', false, true); + const options = { + suffix: 'o=example', + url: `ldaps://localhost:${sslport}`, + dn: 'uid={{id}}, o=example', + tlsOptions: { + ca: fs.readFileSync(__dirname + '/support/cert/cert.pem'), + rejectUnauthorized: true, + }, + }; + try { + await ldap.validateAuthData({ id: 'testuser', password: 'wrong!' }, options); + fail(); + } catch (err) { + expect(err.message).toBe('LDAP: Wrong username or password'); + } + server.close(done); + }); + + it('Should fail with wrong credentials', async done => { + const server = await mockLdapServer(port, 'uid=testuser, o=example'); + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + }; + try { + await ldap.validateAuthData({ id: 'testuser', password: 'wrong!' }, options); + fail(); + } catch (err) { + expect(err.message).toBe('LDAP: Wrong username or password'); + } + server.close(done); + }); + + it('Should succeed if user is in given group', async done => { + const server = await mockLdapServer(port, 'uid=testuser, o=example'); + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + groupCn: 'powerusers', + groupFilter: '(&(uniqueMember=uid={{id}}, o=example)(objectClass=groupOfUniqueNames))', + }; + await ldap.validateAuthData({ id: 'testuser', password: 'secret' }, options); + server.close(done); + }); + + it('Should fail if user is not in given group', async done => { + const server = await mockLdapServer(port, 'uid=testuser, o=example'); + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + groupCn: 'groupTheUserIsNotIn', + groupFilter: '(&(uniqueMember=uid={{id}}, o=example)(objectClass=groupOfUniqueNames))', + }; + try { + await ldap.validateAuthData({ id: 'testuser', password: 'secret' }, options); + fail(); + } catch (err) { + expect(err.message).toBe('LDAP: User not in group'); + } + server.close(done); + }); + + it('Should fail if the LDAP server does not allow searching inside the provided suffix', async done => { + const server = await mockLdapServer(port, 'uid=testuser, o=example'); + const options = { + suffix: 'o=invalid', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + groupCn: 'powerusers', + groupFilter: '(&(uniqueMember=uid={{id}}, o=example)(objectClass=groupOfUniqueNames))', + }; + try { + await ldap.validateAuthData({ id: 'testuser', password: 'secret' }, options); + fail(); + } catch (err) { + expect(err.message).toBe('LDAP group search failed'); + } + server.close(done); + }); + + it('Should fail if the LDAP server encounters an error while searching', async done => { + const server = await mockLdapServer(port, 'uid=testuser, o=example', true); + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + groupCn: 'powerusers', + groupFilter: '(&(uniqueMember=uid={{id}}, o=example)(objectClass=groupOfUniqueNames))', + }; + try { + await ldap.validateAuthData({ id: 'testuser', password: 'secret' }, options); + fail(); + } catch (err) { + expect(err.message).toBe('LDAP group search failed'); + } + server.close(done); + }); + + it('Should delete the password from authData after validation', async done => { + const server = await mockLdapServer(port, 'uid=testuser, o=example', true); + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + }; + const authData = { id: 'testuser', password: 'secret' }; + await ldap.validateAuthData(authData, options); + expect(authData).toEqual({ id: 'testuser' }); + server.close(done); + }); + + it('Should not save the password in the user record after authentication', async done => { + const server = await mockLdapServer(port, 'uid=testuser, o=example', true); + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + }; + await reconfigureServer({ auth: { ldap: options } }); + const authData = { authData: { id: 'testuser', password: 'secret' } }; + const returnedUser = await Parse.User.logInWith('ldap', authData); + const query = new Parse.Query('User'); + const user = await query.equalTo('objectId', returnedUser.id).first({ useMasterKey: true }); + expect(user.get('authData')).toEqual({ ldap: { id: 'testuser' } }); + expect(user.get('authData').ldap.password).toBeUndefined(); + server.close(done); + }); +}); diff --git a/spec/Logger.spec.js b/spec/Logger.spec.js new file mode 100644 index 0000000000..865c5b0c5c --- /dev/null +++ b/spec/Logger.spec.js @@ -0,0 +1,97 @@ +const logging = require('../lib/Adapters/Logger/WinstonLogger'); +const Transport = require('winston-transport'); + +class TestTransport extends Transport { + log(info, callback) { + callback(null, true); + } +} + +describe('WinstonLogger', () => { + it('should add transport', () => { + const testTransport = new TestTransport(); + spyOn(testTransport, 'log'); + logging.addTransport(testTransport); + expect(logging.logger.transports.length).toBe(4); + logging.logger.info('hi'); + expect(testTransport.log).toHaveBeenCalled(); + logging.logger.error('error'); + expect(testTransport.log).toHaveBeenCalled(); + logging.removeTransport(testTransport); + expect(logging.logger.transports.length).toBe(3); + }); + + it('should have files transports', done => { + reconfigureServer().then(() => { + const transports = logging.logger.transports; + expect(transports.length).toBe(3); + done(); + }); + }); + + it('should disable files logs', done => { + reconfigureServer({ + logsFolder: null, + }) + .then(() => { + const transports = logging.logger.transports; + expect(transports.length).toBe(1); + return reconfigureServer(); + }) + .then(done); + }); + + it('should have a timestamp', done => { + logging.logger.info('hi'); + logging.logger.query({ limit: 1 }, (err, results) => { + if (err) { + done.fail(err); + } + expect(results['parse-server'][0].timestamp).toBeDefined(); + done(); + }); + }); + + it('console should not be json', done => { + // Force console transport + reconfigureServer({ + logsFolder: null, + silent: false, + }) + .then(() => { + spyOn(process.stdout, 'write'); + logging.logger.info('hi', { key: 'value' }); + expect(process.stdout.write).toHaveBeenCalled(); + const firstLog = process.stdout.write.calls.first().args[0]; + expect(firstLog).toEqual('info: hi {"key":"value"}' + '\n'); + return reconfigureServer(); + }) + .then(() => { + done(); + }); + }); + + it('should enable JSON logs', done => { + // Force console transport + reconfigureServer({ + logsFolder: null, + jsonLogs: true, + silent: false, + }) + .then(() => { + spyOn(process.stdout, 'write'); + logging.logger.info('hi', { key: 'value' }); + expect(process.stdout.write).toHaveBeenCalled(); + const firstLog = process.stdout.write.calls.first().args[0]; + expect(firstLog).toEqual( + JSON.stringify({ key: 'value', level: 'info', message: 'hi' }) + '\n' + ); + return reconfigureServer({ + jsonLogs: false, + }); + }) + .then(() => { + done(); + }); + }); +}); diff --git a/spec/LoggerController.spec.js b/spec/LoggerController.spec.js index 3e992911c8..37d477444c 100644 --- a/spec/LoggerController.spec.js +++ b/spec/LoggerController.spec.js @@ -1,36 +1,39 @@ -var LoggerController = require('../src/Controllers/LoggerController').LoggerController; -var FileLoggerAdapter = require('../src/Adapters/Logger/FileLoggerAdapter').FileLoggerAdapter; +const LoggerController = require('../lib/Controllers/LoggerController').LoggerController; +const WinstonLoggerAdapter = require('../lib/Adapters/Logger/WinstonLoggerAdapter') + .WinstonLoggerAdapter; describe('LoggerController', () => { - it('can check process a query without throwing', (done) => { + it('can process an empty query without throwing', done => { // Make mock request - var query = {}; + const query = {}; - var loggerController = new LoggerController(new FileLoggerAdapter()); + const loggerController = new LoggerController(new WinstonLoggerAdapter()); expect(() => { - loggerController.getLogs(query).then(function(res) { - expect(res.length).not.toBe(0); - done(); - }).catch((err) => { - console.error(err); - fail("should not fail"); - done(); - }) + loggerController + .getLogs(query) + .then(function (res) { + expect(res.length).not.toBe(0); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); }).not.toThrow(); }); - it('properly validates dateTimes', (done) => { + it('properly validates dateTimes', done => { expect(LoggerController.validDateTime()).toBe(null); - expect(LoggerController.validDateTime("String")).toBe(null); + expect(LoggerController.validDateTime('String')).toBe(null); expect(LoggerController.validDateTime(123456).getTime()).toBe(123456); - expect(LoggerController.validDateTime("2016-01-01Z00:00:00").getTime()).toBe(1451606400000); + expect(LoggerController.validDateTime('2016-01-01Z00:00:00').getTime()).toBe(1451606400000); done(); }); - it('can set the proper default values', (done) => { + it('can set the proper default values', done => { // Make mock request - var result = LoggerController.parseOptions(); + const result = LoggerController.parseOptions(); expect(result.size).toEqual(10); expect(result.order).toEqual('desc'); expect(result.level).toEqual('info'); @@ -38,17 +41,17 @@ describe('LoggerController', () => { done(); }); - it('can process a query without throwing', (done) => { + it('can parse an ascending query without throwing', done => { // Make mock request - var query = { - from: "2016-01-01Z00:00:00", - until: "2016-01-01Z00:00:00", + const query = { + from: '2016-01-01Z00:00:00', + until: '2016-01-01Z00:00:00', size: 5, order: 'asc', - level: 'error' + level: 'error', }; - var result = LoggerController.parseOptions(query); + const result = LoggerController.parseOptions(query); expect(result.from.getTime()).toEqual(1451606400000); expect(result.until.getTime()).toEqual(1451606400000); @@ -59,34 +62,105 @@ describe('LoggerController', () => { done(); }); - it('can check process a query without throwing', (done) => { + it('can process an ascending query without throwing', done => { + const query = { + size: 5, + order: 'asc', + level: 'error', + }; + + const loggerController = new LoggerController(new WinstonLoggerAdapter()); + loggerController.error('can process an ascending query without throwing'); + + expect(() => { + loggerController + .getLogs(query) + .then(function (res) { + expect(res.length).not.toBe(0); + done(); + }) + .catch(err => { + jfail(err); + fail('should not fail'); + done(); + }); + }).not.toThrow(); + }); + + it('can parse a descending query without throwing', done => { // Make mock request - var query = { - from: "2016-01-01", - until: "2016-01-30", + const query = { + from: '2016-01-01Z00:00:00', + until: '2016-01-01Z00:00:00', size: 5, order: 'desc', - level: 'error' + level: 'error', }; - var loggerController = new LoggerController(new FileLoggerAdapter()); + const result = LoggerController.parseOptions(query); + + expect(result.from.getTime()).toEqual(1451606400000); + expect(result.until.getTime()).toEqual(1451606400000); + expect(result.size).toEqual(5); + expect(result.order).toEqual('desc'); + expect(result.level).toEqual('error'); + + done(); + }); + + it('can process a descending query without throwing', done => { + const query = { + size: 5, + order: 'desc', + level: 'error', + }; + + const loggerController = new LoggerController(new WinstonLoggerAdapter()); + loggerController.error('can process a descending query without throwing'); expect(() => { - loggerController.getLogs(query).then(function(res) { - expect(res.length).toBe(0); - done(); - }).catch((err) => { - console.error(err); - fail("should not fail"); - done(); - }) + loggerController + .getLogs(query) + .then(function (res) { + expect(res.length).not.toBe(0); + done(); + }) + .catch(err => { + jfail(err); + fail('should not fail'); + done(); + }); }).not.toThrow(); }); - it('should throw without an adapter', (done) => { + it('should throw without an adapter', done => { expect(() => { - var loggerController = new LoggerController(); + new LoggerController(); }).toThrow(); done(); }); + + it('should replace implementations with verbose', done => { + const adapter = new WinstonLoggerAdapter(); + const logger = new LoggerController(adapter, null, { verbose: true }); + spyOn(adapter, 'log'); + logger.silly('yo!'); + expect(adapter.log).not.toHaveBeenCalled(); + done(); + }); + + it('should replace implementations with logLevel', done => { + const adapter = new WinstonLoggerAdapter(); + const logger = new LoggerController(adapter, null, { logLevel: 'error' }); + spyOn(adapter, 'log'); + logger.warn('yo!'); + logger.info('yo!'); + logger.debug('yo!'); + logger.verbose('yo!'); + logger.silly('yo!'); + expect(adapter.log).not.toHaveBeenCalled(); + logger.error('error'); + expect(adapter.log).toHaveBeenCalled(); + done(); + }); }); diff --git a/spec/LogsRouter.spec.js b/spec/LogsRouter.spec.js index e8907a39b6..d4b77baaa8 100644 --- a/spec/LogsRouter.spec.js +++ b/spec/LogsRouter.spec.js @@ -1,26 +1,29 @@ 'use strict'; -const request = require('request'); -var LogsRouter = require('../src/Routers/LogsRouter').LogsRouter; -var LoggerController = require('../src/Controllers/LoggerController').LoggerController; -var FileLoggerAdapter = require('../src/Adapters/Logger/FileLoggerAdapter').FileLoggerAdapter; +const request = require('../lib/request'); +const LogsRouter = require('../lib/Routers/LogsRouter').LogsRouter; +const LoggerController = require('../lib/Controllers/LoggerController').LoggerController; +const WinstonLoggerAdapter = require('../lib/Adapters/Logger/WinstonLoggerAdapter') + .WinstonLoggerAdapter; -const loggerController = new LoggerController(new FileLoggerAdapter()); +const loggerController = new LoggerController(new WinstonLoggerAdapter()); -describe('LogsRouter', () => { - it('can check valid master key of request', (done) => { +describe_only(() => { + return process.env.PARSE_SERVER_LOG_LEVEL !== 'debug'; +})('LogsRouter', () => { + it('can check valid master key of request', done => { // Make mock request - var request = { + const request = { auth: { - isMaster: true + isMaster: true, }, query: {}, config: { - loggerController: loggerController - } + loggerController: loggerController, + }, }; - var router = new LogsRouter(); + const router = new LogsRouter(); expect(() => { router.validateRequest(request); @@ -28,19 +31,19 @@ describe('LogsRouter', () => { done(); }); - it('can check invalid construction of controller', (done) => { + it('can check invalid construction of controller', done => { // Make mock request - var request = { + const request = { auth: { - isMaster: true + isMaster: true, }, query: {}, config: { - loggerController: undefined // missing controller - } + loggerController: undefined, // missing controller + }, }; - var router = new LogsRouter(); + const router = new LogsRouter(); expect(() => { router.validateRequest(request); @@ -49,17 +52,126 @@ describe('LogsRouter', () => { }); it('can check invalid master key of request', done => { - request.get({ + const logger = require('../lib/logger').default; + const loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + loggerErrorSpy.calls.reset(); + request({ url: 'http://localhost:8378/1/scriptlog', - json: true, headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(403); - expect(body.error).toEqual('unauthorized: master key is required'); + 'X-Parse-REST-API-Key': 'rest', + }, + }).then(fail, response => { + const body = response.data; + expect(response.status).toEqual(403); + expect(body.error).toEqual('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('unauthorized: master key is required')); done(); }); }); + + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + }; + + /** + * Verifies simple passwords in GET login requests with special characters are scrubbed from the verbose log + */ + it_id('e36d6141-2a20-41d0-85fc-d1534c3e4bae')(it)('does scrub simple passwords on GET login', done => { + reconfigureServer({ + verbose: true, + }).then(function () { + request({ + headers: headers, + url: 'http://localhost:8378/1/login?username=test&password=simplepass.com', + }) + .catch(() => {}) + .then(() => { + request({ + url: 'http://localhost:8378/1/scriptlog?size=4&level=verbose', + headers: headers, + }).then(response => { + const body = response.data; + expect(response.status).toEqual(200); + // 4th entry is our actual GET request + expect(body[2].url).toEqual('/1/login?username=test&password=********'); + expect(body[2].message).toEqual( + 'REQUEST for [GET] /1/login?username=test&password=********: {}' + ); + done(); + }); + }); + }); + }); + + /** + * Verifies complex passwords in GET login requests with special characters are scrubbed from the verbose log + */ + it_id('24b277c5-250f-4a35-a449-2c8c519d4c03')(it)('does scrub complex passwords on GET login', done => { + reconfigureServer({ + verbose: true, + }) + .then(function () { + return request({ + headers: headers, + // using urlencoded password, 'simple @,/?:&=+$#pass.com' + url: + 'http://localhost:8378/1/login?username=test&password=simple%20%40%2C%2F%3F%3A%26%3D%2B%24%23pass.com', + }) + .catch(() => {}) + .then(() => { + return request({ + url: 'http://localhost:8378/1/scriptlog?size=4&level=verbose', + headers: headers, + }).then(response => { + const body = response.data; + expect(response.status).toEqual(200); + // 4th entry is our actual GET request + expect(body[2].url).toEqual('/1/login?username=test&password=********'); + expect(body[2].message).toEqual( + 'REQUEST for [GET] /1/login?username=test&password=********: {}' + ); + done(); + }); + }); + }) + .catch(done.fail); + }); + + /** + * Verifies fields in POST login requests are NOT present in the verbose log + */ + it_id('33143ec9-b32d-467c-ba65-ff2bbefdaadd')(it)('does not have password field in POST login', done => { + reconfigureServer({ + verbose: true, + }).then(function () { + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/login', + body: { + username: 'test', + password: 'simplepass.com', + }, + }) + .catch(() => {}) + .then(() => { + request({ + url: 'http://localhost:8378/1/scriptlog?size=4&level=verbose', + headers: headers, + }).then(response => { + const body = response.data; + expect(response.status).toEqual(200); + // 4th entry is our actual GET request + expect(body[2].url).toEqual('/1/login'); + expect(body[2].message).toEqual( + 'REQUEST for [POST] /1/login: {\n "username": "test",\n "password": "********"\n}' + ); + done(); + }); + }); + }); + }); }); diff --git a/spec/Middlewares.spec.js b/spec/Middlewares.spec.js index 45efc2fd2d..d05a56970b 100644 --- a/spec/Middlewares.spec.js +++ b/spec/Middlewares.spec.js @@ -1,69 +1,593 @@ -var middlewares = require('../src/middlewares'); -var AppCache = require('../src/cache').AppCache; +const middlewares = require('../lib/middlewares'); +const AppCache = require('../lib/cache').AppCache; +const { BlockList } = require('net'); + +const AppCachePut = (appId, config) => + AppCache.put(appId, { + ...config, + maintenanceKeyIpsStore: new Map(), + masterKeyIpsStore: new Map(), + readOnlyMasterKeyIpsStore: new Map(), + }); describe('middlewares', () => { + let fakeReq, fakeRes; + beforeEach(() => { + fakeReq = { + ip: '127.0.0.1', + originalUrl: 'http://example.com/parse/', + url: 'http://example.com/', + body: { + _ApplicationId: 'FakeAppId', + }, + headers: {}, + get: key => { + return fakeReq.headers[key.toLowerCase()]; + }, + }; + fakeRes = jasmine.createSpyObj('fakeRes', ['end', 'status']); + AppCachePut(fakeReq.body._ApplicationId, {}); + }); - var fakeReq, fakeRes; + afterEach(() => { + AppCache.del(fakeReq.body._ApplicationId); + }); - beforeEach(() => { - fakeReq = { - originalUrl: 'http://example.com/parse/', - url: 'http://example.com/', - body: { - _ApplicationId: 'FakeAppId' - }, - headers: {}, - get: (key) => { - return fakeReq.headers[key.toLowerCase()] - } - }; - AppCache.put(fakeReq.body._ApplicationId, {}); + it_id('4cc18d90-1763-4725-97fa-f63fb4692fc4')(it)('should use _ContentType if provided', done => { + AppCachePut(fakeReq.body._ApplicationId, { + masterKeyIps: ['127.0.0.1'], + }); + expect(fakeReq.headers['content-type']).toEqual(undefined); + const contentType = 'image/jpeg'; + fakeReq.body._ContentType = contentType; + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { + expect(fakeReq.headers['content-type']).toEqual(contentType); + expect(fakeReq.body._ContentType).toEqual(undefined); + done(); + }); + }); + + it('should give invalid response when keys are configured but no key supplied', async () => { + AppCachePut(fakeReq.body._ApplicationId, { + masterKey: 'masterKey', + restAPIKey: 'restAPIKey', + }); + await middlewares.handleParseHeaders(fakeReq, fakeRes); + expect(fakeRes.status).toHaveBeenCalledWith(403); + }); + + it('should give invalid response when keys are configured but supplied key is incorrect', async () => { + AppCachePut(fakeReq.body._ApplicationId, { + masterKey: 'masterKey', + restAPIKey: 'restAPIKey', + }); + fakeReq.headers['x-parse-rest-api-key'] = 'wrongKey'; + await middlewares.handleParseHeaders(fakeReq, fakeRes); + expect(fakeRes.status).toHaveBeenCalledWith(403); + }); + + it('should give invalid response when keys are configured but different key is supplied', async () => { + AppCachePut(fakeReq.body._ApplicationId, { + masterKey: 'masterKey', + restAPIKey: 'restAPIKey', + }); + fakeReq.headers['x-parse-client-key'] = 'clientKey'; + await middlewares.handleParseHeaders(fakeReq, fakeRes); + expect(fakeRes.status).toHaveBeenCalledWith(403); + }); + + it('should succeed when any one of the configured keys supplied', done => { + AppCachePut(fakeReq.body._ApplicationId, { + clientKey: 'clientKey', + masterKey: 'masterKey', + restAPIKey: 'restAPIKey', + }); + fakeReq.headers['x-parse-rest-api-key'] = 'restAPIKey'; + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { + expect(fakeRes.status).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should succeed when client key supplied but empty', done => { + AppCachePut(fakeReq.body._ApplicationId, { + clientKey: '', + masterKey: 'masterKey', + restAPIKey: 'restAPIKey', + }); + fakeReq.headers['x-parse-client-key'] = ''; + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { + expect(fakeRes.status).not.toHaveBeenCalled(); + done(); }); + }); - afterEach(() => { - AppCache.del(fakeReq.body._ApplicationId); + it('should succeed when no keys are configured and none supplied', done => { + AppCachePut(fakeReq.body._ApplicationId, { + masterKey: 'masterKey', + }); + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { + expect(fakeRes.status).not.toHaveBeenCalled(); + done(); }); + }); - it('should use _ContentType if provided', (done) => { - expect(fakeReq.headers['content-type']).toEqual(undefined); - var contentType = 'image/jpeg'; - fakeReq.body._ContentType = contentType; - middlewares.handleParseHeaders(fakeReq, fakeRes, () => { - expect(fakeReq.headers['content-type']).toEqual(contentType); - expect(fakeReq.body._ContentType).toEqual(undefined); - done() + const BodyParams = { + clientVersion: '_ClientVersion', + installationId: '_InstallationId', + sessionToken: '_SessionToken', + masterKey: '_MasterKey', + javascriptKey: '_JavaScriptKey', + }; + + const BodyKeys = Object.keys(BodyParams); + + BodyKeys.forEach(infoKey => { + const bodyKey = BodyParams[infoKey]; + const keyValue = 'Fake' + bodyKey; + // javascriptKey is the only one that gets defaulted, + const otherKeys = BodyKeys.filter( + otherKey => otherKey !== infoKey && otherKey !== 'javascriptKey' + ); + it_id('f9abd7ac-b1f4-4607-b9b0-365ff0559d84')(it)(`it should pull ${bodyKey} into req.info`, done => { + AppCachePut(fakeReq.body._ApplicationId, { + masterKeyIps: ['0.0.0.0/0'], + }); + fakeReq.ip = '127.0.0.1'; + fakeReq.body[bodyKey] = keyValue; + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { + expect(fakeReq.body[bodyKey]).toEqual(undefined); + expect(fakeReq.info[infoKey]).toEqual(keyValue); + + otherKeys.forEach(otherKey => { + expect(fakeReq.info[otherKey]).toEqual(undefined); }); + + done(); + }); + }); + }); + + it_id('4a0bce41-c536-4482-a873-12ed023380e2')(it)('should not succeed and log if the ip does not belong to masterKeyIps list', async () => { + const logger = require('../lib/logger').logger; + spyOn(logger, 'error').and.callFake(() => {}); + AppCachePut(fakeReq.body._ApplicationId, { + masterKey: 'masterKey', + masterKeyIps: ['10.0.0.1'], + }); + fakeReq.ip = '127.0.0.1'; + fakeReq.headers['x-parse-master-key'] = 'masterKey'; + + const error = await middlewares.handleParseHeaders(fakeReq, fakeRes, () => {}).catch(e => e); + + expect(error).toBeDefined(); + expect(error.message).toEqual(`unauthorized`); + expect(logger.error).toHaveBeenCalledWith( + `Request using master key rejected as the request IP address '127.0.0.1' is not set in Parse Server option 'masterKeyIps'.` + ); + }); + + it('should not succeed and log if the ip does not belong to maintenanceKeyIps list', async () => { + const logger = require('../lib/logger').logger; + spyOn(logger, 'error').and.callFake(() => {}); + AppCachePut(fakeReq.body._ApplicationId, { + maintenanceKey: 'masterKey', + maintenanceKeyIps: ['10.0.0.0', '10.0.0.1'], + }); + fakeReq.ip = '10.0.0.2'; + fakeReq.headers['x-parse-maintenance-key'] = 'masterKey'; + + const error = await middlewares.handleParseHeaders(fakeReq, fakeRes, () => {}).catch(e => e); + + expect(error).toBeDefined(); + expect(error.message).toEqual(`unauthorized`); + expect(logger.error).toHaveBeenCalledWith( + `Request using maintenance key rejected as the request IP address '10.0.0.2' is not set in Parse Server option 'maintenanceKeyIps'.` + ); + }); + + it_id('5b8b9280-53ec-445a-b868-6992931d2236')(it)('should reject maintenance key from non-allowed IP instead of downgrading to anonymous auth', async () => { + await reconfigureServer({ + maintenanceKeyIps: ['10.0.0.1'], + }); + const logger = require('../lib/logger').logger; + spyOn(logger, 'error').and.callFake(() => {}); + AppCachePut(fakeReq.body._ApplicationId, { + maintenanceKey: 'maintenanceKey', + maintenanceKeyIps: ['10.0.0.1'], + masterKey: 'masterKey', + masterKeyIps: ['0.0.0.0/0', '::0'], + }); + fakeReq.ip = '127.0.0.1'; + fakeReq.headers['x-parse-maintenance-key'] = 'maintenanceKey'; + + const error = await middlewares.handleParseHeaders(fakeReq, fakeRes, () => {}).catch(e => e); + + expect(error).toBeDefined(); + expect(error.status).toBe(403); + expect(error.message).toEqual('unauthorized'); + expect(logger.error).toHaveBeenCalledWith( + `Request using maintenance key rejected as the request IP address '127.0.0.1' is not set in Parse Server option 'maintenanceKeyIps'.` + ); + }); + + it_id('2f7fadec-a87c-4626-90d1-65c75653aea9')(it)('should succeed if the ip does belong to masterKeyIps list', async () => { + AppCachePut(fakeReq.body._ApplicationId, { + masterKey: 'masterKey', + masterKeyIps: ['10.0.0.1'], + }); + fakeReq.ip = '10.0.0.1'; + fakeReq.headers['x-parse-master-key'] = 'masterKey'; + await new Promise(resolve => middlewares.handleParseHeaders(fakeReq, fakeRes, resolve)); + expect(fakeReq.auth.isMaster).toBe(true); + }); + + it_id('2b251fd4-d43c-48f4-ada9-c8458e40c12a')(it)('should allow any ip to use masterKey if masterKeyIps is empty', async () => { + AppCachePut(fakeReq.body._ApplicationId, { + masterKey: 'masterKey', + masterKeyIps: ['0.0.0.0/0'], + }); + fakeReq.ip = '10.0.0.1'; + fakeReq.headers['x-parse-master-key'] = 'masterKey'; + await new Promise(resolve => middlewares.handleParseHeaders(fakeReq, fakeRes, resolve)); + expect(fakeReq.auth.isMaster).toBe(true); + }); + + it('should not succeed and log if the ip does not belong to readOnlyMasterKeyIps list', async () => { + const logger = require('../lib/logger').logger; + spyOn(logger, 'error').and.callFake(() => {}); + AppCachePut(fakeReq.body._ApplicationId, { + masterKeyIps: ['0.0.0.0/0'], + readOnlyMasterKey: 'readOnlyMasterKey', + readOnlyMasterKeyIps: ['10.0.0.1'], + }); + fakeReq.ip = '127.0.0.1'; + fakeReq.headers['x-parse-application-id'] = fakeReq.body._ApplicationId; + fakeReq.headers['x-parse-master-key'] = 'readOnlyMasterKey'; + + const error = await middlewares.handleParseHeaders(fakeReq, fakeRes, () => {}).catch(e => e); + + expect(error).toBeDefined(); + expect(error.message).toEqual('unauthorized'); + expect(logger.error).toHaveBeenCalledWith( + `Request using read-only master key rejected as the request IP address '127.0.0.1' is not set in Parse Server option 'readOnlyMasterKeyIps'.` + ); + }); + + it('should succeed if the ip does belong to readOnlyMasterKeyIps list', async () => { + AppCachePut(fakeReq.body._ApplicationId, { + masterKeyIps: ['0.0.0.0/0'], + readOnlyMasterKey: 'readOnlyMasterKey', + readOnlyMasterKeyIps: ['10.0.0.1'], + }); + fakeReq.ip = '10.0.0.1'; + fakeReq.headers['x-parse-application-id'] = fakeReq.body._ApplicationId; + fakeReq.headers['x-parse-master-key'] = 'readOnlyMasterKey'; + await new Promise(resolve => middlewares.handleParseHeaders(fakeReq, fakeRes, resolve)); + expect(fakeReq.auth.isMaster).toBe(true); + expect(fakeReq.auth.isReadOnly).toBe(true); + }); + + it('should allow any ip to use readOnlyMasterKey if readOnlyMasterKeyIps is 0.0.0.0/0', async () => { + AppCachePut(fakeReq.body._ApplicationId, { + masterKeyIps: ['0.0.0.0/0'], + readOnlyMasterKey: 'readOnlyMasterKey', + readOnlyMasterKeyIps: ['0.0.0.0/0'], }); + fakeReq.ip = '10.0.0.1'; + fakeReq.headers['x-parse-application-id'] = fakeReq.body._ApplicationId; + fakeReq.headers['x-parse-master-key'] = 'readOnlyMasterKey'; + await new Promise(resolve => middlewares.handleParseHeaders(fakeReq, fakeRes, resolve)); + expect(fakeReq.auth.isMaster).toBe(true); + expect(fakeReq.auth.isReadOnly).toBe(true); + }); - const BodyParams = { - clientVersion: '_ClientVersion', - installationId: '_InstallationId', - sessionToken: '_SessionToken', - masterKey: '_MasterKey', - javascriptKey: '_JavaScriptKey' + it('can set trust proxy', async () => { + const server = await reconfigureServer({ trustProxy: 1 }); + expect(server.app.parent.settings['trust proxy']).toBe(1); + }); + + it('should properly expose the headers', () => { + const headers = {}; + const res = { + header: (key, value) => { + headers[key] = value; + }, }; + const allowCrossDomain = middlewares.allowCrossDomain(fakeReq.body._ApplicationId); + allowCrossDomain(fakeReq, res, () => {}); + expect(Object.keys(headers).length).toBe(4); + expect(headers['Access-Control-Expose-Headers']).toBe( + 'X-Parse-Job-Status-Id, X-Parse-Push-Status-Id' + ); + }); - const BodyKeys = Object.keys(BodyParams); + it('should set default Access-Control-Allow-Headers if allowHeaders are empty', () => { + AppCachePut(fakeReq.body._ApplicationId, { + allowHeaders: undefined, + }); + const headers = {}; + const res = { + header: (key, value) => { + headers[key] = value; + }, + }; + const allowCrossDomain = middlewares.allowCrossDomain(fakeReq.body._ApplicationId); + allowCrossDomain(fakeReq, res, () => {}); + expect(headers['Access-Control-Allow-Headers']).toContain(middlewares.DEFAULT_ALLOWED_HEADERS); - BodyKeys.forEach((infoKey) => { - const bodyKey = BodyParams[infoKey]; - const keyValue = 'Fake' + bodyKey; - // javascriptKey is the only one that gets defaulted, - const otherKeys = BodyKeys.filter((otherKey) => otherKey !== infoKey && otherKey !== 'javascriptKey'); + AppCachePut(fakeReq.body._ApplicationId, { + allowHeaders: [], + }); + allowCrossDomain(fakeReq, res, () => {}); + expect(headers['Access-Control-Allow-Headers']).toContain(middlewares.DEFAULT_ALLOWED_HEADERS); + }); - it(`it should pull ${bodyKey} into req.info`, (done) => { - fakeReq.body[bodyKey] = keyValue; + it('should append custom headers to Access-Control-Allow-Headers if allowHeaders provided', () => { + AppCachePut(fakeReq.body._ApplicationId, { + allowHeaders: ['Header-1', 'Header-2'], + }); + const headers = {}; + const res = { + header: (key, value) => { + headers[key] = value; + }, + }; + const allowCrossDomain = middlewares.allowCrossDomain(fakeReq.body._ApplicationId); + allowCrossDomain(fakeReq, res, () => {}); + expect(headers['Access-Control-Allow-Headers']).toContain('Header-1, Header-2'); + expect(headers['Access-Control-Allow-Headers']).toContain(middlewares.DEFAULT_ALLOWED_HEADERS); + }); - middlewares.handleParseHeaders(fakeReq, fakeRes, () => { - expect(fakeReq.body[bodyKey]).toEqual(undefined); - expect(fakeReq.info[infoKey]).toEqual(keyValue); + it('should set default Access-Control-Allow-Origin if allowOrigin is empty', () => { + AppCachePut(fakeReq.body._ApplicationId, { + allowOrigin: undefined, + }); + const headers = {}; + const res = { + header: (key, value) => { + headers[key] = value; + }, + }; + const allowCrossDomain = middlewares.allowCrossDomain(fakeReq.body._ApplicationId); + allowCrossDomain(fakeReq, res, () => {}); + expect(headers['Access-Control-Allow-Origin']).toEqual('*'); + }); - otherKeys.forEach((otherKey) => { - expect(fakeReq.info[otherKey]).toEqual(undefined); - }); + it('should set custom origin to Access-Control-Allow-Origin if allowOrigin is provided', () => { + AppCachePut(fakeReq.body._ApplicationId, { + allowOrigin: 'https://parseplatform.org/', + }); + const headers = {}; + const res = { + header: (key, value) => { + headers[key] = value; + }, + }; + const allowCrossDomain = middlewares.allowCrossDomain(fakeReq.body._ApplicationId); + allowCrossDomain(fakeReq, res, () => {}); + expect(headers['Access-Control-Allow-Origin']).toEqual('https://parseplatform.org/'); + }); - done(); - }); - }); + it('should support multiple origins if several are defined in allowOrigin as an array', () => { + AppCache.put(fakeReq.body._ApplicationId, { + allowOrigin: ['https://a.com', 'https://b.com', 'https://c.com'], + }); + const headers = {}; + const res = { + header: (key, value) => { + headers[key] = value; + }, + }; + const allowCrossDomain = middlewares.allowCrossDomain(fakeReq.body._ApplicationId); + // Test with the first domain + fakeReq.headers.origin = 'https://a.com'; + allowCrossDomain(fakeReq, res, () => {}); + expect(headers['Access-Control-Allow-Origin']).toEqual('https://a.com'); + // Test with the second domain + fakeReq.headers.origin = 'https://b.com'; + allowCrossDomain(fakeReq, res, () => {}); + expect(headers['Access-Control-Allow-Origin']).toEqual('https://b.com'); + // Test with the third domain + fakeReq.headers.origin = 'https://c.com'; + allowCrossDomain(fakeReq, res, () => {}); + expect(headers['Access-Control-Allow-Origin']).toEqual('https://c.com'); + // Test with an unauthorized domain + fakeReq.headers.origin = 'https://unauthorized.com'; + allowCrossDomain(fakeReq, res, () => {}); + expect(headers['Access-Control-Allow-Origin']).toEqual('https://a.com'); + }); + + it('should use user provided on field userFromJWT', done => { + AppCachePut(fakeReq.body._ApplicationId, { + masterKey: 'masterKey', + }); + fakeReq.userFromJWT = 'fake-user'; + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { + expect(fakeReq.auth.user).toEqual('fake-user'); + done(); + }); + }); + + it('should give invalid response when upload file without x-parse-application-id in header', () => { + AppCachePut(fakeReq.body._ApplicationId, { + masterKey: 'masterKey', + }); + fakeReq.body = Buffer.from('fake-file'); + middlewares.handleParseHeaders(fakeReq, fakeRes); + expect(fakeRes.status).toHaveBeenCalledWith(403); + }); + + it('should match address', () => { + const ipv6 = '2001:0db8:85a3:0000:0000:8a2e:0370:7334'; + const anotherIpv6 = '::ffff:101.10.0.1'; + const ipv4 = '192.168.0.101'; + const localhostV6 = '::1'; + const localhostV62 = '::ffff:127.0.0.1'; + const localhostV4 = '127.0.0.1'; + + const v6 = [ipv6, anotherIpv6]; + v6.forEach(ip => { + expect(middlewares.checkIp(ip, ['::/0'], new Map())).toBe(true); + expect(middlewares.checkIp(ip, ['::'], new Map())).toBe(true); + expect(middlewares.checkIp(ip, ['0.0.0.0'], new Map())).toBe(false); + expect(middlewares.checkIp(ip, ['0.0.0.0/0'], new Map())).toBe(false); + expect(middlewares.checkIp(ip, ['123.123.123.123'], new Map())).toBe(false); + }); + + expect(middlewares.checkIp(ipv6, [anotherIpv6], new Map())).toBe(false); + expect(middlewares.checkIp(ipv6, [ipv6], new Map())).toBe(true); + expect(middlewares.checkIp(ipv6, ['2001:db8:85a3:0:0:8a2e:0:0/100'], new Map())).toBe(true); + + expect(middlewares.checkIp(ipv4, ['::'], new Map())).toBe(false); + expect(middlewares.checkIp(ipv4, ['::/0'], new Map())).toBe(false); + expect(middlewares.checkIp(ipv4, ['0.0.0.0'], new Map())).toBe(true); + expect(middlewares.checkIp(ipv4, ['0.0.0.0/0'], new Map())).toBe(true); + expect(middlewares.checkIp(ipv4, ['123.123.123.123'], new Map())).toBe(false); + expect(middlewares.checkIp(ipv4, [ipv4], new Map())).toBe(true); + expect(middlewares.checkIp(ipv4, ['192.168.0.0/24'], new Map())).toBe(true); + + expect(middlewares.checkIp(localhostV4, ['::1'], new Map())).toBe(false); + expect(middlewares.checkIp(localhostV6, ['::1'], new Map())).toBe(true); + // ::ffff:127.0.0.1 is a padded ipv4 address but not ::1 + expect(middlewares.checkIp(localhostV62, ['::1'], new Map())).toBe(false); + // ::ffff:127.0.0.1 is a padded ipv4 address and is a match for 127.0.0.1 + expect(middlewares.checkIp(localhostV62, ['127.0.0.1'], new Map())).toBe(true); + }); + + describe('body field type validation', () => { + beforeEach(() => { + AppCachePut(fakeReq.body._ApplicationId, { + masterKeyIps: ['0.0.0.0/0'], + }); + }); + + it('should reject non-string _SessionToken in body', async () => { + fakeReq.body._SessionToken = { toString: 'evil' }; + await middlewares.handleParseHeaders(fakeReq, fakeRes); + expect(fakeRes.status).toHaveBeenCalledWith(403); + }); + + it('should reject non-string _ClientVersion in body', async () => { + fakeReq.body._ClientVersion = { toLowerCase: 'evil' }; + await middlewares.handleParseHeaders(fakeReq, fakeRes); + expect(fakeRes.status).toHaveBeenCalledWith(403); + }); + + it('should reject non-string _InstallationId in body', async () => { + fakeReq.body._InstallationId = { toString: 'evil' }; + await middlewares.handleParseHeaders(fakeReq, fakeRes); + expect(fakeRes.status).toHaveBeenCalledWith(403); }); -}); \ No newline at end of file + + it('should reject non-string _ContentType in body', async () => { + fakeReq.body._ContentType = { toString: 'evil' }; + await middlewares.handleParseHeaders(fakeReq, fakeRes); + expect(fakeRes.status).toHaveBeenCalledWith(403); + }); + + it('should reject non-string base64 in file-via-JSON upload', async () => { + fakeReq.body = Buffer.from( + JSON.stringify({ + _ApplicationId: 'FakeAppId', + base64: { toString: 'evil' }, + }) + ); + await middlewares.handleParseHeaders(fakeReq, fakeRes); + expect(fakeRes.status).toHaveBeenCalledWith(403); + }); + + it('should not crash the server process on non-string body fields', async () => { + // Verify that type confusion in body fields does not crash the Node.js process. + // Each request should be handled independently without affecting server stability. + const payloads = [ + { _SessionToken: { toString: 'evil' } }, + { _ClientVersion: { toLowerCase: 'evil' } }, + { _InstallationId: [1, 2, 3] }, + { _ContentType: { toString: 'evil' } }, + ]; + for (const payload of payloads) { + const req = { + ip: '127.0.0.1', + originalUrl: 'http://example.com/parse/', + url: 'http://example.com/', + body: { _ApplicationId: 'FakeAppId', ...payload }, + headers: {}, + get: key => req.headers[key.toLowerCase()], + }; + const res = jasmine.createSpyObj('res', ['end', 'status']); + await middlewares.handleParseHeaders(req, res); + expect(res.status).toHaveBeenCalledWith(403); + } + // Server process is still alive — a subsequent valid request works + const validReq = { + ip: '127.0.0.1', + originalUrl: 'http://example.com/parse/', + url: 'http://example.com/', + body: { _ApplicationId: 'FakeAppId' }, + headers: {}, + get: key => validReq.headers[key.toLowerCase()], + }; + const validRes = jasmine.createSpyObj('validRes', ['end', 'status']); + let nextCalled = false; + await middlewares.handleParseHeaders(validReq, validRes, () => { + nextCalled = true; + }); + expect(nextCalled).toBe(true); + expect(validRes.status).not.toHaveBeenCalled(); + }); + + it('should still accept valid string body fields', done => { + fakeReq.body._SessionToken = 'r:validtoken'; + fakeReq.body._ClientVersion = 'js1.0.0'; + fakeReq.body._InstallationId = 'install123'; + fakeReq.body._ContentType = 'application/json'; + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { + expect(fakeReq.info.sessionToken).toEqual('r:validtoken'); + expect(fakeReq.info.clientVersion).toEqual('js1.0.0'); + expect(fakeReq.info.installationId).toEqual('install123'); + expect(fakeReq.headers['content-type']).toEqual('application/json'); + done(); + }); + }); + }); + + it('should match address with cache', () => { + const ipv6 = '2001:0db8:85a3:0000:0000:8a2e:0370:7334'; + const cache1 = new Map(); + const spyBlockListCheck = spyOn(BlockList.prototype, 'check').and.callThrough(); + expect(middlewares.checkIp(ipv6, ['::'], cache1)).toBe(true); + expect(cache1.get('2001:0db8:85a3:0000:0000:8a2e:0370:7334')).toBe(undefined); + expect(cache1.get('allowAllIpv6')).toBe(true); + expect(spyBlockListCheck).toHaveBeenCalledTimes(0); + + const cache2 = new Map(); + expect(middlewares.checkIp('::1', ['::1'], cache2)).toBe(true); + expect(cache2.get('::1')).toBe(true); + expect(spyBlockListCheck).toHaveBeenCalledTimes(1); + expect(middlewares.checkIp('::1', ['::1'], cache2)).toBe(true); + expect(spyBlockListCheck).toHaveBeenCalledTimes(1); + spyBlockListCheck.calls.reset(); + + const cache3 = new Map(); + expect(middlewares.checkIp('127.0.0.1', ['127.0.0.1'], cache3)).toBe(true); + expect(cache3.get('127.0.0.1')).toBe(true); + expect(spyBlockListCheck).toHaveBeenCalledTimes(1); + expect(middlewares.checkIp('127.0.0.1', ['127.0.0.1'], cache3)).toBe(true); + expect(spyBlockListCheck).toHaveBeenCalledTimes(1); + spyBlockListCheck.calls.reset(); + + const cache4 = new Map(); + const ranges = ['127.0.0.1', '192.168.0.0/24']; + // should not cache negative match + expect(middlewares.checkIp('123.123.123.123', ranges, cache4)).toBe(false); + expect(cache4.get('123.123.123.123')).toBe(undefined); + expect(spyBlockListCheck).toHaveBeenCalledTimes(1); + spyBlockListCheck.calls.reset(); + + // should not cache cidr + expect(middlewares.checkIp('192.168.0.101', ranges, cache4)).toBe(true); + expect(cache4.get('192.168.0.101')).toBe(undefined); + expect(spyBlockListCheck).toHaveBeenCalledTimes(1); + }); +}); diff --git a/spec/MockAdapter.js b/spec/MockAdapter.js deleted file mode 100644 index c3f557849d..0000000000 --- a/spec/MockAdapter.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = function(options) { - return { - options: options - }; -}; diff --git a/spec/MockEmailAdapterWithOptions.js b/spec/MockEmailAdapterWithOptions.js deleted file mode 100644 index 8a3095e21f..0000000000 --- a/spec/MockEmailAdapterWithOptions.js +++ /dev/null @@ -1,10 +0,0 @@ -module.exports = options => { - if (!options) { - throw "Options were not provided" - } - return { - sendVerificationEmail: () => Promise.resolve(), - sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => Promise.resolve() - } -} diff --git a/spec/MongoSchemaCollectionAdapter.spec.js b/spec/MongoSchemaCollectionAdapter.spec.js index ba22c1fab3..8e376b9d1d 100644 --- a/spec/MongoSchemaCollectionAdapter.spec.js +++ b/spec/MongoSchemaCollectionAdapter.spec.js @@ -1,43 +1,58 @@ 'use strict'; -const MongoSchemaCollection = require('../src/Adapters/Storage/Mongo/MongoSchemaCollection').default; +const MongoSchemaCollection = require('../lib/Adapters/Storage/Mongo/MongoSchemaCollection') + .default; describe('MongoSchemaCollection', () => { it('can transform legacy _client_permissions keys to parse format', done => { - expect(MongoSchemaCollection._TESTmongoSchemaToParseSchema({ - "_id":"_Installation", - "_client_permissions":{ - "get":true, - "find":true, - "update":true, - "create":true, - "delete":true, - }, - "_metadata":{ - "class_permissions":{ - "get":{"*":true}, - "find":{"*":true}, - "update":{"*":true}, - "create":{"*":true}, - "delete":{"*":true}, - "addField":{"*":true}, - } - }, - "installationId":"string", - "deviceToken":"string", - "deviceType":"string", - "channels":"array", - "user":"*_User", - "pushType":"string", - "GCMSenderId":"string", - "timeZone":"string", - "localeIdentifier":"string", - "badge":"number", - "appVersion":"string", - "appName":"string", - "appIdentifier":"string", - "parseVersion":"string", - })).toEqual({ + expect( + MongoSchemaCollection._TESTmongoSchemaToParseSchema({ + _id: '_Installation', + _client_permissions: { + get: true, + find: true, + count: true, + update: true, + create: true, + delete: true, + }, + _metadata: { + class_permissions: { + ACL: { + '*': { + read: true, + write: true, + }, + }, + get: { '*': true }, + find: { '*': true }, + count: { '*': true }, + update: { '*': true }, + create: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': [] }, + }, + indexes: { + name1: { deviceToken: 1 }, + }, + }, + installationId: 'string', + deviceToken: 'string', + deviceType: 'string', + channels: 'array', + user: '*_User', + pushType: 'string', + GCMSenderId: 'string', + timeZone: 'string', + localeIdentifier: 'string', + badge: 'number', + appVersion: 'string', + appName: 'string', + appIdentifier: 'string', + parseVersion: 'string', + }) + ).toEqual({ className: '_Installation', fields: { installationId: { type: 'String' }, @@ -60,13 +75,24 @@ describe('MongoSchemaCollection', () => { objectId: { type: 'String' }, }, classLevelPermissions: { + ACL: { + '*': { + read: true, + write: true, + }, + }, find: { '*': true }, get: { '*': true }, + count: { '*': true }, create: { '*': true }, update: { '*': true }, delete: { '*': true }, addField: { '*': true }, - } + protectedFields: { '*': [] }, + }, + indexes: { + name1: { deviceToken: 1 }, + }, }); done(); }); diff --git a/spec/MongoStorageAdapter.spec.js b/spec/MongoStorageAdapter.spec.js index 6c2666a4b0..facf1b2c41 100644 --- a/spec/MongoStorageAdapter.spec.js +++ b/spec/MongoStorageAdapter.spec.js @@ -1,22 +1,31 @@ 'use strict'; -const MongoStorageAdapter = require('../src/Adapters/Storage/Mongo/MongoStorageAdapter'); -const MongoClient = require('mongodb').MongoClient; +const MongoStorageAdapter = require('../lib/Adapters/Storage/Mongo/MongoStorageAdapter').default; +const { MongoClient, Collection } = require('mongodb'); const databaseURI = 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase'; +const request = require('../lib/request'); +const Config = require('../lib/Config'); +const TestUtils = require('../lib/TestUtils'); +const Utils = require('../lib/Utils'); +const { randomUUID: uuidv4 } = require('crypto'); + +const fakeClient = { + s: { options: { dbName: null } }, + db: () => null, +}; // These tests are specific to the mongo storage adapter + mongo storage format // and will eventually be moved into their own repo -describe('MongoStorageAdapter', () => { - beforeEach(done => { - new MongoStorageAdapter({ uri: databaseURI }) - .deleteAllClasses() - .then(done, fail); +describe_only_db('mongo')('MongoStorageAdapter', () => { + beforeEach(async () => { + await new MongoStorageAdapter({ uri: databaseURI }).deleteAllClasses(); + Config.get(Parse.applicationId).schemaCache.clear(); }); it('auto-escapes symbols in auth information', () => { - spyOn(MongoClient, 'connect').and.returnValue(Promise.resolve(null)); + spyOn(MongoClient, 'connect').and.returnValue(Promise.resolve(fakeClient)); new MongoStorageAdapter({ - uri: 'mongodb://user!with@+ symbols:password!with@+ symbols@localhost:1234/parse' + uri: 'mongodb://user!with@+ symbols:password!with@+ symbols@localhost:1234/parse', }).connect(); expect(MongoClient.connect).toHaveBeenCalledWith( 'mongodb://user!with%40%2B%20symbols:password!with%40%2B%20symbols@localhost:1234/parse', @@ -25,9 +34,9 @@ describe('MongoStorageAdapter', () => { }); it("doesn't double escape already URI-encoded information", () => { - spyOn(MongoClient, 'connect').and.returnValue(Promise.resolve(null)); + spyOn(MongoClient, 'connect').and.returnValue(Promise.resolve(fakeClient)); new MongoStorageAdapter({ - uri: 'mongodb://user!with%40%2B%20symbols:password!with%40%2B%20symbols@localhost:1234/parse' + uri: 'mongodb://user!with%40%2B%20symbols:password!with%40%2B%20symbols@localhost:1234/parse', }).connect(); expect(MongoClient.connect).toHaveBeenCalledWith( 'mongodb://user!with%40%2B%20symbols:password!with%40%2B%20symbols@localhost:1234/parse', @@ -35,11 +44,12 @@ describe('MongoStorageAdapter', () => { ); }); - // https://github.com/ParsePlatform/parse-server/pull/148#issuecomment-180407057 + // https://github.com/parse-community/parse-server/pull/148#issuecomment-180407057 it('preserves replica sets', () => { - spyOn(MongoClient, 'connect').and.returnValue(Promise.resolve(null)); + spyOn(MongoClient, 'connect').and.returnValue(Promise.resolve(fakeClient)); new MongoStorageAdapter({ - uri: 'mongodb://test:testpass@ds056315-a0.mongolab.com:59325,ds059315-a1.mongolab.com:59315/testDBname?replicaSet=rs-ds059415' + uri: + 'mongodb://test:testpass@ds056315-a0.mongolab.com:59325,ds059315-a1.mongolab.com:59315/testDBname?replicaSet=rs-ds059415', }).connect(); expect(MongoClient.connect).toHaveBeenCalledWith( 'mongodb://test:testpass@ds056315-a0.mongolab.com:59325,ds059315-a1.mongolab.com:59315/testDBname?replicaSet=rs-ds059415', @@ -48,108 +58,1234 @@ describe('MongoStorageAdapter', () => { }); it('stores objectId in _id', done => { - let adapter = new MongoStorageAdapter({ uri: databaseURI }); - adapter.createObject('Foo', { fields: {} }, { objectId: 'abcde' }) - .then(() => adapter._rawFind('Foo', {})) - .then(results => { - expect(results.length).toEqual(1); - var obj = results[0]; - expect(obj._id).toEqual('abcde'); - expect(obj.objectId).toBeUndefined(); - done(); + const adapter = new MongoStorageAdapter({ uri: databaseURI }); + adapter + .createObject('Foo', { fields: {} }, { objectId: 'abcde' }) + .then(() => adapter._rawFind('Foo', {})) + .then(results => { + expect(results.length).toEqual(1); + const obj = results[0]; + expect(obj._id).toEqual('abcde'); + expect(obj.objectId).toBeUndefined(); + done(); + }); + }); + + it('find succeeds when query is within maxTimeMS', done => { + const maxTimeMS = 250; + const adapter = new MongoStorageAdapter({ + uri: databaseURI, + mongoOptions: { maxTimeMS }, + }); + adapter + .createObject('Foo', { fields: {} }, { objectId: 'abcde' }) + .then(() => adapter._rawFind('Foo', { $where: `sleep(${maxTimeMS / 2})` })) + .then( + () => done(), + err => { + done.fail(`maxTimeMS should not affect fast queries ${err}`); + } + ); + }); + + it('find fails when query exceeds maxTimeMS', done => { + const maxTimeMS = 250; + const adapter = new MongoStorageAdapter({ + uri: databaseURI, + mongoOptions: { maxTimeMS }, + }); + adapter + .createObject('Foo', { fields: {} }, { objectId: 'abcde' }) + .then(() => adapter._rawFind('Foo', { $where: `sleep(${maxTimeMS * 2})` })) + .then( + () => { + done.fail('Find succeeded despite taking too long!'); + }, + err => { + expect(err.name).toEqual('MongoServerError'); + expect(err.code).toEqual(50); + expect(err.message).toMatch('operation exceeded time limit'); + done(); + } + ); + }); + + it('passes batchSize to the MongoDB driver find() call', async () => { + const batchSize = 50; + const adapter = new MongoStorageAdapter({ + uri: databaseURI, + mongoOptions: { batchSize }, + }); + await adapter.createObject('BatchTest', { fields: {} }, { objectId: 'obj1' }); + + // Spy on the MongoDB driver's Collection.prototype.find to verify batchSize is forwarded + const originalFind = Collection.prototype.find; + let capturedOptions; + spyOn(Collection.prototype, 'find').and.callFake(function (query, options) { + capturedOptions = options; + return originalFind.call(this, query, options); + }); + + await adapter.find('BatchTest', { fields: {} }, {}, {}); + expect(capturedOptions).toBeDefined(); + expect(capturedOptions.batchSize).toEqual(50); + }); + + it('passes batchSize to the MongoDB driver aggregate() call', async () => { + const batchSize = 50; + const adapter = new MongoStorageAdapter({ + uri: databaseURI, + mongoOptions: { batchSize }, + }); + await adapter.createObject('AggBatchTest', { fields: { count: { type: 'Number' } } }, { objectId: 'obj1', count: 1 }); + + // Spy on the MongoDB driver's Collection.prototype.aggregate to verify batchSize is forwarded + const originalAggregate = Collection.prototype.aggregate; + let capturedOptions; + spyOn(Collection.prototype, 'aggregate').and.callFake(function (pipeline, options) { + capturedOptions = options; + return originalAggregate.call(this, pipeline, options); }); + + await adapter.aggregate('AggBatchTest', { fields: { count: { type: 'Number' } } }, [{ $match: {} }]); + expect(capturedOptions).toBeDefined(); + expect(capturedOptions.batchSize).toEqual(50); }); - it('stores pointers with a _p_ prefix', (done) => { - let obj = { + it('defaults batchSize to 1000', async () => { + await reconfigureServer({ + databaseURI: databaseURI, + collectionPrefix: 'test_', + databaseAdapter: undefined, + }); + const adapter = Config.get(Parse.applicationId).database.adapter; + expect(adapter._batchSize).toEqual(1000); + }); + + it('stores pointers with a _p_ prefix', done => { + const obj = { objectId: 'bar', aPointer: { __type: 'Pointer', className: 'JustThePointer', - objectId: 'qwerty' - } + objectId: 'qwerty', + }, }; - let adapter = new MongoStorageAdapter({ uri: databaseURI }); - adapter.createObject('APointerDarkly', { fields: { - objectId: { type: 'String' }, - aPointer: { type: 'Pointer', targetClass: 'JustThePointer' }, - }}, obj) - .then(() => adapter._rawFind('APointerDarkly', {})) - .then(results => { - expect(results.length).toEqual(1); - let output = results[0]; - expect(typeof output._id).toEqual('string'); - expect(typeof output._p_aPointer).toEqual('string'); - expect(output._p_aPointer).toEqual('JustThePointer$qwerty'); - expect(output.aPointer).toBeUndefined(); - done(); - }); + const adapter = new MongoStorageAdapter({ uri: databaseURI }); + adapter + .createObject( + 'APointerDarkly', + { + fields: { + objectId: { type: 'String' }, + aPointer: { type: 'Pointer', targetClass: 'JustThePointer' }, + }, + }, + obj + ) + .then(() => adapter._rawFind('APointerDarkly', {})) + .then(results => { + expect(results.length).toEqual(1); + const output = results[0]; + expect(typeof output._id).toEqual('string'); + expect(typeof output._p_aPointer).toEqual('string'); + expect(output._p_aPointer).toEqual('JustThePointer$qwerty'); + expect(output.aPointer).toBeUndefined(); + done(); + }); }); it('handles object and subdocument', done => { - let adapter = new MongoStorageAdapter({ uri: databaseURI }); - let schema = { fields : { subdoc: { type: 'Object' } } }; - let obj = { subdoc: {foo: 'bar', wu: 'tan'} }; - adapter.createObject('MyClass', schema, obj) - .then(() => adapter._rawFind('MyClass', {})) - .then(results => { - expect(results.length).toEqual(1); - let mob = results[0]; - expect(typeof mob.subdoc).toBe('object'); - expect(mob.subdoc.foo).toBe('bar'); - expect(mob.subdoc.wu).toBe('tan'); - let obj = { 'subdoc.wu': 'clan' }; - return adapter.findOneAndUpdate('MyClass', schema, {}, obj); - }) - .then(() => adapter._rawFind('MyClass', {})) - .then(results => { - expect(results.length).toEqual(1); - let mob = results[0]; - expect(typeof mob.subdoc).toBe('object'); - expect(mob.subdoc.foo).toBe('bar'); - expect(mob.subdoc.wu).toBe('clan'); - done(); - }); - }); - - it('handles array, object, date', (done) => { - let adapter = new MongoStorageAdapter({ uri: databaseURI }); - let obj = { + const adapter = new MongoStorageAdapter({ uri: databaseURI }); + const schema = { fields: { subdoc: { type: 'Object' } } }; + const obj = { subdoc: { foo: 'bar', wu: 'tan' } }; + adapter + .createObject('MyClass', schema, obj) + .then(() => adapter._rawFind('MyClass', {})) + .then(results => { + expect(results.length).toEqual(1); + const mob = results[0]; + expect(typeof mob.subdoc).toBe('object'); + expect(mob.subdoc.foo).toBe('bar'); + expect(mob.subdoc.wu).toBe('tan'); + const obj = { 'subdoc.wu': 'clan' }; + return adapter.findOneAndUpdate('MyClass', schema, {}, obj); + }) + .then(() => adapter._rawFind('MyClass', {})) + .then(results => { + expect(results.length).toEqual(1); + const mob = results[0]; + expect(typeof mob.subdoc).toBe('object'); + expect(mob.subdoc.foo).toBe('bar'); + expect(mob.subdoc.wu).toBe('clan'); + done(); + }); + }); + + it('handles creating an array, object, date', done => { + const adapter = new MongoStorageAdapter({ uri: databaseURI }); + const obj = { array: [1, 2, 3], - object: {foo: 'bar'}, + object: { foo: 'bar' }, date: { __type: 'Date', iso: '2016-05-26T20:55:01.154Z', }, }; - let schema = { fields: { - array: { type: 'Array' }, - object: { type: 'Object' }, - date: { type: 'Date' }, - } }; - adapter.createObject('MyClass', schema, obj) - .then(() => adapter._rawFind('MyClass', {})) - .then(results => { - expect(results.length).toEqual(1); - let mob = results[0]; - expect(mob.array instanceof Array).toBe(true); - expect(typeof mob.object).toBe('object'); - expect(mob.date instanceof Date).toBe(true); - return adapter.find('MyClass', schema, {}, {}); - }) - .then(results => { - expect(results.length).toEqual(1); - let mob = results[0]; - expect(mob.array instanceof Array).toBe(true); - expect(typeof mob.object).toBe('object'); - expect(mob.date.__type).toBe('Date'); - expect(mob.date.iso).toBe('2016-05-26T20:55:01.154Z'); - done(); - }) - .catch(error => { - console.log(error); - fail(); - done(); + const schema = { + fields: { + array: { type: 'Array' }, + object: { type: 'Object' }, + date: { type: 'Date' }, + }, + }; + adapter + .createObject('MyClass', schema, obj) + .then(() => adapter._rawFind('MyClass', {})) + .then(results => { + expect(results.length).toEqual(1); + const mob = results[0]; + expect(Array.isArray(mob.array)).toBe(true); + expect(typeof mob.object).toBe('object'); + expect(Utils.isDate(mob.date)).toBe(true); + return adapter.find('MyClass', schema, {}, {}); + }) + .then(results => { + expect(results.length).toEqual(1); + const mob = results[0]; + expect(Array.isArray(mob.array)).toBe(true); + expect(typeof mob.object).toBe('object'); + expect(mob.date.__type).toBe('Date'); + expect(mob.date.iso).toBe('2016-05-26T20:55:01.154Z'); + done(); + }) + .catch(error => { + console.log(error); + fail(); + done(); + }); + }); + + it('handles nested dates', async () => { + await new Parse.Object('MyClass', { + foo: { + test: { + date: new Date(), + }, + }, + bar: { + date: new Date(), + }, + date: new Date(), + }).save(); + const adapter = Config.get(Parse.applicationId).database.adapter; + const [object] = await adapter._rawFind('MyClass', {}); + expect(Utils.isDate(object.date)).toBeTrue(); + expect(Utils.isDate(object.bar.date)).toBeTrue(); + expect(Utils.isDate(object.foo.test.date)).toBeTrue(); + }); + + it('handles nested dates in array ', async () => { + await new Parse.Object('MyClass', { + foo: { + test: { + date: [new Date()], + }, + }, + bar: { + date: [new Date()], + }, + date: [new Date()], + }).save(); + const adapter = Config.get(Parse.applicationId).database.adapter; + const [object] = await adapter._rawFind('MyClass', {}); + expect(Utils.isDate(object.date[0])).toBeTrue(); + expect(Utils.isDate(object.bar.date[0])).toBeTrue(); + expect(Utils.isDate(object.foo.test.date[0])).toBeTrue(); + const obj = await new Parse.Query('MyClass').first({ useMasterKey: true }); + expect(Utils.isDate(obj.get('date')[0])).toBeTrue(); + expect(Utils.isDate(obj.get('bar').date[0])).toBeTrue(); + expect(Utils.isDate(obj.get('foo').test.date[0])).toBeTrue(); + }); + + it('upserts with $setOnInsert', async () => { + const uuid1 = uuidv4(); + const uuid2 = uuidv4(); + const schema = { + className: 'MyClass', + fields: { + x: { type: 'Number' }, + count: { type: 'Number' }, + }, + classLevelPermissions: {}, + }; + + const myClassSchema = new Parse.Schema(schema.className); + myClassSchema.setCLP(schema.classLevelPermissions); + await myClassSchema.save(); + + const query = { + x: 1, + }; + const update = { + objectId: { + __op: 'SetOnInsert', + amount: uuid1, + }, + count: { + __op: 'Increment', + amount: 1, + }, + }; + await Parse.Server.database.update('MyClass', query, update, { upsert: true }); + update.objectId.amount = uuid2; + await Parse.Server.database.update('MyClass', query, update, { upsert: true }); + + const res = await Parse.Server.database.find(schema.className, {}, {}); + expect(res.length).toBe(1); + expect(res[0].objectId).toBe(uuid1); + expect(res[0].count).toBe(2); + expect(res[0].x).toBe(1); + }); + + it('handles updating a single object with array, object date', done => { + const adapter = new MongoStorageAdapter({ uri: databaseURI }); + + const schema = { + fields: { + array: { type: 'Array' }, + object: { type: 'Object' }, + date: { type: 'Date' }, + }, + }; + + adapter + .createObject('MyClass', schema, {}) + .then(() => adapter._rawFind('MyClass', {})) + .then(results => { + expect(results.length).toEqual(1); + const update = { + array: [1, 2, 3], + object: { foo: 'bar' }, + date: { + __type: 'Date', + iso: '2016-05-26T20:55:01.154Z', + }, + }; + const query = {}; + return adapter.findOneAndUpdate('MyClass', schema, query, update); + }) + .then(results => { + const mob = results; + expect(Array.isArray(mob.array)).toBe(true); + expect(typeof mob.object).toBe('object'); + expect(mob.date.__type).toBe('Date'); + expect(mob.date.iso).toBe('2016-05-26T20:55:01.154Z'); + return adapter._rawFind('MyClass', {}); + }) + .then(results => { + expect(results.length).toEqual(1); + const mob = results[0]; + expect(Array.isArray(mob.array)).toBe(true); + expect(typeof mob.object).toBe('object'); + expect(Utils.isDate(mob.date)).toBe(true); + done(); + }) + .catch(error => { + console.log(error); + fail(); + done(); + }); + }); + + it('handleShutdown, close connection', async () => { + const adapter = new MongoStorageAdapter({ uri: databaseURI }); + + const schema = { + fields: { + array: { type: 'Array' }, + object: { type: 'Object' }, + date: { type: 'Date' }, + }, + }; + + await adapter.createObject('MyClass', schema, {}); + const status = await adapter.database.admin().serverStatus(); + expect(status.connections.current > 0).toEqual(true); + + await adapter.handleShutdown(); + try { + await adapter.database.admin().serverStatus(); + expect(false).toBe(true); + } catch (e) { + expect(e.message).toEqual('Client must be connected before running operations'); + } + }); + + it('getClass if exists', async () => { + const adapter = new MongoStorageAdapter({ uri: databaseURI }); + + const schema = { + fields: { + array: { type: 'Array' }, + object: { type: 'Object' }, + date: { type: 'Date' }, + }, + }; + + await adapter.createClass('MyClass', schema); + const myClassSchema = await adapter.getClass('MyClass'); + expect(myClassSchema).toBeDefined(); + }); + + it('getClass if not exists', async () => { + const adapter = new MongoStorageAdapter({ uri: databaseURI }); + await expectAsync(adapter.getClass('UnknownClass')).toBeRejectedWith(undefined); + }); + + it_only_mongodb_version('<5.1 || >=6')('should use index for caseInsensitive query', async () => { + const user = new Parse.User(); + user.set('username', 'Bugs'); + user.set('password', 'Bunny'); + await user.signUp(); + + const database = Config.get(Parse.applicationId).database; + await database.adapter.dropAllIndexes('_User'); + + const preIndexPlan = await database.find( + '_User', + { username: 'bugs' }, + { caseInsensitive: true, explain: true } + ); + + const schema = await new Parse.Schema('_User').get(); + + await database.adapter.ensureIndex( + '_User', + schema, + ['username'], + 'case_insensitive_username', + true + ); + + const postIndexPlan = await database.find( + '_User', + { username: 'bugs' }, + { caseInsensitive: true, explain: true } + ); + expect(preIndexPlan.executionStats.executionStages.stage).toBe('COLLSCAN'); + expect(postIndexPlan.executionStats.executionStages.stage).toBe('FETCH'); + }); + + it('should delete field without index', async () => { + const database = Config.get(Parse.applicationId).database; + const obj = new Parse.Object('MyObject'); + obj.set('test', 1); + await obj.save(); + const schemaBeforeDeletion = await new Parse.Schema('MyObject').get(); + await database.adapter.deleteFields('MyObject', schemaBeforeDeletion, ['test']); + const schemaAfterDeletion = await new Parse.Schema('MyObject').get(); + expect(schemaBeforeDeletion.fields.test).toBeDefined(); + expect(schemaAfterDeletion.fields.test).toBeUndefined(); + }); + + it('should delete field with index', async () => { + const database = Config.get(Parse.applicationId).database; + const obj = new Parse.Object('MyObject'); + obj.set('test', 1); + await obj.save(); + const schemaBeforeDeletion = await new Parse.Schema('MyObject').get(); + await database.adapter.ensureIndex('MyObject', schemaBeforeDeletion, ['test']); + await database.adapter.deleteFields('MyObject', schemaBeforeDeletion, ['test']); + const schemaAfterDeletion = await new Parse.Schema('MyObject').get(); + expect(schemaBeforeDeletion.fields.test).toBeDefined(); + expect(schemaAfterDeletion.fields.test).toBeUndefined(); + }); + + it('should create index with partialFilterExpression', async () => { + const database = Config.get(Parse.applicationId).database; + const adapter = database.adapter; + + const user = new Parse.User(); + user.set('username', 'testuser'); + user.set('password', 'testpass'); + await user.signUp(); + + const schema = await new Parse.Schema('_User').get(); + const partialFilterExpression = { _email_verify_token: { $exists: true } }; + + await adapter.ensureIndex('_User', schema, ['username'], 'partial_username_index', false, { + partialFilterExpression, + sparse: false, + }); + + const indexes = await adapter.getIndexes('_User'); + const createdIndex = indexes.find(idx => idx.name === 'partial_username_index'); + expect(createdIndex).toBeDefined(); + expect(createdIndex.partialFilterExpression).toEqual({ _email_verify_token: { $exists: true } }); + expect(createdIndex.sparse).toBeFalsy(); + }); + + if (process.env.MONGODB_TOPOLOGY === 'replicaset') { + describe('transactions', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + beforeEach(async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI: + 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase?replicaSet=replicaset', + }); + await TestUtils.destroyAllDataPermanently(true); + }); + + it('should use transaction in a batch with transaction = true', async () => { + const myObject = new Parse.Object('MyObject'); + await myObject.save(); + + spyOn(Collection.prototype, 'findOneAndUpdate').and.callThrough(); + + await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { + method: 'PUT', + path: '/1/classes/MyObject/' + myObject.id, + body: { myAttribute: 'myValue' }, + }, + ], + transaction: true, + }), + }); + + let found = false; + Collection.prototype.findOneAndUpdate.calls.all().forEach(call => { + found = true; + expect(call.args[2].session.transaction.state).toBe('TRANSACTION_COMMITTED'); + }); + expect(found).toBe(true); + }); + + it('should not use transaction in a batch with transaction = false', async () => { + const myObject = new Parse.Object('MyObject'); + await myObject.save(); + + spyOn(Collection.prototype, 'findOneAndUpdate').and.callThrough(); + + await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { + method: 'PUT', + path: '/1/classes/MyObject/' + myObject.id, + body: { myAttribute: 'myValue' }, + }, + ], + transaction: false, + }), + }); + + let found = false; + Collection.prototype.findOneAndUpdate.calls.all().forEach(call => { + found = true; + expect(call.args[2].session).toBeFalsy(); + }); + expect(found).toBe(true); + }); + + it('should not use transaction in a batch with no transaction option sent', async () => { + const myObject = new Parse.Object('MyObject'); + await myObject.save(); + + spyOn(Collection.prototype, 'findOneAndUpdate').and.callThrough(); + + await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { + method: 'PUT', + path: '/1/classes/MyObject/' + myObject.id, + body: { myAttribute: 'myValue' }, + }, + ], + }), + }); + + let found = false; + Collection.prototype.findOneAndUpdate.calls.all().forEach(call => { + found = true; + expect(call.args[2].session).toBeFalsy(); + }); + expect(found).toBe(true); + }); + + it('should not use transaction in a put request', async () => { + const myObject = new Parse.Object('MyObject'); + await myObject.save(); + + spyOn(Collection.prototype, 'findOneAndUpdate').and.callThrough(); + + await request({ + method: 'PUT', + headers: headers, + url: 'http://localhost:8378/1/classes/MyObject/' + myObject.id, + body: { myAttribute: 'myValue' }, + }); + + let found = false; + Collection.prototype.findOneAndUpdate.calls.all().forEach(call => { + found = true; + expect(call.args[2].session).toBeFalsy(); + }); + expect(found).toBe(true); + }); + + it('should not use transactions when using SDK insert', async () => { + spyOn(Collection.prototype, 'insertOne').and.callThrough(); + + const myObject = new Parse.Object('MyObject'); + await myObject.save(); + + const calls = Collection.prototype.insertOne.calls.all(); + expect(calls.length).toBeGreaterThan(0); + calls.forEach(call => { + expect(call.args[1].session).toBeFalsy(); + }); + }); + + it('should not use transactions when using SDK update', async () => { + spyOn(Collection.prototype, 'findOneAndUpdate').and.callThrough(); + + const myObject = new Parse.Object('MyObject'); + await myObject.save(); + + myObject.set('myAttribute', 'myValue'); + await myObject.save(); + + const calls = Collection.prototype.findOneAndUpdate.calls.all(); + expect(calls.length).toBeGreaterThan(0); + calls.forEach(call => { + expect(call.args[2].session).toBeFalsy(); + }); + }); + + it('should not use transactions when using SDK delete', async () => { + spyOn(Collection.prototype, 'deleteMany').and.callThrough(); + + const myObject = new Parse.Object('MyObject'); + await myObject.save(); + + await myObject.destroy(); + + const calls = Collection.prototype.deleteMany.calls.all(); + expect(calls.length).toBeGreaterThan(0); + calls.forEach(call => { + expect(call.args[1].session).toBeFalsy(); + }); + }); + }); + + describe('watch _SCHEMA', () => { + it('should change', async done => { + const adapter = new MongoStorageAdapter({ + uri: databaseURI, + collectionPrefix: '', + mongoOptions: { enableSchemaHooks: true }, + }); + await reconfigureServer({ databaseAdapter: adapter }); + expect(adapter.enableSchemaHooks).toBe(true); + spyOn(adapter, '_onchange'); + const schema = { + fields: { + array: { type: 'Array' }, + object: { type: 'Object' }, + date: { type: 'Date' }, + }, + }; + + await adapter.createClass('Stuff', schema); + const myClassSchema = await adapter.getClass('Stuff'); + expect(myClassSchema).toBeDefined(); + setTimeout(() => { + expect(adapter._onchange).toHaveBeenCalled(); + done(); + }, 5000); + }); + }); + } + + describe('index creation options', () => { + beforeEach(async () => { + await new MongoStorageAdapter({ uri: databaseURI }).deleteAllClasses(); + }); + + async function getIndexes(collectionName) { + const adapter = Config.get(Parse.applicationId).database.adapter; + const collections = await adapter.database.listCollections({ name: collectionName }).toArray(); + if (collections.length === 0) { + return []; + } + return await adapter.database.collection(collectionName).indexes(); + } + + it('should skip username index when createIndexUserUsername is false', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserUsername: false }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === 'username_1')).toBeUndefined(); + }); + + it('should create username index when createIndexUserUsername is true', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserUsername: true }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === 'username_1')).toBeDefined(); + }); + + it('should skip case-insensitive username index when createIndexUserUsernameCaseInsensitive is false', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserUsernameCaseInsensitive: false }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === 'case_insensitive_username')).toBeUndefined(); + }); + + it('should create case-insensitive username index when createIndexUserUsernameCaseInsensitive is true', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserUsernameCaseInsensitive: true }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === 'case_insensitive_username')).toBeDefined(); + }); + + it('should skip email index when createIndexUserEmail is false', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserEmail: false }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === 'email_1')).toBeUndefined(); + }); + + it('should create email index when createIndexUserEmail is true', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserEmail: true }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === 'email_1')).toBeDefined(); + }); + + it('should skip case-insensitive email index when createIndexUserEmailCaseInsensitive is false', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserEmailCaseInsensitive: false }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === 'case_insensitive_email')).toBeUndefined(); + }); + + it('should create case-insensitive email index when createIndexUserEmailCaseInsensitive is true', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserEmailCaseInsensitive: true }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === 'case_insensitive_email')).toBeDefined(); + }); + + it('should skip email verify token index when createIndexUserEmailVerifyToken is false', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserEmailVerifyToken: false }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === '_email_verify_token' || idx.name === '_email_verify_token_1')).toBeUndefined(); + }); + + it('should create email verify token index when createIndexUserEmailVerifyToken is true', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserEmailVerifyToken: true }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === '_email_verify_token' || idx.name === '_email_verify_token_1')).toBeDefined(); + }); + + it('should skip password reset token index when createIndexUserPasswordResetToken is false', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserPasswordResetToken: false }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === '_perishable_token' || idx.name === '_perishable_token_1')).toBeUndefined(); + }); + + it('should create password reset token index when createIndexUserPasswordResetToken is true', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexUserPasswordResetToken: true }, + }); + const indexes = await getIndexes('_User'); + expect(indexes.find(idx => idx.name === '_perishable_token' || idx.name === '_perishable_token_1')).toBeDefined(); + }); + + it('should skip role name index when createIndexRoleName is false', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexRoleName: false }, + }); + const indexes = await getIndexes('_Role'); + expect(indexes.find(idx => idx.name === 'name_1')).toBeUndefined(); + }); + + it('should create role name index when createIndexRoleName is true', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: { createIndexRoleName: true }, + }); + const indexes = await getIndexes('_Role'); + expect(indexes.find(idx => idx.name === 'name_1')).toBeDefined(); + }); + + it('should create all indexes by default when options are undefined', async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + databaseOptions: {}, + }); + + const userIndexes = await getIndexes('_User'); + const roleIndexes = await getIndexes('_Role'); + + // Verify all indexes are created with default behavior (backward compatibility) + expect(userIndexes.find(idx => idx.name === 'username_1')).toBeDefined(); + expect(userIndexes.find(idx => idx.name === 'case_insensitive_username')).toBeDefined(); + expect(userIndexes.find(idx => idx.name === 'email_1')).toBeDefined(); + expect(userIndexes.find(idx => idx.name === 'case_insensitive_email')).toBeDefined(); + expect(userIndexes.find(idx => idx.name === '_email_verify_token' || idx.name === '_email_verify_token_1')).toBeDefined(); + expect(userIndexes.find(idx => idx.name === '_perishable_token' || idx.name === '_perishable_token_1')).toBeDefined(); + expect(roleIndexes.find(idx => idx.name === 'name_1')).toBeDefined(); + }); + }); + + describe('logClientEvents', () => { + it('should log MongoDB client events when configured', async () => { + const logger = require('../lib/logger').logger; + const logSpy = spyOn(logger, 'warn'); + + const logClientEvents = [ + { + name: 'serverDescriptionChanged', + keys: ['address'], + logLevel: 'warn', + }, + ]; + + const adapter = new MongoStorageAdapter({ + uri: databaseURI, + mongoOptions: { logClientEvents }, + }); + + // Connect to trigger event listeners setup + await adapter.connect(); + + // Manually trigger the event to test the listener + const mockEvent = { + address: 'localhost:27017', + previousDescription: { type: 'Unknown' }, + newDescription: { type: 'Standalone' }, + }; + + adapter.client.emit('serverDescriptionChanged', mockEvent); + + // Verify the log was called with the correct message + expect(logSpy).toHaveBeenCalledWith( + jasmine.stringMatching(/MongoDB client event serverDescriptionChanged:.*"address":"localhost:27017"/) + ); + + await adapter.handleShutdown(); + }); + + it('should log entire event when keys are not specified', async () => { + const logger = require('../lib/logger').logger; + const logSpy = spyOn(logger, 'info'); + + const logClientEvents = [ + { + name: 'connectionPoolReady', + logLevel: 'info', + }, + ]; + + const adapter = new MongoStorageAdapter({ + uri: databaseURI, + mongoOptions: { logClientEvents }, + }); + + await adapter.connect(); + + const mockEvent = { + address: 'localhost:27017', + options: { maxPoolSize: 100 }, + }; + + adapter.client.emit('connectionPoolReady', mockEvent); + + expect(logSpy).toHaveBeenCalledWith( + jasmine.stringMatching(/MongoDB client event connectionPoolReady:.*"address":"localhost:27017".*"options"/) + ); + + await adapter.handleShutdown(); + }); + + it('should extract nested keys using dot notation', async () => { + const logger = require('../lib/logger').logger; + const logSpy = spyOn(logger, 'warn'); + + const logClientEvents = [ + { + name: 'topologyDescriptionChanged', + keys: ['previousDescription.type', 'newDescription.type', 'newDescription.servers.size'], + logLevel: 'warn', + }, + ]; + + const adapter = new MongoStorageAdapter({ + uri: databaseURI, + mongoOptions: { logClientEvents }, + }); + + await adapter.connect(); + + const mockEvent = { + topologyId: 1, + previousDescription: { type: 'Unknown' }, + newDescription: { + type: 'ReplicaSetWithPrimary', + servers: { size: 3 }, + }, + }; + + adapter.client.emit('topologyDescriptionChanged', mockEvent); + + expect(logSpy).toHaveBeenCalledWith( + jasmine.stringMatching(/MongoDB client event topologyDescriptionChanged:.*"previousDescription.type":"Unknown".*"newDescription.type":"ReplicaSetWithPrimary".*"newDescription.servers.size":3/) + ); + + await adapter.handleShutdown(); + }); + + it('should handle invalid log level gracefully', async () => { + const logger = require('../lib/logger').logger; + const infoSpy = spyOn(logger, 'info'); + + const logClientEvents = [ + { + name: 'connectionPoolReady', + keys: ['address'], + logLevel: 'invalidLogLevel', // Invalid log level + }, + ]; + + const adapter = new MongoStorageAdapter({ + uri: databaseURI, + mongoOptions: { logClientEvents }, + }); + + await adapter.connect(); + + const mockEvent = { + address: 'localhost:27017', + }; + + adapter.client.emit('connectionPoolReady', mockEvent); + + // Should fallback to 'info' level + expect(infoSpy).toHaveBeenCalledWith( + jasmine.stringMatching(/MongoDB client event connectionPoolReady:.*"address":"localhost:27017"/) + ); + + await adapter.handleShutdown(); + }); + + it('should handle Map and Set instances in events', async () => { + const logger = require('../lib/logger').logger; + const warnSpy = spyOn(logger, 'warn'); + + const logClientEvents = [ + { + name: 'customEvent', + logLevel: 'warn', + }, + ]; + + const adapter = new MongoStorageAdapter({ + uri: databaseURI, + mongoOptions: { logClientEvents }, + }); + + await adapter.connect(); + + const mockEvent = { + mapData: new Map([['key1', 'value1'], ['key2', 'value2']]), + setData: new Set([1, 2, 3]), + }; + + adapter.client.emit('customEvent', mockEvent); + + // Should serialize Map and Set properly + expect(warnSpy).toHaveBeenCalledWith( + jasmine.stringMatching(/MongoDB client event customEvent:.*"mapData":\{"key1":"value1","key2":"value2"\}.*"setData":\[1,2,3\]/) + ); + + await adapter.handleShutdown(); + }); + + it('should handle missing keys in event object', async () => { + const logger = require('../lib/logger').logger; + const infoSpy = spyOn(logger, 'info'); + + const logClientEvents = [ + { + name: 'testEvent', + keys: ['nonexistent.nested.key', 'another.missing'], + logLevel: 'info', + }, + ]; + + const adapter = new MongoStorageAdapter({ + uri: databaseURI, + mongoOptions: { logClientEvents }, + }); + + await adapter.connect(); + + const mockEvent = { + actualField: 'value', + }; + + adapter.client.emit('testEvent', mockEvent); + + // Should handle missing keys gracefully with undefined values + expect(infoSpy).toHaveBeenCalledWith( + jasmine.stringMatching(/MongoDB client event testEvent:/) + ); + + await adapter.handleShutdown(); + }); + + it('should handle circular references gracefully', async () => { + const logger = require('../lib/logger').logger; + const infoSpy = spyOn(logger, 'info'); + + const logClientEvents = [ + { + name: 'circularEvent', + logLevel: 'info', + }, + ]; + + const adapter = new MongoStorageAdapter({ + uri: databaseURI, + mongoOptions: { logClientEvents }, + }); + + await adapter.connect(); + + // Create circular reference + const mockEvent = { name: 'test' }; + mockEvent.self = mockEvent; + + adapter.client.emit('circularEvent', mockEvent); + + // Should handle circular reference with [Circular] marker + expect(infoSpy).toHaveBeenCalledWith( + jasmine.stringMatching(/MongoDB client event circularEvent:.*\[Circular\]/) + ); + + await adapter.handleShutdown(); + }); + }); + + describe('transient error handling', () => { + it('should transform MongoWaitQueueTimeoutError to Parse.Error.INTERNAL_SERVER_ERROR', async () => { + const adapter = new MongoStorageAdapter({ uri: databaseURI }); + await adapter.connect(); + + // Create a mock error with the MongoWaitQueueTimeoutError name + const mockError = new Error('Timed out while checking out a connection from connection pool'); + mockError.name = 'MongoWaitQueueTimeoutError'; + + try { + adapter.handleError(mockError); + fail('Expected handleError to throw'); + } catch (error) { + expect(error instanceof Parse.Error).toBe(true); + expect(error.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR); + expect(error.message).toBe('Database error'); + } + }); + + it('should transform MongoServerSelectionError to Parse.Error.INTERNAL_SERVER_ERROR', async () => { + const adapter = new MongoStorageAdapter({ uri: databaseURI }); + await adapter.connect(); + + const mockError = new Error('Server selection timed out'); + mockError.name = 'MongoServerSelectionError'; + + try { + adapter.handleError(mockError); + fail('Expected handleError to throw'); + } catch (error) { + expect(error instanceof Parse.Error).toBe(true); + expect(error.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR); + expect(error.message).toBe('Database error'); + } + }); + + it('should transform MongoNetworkTimeoutError to Parse.Error.INTERNAL_SERVER_ERROR', async () => { + const adapter = new MongoStorageAdapter({ uri: databaseURI }); + await adapter.connect(); + + const mockError = new Error('Network timeout'); + mockError.name = 'MongoNetworkTimeoutError'; + + try { + adapter.handleError(mockError); + fail('Expected handleError to throw'); + } catch (error) { + expect(error instanceof Parse.Error).toBe(true); + expect(error.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR); + expect(error.message).toBe('Database error'); + } + }); + + it('should transform MongoNetworkError to Parse.Error.INTERNAL_SERVER_ERROR', async () => { + const adapter = new MongoStorageAdapter({ uri: databaseURI }); + await adapter.connect(); + + const mockError = new Error('Network error'); + mockError.name = 'MongoNetworkError'; + + try { + adapter.handleError(mockError); + fail('Expected handleError to throw'); + } catch (error) { + expect(error instanceof Parse.Error).toBe(true); + expect(error.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR); + expect(error.message).toBe('Database error'); + } + }); + + it('should transform TransientTransactionError to Parse.Error.INTERNAL_SERVER_ERROR', async () => { + const adapter = new MongoStorageAdapter({ uri: databaseURI }); + await adapter.connect(); + + const mockError = new Error('Transient transaction error'); + mockError.hasErrorLabel = label => label === 'TransientTransactionError'; + + try { + adapter.handleError(mockError); + fail('Expected handleError to throw'); + } catch (error) { + expect(error instanceof Parse.Error).toBe(true); + expect(error.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR); + expect(error.message).toBe('Database error'); + } + }); + + it('should not transform non-transient errors', async () => { + const adapter = new MongoStorageAdapter({ uri: databaseURI }); + await adapter.connect(); + + const mockError = new Error('Some other error'); + mockError.name = 'SomeOtherError'; + + try { + adapter.handleError(mockError); + fail('Expected handleError to throw'); + } catch (error) { + expect(error instanceof Parse.Error).toBe(false); + expect(error.message).toBe('Some other error'); + } + }); + + it('should handle null/undefined errors', async () => { + const adapter = new MongoStorageAdapter({ uri: databaseURI }); + await adapter.connect(); + + try { + adapter.handleError(null); + fail('Expected handleError to throw'); + } catch (error) { + expect(error).toBeNull(); + } + + try { + adapter.handleError(undefined); + fail('Expected handleError to throw'); + } catch (error) { + expect(error).toBeUndefined(); + } + }); + }); + + describe('MongoDB Client Metadata', () => { + it('should not pass metadata to MongoClient by default', async () => { + const adapter = new MongoStorageAdapter({ uri: databaseURI }); + await adapter.connect(); + const driverInfo = adapter.client.s.options.driverInfo; + // Either driverInfo should be undefined, or it should not contain our custom metadata + if (driverInfo) { + expect(driverInfo.name).toBeUndefined(); + } + await adapter.handleShutdown(); + }); + + it('should pass custom metadata to MongoClient when configured', async () => { + const customMetadata = { name: 'MyParseServer', version: '1.0.0' }; + const adapter = new MongoStorageAdapter({ + uri: databaseURI, + mongoOptions: { clientMetadata: customMetadata } + }); + await adapter.connect(); + expect(adapter.client.s.options.driverInfo.name).toBe(customMetadata.name); + expect(adapter.client.s.options.driverInfo.version).toBe(customMetadata.version); + await adapter.handleShutdown(); }); }); }); diff --git a/spec/MongoTransform.spec.js b/spec/MongoTransform.spec.js index 6a6ba9f7b2..a3c61214a8 100644 --- a/spec/MongoTransform.spec.js +++ b/spec/MongoTransform.spec.js @@ -1,245 +1,398 @@ // These tests are unit tests designed to only test transform.js. -"use strict"; +'use strict'; -let transform = require('../src/Adapters/Storage/Mongo/MongoTransform'); -let dd = require('deep-diff'); -let mongodb = require('mongodb'); +const transform = require('../lib/Adapters/Storage/Mongo/MongoTransform'); +const dd = require('deep-diff'); +const mongodb = require('mongodb'); +const Utils = require('../lib/Utils'); describe('parseObjectToMongoObjectForCreate', () => { - it('a basic number', (done) => { - var input = {five: 5}; - var output = transform.parseObjectToMongoObjectForCreate(null, input, { - fields: {five: {type: 'Number'}} + it('a basic number', done => { + const input = { five: 5 }; + const output = transform.parseObjectToMongoObjectForCreate(null, input, { + fields: { five: { type: 'Number' } }, }); jequal(input, output); done(); }); - it('an object with null values', (done) => { - var input = {objectWithNullValues: {isNull: null, notNull: 3}}; - var output = transform.parseObjectToMongoObjectForCreate(null, input, { - fields: {objectWithNullValues: {type: 'object'}} + it('an object with null values', done => { + const input = { objectWithNullValues: { isNull: null, notNull: 3 } }; + const output = transform.parseObjectToMongoObjectForCreate(null, input, { + fields: { objectWithNullValues: { type: 'object' } }, }); jequal(input, output); done(); }); - it('built-in timestamps', (done) => { - var input = { - createdAt: "2015-10-06T21:24:50.332Z", - updatedAt: "2015-10-06T21:24:50.332Z" + it('built-in timestamps with date', done => { + const input = { + createdAt: '2015-10-06T21:24:50.332Z', + updatedAt: '2015-10-06T21:24:50.332Z', }; - var output = transform.parseObjectToMongoObjectForCreate(null, input, { fields: {} }); - expect(output._created_at instanceof Date).toBe(true); - expect(output._updated_at instanceof Date).toBe(true); + const output = transform.parseObjectToMongoObjectForCreate(null, input, { + fields: {}, + }); + expect(Utils.isDate(output._created_at)).toBe(true); + expect(Utils.isDate(output._updated_at)).toBe(true); done(); }); - it('array of pointers', (done) => { - var pointer = { + it('array of pointers', done => { + const pointer = { __type: 'Pointer', objectId: 'myId', className: 'Blah', }; - var out = transform.parseObjectToMongoObjectForCreate(null, {pointers: [pointer]},{ - fields: {pointers: {type: 'Array'}} - }); + const out = transform.parseObjectToMongoObjectForCreate( + null, + { pointers: [pointer] }, + { + fields: { pointers: { type: 'Array' } }, + } + ); jequal([pointer], out.pointers); done(); }); //TODO: object creation requests shouldn't be seeing __op delete, it makes no sense to //have __op delete in a new object. Figure out what this should actually be testing. - xit('a delete op', (done) => { - var input = {deleteMe: {__op: 'Delete'}}; - var output = transform.parseObjectToMongoObjectForCreate(null, input, { fields: {} }); + xit('a delete op', done => { + const input = { deleteMe: { __op: 'Delete' } }; + const output = transform.parseObjectToMongoObjectForCreate(null, input, { + fields: {}, + }); jequal(output, {}); done(); }); it('Doesnt allow ACL, as Parse Server should tranform ACL to _wperm + _rperm', done => { - var input = {ACL: {'0123': {'read': true, 'write': true}}}; - expect(() => transform.parseObjectToMongoObjectForCreate(null, input, { fields: {} })).toThrow(); + const input = { ACL: { '0123': { read: true, write: true } } }; + expect(() => + transform.parseObjectToMongoObjectForCreate(null, input, { fields: {} }) + ).toThrow(); done(); }); - it('plain', (done) => { - var geoPoint = {__type: 'GeoPoint', longitude: 180, latitude: -180}; - var out = transform.parseObjectToMongoObjectForCreate(null, {location: geoPoint},{ - fields: {location: {type: 'GeoPoint'}} - }); - expect(out.location).toEqual([180, -180]); + it('parse geopoint to mongo', done => { + const lat = -45; + const lng = 45; + const geoPoint = { __type: 'GeoPoint', latitude: lat, longitude: lng }; + const out = transform.parseObjectToMongoObjectForCreate( + null, + { location: geoPoint }, + { + fields: { location: { type: 'GeoPoint' } }, + } + ); + expect(out.location).toEqual([lng, lat]); done(); }); - it('in array', (done) => { - var geoPoint = {__type: 'GeoPoint', longitude: 180, latitude: -180}; - var out = transform.parseObjectToMongoObjectForCreate(null, {locations: [geoPoint, geoPoint]},{ - fields: {locations: {type: 'Array'}} - }); + it('parse polygon to mongo', done => { + const lat1 = -45; + const lng1 = 45; + const lat2 = -55; + const lng2 = 55; + const lat3 = -65; + const lng3 = 65; + const polygon = { + __type: 'Polygon', + coordinates: [ + [lat1, lng1], + [lat2, lng2], + [lat3, lng3], + ], + }; + const out = transform.parseObjectToMongoObjectForCreate( + null, + { location: polygon }, + { + fields: { location: { type: 'Polygon' } }, + } + ); + expect(out.location.coordinates).toEqual([ + [ + [lng1, lat1], + [lng2, lat2], + [lng3, lat3], + [lng1, lat1], + ], + ]); + done(); + }); + + it('in array', done => { + const geoPoint = { __type: 'GeoPoint', longitude: 180, latitude: -180 }; + const out = transform.parseObjectToMongoObjectForCreate( + null, + { locations: [geoPoint, geoPoint] }, + { + fields: { locations: { type: 'Array' } }, + } + ); expect(out.locations).toEqual([geoPoint, geoPoint]); done(); }); - it('in sub-object', (done) => { - var geoPoint = {__type: 'GeoPoint', longitude: 180, latitude: -180}; - var out = transform.parseObjectToMongoObjectForCreate(null, { locations: { start: geoPoint }},{ - fields: {locations: {type: 'Object'}} - }); + it('in sub-object', done => { + const geoPoint = { __type: 'GeoPoint', longitude: 180, latitude: -180 }; + const out = transform.parseObjectToMongoObjectForCreate( + null, + { locations: { start: geoPoint } }, + { + fields: { locations: { type: 'Object' } }, + } + ); expect(out).toEqual({ locations: { start: geoPoint } }); done(); }); - it('objectId', (done) => { - var out = transform.transformWhere(null, {objectId: 'foo'}); + it('objectId', done => { + const out = transform.transformWhere(null, { objectId: 'foo' }); expect(out._id).toEqual('foo'); done(); }); - it('objectId in a list', (done) => { - var input = { - objectId: {'$in': ['one', 'two', 'three']}, + it('objectId in a list', done => { + const input = { + objectId: { $in: ['one', 'two', 'three'] }, }; - var output = transform.transformWhere(null, input); + const output = transform.transformWhere(null, input); jequal(input.objectId, output._id); done(); }); - it('built-in timestamps', (done) => { - var input = {createdAt: new Date(), updatedAt: new Date()}; - var output = transform.mongoObjectToParseObject(null, input, { fields: {} }); + it('built-in timestamps', done => { + const input = { createdAt: new Date(), updatedAt: new Date() }; + const output = transform.mongoObjectToParseObject(null, input, { + fields: {}, + }); expect(typeof output.createdAt).toEqual('string'); expect(typeof output.updatedAt).toEqual('string'); done(); }); - it('pointer', (done) => { - var input = {_p_userPointer: '_User$123'}; - var output = transform.mongoObjectToParseObject(null, input, { + it('pointer', done => { + const input = { _p_userPointer: '_User$123' }; + const output = transform.mongoObjectToParseObject(null, input, { fields: { userPointer: { type: 'Pointer', targetClass: '_User' } }, }); expect(typeof output.userPointer).toEqual('object'); - expect(output.userPointer).toEqual( - {__type: 'Pointer', className: '_User', objectId: '123'} - ); + expect(output.userPointer).toEqual({ + __type: 'Pointer', + className: '_User', + objectId: '123', + }); done(); }); - it('null pointer', (done) => { - var input = {_p_userPointer: null}; - var output = transform.mongoObjectToParseObject(null, input, { + it('null pointer', done => { + const input = { _p_userPointer: null }; + const output = transform.mongoObjectToParseObject(null, input, { fields: { userPointer: { type: 'Pointer', targetClass: '_User' } }, }); expect(output.userPointer).toBeUndefined(); done(); }); - it('file', (done) => { - var input = {picture: 'pic.jpg'}; - var output = transform.mongoObjectToParseObject(null, input, { - fields: { picture: { type: 'File' }}, + it('file', done => { + const input = { picture: 'pic.jpg' }; + const output = transform.mongoObjectToParseObject(null, input, { + fields: { picture: { type: 'File' } }, }); expect(typeof output.picture).toEqual('object'); - expect(output.picture).toEqual({__type: 'File', name: 'pic.jpg'}); + expect(output.picture).toEqual({ __type: 'File', name: 'pic.jpg' }); done(); }); - it('geopoint', (done) => { - var input = {location: [180, -180]}; - var output = transform.mongoObjectToParseObject(null, input, { - fields: { location: { type: 'GeoPoint' }}, + it('mongo geopoint to parse', done => { + const lat = -45; + const lng = 45; + const input = { location: [lng, lat] }; + const output = transform.mongoObjectToParseObject(null, input, { + fields: { location: { type: 'GeoPoint' } }, }); expect(typeof output.location).toEqual('object'); - expect(output.location).toEqual( - {__type: 'GeoPoint', longitude: 180, latitude: -180} - ); + expect(output.location).toEqual({ + __type: 'GeoPoint', + latitude: lat, + longitude: lng, + }); done(); }); - it('nested array', (done) => { - var input = {arr: [{_testKey: 'testValue' }]}; - var output = transform.mongoObjectToParseObject(null, input, { + it('mongo polygon to parse', done => { + const lat = -45; + const lng = 45; + // Mongo stores polygon in WGS84 lng/lat + const input = { + location: { + type: 'Polygon', + coordinates: [ + [ + [lat, lng], + [lat, lng], + ], + ], + }, + }; + const output = transform.mongoObjectToParseObject(null, input, { + fields: { location: { type: 'Polygon' } }, + }); + expect(typeof output.location).toEqual('object'); + expect(output.location).toEqual({ + __type: 'Polygon', + coordinates: [ + [lng, lat], + [lng, lat], + ], + }); + done(); + }); + + it('bytes', done => { + const input = { binaryData: 'aGVsbG8gd29ybGQ=' }; + const output = transform.mongoObjectToParseObject(null, input, { + fields: { binaryData: { type: 'Bytes' } }, + }); + expect(typeof output.binaryData).toEqual('object'); + expect(output.binaryData).toEqual({ + __type: 'Bytes', + base64: 'aGVsbG8gd29ybGQ=', + }); + done(); + }); + + it('nested array', done => { + const input = { arr: [{ _testKey: 'testValue' }] }; + const output = transform.mongoObjectToParseObject(null, input, { fields: { arr: { type: 'Array' } }, }); expect(Array.isArray(output.arr)).toEqual(true); - expect(output.arr).toEqual([{ _testKey: 'testValue'}]); + expect(output.arr).toEqual([{ _testKey: 'testValue' }]); done(); }); it('untransforms objects containing nested special keys', done => { - let input = {array: [{ - _id: "Test ID", - _hashed_password: "I Don't know why you would name a key this, but if you do it should work", - _tombstone: { - _updated_at: "I'm sure people will nest keys like this", - _acl: 7, - _id: { someString: "str", someNumber: 7}, - regularKey: { moreContents: [1, 2, 3] }, - }, - regularKey: "some data", - }]} - let output = transform.mongoObjectToParseObject(null, input, { - fields: { array: { type: 'Array' }}, + const input = { + array: [ + { + _id: 'Test ID', + _hashed_password: + "I Don't know why you would name a key this, but if you do it should work", + _tombstone: { + _updated_at: "I'm sure people will nest keys like this", + _acl: 7, + _id: { someString: 'str', someNumber: 7 }, + regularKey: { moreContents: [1, 2, 3] }, + }, + regularKey: 'some data', + }, + ], + }; + const output = transform.mongoObjectToParseObject(null, input, { + fields: { array: { type: 'Array' } }, }); expect(dd(output, input)).toEqual(undefined); done(); }); - it('changes new pointer key', (done) => { - var input = { - somePointer: {__type: 'Pointer', className: 'Micro', objectId: 'oft'} + it('changes new pointer key', done => { + const input = { + somePointer: { __type: 'Pointer', className: 'Micro', objectId: 'oft' }, }; - var output = transform.parseObjectToMongoObjectForCreate(null, input, { - fields: {somePointer: {type: 'Pointer'}} + const output = transform.parseObjectToMongoObjectForCreate(null, input, { + fields: { somePointer: { type: 'Pointer' } }, }); expect(typeof output._p_somePointer).toEqual('string'); expect(output._p_somePointer).toEqual('Micro$oft'); done(); }); - it('changes existing pointer keys', (done) => { - var input = { - userPointer: {__type: 'Pointer', className: '_User', objectId: 'qwerty'} + it('changes existing pointer keys', done => { + const input = { + userPointer: { + __type: 'Pointer', + className: '_User', + objectId: 'qwerty', + }, }; - var output = transform.parseObjectToMongoObjectForCreate(null, input, { - fields: {userPointer: {type: 'Pointer'}} + const output = transform.parseObjectToMongoObjectForCreate(null, input, { + fields: { userPointer: { type: 'Pointer' } }, }); expect(typeof output._p_userPointer).toEqual('string'); expect(output._p_userPointer).toEqual('_User$qwerty'); done(); }); - it('writes the old ACL format in addition to rperm and wperm', (done) => { - var input = { + it('writes the old ACL format in addition to rperm and wperm on create', done => { + const input = { _rperm: ['*'], _wperm: ['Kevin'], }; - var output = transform.parseObjectToMongoObjectForCreate(null, input, { fields: {} }); + const output = transform.parseObjectToMongoObjectForCreate(null, input, { + fields: {}, + }); expect(typeof output._acl).toEqual('object'); - expect(output._acl["Kevin"].w).toBeTruthy(); - expect(output._acl["Kevin"].r).toBeUndefined(); + expect(output._acl['Kevin'].w).toBeTruthy(); + expect(output._acl['Kevin'].r).toBeUndefined(); + expect(output._rperm).toEqual(input._rperm); + expect(output._wperm).toEqual(input._wperm); done(); - }) + }); - it('untransforms from _rperm and _wperm to ACL', (done) => { - var input = { - _rperm: ["*"], - _wperm: ["Kevin"] + it('removes Relation types', done => { + const input = { + aRelation: { __type: 'Relation', className: 'Stuff' }, }; - var output = transform.mongoObjectToParseObject(null, input, { fields: {} }); + const output = transform.parseObjectToMongoObjectForCreate(null, input, { + fields: { + aRelation: { __type: 'Relation', className: 'Stuff' }, + }, + }); + expect(output).toEqual({}); + done(); + }); + + it('writes the old ACL format in addition to rperm and wperm on update', done => { + const input = { + _rperm: ['*'], + _wperm: ['Kevin'], + }; + + const output = transform.transformUpdate(null, input, { fields: {} }); + const set = output.$set; + expect(typeof set).toEqual('object'); + expect(typeof set._acl).toEqual('object'); + expect(set._acl['Kevin'].w).toBeTruthy(); + expect(set._acl['Kevin'].r).toBeUndefined(); + expect(set._rperm).toEqual(input._rperm); + expect(set._wperm).toEqual(input._wperm); + done(); + }); + + it('untransforms from _rperm and _wperm to ACL', done => { + const input = { + _rperm: ['*'], + _wperm: ['Kevin'], + }; + const output = transform.mongoObjectToParseObject(null, input, { + fields: {}, + }); expect(output._rperm).toEqual(['*']); expect(output._wperm).toEqual(['Kevin']); - expect(output.ACL).toBeUndefined() + expect(output.ACL).toBeUndefined(); done(); }); - it('untransforms mongodb number types', (done) => { - var input = { + it('untransforms mongodb number types', done => { + const input = { long: mongodb.Long.fromNumber(Number.MAX_SAFE_INTEGER), - double: new mongodb.Double(Number.MAX_VALUE) - } - var output = transform.mongoObjectToParseObject(null, input, { + double: new mongodb.Double(Number.MAX_VALUE), + }; + const output = transform.mongoObjectToParseObject(null, input, { fields: { long: { type: 'Number' }, double: { type: 'Number' }, @@ -250,4 +403,294 @@ describe('parseObjectToMongoObjectForCreate', () => { done(); }); + it('Date object where iso attribute is of type Date', done => { + const input = { + ts: { __type: 'Date', iso: new Date('2017-01-18T00:00:00.000Z') }, + }; + const output = transform.mongoObjectToParseObject(null, input, { + fields: { + ts: { type: 'Date' }, + }, + }); + expect(output.ts.iso).toEqual('2017-01-18T00:00:00.000Z'); + done(); + }); + + it('Date object where iso attribute is of type String', done => { + const input = { + ts: { __type: 'Date', iso: '2017-01-18T00:00:00.000Z' }, + }; + const output = transform.mongoObjectToParseObject(null, input, { + fields: { + ts: { type: 'Date' }, + }, + }); + expect(output.ts.iso).toEqual('2017-01-18T00:00:00.000Z'); + done(); + }); + + it('object with undefined nested values', () => { + const input = { + _id: 'vQHyinCW1l', + urls: { firstUrl: 'https://', secondUrl: undefined }, + }; + const output = transform.mongoObjectToParseObject(null, input, { + fields: { + urls: { type: 'Object' }, + }, + }); + expect(output.urls).toEqual({ + firstUrl: 'https://', + secondUrl: undefined, + }); + }); + + it('undefined objects', () => { + const input = { + _id: 'vQHyinCW1l', + urls: undefined, + }; + const output = transform.mongoObjectToParseObject(null, input, { + fields: { + urls: { type: 'Object' }, + }, + }); + expect(output.urls).toBeUndefined(); + }); + + it('$regex in $all list', done => { + const input = { + arrayField: { + $all: [{ $regex: '^\\Qone\\E' }, { $regex: '^\\Qtwo\\E' }, { $regex: '^\\Qthree\\E' }], + }, + }; + const outputValue = { + arrayField: { $all: [/^\Qone\E/, /^\Qtwo\E/, /^\Qthree\E/] }, + }; + + const output = transform.transformWhere(null, input); + jequal(outputValue.arrayField, output.arrayField); + done(); + }); + + it('$regex in $all list must be { $regex: "string" }', done => { + const input = { + arrayField: { $all: [{ $regex: 1 }] }, + }; + + expect(() => { + transform.transformWhere(null, input); + }).toThrow(); + done(); + }); + + it('all values in $all must be $regex (start with string) or non $regex (start with string)', done => { + const input = { + arrayField: { + $all: [{ $regex: '^\\Qone\\E' }, { $unknown: '^\\Qtwo\\E' }], + }, + }; + + expect(() => { + transform.transformWhere(null, input); + }).toThrow(); + done(); + }); + + it('ignores User authData field in DB so it can be synthesized in code', done => { + const input = { + _id: '123', + _auth_data_acme: { id: 'abc' }, + authData: null, + }; + const output = transform.mongoObjectToParseObject('_User', input, { + fields: {}, + }); + expect(output.authData.acme.id).toBe('abc'); + done(); + }); + + it('can set authData when not User class', done => { + const input = { + _id: '123', + authData: 'random', + }; + const output = transform.mongoObjectToParseObject('TestObject', input, { + fields: {}, + }); + expect(output.authData).toBe('random'); + done(); + }); + + it('should only transform authData.provider.id for _User class', () => { + // Test that for _User class, authData.facebook.id is transformed + const userInput = { + 'authData.facebook.id': '10000000000000001', + }; + const userOutput = transform.transformWhere('_User', userInput, { fields: {} }); + expect(userOutput['_auth_data_facebook.id']).toBe('10000000000000001'); + + // Test that for non-User classes, authData.facebook.id is NOT transformed + const customInput = { + 'authData.facebook.id': '10000000000000001', + }; + const customOutput = transform.transformWhere('SpamAlerts', customInput, { fields: {} }); + expect(customOutput['authData.facebook.id']).toBe('10000000000000001'); + expect(customOutput['_auth_data_facebook.id']).toBeUndefined(); + }); +}); + +it('cannot have a custom field name beginning with underscore', done => { + const input = { + _id: '123', + _thisFieldNameIs: 'invalid', + }; + try { + transform.mongoObjectToParseObject('TestObject', input, { + fields: {}, + }); + } catch (e) { + expect(e).toBeDefined(); + } + done(); +}); + +describe('transformUpdate', () => { + it('removes Relation types', done => { + const input = { + aRelation: { __type: 'Relation', className: 'Stuff' }, + }; + const output = transform.transformUpdate(null, input, { + fields: { + aRelation: { __type: 'Relation', className: 'Stuff' }, + }, + }); + expect(output).toEqual({}); + done(); + }); +}); + +describe('transformConstraint', () => { + describe('$relativeTime', () => { + it('should error on $eq, $ne, and $exists', () => { + expect(() => { + transform.transformConstraint({ + $eq: { + ttl: { + $relativeTime: '12 days ago', + }, + }, + }); + }).toThrow(); + + expect(() => { + transform.transformConstraint({ + $ne: { + ttl: { + $relativeTime: '12 days ago', + }, + }, + }); + }).toThrow(); + + expect(() => { + transform.transformConstraint({ + $exists: { + $relativeTime: '12 days ago', + }, + }); + }).toThrow(); + }); + }); +}); + +describe('relativeTimeToDate', () => { + const now = new Date('2017-09-26T13:28:16.617Z'); + + describe('In the future', () => { + it('should parse valid natural time', () => { + const text = 'in 1 year 2 weeks 12 days 10 hours 24 minutes 30 seconds'; + const { result, status, info } = Utils.relativeTimeToDate(text, now); + expect(result.toISOString()).toBe('2018-10-22T23:52:46.617Z'); + expect(status).toBe('success'); + expect(info).toBe('future'); + }); + }); + + describe('In the past', () => { + it('should parse valid natural time', () => { + const text = '2 days 12 hours 1 minute 12 seconds ago'; + const { result, status, info } = Utils.relativeTimeToDate(text, now); + expect(result.toISOString()).toBe('2017-09-24T01:27:04.617Z'); + expect(status).toBe('success'); + expect(info).toBe('past'); + }); + }); + + describe('From now', () => { + it('should equal current time', () => { + const text = 'now'; + const { result, status, info } = Utils.relativeTimeToDate(text, now); + expect(result.toISOString()).toBe('2017-09-26T13:28:16.617Z'); + expect(status).toBe('success'); + expect(info).toBe('present'); + }); + }); + + describe('Error cases', () => { + it('should error if string is completely gibberish', () => { + expect(Utils.relativeTimeToDate('gibberishasdnklasdnjklasndkl123j123')).toEqual({ + status: 'error', + info: "Time should either start with 'in' or end with 'ago'", + }); + }); + + it('should error if string contains neither `ago` nor `in`', () => { + expect(Utils.relativeTimeToDate('12 hours 1 minute')).toEqual({ + status: 'error', + info: "Time should either start with 'in' or end with 'ago'", + }); + }); + + it('should error if there are missing units or numbers', () => { + expect(Utils.relativeTimeToDate('in 12 hours 1')).toEqual({ + status: 'error', + info: 'Invalid time string. Dangling unit or number.', + }); + + expect(Utils.relativeTimeToDate('12 hours minute ago')).toEqual({ + status: 'error', + info: 'Invalid time string. Dangling unit or number.', + }); + }); + + it('should error on floating point numbers', () => { + expect(Utils.relativeTimeToDate('in 12.3 hours')).toEqual({ + status: 'error', + info: "'12.3' is not an integer.", + }); + }); + + it('should error if numbers are invalid', () => { + expect(Utils.relativeTimeToDate('12 hours 123a minute ago')).toEqual({ + status: 'error', + info: "'123a' is not an integer.", + }); + }); + + it('should error on invalid interval units', () => { + expect(Utils.relativeTimeToDate('4 score 7 years ago')).toEqual({ + status: 'error', + info: "Invalid interval: 'score'", + }); + }); + + it("should error when string contains 'ago' and 'in'", () => { + expect(Utils.relativeTimeToDate('in 1 day 2 minutes ago')).toEqual({ + status: 'error', + info: "Time cannot have both 'in' and 'ago'", + }); + }); + }); + }); diff --git a/spec/NullCacheAdapter.spec.js b/spec/NullCacheAdapter.spec.js new file mode 100644 index 0000000000..f5d5e508f4 --- /dev/null +++ b/spec/NullCacheAdapter.spec.js @@ -0,0 +1,32 @@ +const NullCacheAdapter = require('../lib/Adapters/Cache/NullCacheAdapter').default; + +describe('NullCacheAdapter', function () { + const KEY = 'hello'; + const VALUE = 'world'; + + it('should expose promisifyed methods', done => { + const cache = new NullCacheAdapter({ + ttl: NaN, + }); + + // Verify all methods return promises. + Promise.all([cache.put(KEY, VALUE), cache.del(KEY), cache.get(KEY), cache.clear()]).then(() => { + done(); + }); + }); + + it('should get/set/clear', done => { + const cache = new NullCacheAdapter({ + ttl: NaN, + }); + + cache + .put(KEY, VALUE) + .then(() => cache.get(KEY)) + .then(value => expect(value).toEqual(null)) + .then(() => cache.clear()) + .then(() => cache.get(KEY)) + .then(value => expect(value).toEqual(null)) + .then(done); + }); +}); diff --git a/spec/OAuth.spec.js b/spec/OAuth.spec.js deleted file mode 100644 index 60cb7d9189..0000000000 --- a/spec/OAuth.spec.js +++ /dev/null @@ -1,324 +0,0 @@ -var OAuth = require("../src/authDataManager/OAuth1Client"); -var request = require('request'); -var Config = require("../src/Config"); -var defaultColumns = require('../src/Controllers/SchemaController').defaultColumns; - -describe('OAuth', function() { - it("Nonce should have right length", (done) => { - jequal(OAuth.nonce().length, 30); - done(); - }); - - it("Should properly build parameter string", (done) => { - var string = OAuth.buildParameterString({c:1, a:2, b:3}) - jequal(string, "a=2&b=3&c=1"); - done(); - }); - - it("Should properly build empty parameter string", (done) => { - var string = OAuth.buildParameterString() - jequal(string, ""); - done(); - }); - - it("Should properly build signature string", (done) => { - var string = OAuth.buildSignatureString("get", "http://dummy.com", ""); - jequal(string, "GET&http%3A%2F%2Fdummy.com&"); - done(); - }); - - it("Should properly generate request signature", (done) => { - var request = { - host: "dummy.com", - path: "path" - }; - - var oauth_params = { - oauth_timestamp: 123450000, - oauth_nonce: "AAAAAAAAAAAAAAAAA", - oauth_consumer_key: "hello", - oauth_token: "token" - }; - - var consumer_secret = "world"; - var auth_token_secret = "secret"; - request = OAuth.signRequest(request, oauth_params, consumer_secret, auth_token_secret); - jequal(request.headers['Authorization'], 'OAuth oauth_consumer_key="hello", oauth_nonce="AAAAAAAAAAAAAAAAA", oauth_signature="8K95bpQcDi9Nd2GkhumTVcw4%2BXw%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="123450000", oauth_token="token", oauth_version="1.0"'); - done(); - }); - - it("Should properly build request", (done) => { - var options = { - host: "dummy.com", - consumer_key: "hello", - consumer_secret: "world", - auth_token: "token", - auth_token_secret: "secret", - // Custom oauth params for tests - oauth_params: { - oauth_timestamp: 123450000, - oauth_nonce: "AAAAAAAAAAAAAAAAA" - } - }; - var path = "path"; - var method = "get"; - - var oauthClient = new OAuth(options); - var req = oauthClient.buildRequest(method, path, {"query": "param"}); - - jequal(req.host, options.host); - jequal(req.path, "/"+path+"?query=param"); - jequal(req.method, "GET"); - jequal(req.headers['Content-Type'], 'application/x-www-form-urlencoded'); - jequal(req.headers['Authorization'], 'OAuth oauth_consumer_key="hello", oauth_nonce="AAAAAAAAAAAAAAAAA", oauth_signature="wNkyEkDE%2F0JZ2idmqyrgHdvC0rs%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="123450000", oauth_token="token", oauth_version="1.0"') - done(); - }); - - - function validateCannotAuthenticateError(data, done) { - jequal(typeof data, "object"); - jequal(typeof data.errors, "object"); - var errors = data.errors; - jequal(typeof errors[0], "object"); - // Cannot authenticate error - jequal(errors[0].code, 32); - done(); - } - - it("Should fail a GET request", (done) => { - var options = { - host: "api.twitter.com", - consumer_key: "XXXXXXXXXXXXXXXXXXXXXXXXX", - consumer_secret: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - }; - var path = "/1.1/help/configuration.json"; - var params = {"lang": "en"}; - var oauthClient = new OAuth(options); - oauthClient.get(path, params).then(function(data){ - validateCannotAuthenticateError(data, done); - }) - }); - - it("Should fail a POST request", (done) => { - var options = { - host: "api.twitter.com", - consumer_key: "XXXXXXXXXXXXXXXXXXXXXXXXX", - consumer_secret: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - }; - var body = { - lang: "en" - }; - var path = "/1.1/account/settings.json"; - - var oauthClient = new OAuth(options); - oauthClient.post(path, null, body).then(function(data){ - validateCannotAuthenticateError(data, done); - }) - }); - - it("Should fail a request", (done) => { - var options = { - host: "localhost", - consumer_key: "XXXXXXXXXXXXXXXXXXXXXXXXX", - consumer_secret: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - }; - var body = { - lang: "en" - }; - var path = "/"; - - var oauthClient = new OAuth(options); - oauthClient.post(path, null, body).then(function(data){ - jequal(false, true); - done(); - }).catch(function(){ - jequal(true, true); - done(); - }) - }); - - ["facebook", "github", "instagram", "google", "linkedin", "meetup", "twitter"].map(function(providerName){ - it("Should validate structure of "+providerName, (done) => { - var provider = require("../src/authDataManager/"+providerName); - jequal(typeof provider.validateAuthData, "function"); - jequal(typeof provider.validateAppId, "function"); - jequal(provider.validateAuthData({}, {}).constructor, Promise.prototype.constructor); - jequal(provider.validateAppId("app", "key", {}).constructor, Promise.prototype.constructor); - done(); - }); - }); - - var getMockMyOauthProvider = function() { - return { - authData: { - id: "12345", - access_token: "12345", - expiration_date: new Date().toJSON(), - }, - shouldError: false, - loggedOut: false, - synchronizedUserId: null, - synchronizedAuthToken: null, - synchronizedExpiration: null, - - authenticate: function(options) { - if (this.shouldError) { - options.error(this, "An error occurred"); - } else if (this.shouldCancel) { - options.error(this, null); - } else { - options.success(this, this.authData); - } - }, - restoreAuthentication: function(authData) { - if (!authData) { - this.synchronizedUserId = null; - this.synchronizedAuthToken = null; - this.synchronizedExpiration = null; - return true; - } - this.synchronizedUserId = authData.id; - this.synchronizedAuthToken = authData.access_token; - this.synchronizedExpiration = authData.expiration_date; - return true; - }, - getAuthType: function() { - return "myoauth"; - }, - deauthenticate: function() { - this.loggedOut = true; - this.restoreAuthentication(null); - } - }; - }; - - var ExtendedUser = Parse.User.extend({ - extended: function() { - return true; - } - }); - - var createOAuthUser = function(callback) { - var jsonBody = { - authData: { - myoauth: getMockMyOauthProvider().authData - } - }; - - var options = { - headers: {'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Installation-Id': 'yolo', - 'Content-Type': 'application/json' }, - url: 'http://localhost:8378/1/users', - body: JSON.stringify(jsonBody) - }; - - return request.post(options, callback); - } - - it_exclude_dbs(['postgres'])("should create user with REST API", done => { - createOAuthUser((error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - ok(b.sessionToken); - expect(b.objectId).not.toBeNull(); - expect(b.objectId).not.toBeUndefined(); - var sessionToken = b.sessionToken; - var q = new Parse.Query("_Session"); - q.equalTo('sessionToken', sessionToken); - q.first({useMasterKey: true}).then((res) => { - expect(res.get("installationId")).toEqual('yolo'); - done(); - }).fail((err) => { - fail('should not fail fetching the session'); - done(); - }) - }); - }); - - it_exclude_dbs(['postgres'])("should only create a single user with REST API", (done) => { - var objectId; - createOAuthUser((error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(b.objectId).not.toBeNull(); - expect(b.objectId).not.toBeUndefined(); - objectId = b.objectId; - - createOAuthUser((error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(b.objectId).not.toBeNull(); - expect(b.objectId).not.toBeUndefined(); - expect(b.objectId).toBe(objectId); - done(); - }); - }); - }); - - it_exclude_dbs(['postgres'])("unlink and link with custom provider", (done) => { - var provider = getMockMyOauthProvider(); - Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("myoauth", { - success: function(model) { - ok(model instanceof Parse.User, "Model should be a Parse.User"); - strictEqual(Parse.User.current(), model); - ok(model.extended(), "Should have used the subclass."); - strictEqual(provider.authData.id, provider.synchronizedUserId); - strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); - strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); - ok(model._isLinked("myoauth"), "User should be linked to myoauth"); - - model._unlinkFrom("myoauth", { - success: function(model) { - - ok(!model._isLinked("myoauth"), - "User should not be linked to myoauth"); - ok(!provider.synchronizedUserId, "User id should be cleared"); - ok(!provider.synchronizedAuthToken, "Auth token should be cleared"); - ok(!provider.synchronizedExpiration, - "Expiration should be cleared"); - // make sure the auth data is properly deleted - var config = new Config(Parse.applicationId); - config.database.adapter.find('_User', { - fields: Object.assign({}, defaultColumns._Default, defaultColumns._Installation), - }, { objectId: model.id }, {}) - .then(res => { - expect(res.length).toBe(1); - expect(res[0]._auth_data_myoauth).toBeUndefined(); - expect(res[0]._auth_data_myoauth).not.toBeNull(); - - model._linkWith("myoauth", { - success: function(model) { - ok(provider.synchronizedUserId, "User id should have a value"); - ok(provider.synchronizedAuthToken, - "Auth token should have a value"); - ok(provider.synchronizedExpiration, - "Expiration should have a value"); - ok(model._isLinked("myoauth"), - "User should be linked to myoauth"); - done(); - }, - error: function(model, error) { - ok(false, "linking again should succeed"); - done(); - } - }); - }); - }, - error: function(model, error) { - ok(false, "unlinking should succeed"); - done(); - } - }); - }, - error: function(model, error) { - ok(false, "linking should have worked"); - done(); - } - }); - }); - - -}) diff --git a/spec/OAuth1.spec.js b/spec/OAuth1.spec.js new file mode 100644 index 0000000000..34dc8b6925 --- /dev/null +++ b/spec/OAuth1.spec.js @@ -0,0 +1,162 @@ +const OAuth = require('../lib/Adapters/Auth/OAuth1Client'); + +describe('OAuth', function () { + it('Nonce should have right length', done => { + jequal(OAuth.nonce().length, 30); + done(); + }); + + it('Should properly build parameter string', done => { + const string = OAuth.buildParameterString({ c: 1, a: 2, b: 3 }); + jequal(string, 'a=2&b=3&c=1'); + done(); + }); + + it('Should properly build empty parameter string', done => { + const string = OAuth.buildParameterString(); + jequal(string, ''); + done(); + }); + + it('Should properly build signature string', done => { + const string = OAuth.buildSignatureString('get', 'http://dummy.com', ''); + jequal(string, 'GET&http%3A%2F%2Fdummy.com&'); + done(); + }); + + it('Should properly generate request signature', done => { + let request = { + host: 'dummy.com', + path: 'path', + }; + + const oauth_params = { + oauth_timestamp: 123450000, + oauth_nonce: 'AAAAAAAAAAAAAAAAA', + oauth_consumer_key: 'hello', + oauth_token: 'token', + }; + + const consumer_secret = 'world'; + const auth_token_secret = 'secret'; + request = OAuth.signRequest(request, oauth_params, consumer_secret, auth_token_secret); + jequal( + request.headers['Authorization'], + 'OAuth oauth_consumer_key="hello", oauth_nonce="AAAAAAAAAAAAAAAAA", oauth_signature="8K95bpQcDi9Nd2GkhumTVcw4%2BXw%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="123450000", oauth_token="token", oauth_version="1.0"' + ); + done(); + }); + + it('Should properly build request', done => { + const options = { + host: 'dummy.com', + consumer_key: 'hello', + consumer_secret: 'world', + auth_token: 'token', + auth_token_secret: 'secret', + // Custom oauth params for tests + oauth_params: { + oauth_timestamp: 123450000, + oauth_nonce: 'AAAAAAAAAAAAAAAAA', + }, + }; + const path = 'path'; + const method = 'get'; + + const oauthClient = new OAuth(options); + const req = oauthClient.buildRequest(method, path, { query: 'param' }); + + jequal(req.host, options.host); + jequal(req.path, '/' + path + '?query=param'); + jequal(req.method, 'GET'); + jequal(req.headers['Content-Type'], 'application/x-www-form-urlencoded'); + jequal( + req.headers['Authorization'], + 'OAuth oauth_consumer_key="hello", oauth_nonce="AAAAAAAAAAAAAAAAA", oauth_signature="wNkyEkDE%2F0JZ2idmqyrgHdvC0rs%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="123450000", oauth_token="token", oauth_version="1.0"' + ); + done(); + }); + + function validateCannotAuthenticateError(data, done) { + jequal(typeof data, 'object'); + jequal(typeof data.errors, 'object'); + const errors = data.errors; + jequal(typeof errors[0], 'object'); + // Cannot authenticate error + jequal(errors[0].code, 32); + done(); + } + + xit('GET request for a resource that requires OAuth should fail with invalid credentials', done => { + /* + This endpoint has been chosen to make a request to an endpoint that requires OAuth which fails due to missing authentication. + Any other endpoint from the Twitter API that requires OAuth can be used instead in case the currently used endpoint deprecates. + */ + const options = { + host: 'api.twitter.com', + consumer_key: 'invalid_consumer_key', + consumer_secret: 'invalid_consumer_secret', + }; + const path = '/1.1/favorites/list.json'; + const params = { lang: 'en' }; + const oauthClient = new OAuth(options); + oauthClient.get(path, params).then(function (data) { + validateCannotAuthenticateError(data, done); + }); + }); + + xit('POST request for a resource that requires OAuth should fail with invalid credentials', done => { + /* + This endpoint has been chosen to make a request to an endpoint that requires OAuth which fails due to missing authentication. + Any other endpoint from the Twitter API that requires OAuth can be used instead in case the currently used endpoint deprecates. + */ + const options = { + host: 'api.twitter.com', + consumer_key: 'invalid_consumer_key', + consumer_secret: 'invalid_consumer_secret', + }; + const body = { + lang: 'en', + }; + const path = '/1.1/account/settings.json'; + + const oauthClient = new OAuth(options); + oauthClient.post(path, null, body).then(function (data) { + validateCannotAuthenticateError(data, done); + }); + }); + + it('Should fail a request', done => { + const options = { + host: 'localhost', + consumer_key: 'XXXXXXXXXXXXXXXXXXXXXXXXX', + consumer_secret: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + }; + const body = { + lang: 'en', + }; + const path = '/'; + + const oauthClient = new OAuth(options); + oauthClient + .post(path, null, body) + .then(function () { + jequal(false, true); + done(); + }) + .catch(function () { + jequal(true, true); + done(); + }); + }); + + it('Should fail with missing options', done => { + const options = undefined; + try { + new OAuth(options); + } catch (error) { + jequal(error.message, 'No options passed to OAuth'); + done(); + } + }); +}); diff --git a/spec/PagesRouter.spec.js b/spec/PagesRouter.spec.js new file mode 100644 index 0000000000..68dfb0b17b --- /dev/null +++ b/spec/PagesRouter.spec.js @@ -0,0 +1,1683 @@ +'use strict'; + +const request = require('../lib/request'); +const path = require('path'); +const fs = require('fs').promises; +const mustache = require('mustache'); +const Utils = require('../lib/Utils'); +const { Page } = require('../lib/Page'); +const Config = require('../lib/Config'); +const Definitions = require('../lib/Options/Definitions'); +const UserController = require('../lib/Controllers/UserController').UserController; +const { + PagesRouter, + pages, + pageParams, + pageParamHeaderPrefix, +} = require('../lib/Routers/PagesRouter'); + +describe('Pages Router', () => { + describe('basic request', () => { + let config; + + beforeEach(async () => { + config = { + appId: 'test', + appName: 'exampleAppname', + publicServerURL: 'http://localhost:8378/1', + pages: {}, + }; + await reconfigureServer(config); + }); + + it('responds with file content on direct page request', async () => { + const urls = [ + 'http://localhost:8378/1/apps/email_verification_link_invalid.html', + 'http://localhost:8378/1/apps/choose_password?appId=test', + 'http://localhost:8378/1/apps/email_verification_success.html', + 'http://localhost:8378/1/apps/password_reset_success.html', + 'http://localhost:8378/1/apps/custom_json.html', + ]; + for (const url of urls) { + const response = await request({ url }).catch(e => e); + expect(response.status).toBe(200); + } + }); + + it('can load file from custom pages path', async () => { + config.pages.pagesPath = './public'; + await reconfigureServer(config); + + const response = await request({ + url: 'http://localhost:8378/1/apps/email_verification_link_invalid.html', + }).catch(e => e); + expect(response.status).toBe(200); + }); + + it('can load file from custom pages endpoint', async () => { + config.pages.pagesEndpoint = 'pages'; + await reconfigureServer(config); + + const response = await request({ + url: `http://localhost:8378/1/pages/email_verification_link_invalid.html`, + }).catch(e => e); + expect(response.status).toBe(200); + }); + + it('responds with 404 if publicServerURL is not configured', async () => { + await reconfigureServer({ + appName: 'unused', + }); + const urls = [ + 'http://localhost:8378/1/apps/test/verify_email', + 'http://localhost:8378/1/apps/choose_password?appId=test', + 'http://localhost:8378/1/apps/test/request_password_reset', + ]; + for (const url of urls) { + const response = await request({ url }).catch(e => e); + expect(response.status).toBe(404); + } + }); + + it('responds with 403 access denied with invalid appId', async () => { + const reqs = [ + { url: 'http://localhost:8378/1/apps/invalid/verify_email', method: 'GET' }, + { url: 'http://localhost:8378/1/apps/choose_password?id=invalid', method: 'GET' }, + { url: 'http://localhost:8378/1/apps/invalid/request_password_reset', method: 'GET' }, + { url: 'http://localhost:8378/1/apps/invalid/request_password_reset', method: 'POST' }, + { url: 'http://localhost:8378/1/apps/invalid/resend_verification_email', method: 'POST' }, + ]; + for (const req of reqs) { + const response = await request(req).catch(e => e); + expect(response.status).toBe(403); + } + }); + }); + + describe('AJAX requests', () => { + beforeEach(async () => { + await reconfigureServer({ + appName: 'exampleAppname', + publicServerURL: 'http://localhost:8378/1', + }); + }); + + it('request_password_reset: responds with AJAX success', async () => { + spyOn(UserController.prototype, 'updatePassword').and.callFake(() => Promise.resolve()); + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=user1&token=43634643`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest', + }, + followRedirects: false, + }).catch(e => e); + expect(res.status).toBe(200); + expect(res.text).toEqual('"Password successfully reset"'); + }); + + it('request_password_reset: responds with AJAX error on missing password', async () => { + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=&token=132414`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest', + }, + followRedirects: false, + }); + } catch (error) { + expect(error.status).not.toBe(302); + expect(error.text).toEqual('{"code":201,"error":"Missing password"}'); + } + }); + + it('request_password_reset: responds with AJAX error on missing token', async () => { + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=user1&token=`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest', + }, + followRedirects: false, + }); + } catch (error) { + expect(error.status).not.toBe(302); + expect(error.text).toEqual('{"code":-1,"error":"Missing token"}'); + } + }); + }); + + describe('pages', () => { + let router = new PagesRouter(); + let req; + let config; + let goToPage; + let pageResponse; + let redirectResponse; + let readFile; + let exampleLocale; + + const fillPlaceholders = (text, fill) => text.replace(/({{2,3}.*?}{2,3})/g, fill); + async function reconfigureServerWithPagesConfig(pagesConfig) { + config.pages = pagesConfig; + await reconfigureServer(config); + } + + beforeEach(async () => { + router = new PagesRouter(); + readFile = spyOn(fs, 'readFile').and.callThrough(); + goToPage = spyOn(PagesRouter.prototype, 'goToPage').and.callThrough(); + pageResponse = spyOn(PagesRouter.prototype, 'pageResponse').and.callThrough(); + redirectResponse = spyOn(PagesRouter.prototype, 'redirectResponse').and.callThrough(); + exampleLocale = 'de-AT'; + config = { + appId: 'test', + appName: 'ExampleAppName', + verifyUserEmails: true, + emailAdapter: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + publicServerURL: 'http://localhost:8378/1', + pages: { + enableLocalization: true, + customUrls: {}, + }, + }; + req = { + method: 'GET', + config, + query: { + locale: exampleLocale, + }, + }; + }); + + describe('server options', () => { + it('uses default configuration when none is set', async () => { + await reconfigureServerWithPagesConfig({}); + expect(Config.get(Parse.applicationId).pages.enableLocalization).toBe( + Definitions.PagesOptions.enableLocalization.default + ); + expect(Config.get(Parse.applicationId).pages.localizationJsonPath).toBe( + Definitions.PagesOptions.localizationJsonPath.default + ); + expect(Config.get(Parse.applicationId).pages.localizationFallbackLocale).toBe( + Definitions.PagesOptions.localizationFallbackLocale.default + ); + expect(Config.get(Parse.applicationId).pages.placeholders).toBe( + Definitions.PagesOptions.placeholders.default + ); + expect(Config.get(Parse.applicationId).pages.forceRedirect).toBe( + Definitions.PagesOptions.forceRedirect.default + ); + expect(Config.get(Parse.applicationId).pages.pagesPath).toBeUndefined(); + expect(Config.get(Parse.applicationId).pages.pagesEndpoint).toBe( + Definitions.PagesOptions.pagesEndpoint.default + ); + expect(Config.get(Parse.applicationId).pages.customUrls).toBe( + Definitions.PagesOptions.customUrls.default + ); + expect(Config.get(Parse.applicationId).pages.customRoutes).toBe( + Definitions.PagesOptions.customRoutes.default + ); + }); + + it('throws on invalid configuration', async () => { + const options = [ + [], + 'a', + 0, + true, + { enableLocalization: 'a' }, + { enableLocalization: 0 }, + { enableLocalization: {} }, + { enableLocalization: [] }, + { forceRedirect: 'a' }, + { forceRedirect: 0 }, + { forceRedirect: {} }, + { forceRedirect: [] }, + { placeholders: true }, + { placeholders: 'a' }, + { placeholders: 0 }, + { placeholders: [] }, + { pagesPath: true }, + { pagesPath: 0 }, + { pagesPath: {} }, + { pagesPath: [] }, + { pagesEndpoint: true }, + { pagesEndpoint: 0 }, + { pagesEndpoint: {} }, + { pagesEndpoint: [] }, + { customUrls: true }, + { customUrls: 0 }, + { customUrls: 'a' }, + { customUrls: [] }, + { localizationJsonPath: true }, + { localizationJsonPath: 0 }, + { localizationJsonPath: {} }, + { localizationJsonPath: [] }, + { localizationFallbackLocale: true }, + { localizationFallbackLocale: 0 }, + { localizationFallbackLocale: {} }, + { localizationFallbackLocale: [] }, + { customRoutes: true }, + { customRoutes: 0 }, + { customRoutes: 'a' }, + { customRoutes: {} }, + ]; + for (const option of options) { + await expectAsync(reconfigureServerWithPagesConfig(option)).toBeRejected(); + } + }); + }); + + describe('placeholders', () => { + it('replaces placeholder in response content', async () => { + await expectAsync(router.goToPage(req, pages.passwordResetLinkInvalid)).toBeResolved(); + + expect(readFile.calls.all()[0].returnValue).toBeDefined(); + const originalContent = await readFile.calls.all()[0].returnValue; + expect(originalContent).toContain('{{appName}}'); + + expect(pageResponse.calls.all()[0].returnValue).toBeDefined(); + const replacedContent = await pageResponse.calls.all()[0].returnValue; + expect(replacedContent.text).not.toContain('{{appName}}'); + expect(replacedContent.text).toContain(req.config.appName); + }); + + it('removes undefined placeholder in response content', async () => { + await expectAsync(router.goToPage(req, pages.passwordReset)).toBeResolved(); + + expect(readFile.calls.all()[0].returnValue).toBeDefined(); + const originalContent = await readFile.calls.all()[0].returnValue; + expect(originalContent).toContain('{{error}}'); + + // There is no error placeholder value set by default, so the + // {{error}} placeholder should just be removed from content + expect(pageResponse.calls.all()[0].returnValue).toBeDefined(); + const replacedContent = await pageResponse.calls.all()[0].returnValue; + expect(replacedContent.text).not.toContain('{{error}}'); + }); + + it('fills placeholders from config object', async () => { + config.pages.enableLocalization = false; + config.pages.placeholders = { + title: 'setViaConfig', + }; + await reconfigureServer(config); + const response = await request({ + url: 'http://localhost:8378/1/apps/custom_json.html', + followRedirects: false, + method: 'GET', + }); + expect(response.status).toEqual(200); + expect(response.text).toContain(config.pages.placeholders.title); + }); + + it('fills placeholders from config function', async () => { + config.pages.enableLocalization = false; + config.pages.placeholders = () => { + return { title: 'setViaConfig' }; + }; + await reconfigureServer(config); + const response = await request({ + url: 'http://localhost:8378/1/apps/custom_json.html', + followRedirects: false, + method: 'GET', + }); + expect(response.status).toEqual(200); + expect(response.text).toContain(config.pages.placeholders().title); + }); + + it('fills placeholders from config promise', async () => { + config.pages.enableLocalization = false; + config.pages.placeholders = async () => { + return { title: 'setViaConfig' }; + }; + await reconfigureServer(config); + const response = await request({ + url: 'http://localhost:8378/1/apps/custom_json.html', + followRedirects: false, + method: 'GET', + }); + expect(response.status).toEqual(200); + expect(response.text).toContain((await config.pages.placeholders()).title); + }); + }); + + describe('localization', () => { + it('returns default file if localization is disabled', async () => { + delete req.config.pages.enableLocalization; + + await expectAsync(router.goToPage(req, pages.passwordResetLinkInvalid)).toBeResolved(); + expect(pageResponse.calls.all()[0].args[0]).toBeDefined(); + expect(pageResponse.calls.all()[0].args[0]).not.toMatch( + new RegExp(`\/de(-AT)?\/${pages.passwordResetLinkInvalid.defaultFile}`) + ); + }); + + it('returns default file if no locale is specified', async () => { + delete req.query.locale; + + await expectAsync(router.goToPage(req, pages.passwordResetLinkInvalid)).toBeResolved(); + expect(pageResponse.calls.all()[0].args[0]).toBeDefined(); + expect(pageResponse.calls.all()[0].args[0]).not.toMatch( + new RegExp(`\/de(-AT)?\/${pages.passwordResetLinkInvalid.defaultFile}`) + ); + }); + + it('returns custom page regardless of localization enabled', async () => { + req.config.pages.customUrls = { + passwordResetLinkInvalid: 'http://invalid-link.example.com', + }; + + await expectAsync(router.goToPage(req, pages.passwordResetLinkInvalid)).toBeResolved(); + expect(pageResponse).not.toHaveBeenCalled(); + expect(redirectResponse.calls.all()[0].args[0]).toBe( + req.config.pages.customUrls.passwordResetLinkInvalid + ); + }); + + it('returns file for locale match', async () => { + await expectAsync(router.goToPage(req, pages.passwordResetLinkInvalid)).toBeResolved(); + expect(pageResponse.calls.all()[0].args[0]).toBeDefined(); + expect(pageResponse.calls.all()[0].args[0]).toMatch( + new RegExp(`\/${req.query.locale}\/${pages.passwordResetLinkInvalid.defaultFile}`) + ); + }); + + it('returns file for language match', async () => { + // Pretend no locale matching file exists + spyOn(Utils, 'fileExists').and.callFake(async path => { + return !path.includes( + `/${req.query.locale}/${pages.passwordResetLinkInvalid.defaultFile}` + ); + }); + + await expectAsync(router.goToPage(req, pages.passwordResetLinkInvalid)).toBeResolved(); + expect(pageResponse.calls.all()[0].args[0]).toBeDefined(); + expect(pageResponse.calls.all()[0].args[0]).toMatch( + new RegExp(`\/de\/${pages.passwordResetLinkInvalid.defaultFile}`) + ); + }); + + it('returns default file for neither locale nor language match', async () => { + req.query.locale = 'yo-LO'; + + await expectAsync(router.goToPage(req, pages.passwordResetLinkInvalid)).toBeResolved(); + expect(pageResponse.calls.all()[0].args[0]).toBeDefined(); + expect(pageResponse.calls.all()[0].args[0]).not.toMatch( + new RegExp(`\/yo(-LO)?\/${pages.passwordResetLinkInvalid.defaultFile}`) + ); + }); + }); + + describe('localization with JSON resource', () => { + let jsonPageFile; + let jsonPageUrl; + let jsonResource; + + beforeEach(async () => { + jsonPageFile = 'custom_json.html'; + jsonPageUrl = new URL(`${config.publicServerURL}/apps/${jsonPageFile}`); + jsonResource = require('../public/custom_json.json'); + + config.pages.enableLocalization = true; + config.pages.localizationJsonPath = './public/custom_json.json'; + config.pages.localizationFallbackLocale = 'en'; + await reconfigureServer(config); + }); + + it('does not localize with JSON resource if localization is disabled', async () => { + config.pages.enableLocalization = false; + config.pages.localizationJsonPath = './public/custom_json.json'; + config.pages.localizationFallbackLocale = 'en'; + await reconfigureServer(config); + + const response = await request({ + url: jsonPageUrl.toString(), + followRedirects: false, + }).catch(e => e); + expect(response.status).toBe(200); + expect(pageResponse.calls.all()[0].args[1]).toEqual({}); + expect(pageResponse.calls.all()[0].args[2]).toEqual({}); + + // Ensure header contains no page params + const pageParamHeaders = Object.keys(response.headers).filter(header => + header.startsWith(pageParamHeaderPrefix) + ); + expect(pageParamHeaders.length).toBe(0); + + // Ensure page response does not contain any translation + const flattenedJson = Utils.flattenObject(jsonResource); + for (const value of Object.values(flattenedJson)) { + const valueWithoutPlaceholder = fillPlaceholders(value, ''); + expect(response.text).not.toContain(valueWithoutPlaceholder); + } + }); + + it('localizes static page with JSON resource and fallback locale', async () => { + const response = await request({ + url: jsonPageUrl.toString(), + followRedirects: false, + }).catch(e => e); + expect(response.status).toBe(200); + + // Ensure page response contains translation of fallback locale + const translation = jsonResource[config.pages.localizationFallbackLocale].translation; + for (const value of Object.values(translation)) { + const valueWithoutPlaceholder = fillPlaceholders(value, ''); + expect(response.text).toContain(valueWithoutPlaceholder); + } + }); + + it('localizes static page with JSON resource and request locale', async () => { + // Add locale to request URL + jsonPageUrl.searchParams.set('locale', exampleLocale); + + const response = await request({ + url: jsonPageUrl.toString(), + followRedirects: false, + }).catch(e => e); + expect(response.status).toBe(200); + + // Ensure page response contains translations of request locale + const translation = jsonResource[exampleLocale].translation; + for (const value of Object.values(translation)) { + const valueWithoutPlaceholder = fillPlaceholders(value, ''); + expect(response.text).toContain(valueWithoutPlaceholder); + } + }); + + it('localizes static page with JSON resource and language matching request locale', async () => { + // Add locale to request URL that has no locale match but only a language + // match in the JSON resource + jsonPageUrl.searchParams.set('locale', 'de-CH'); + + const response = await request({ + url: jsonPageUrl.toString(), + followRedirects: false, + }).catch(e => e); + expect(response.status).toBe(200); + + // Ensure page response contains translations of requst language + const translation = jsonResource['de'].translation; + for (const value of Object.values(translation)) { + const valueWithoutPlaceholder = fillPlaceholders(value, ''); + expect(response.text).toContain(valueWithoutPlaceholder); + } + }); + + it('localizes static page with JSON resource and fills placeholders in JSON values', async () => { + // Add app ID to request URL so that the request is assigned to a Parse Server app + // and placeholders within translations strings can be replaced with default page + // parameters such as `appId` + jsonPageUrl.searchParams.set('appId', config.appId); + jsonPageUrl.searchParams.set('locale', exampleLocale); + + const response = await request({ + url: jsonPageUrl.toString(), + followRedirects: false, + }).catch(e => e); + expect(response.status).toBe(200); + + // Fill placeholders in transation + let translation = jsonResource[exampleLocale].translation; + translation = JSON.stringify(translation); + translation = mustache.render(translation, { appName: config.appName }); + translation = JSON.parse(translation); + + // Ensure page response contains translation of request locale + for (const value of Object.values(translation)) { + expect(response.text).toContain(value); + } + }); + + it('localizes feature page with JSON resource and fills placeholders in JSON values', async () => { + // Fake any page to load the JSON page file + spyOnProperty(Page.prototype, 'defaultFile').and.returnValue(jsonPageFile); + + const response = await request({ + url: `http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&locale=${exampleLocale}`, + followRedirects: false, + }).catch(e => e); + expect(response.status).toEqual(200); + + // Fill placeholders in transation + let translation = jsonResource[exampleLocale].translation; + translation = JSON.stringify(translation); + translation = mustache.render(translation, { appName: config.appName }); + translation = JSON.parse(translation); + + // Ensure page response contains translation of request locale + for (const value of Object.values(translation)) { + expect(response.text).toContain(value); + } + }); + }); + + describe('response type', () => { + it('returns a file for GET request', async () => { + await expectAsync(router.goToPage(req, pages.passwordResetLinkInvalid)).toBeResolved(); + expect(pageResponse).toHaveBeenCalled(); + expect(redirectResponse).not.toHaveBeenCalled(); + }); + + it('returns a redirect for POST request', async () => { + req.method = 'POST'; + await expectAsync(router.goToPage(req, pages.passwordResetLinkInvalid)).toBeResolved(); + expect(pageResponse).not.toHaveBeenCalled(); + expect(redirectResponse).toHaveBeenCalled(); + }); + + it('returns a redirect for custom pages for GET and POST request', async () => { + req.config.pages.customUrls = { + passwordResetLinkInvalid: 'http://invalid-link.example.com', + }; + + for (const method of ['GET', 'POST']) { + req.method = method; + await expectAsync(router.goToPage(req, pages.passwordResetLinkInvalid)).toBeResolved(); + expect(pageResponse).not.toHaveBeenCalled(); + expect(redirectResponse).toHaveBeenCalled(); + } + }); + + it('responds to POST request with redirect response', async () => { + await reconfigureServer(config); + const response = await request({ + url: + 'http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&locale=de-AT', + followRedirects: false, + method: 'POST', + }); + expect(response.status).toEqual(303); + expect(response.headers.location).toContain( + 'http://localhost:8378/1/apps/de-AT/password_reset_link_invalid.html' + ); + }); + + it('responds to GET request with content response', async () => { + await reconfigureServer(config); + const response = await request({ + url: + 'http://localhost:8378/1/apps/test/request_password_reset?token=exampleToken&locale=de-AT', + followRedirects: false, + method: 'GET', + }); + expect(response.status).toEqual(200); + expect(response.text).toContain(''); + }); + }); + + describe('end-to-end tests', () => { + it('localizes end-to-end for password reset: success', async () => { + await reconfigureServer(config); + const sendPasswordResetEmail = spyOn( + config.emailAdapter, + 'sendPasswordResetEmail' + ).and.callThrough(); + const user = new Parse.User(); + user.setUsername('exampleUsername'); + user.setPassword('examplePassword'); + user.set('email', 'mail@example.com'); + await user.signUp(); + await Parse.User.requestPasswordReset(user.getEmail()); + + const link = sendPasswordResetEmail.calls.all()[0].args[0].link; + const linkWithLocale = new URL(link); + linkWithLocale.searchParams.append(pageParams.locale, exampleLocale); + + const linkResponse = await request({ + url: linkWithLocale.toString(), + followRedirects: false, + }); + expect(linkResponse.status).toBe(200); + + const appId = linkResponse.headers['x-parse-page-param-appid']; + const token = linkResponse.headers['x-parse-page-param-token']; + const locale = linkResponse.headers['x-parse-page-param-locale']; + const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl']; + const passwordResetPagePath = pageResponse.calls.all()[0].args[0]; + expect(appId).toBeDefined(); + expect(token).toBeDefined(); + expect(locale).toBeDefined(); + expect(publicServerUrl).toBeDefined(); + expect(passwordResetPagePath).toMatch( + new RegExp(`\/${exampleLocale}\/${pages.passwordReset.defaultFile}`) + ); + pageResponse.calls.reset(); + + const formUrl = `${publicServerUrl}/apps/${appId}/request_password_reset`; + const formResponse = await request({ + url: formUrl, + method: 'POST', + body: { + token, + locale, + new_password: 'newPassword', + }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + followRedirects: false, + }); + expect(formResponse.status).toEqual(200); + expect(pageResponse.calls.all()[0].args[0]).toContain( + `/${locale}/${pages.passwordResetSuccess.defaultFile}` + ); + }); + + it('localizes end-to-end for password reset: invalid link', async () => { + await reconfigureServer(config); + const sendPasswordResetEmail = spyOn( + config.emailAdapter, + 'sendPasswordResetEmail' + ).and.callThrough(); + const user = new Parse.User(); + user.setUsername('exampleUsername'); + user.setPassword('examplePassword'); + user.set('email', 'mail@example.com'); + await user.signUp(); + await Parse.User.requestPasswordReset(user.getEmail()); + + const link = sendPasswordResetEmail.calls.all()[0].args[0].link; + const linkWithLocale = new URL(link); + linkWithLocale.searchParams.append(pageParams.locale, exampleLocale); + linkWithLocale.searchParams.set(pageParams.token, 'invalidToken'); + + const linkResponse = await request({ + url: linkWithLocale.toString(), + followRedirects: false, + }); + expect(linkResponse.status).toBe(200); + + const pagePath = pageResponse.calls.all()[0].args[0]; + expect(pagePath).toMatch( + new RegExp(`\/${exampleLocale}\/${pages.passwordResetLinkInvalid.defaultFile}`) + ); + }); + + it_id('2845c2ea-23ba-45d2-a33f-63181d419bca')(it)('localizes end-to-end for verify email: success', async () => { + await reconfigureServer(config); + const sendVerificationEmail = spyOn( + config.emailAdapter, + 'sendVerificationEmail' + ).and.callThrough(); + const user = new Parse.User(); + user.setUsername('exampleUsername'); + user.setPassword('examplePassword'); + user.set('email', 'mail@example.com'); + await user.signUp(); + await jasmine.timeout(); + + const link = sendVerificationEmail.calls.all()[0].args[0].link; + const linkWithLocale = new URL(link); + linkWithLocale.searchParams.append(pageParams.locale, exampleLocale); + + const linkResponse = await request({ + url: linkWithLocale.toString(), + followRedirects: false, + }); + expect(linkResponse.status).toBe(200); + + const pagePath = pageResponse.calls.all()[0].args[0]; + expect(pagePath).toMatch( + new RegExp(`\/${exampleLocale}\/${pages.emailVerificationSuccess.defaultFile}`) + ); + }); + + it_id('f2272b94-b4ac-474f-8e47-1ca74de136f5')(it)('localizes end-to-end for verify email: invalid verification link - link send success', async () => { + await reconfigureServer(config); + const sendVerificationEmail = spyOn( + config.emailAdapter, + 'sendVerificationEmail' + ).and.callThrough(); + const user = new Parse.User(); + user.setUsername('exampleUsername'); + user.setPassword('examplePassword'); + user.set('email', 'mail@example.com'); + await user.signUp(); + await jasmine.timeout(); + + const link = sendVerificationEmail.calls.all()[0].args[0].link; + const linkWithLocale = new URL(link); + linkWithLocale.searchParams.append(pageParams.locale, exampleLocale); + linkWithLocale.searchParams.set(pageParams.token, 'invalidToken'); + + const linkResponse = await request({ + url: linkWithLocale.toString(), + followRedirects: false, + }); + expect(linkResponse.status).toBe(200); + + const appId = linkResponse.headers['x-parse-page-param-appid']; + const locale = linkResponse.headers['x-parse-page-param-locale']; + const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl']; + const invalidVerificationPagePath = pageResponse.calls.all()[0].args[0]; + expect(appId).toBeDefined(); + expect(locale).toBe(exampleLocale); + expect(publicServerUrl).toBeDefined(); + expect(invalidVerificationPagePath).toMatch( + new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkInvalid.defaultFile}`) + ); + + const formUrl = `${publicServerUrl}/apps/${appId}/resend_verification_email`; + const formResponse = await request({ + url: formUrl, + method: 'POST', + body: { + locale, + username: 'exampleUsername', + }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + followRedirects: false, + }); + expect(formResponse.status).toEqual(303); + expect(formResponse.text).toContain( + `/${locale}/${pages.emailVerificationSendSuccess.defaultFile}` + ); + }); + + it_id('1d46d36a-e455-4ae7-8717-e0d286e95f02')(it)('localizes end-to-end for verify email: invalid verification link - link send fail', async () => { + await reconfigureServer(config); + const sendVerificationEmail = spyOn( + config.emailAdapter, + 'sendVerificationEmail' + ).and.callThrough(); + const user = new Parse.User(); + user.setUsername('exampleUsername'); + user.setPassword('examplePassword'); + user.set('email', 'mail@example.com'); + await user.signUp(); + await jasmine.timeout(); + + const link = sendVerificationEmail.calls.all()[0].args[0].link; + const linkWithLocale = new URL(link); + linkWithLocale.searchParams.append(pageParams.locale, exampleLocale); + linkWithLocale.searchParams.set(pageParams.token, 'invalidToken'); + + const linkResponse = await request({ + url: linkWithLocale.toString(), + followRedirects: false, + }); + expect(linkResponse.status).toBe(200); + + const appId = linkResponse.headers['x-parse-page-param-appid']; + const locale = linkResponse.headers['x-parse-page-param-locale']; + const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl']; + await jasmine.timeout(); + + const invalidVerificationPagePath = pageResponse.calls.all()[0].args[0]; + expect(appId).toBeDefined(); + expect(locale).toBe(exampleLocale); + expect(publicServerUrl).toBeDefined(); + expect(invalidVerificationPagePath).toMatch( + new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkInvalid.defaultFile}`) + ); + + spyOn(UserController.prototype, 'resendVerificationEmail').and.callFake(() => + Promise.reject('failed to resend verification email') + ); + + const formUrl = `${publicServerUrl}/apps/${appId}/resend_verification_email`; + const formResponse = await request({ + url: formUrl, + method: 'POST', + body: { + locale, + username: 'exampleUsername', + }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + followRedirects: false, + }); + expect(formResponse.status).toEqual(303); + // With emailVerifySuccessOnInvalidEmail: true (default), the resend + // page always redirects to the success page to prevent user enumeration + expect(formResponse.text).toContain( + `/${locale}/${pages.emailVerificationSendSuccess.defaultFile}` + ); + }); + + it('localizes end-to-end for verify email: invalid verification link - link send fail with emailVerifySuccessOnInvalidEmail disabled', async () => { + config.emailVerifySuccessOnInvalidEmail = false; + await reconfigureServer(config); + const sendVerificationEmail = spyOn( + config.emailAdapter, + 'sendVerificationEmail' + ).and.callThrough(); + const user = new Parse.User(); + user.setUsername('exampleUsername'); + user.setPassword('examplePassword'); + user.set('email', 'mail@example.com'); + await user.signUp(); + await jasmine.timeout(); + + const link = sendVerificationEmail.calls.all()[0].args[0].link; + const linkWithLocale = new URL(link); + linkWithLocale.searchParams.append(pageParams.locale, exampleLocale); + linkWithLocale.searchParams.set(pageParams.token, 'invalidToken'); + + const linkResponse = await request({ + url: linkWithLocale.toString(), + followRedirects: false, + }); + expect(linkResponse.status).toBe(200); + + const appId = linkResponse.headers['x-parse-page-param-appid']; + const locale = linkResponse.headers['x-parse-page-param-locale']; + const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl']; + await jasmine.timeout(); + + const invalidVerificationPagePath = pageResponse.calls.all()[0].args[0]; + expect(appId).toBeDefined(); + expect(locale).toBe(exampleLocale); + expect(publicServerUrl).toBeDefined(); + expect(invalidVerificationPagePath).toMatch( + new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkInvalid.defaultFile}`) + ); + + spyOn(UserController.prototype, 'resendVerificationEmail').and.callFake(() => + Promise.reject('failed to resend verification email') + ); + + const formUrl = `${publicServerUrl}/apps/${appId}/resend_verification_email`; + const formResponse = await request({ + url: formUrl, + method: 'POST', + body: { + locale, + username: 'exampleUsername', + }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + followRedirects: false, + }); + expect(formResponse.status).toEqual(303); + // With emailVerifySuccessOnInvalidEmail: false, the resend page + // redirects to the fail page + expect(formResponse.text).toContain( + `/${locale}/${pages.emailVerificationSendFail.defaultFile}` + ); + }); + + it('localizes end-to-end for resend verification email: invalid link', async () => { + await reconfigureServer(config); + const formUrl = `${config.publicServerURL}/apps/${config.appId}/resend_verification_email`; + const formResponse = await request({ + url: formUrl, + method: 'POST', + body: { + locale: exampleLocale, + }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + followRedirects: false, + }); + expect(formResponse.status).toEqual(303); + expect(formResponse.text).toContain( + `/${exampleLocale}/${pages.emailVerificationLinkInvalid.defaultFile}` + ); + }); + }); + + describe('failing with missing parameters', () => { + it('verifyEmail: throws on missing server configuration', async () => { + delete req.config; + const verifyEmail = req => (() => new PagesRouter().verifyEmail(req)).bind(null); + expect(verifyEmail(req)).toThrow(); + }); + + it('resendVerificationEmail: throws on missing server configuration', async () => { + delete req.config; + const resendVerificationEmail = req => + (() => new PagesRouter().resendVerificationEmail(req)).bind(null); + expect(resendVerificationEmail(req)).toThrow(); + }); + + it('requestResetPassword: throws on missing server configuration', async () => { + delete req.config; + const requestResetPassword = req => + (() => new PagesRouter().requestResetPassword(req)).bind(null); + expect(requestResetPassword(req)).toThrow(); + }); + + it('resetPassword: throws on missing server configuration', async () => { + delete req.config; + const resetPassword = req => (() => new PagesRouter().resetPassword(req)).bind(null); + expect(resetPassword(req)).toThrow(); + }); + + it('verifyEmail: responds with invalid link on missing username', async () => { + req.query.token = 'exampleToken'; + req.params = {}; + req.config.userController = { verifyEmail: () => Promise.reject() }; + const verifyEmail = req => new PagesRouter().verifyEmail(req); + + await verifyEmail(req); + expect(goToPage.calls.all()[0].args[1]).toBe(pages.emailVerificationLinkInvalid); + }); + + it('resetPassword: responds with page choose password with error message on failed password update', async () => { + req.body = { + token: 'exampleToken', + username: 'exampleUsername', + new_password: 'examplePassword', + }; + const error = 'exampleError'; + req.config.userController = { updatePassword: () => Promise.reject(error) }; + const resetPassword = req => new PagesRouter().resetPassword(req); + + await resetPassword(req); + expect(goToPage.calls.all()[0].args[1]).toBe(pages.passwordReset); + expect(goToPage.calls.all()[0].args[2].error).toBe(error); + }); + + it('resetPassword: responds with AJAX error with error message on failed password update', async () => { + req.xhr = true; + req.body = { + token: 'exampleToken', + username: 'exampleUsername', + new_password: 'examplePassword', + }; + const error = 'exampleError'; + req.config.userController = { updatePassword: () => Promise.reject(error) }; + const resetPassword = req => new PagesRouter().resetPassword(req).catch(e => e); + + const response = await resetPassword(req); + expect(response.code).toBe(Parse.Error.OTHER_CAUSE); + }); + }); + + describe('exploits', () => { + it('rejects requesting file outside of pages scope with UNIX path patterns', async () => { + await reconfigureServer(config); + + // Do not compose this URL with `new URL(...)` because that would normalize + // the URL and remove path patterns; the path patterns must reach the router + const url = `${config.publicServerURL}/apps/../.gitignore`; + const response = await request({ + url: url, + followRedirects: false, + }).catch(e => e); + expect(response.status).toBe(404); + expect(response.text).toBe('Not found.'); + }); + + it('rejects requesting file from sibling directory with prefix-colliding name via encoded path traversal', async () => { + // Create a temporary pages directory and a sibling directory whose name + // starts with the same prefix (e.g. "pages" vs "pages-secret"), which + // would bypass a naive `startsWith` check without a path separator. + const baseDir = path.join(__dirname, 'tmp-pages-exploit-test'); + const pagesDir = path.join(baseDir, 'pages'); + const siblingDir = path.join(baseDir, 'pages-secret'); + const marker = `SECRET_CONTENT_${Date.now()}`; + + try { + await fs.mkdir(pagesDir, { recursive: true }); + await fs.mkdir(siblingDir, { recursive: true }); + // Copy a required HTML file so the pages router initializes correctly + const publicDir = path.resolve(__dirname, '../public'); + const htmlFile = await fs.readFile( + path.join(publicDir, 'email_verification_link_invalid.html'), + 'utf-8' + ); + await fs.writeFile( + path.join(pagesDir, 'email_verification_link_invalid.html'), + htmlFile + ); + // Write a secret file in the sibling directory + await fs.writeFile(path.join(siblingDir, 'secret.txt'), marker); + + config.pages.pagesPath = pagesDir; + await reconfigureServer(config); + + // Use URL-encoded path traversal: %2e%2e%2f = ../ + // This reaches the sibling "pages-secret" directory which shares + // the "pages" prefix with the configured pagesPath directory name. + const url = `${config.publicServerURL}/apps/%2e%2e%2fpages-secret%2fsecret.txt`; + const response = await request({ + url: url, + followRedirects: false, + }).catch(e => e); + + expect(response.status).toBe(404); + expect(response.text).not.toContain(marker); + } finally { + await fs.rm(baseDir, { recursive: true, force: true }); + } + }); + + it('rejects non-string token in verifyEmail', async () => { + await reconfigureServer(config); + const url = `${config.publicServerURL}/apps/test/verify_email?token[toString]=abc`; + const response = await request({ + url: url, + followRedirects: false, + }).catch(e => e); + expect(response.status).not.toBe(500); + }); + + it('rejects non-string token in requestResetPassword', async () => { + await reconfigureServer(config); + const url = `${config.publicServerURL}/apps/test/request_password_reset?token[toString]=abc`; + const response = await request({ + url: url, + followRedirects: false, + }).catch(e => e); + expect(response.status).not.toBe(500); + }); + + it('rejects non-string token in resetPassword via POST', async () => { + await reconfigureServer(config); + const url = `${config.publicServerURL}/apps/test/request_password_reset`; + const response = await request({ + method: 'POST', + url: url, + headers: { + 'Content-Type': 'application/json', + }, + body: { token: { toString: 'abc' }, new_password: 'newpass123' }, + followRedirects: false, + }).catch(e => e); + expect(response.status).not.toBe(500); + }); + + it('rejects non-string token in resendVerificationEmail via POST', async () => { + await reconfigureServer(config); + const url = `${config.publicServerURL}/apps/test/resend_verification_email`; + const response = await request({ + method: 'POST', + url: url, + headers: { + 'Content-Type': 'application/json', + }, + body: { token: { toString: 'abc' } }, + followRedirects: false, + }).catch(e => e); + expect(response.status).not.toBe(500); + }); + + it('does not leak email verification status via resend page when emailVerifySuccessOnInvalidEmail is true', async () => { + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => {}, + sendMail: () => {}, + }; + await reconfigureServer({ + ...config, + verifyUserEmails: true, + emailVerifySuccessOnInvalidEmail: true, + emailAdapter, + }); + + // Create a user with unverified email + const user = new Parse.User(); + user.setUsername('realuser'); + user.setPassword('password123'); + user.setEmail('real@example.com'); + await user.signUp(); + + const formUrl = `${config.publicServerURL}/apps/${config.appId}/resend_verification_email`; + + // Resend for existing unverified user + const existingResponse = await request({ + method: 'POST', + url: formUrl, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'username=realuser', + followRedirects: false, + }).catch(e => e); + + // Resend for non-existing user + const nonExistingResponse = await request({ + method: 'POST', + url: formUrl, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'username=fakeuser', + followRedirects: false, + }).catch(e => e); + + // Both should redirect to the same page (success) to prevent enumeration + expect(existingResponse.status).toBe(303); + expect(nonExistingResponse.status).toBe(303); + expect(existingResponse.headers.location).toContain('email_verification_send_success'); + expect(nonExistingResponse.headers.location).toContain('email_verification_send_success'); + }); + + it('does leak email verification status via resend page when emailVerifySuccessOnInvalidEmail is false', async () => { + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => {}, + sendMail: () => {}, + }; + await reconfigureServer({ + ...config, + verifyUserEmails: true, + emailVerifySuccessOnInvalidEmail: false, + emailAdapter, + }); + + const formUrl = `${config.publicServerURL}/apps/${config.appId}/resend_verification_email`; + + // Resend for non-existing user should redirect to fail page + const nonExistingResponse = await request({ + method: 'POST', + url: formUrl, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'username=fakeuser', + followRedirects: false, + }).catch(e => e); + + expect(nonExistingResponse.status).toBe(303); + expect(nonExistingResponse.headers.location).toContain('email_verification_send_fail'); + }); + + it('does not create file existence oracle via path traversal in locale query parameter', async () => { + // Create a canary file at a traversable path to test the oracle + const canaryDir = path.join(__dirname, 'tmp-locale-oracle-test'); + try { + await fs.mkdir(canaryDir, { recursive: true }); + await fs.writeFile(path.join(canaryDir, 'password_reset.html'), 'canary'); + + config.pages.enableLocalization = true; + await reconfigureServer(config); + + // Calculate traversal from pages directory to canary directory + const pagesPath = path.resolve(__dirname, '../public'); + const relativePath = path.relative(pagesPath, canaryDir); + + // Request with path traversal locale pointing to existing canary file + const existsResponse = await request({ + url: `${config.publicServerURL}/apps/${config.appId}/request_password_reset?token=test&username=test&locale=${encodeURIComponent(relativePath)}`, + followRedirects: false, + }).catch(e => e); + + // Request with path traversal locale pointing to non-existing directory + const notExistsResponse = await request({ + url: `${config.publicServerURL}/apps/${config.appId}/request_password_reset?token=test&username=test&locale=${encodeURIComponent('../../../../../../tmp/nonexistent-dir')}`, + followRedirects: false, + }).catch(e => e); + + // Both responses must have the same status — no differential oracle + expect(existsResponse.status).toBe(notExistsResponse.status); + // Canary content must never be served + expect(existsResponse.text).not.toContain('canary'); + expect(notExistsResponse.text).not.toContain('canary'); + } finally { + await fs.rm(canaryDir, { recursive: true, force: true }); + } + }); + + it('does not create file existence oracle via path traversal in locale header', async () => { + // Create a canary file at a traversable path + const canaryDir = path.join(__dirname, 'tmp-locale-header-test'); + try { + await fs.mkdir(canaryDir, { recursive: true }); + await fs.writeFile(path.join(canaryDir, 'password_reset.html'), 'canary'); + + config.pages.enableLocalization = true; + await reconfigureServer(config); + + const pagesPath = path.resolve(__dirname, '../public'); + const relativePath = path.relative(pagesPath, canaryDir); + + // Request with path traversal locale via header pointing to existing file + const existsResponse = await request({ + url: `${config.publicServerURL}/apps/${config.appId}/request_password_reset?token=test&username=test`, + headers: { 'x-parse-page-param-locale': relativePath }, + followRedirects: false, + }).catch(e => e); + + // Request with path traversal locale via header pointing to non-existing directory + const notExistsResponse = await request({ + url: `${config.publicServerURL}/apps/${config.appId}/request_password_reset?token=test&username=test`, + headers: { 'x-parse-page-param-locale': '../../../../../../tmp/nonexistent-dir' }, + followRedirects: false, + }).catch(e => e); + + // Both responses must have the same status — no differential oracle + expect(existsResponse.status).toBe(notExistsResponse.status); + // Canary content must never be served + expect(existsResponse.text).not.toContain('canary'); + expect(notExistsResponse.text).not.toContain('canary'); + } finally { + await fs.rm(canaryDir, { recursive: true, force: true }); + } + }); + }); + + describe('custom route', () => { + it('handles custom route with GET', async () => { + config.pages.customRoutes = [ + { + method: 'GET', + path: 'custom_page', + handler: async req => { + expect(req).toBeDefined(); + expect(req.method).toBe('GET'); + return { file: 'custom_page.html' }; + }, + }, + ]; + await reconfigureServer(config); + const handlerSpy = spyOn(config.pages.customRoutes[0], 'handler').and.callThrough(); + + const url = `${config.publicServerURL}/apps/${config.appId}/custom_page`; + const response = await request({ + url: url, + followRedirects: false, + }).catch(e => e); + expect(response.status).toBe(200); + expect(response.text).toMatch(config.appName); + expect(handlerSpy).toHaveBeenCalled(); + }); + + it('handles custom route with POST', async () => { + config.pages.customRoutes = [ + { + method: 'POST', + path: 'custom_page', + handler: async req => { + expect(req).toBeDefined(); + expect(req.method).toBe('POST'); + return { file: 'custom_page.html' }; + }, + }, + ]; + const handlerSpy = spyOn(config.pages.customRoutes[0], 'handler').and.callThrough(); + await reconfigureServer(config); + + const url = `${config.publicServerURL}/apps/${config.appId}/custom_page`; + const response = await request({ + url: url, + followRedirects: false, + method: 'POST', + }).catch(e => e); + expect(response.status).toBe(200); + expect(response.text).toMatch(config.appName); + expect(handlerSpy).toHaveBeenCalled(); + }); + + it('handles multiple custom routes', async () => { + config.pages.customRoutes = [ + { + method: 'GET', + path: 'custom_page', + handler: async req => { + expect(req).toBeDefined(); + expect(req.method).toBe('GET'); + return { file: 'custom_page.html' }; + }, + }, + { + method: 'POST', + path: 'custom_page', + handler: async req => { + expect(req).toBeDefined(); + expect(req.method).toBe('POST'); + return { file: 'custom_page.html' }; + }, + }, + ]; + const getHandlerSpy = spyOn(config.pages.customRoutes[0], 'handler').and.callThrough(); + const postHandlerSpy = spyOn(config.pages.customRoutes[1], 'handler').and.callThrough(); + await reconfigureServer(config); + + const url = `${config.publicServerURL}/apps/${config.appId}/custom_page`; + const getResponse = await request({ + url: url, + followRedirects: false, + method: 'GET', + }).catch(e => e); + expect(getResponse.status).toBe(200); + expect(getResponse.text).toMatch(config.appName); + expect(getHandlerSpy).toHaveBeenCalled(); + + const postResponse = await request({ + url: url, + followRedirects: false, + method: 'POST', + }).catch(e => e); + expect(postResponse.status).toBe(200); + expect(postResponse.text).toMatch(config.appName); + expect(postHandlerSpy).toHaveBeenCalled(); + }); + + it('handles custom route with async handler', async () => { + config.pages.customRoutes = [ + { + method: 'GET', + path: 'custom_page', + handler: async req => { + expect(req).toBeDefined(); + expect(req.method).toBe('GET'); + const file = await new Promise(resolve => + setTimeout(resolve('custom_page.html'), 1000) + ); + return { file }; + }, + }, + ]; + await reconfigureServer(config); + const handlerSpy = spyOn(config.pages.customRoutes[0], 'handler').and.callThrough(); + + const url = `${config.publicServerURL}/apps/${config.appId}/custom_page`; + const response = await request({ + url: url, + followRedirects: false, + }).catch(e => e); + expect(response.status).toBe(200); + expect(response.text).toMatch(config.appName); + expect(handlerSpy).toHaveBeenCalled(); + }); + + it('returns 404 if custom route does not return page', async () => { + config.pages.customRoutes = [ + { + method: 'GET', + path: 'custom_page', + handler: async () => {}, + }, + ]; + await reconfigureServer(config); + const handlerSpy = spyOn(config.pages.customRoutes[0], 'handler').and.callThrough(); + + const url = `${config.publicServerURL}/apps/${config.appId}/custom_page`; + const response = await request({ + url: url, + followRedirects: false, + }).catch(e => e); + expect(response.status).toBe(404); + expect(response.text).toMatch('Not found'); + expect(handlerSpy).toHaveBeenCalled(); + }); + }); + + describe('custom endpoint', () => { + it('password reset works with custom endpoint', async () => { + config.pages.pagesEndpoint = 'customEndpoint'; + await reconfigureServer(config); + const sendPasswordResetEmail = spyOn( + config.emailAdapter, + 'sendPasswordResetEmail' + ).and.callThrough(); + const user = new Parse.User(); + user.setUsername('exampleUsername'); + user.setPassword('examplePassword'); + user.set('email', 'mail@example.com'); + await user.signUp(); + await Parse.User.requestPasswordReset(user.getEmail()); + + const link = sendPasswordResetEmail.calls.all()[0].args[0].link; + const linkResponse = await request({ + url: link, + followRedirects: false, + }); + expect(linkResponse.status).toBe(200); + + const appId = linkResponse.headers['x-parse-page-param-appid']; + const token = linkResponse.headers['x-parse-page-param-token']; + const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl']; + const passwordResetPagePath = pageResponse.calls.all()[0].args[0]; + expect(appId).toBeDefined(); + expect(token).toBeDefined(); + expect(publicServerUrl).toBeDefined(); + expect(passwordResetPagePath).toMatch(new RegExp(`\/${pages.passwordReset.defaultFile}`)); + pageResponse.calls.reset(); + + const formUrl = `${publicServerUrl}/${config.pages.pagesEndpoint}/${appId}/request_password_reset`; + const formResponse = await request({ + url: formUrl, + method: 'POST', + body: { + token, + new_password: 'newPassword', + }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + followRedirects: false, + }); + expect(formResponse.status).toEqual(200); + expect(pageResponse.calls.all()[0].args[0]).toContain( + `/${pages.passwordResetSuccess.defaultFile}` + ); + }); + + it_id('81c1c28e-5dfd-4ffb-a09b-283156c08483')(it)('email verification works with custom endpoint', async () => { + config.pages.pagesEndpoint = 'customEndpoint'; + await reconfigureServer(config); + const sendVerificationEmail = spyOn( + config.emailAdapter, + 'sendVerificationEmail' + ).and.callThrough(); + const user = new Parse.User(); + user.setUsername('exampleUsername'); + user.setPassword('examplePassword'); + user.set('email', 'mail@example.com'); + await user.signUp(); + await jasmine.timeout(); + + const link = sendVerificationEmail.calls.all()[0].args[0].link; + const linkResponse = await request({ + url: link, + followRedirects: false, + }); + expect(linkResponse.status).toBe(200); + const pagePath = pageResponse.calls.all()[0].args[0]; + expect(pagePath).toMatch(new RegExp(`\/${pages.emailVerificationSuccess.defaultFile}`)); + }); + }); + }); + + describe('async publicServerURL', () => { + it('resolves async publicServerURL for password reset page', async () => { + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + await reconfigureServer({ + appId: 'test', + appName: 'exampleAppname', + verifyUserEmails: true, + emailAdapter, + publicServerURL: () => 'http://localhost:8378/1', + }); + + const user = new Parse.User(); + user.setUsername('asyncUrlUser'); + user.setPassword('examplePassword'); + user.set('email', 'async-url@example.com'); + await user.signUp(); + await Parse.User.requestPasswordReset('async-url@example.com'); + + const response = await request({ + url: 'http://localhost:8378/1/apps/test/request_password_reset?token=invalidToken', + followRedirects: false, + }).catch(e => e); + expect(response.status).toBe(200); + expect(response.text).toContain('Invalid password reset link!'); + }); + + it('resolves async publicServerURL for email verification page', async () => { + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + await reconfigureServer({ + appId: 'test', + appName: 'exampleAppname', + verifyUserEmails: true, + emailAdapter, + publicServerURL: () => 'http://localhost:8378/1', + }); + + const response = await request({ + url: 'http://localhost:8378/1/apps/test/verify_email?token=invalidToken', + followRedirects: false, + }).catch(e => e); + expect(response.status).toBe(200); + expect(response.text).toContain('Invalid verification link!'); + }); + }); + + describe('pagesPath resolution', () => { + it('should serve pages when current working directory differs from module directory', async () => { + const originalCwd = process.cwd(); + const os = require('os'); + process.chdir(os.tmpdir()); + + try { + await reconfigureServer({ + appId: 'test', + appName: 'exampleAppname', + publicServerURL: 'http://localhost:8378/1', + }); + + // Request the password reset page with an invalid token; + // even with an invalid token, the server should serve the + // "invalid link" page (200), not a 404. A 404 indicates the + // HTML template files could not be found because pagesPath + // resolved to the wrong directory. + const response = await request({ + url: 'http://localhost:8378/1/apps/test/request_password_reset?token=invalidToken', + }).catch(e => e); + expect(response.status).toBe(200); + expect(response.text).toContain('Invalid password reset link'); + } finally { + process.chdir(originalCwd); + } + }); + }); + + describe('special characters in config', () => { + it('should not URI-encode page param headers by default', async () => { + await reconfigureServer({ + appId: 'test', + appName: 'ExampleAppName', + publicServerURL: 'http://localhost:8378/1', + }); + + const response = await request({ + url: 'http://localhost:8378/1/apps/choose_password?appId=test', + }); + expect(response.status).toBe(200); + expect(response.headers['x-parse-page-param-appname']).toBe('ExampleAppName'); + expect(response.headers['x-parse-page-param-publicserverurl']).toBe( + 'http://localhost:8378/1' + ); + }); + + it('should URI-encode page param headers when encodePageParamHeaders is true', async () => { + await reconfigureServer({ + appId: 'test', + appName: 'Productâ„ĸ', + publicServerURL: 'http://localhost:8378/1', + pages: { + encodePageParamHeaders: true, + }, + }); + + const response = await request({ + url: 'http://localhost:8378/1/apps/choose_password?appId=test', + }); + expect(response.status).toBe(200); + expect(response.headers['x-parse-page-param-appname']).toBe( + encodeURIComponent('Productâ„ĸ') + ); + expect(response.headers['x-parse-page-param-publicserverurl']).toBe( + encodeURIComponent('http://localhost:8378/1') + ); + }); + }); + + describe('XSS Protection', () => { + beforeEach(async () => { + await reconfigureServer({ + appId: 'test', + appName: 'exampleAppname', + publicServerURL: 'http://localhost:8378/1', + }); + }); + + it('should escape XSS payloads in token parameter', async () => { + const xssPayload = '">'; + const response = await request({ + url: `http://localhost:8378/1/apps/choose_password?token=${encodeURIComponent(xssPayload)}&username=test&appId=test`, + }); + + expect(response.status).toBe(200); + expect(response.text).not.toContain(''); + expect(response.text).toContain('"><script>'); + }); + + it('should escape XSS in username parameter', async () => { + const xssUsername = ''; + const response = await request({ + url: `http://localhost:8378/1/apps/choose_password?username=${encodeURIComponent(xssUsername)}&appId=test`, + }); + + expect(response.status).toBe(200); + expect(response.text).not.toContain(''); + expect(response.text).toContain('<img'); + }); + + it('should reject XSS payload in locale parameter', async () => { + const xssLocale = '">'; + const response = await request({ + url: `http://localhost:8378/1/apps/choose_password?locale=${encodeURIComponent(xssLocale)}&appId=test`, + }); + + expect(response.status).toBe(200); + // Invalid locale is rejected by format validation, so the XSS + // payload never reaches the page content + expect(response.text).not.toContain(''); + expect(response.text).not.toContain('"><svg'); + }); + + it('should reject non-ASCII characters in locale parameter', async () => { + // Non-ASCII characters like ğ (U+011F) would cause ERR_INVALID_CHAR + // when set as HTTP header value if not rejected by locale validation + const nonAsciiLocale = 'ğ'; + const response = await request({ + url: `http://localhost:8378/1/apps/choose_password?locale=${encodeURIComponent(nonAsciiLocale)}&appId=test`, + }); + + expect(response.status).toBe(200); + // Non-ASCII locale is rejected by format validation; + // no ERR_INVALID_CHAR error occurs + expect(response.headers['x-parse-page-param-locale']).toBeUndefined(); + }); + + it('should handle legitimate usernames with quotes correctly', async () => { + const username = "O'Brien"; + const response = await request({ + url: `http://localhost:8378/1/apps/choose_password?username=${encodeURIComponent(username)}&appId=test`, + }); + + expect(response.status).toBe(200); + // Should be properly escaped as HTML entity + expect(response.text).toContain('O'Brien'); + // Should NOT contain unescaped quote that breaks HTML + expect(response.text).not.toContain('value="O\'Brien"'); + }); + + it('should handle legitimate usernames with ampersands correctly', async () => { + const username = 'Smith & Co'; + const response = await request({ + url: `http://localhost:8378/1/apps/choose_password?username=${encodeURIComponent(username)}&appId=test`, + }); + + expect(response.status).toBe(200); + // Should be properly escaped + expect(response.text).toContain('Smith & Co'); + }); + }); +}); diff --git a/spec/Parse.Push.spec.js b/spec/Parse.Push.spec.js index d0bad72835..6303496de1 100644 --- a/spec/Parse.Push.spec.js +++ b/spec/Parse.Push.spec.js @@ -1,166 +1,348 @@ 'use strict'; -let request = require('request'); +const request = require('../lib/request'); -describe('Parse.Push', () => { - var setup = function() { - var pushAdapter = { - send: function(body, installations) { - var badge = body.data.badge; - let promises = installations.map((installation) => { - if (installation.deviceType == "ios") { - expect(installation.badge).toEqual(badge); - expect(installation.originalBadge+1).toEqual(installation.badge); - } else { - expect(installation.badge).toBeUndefined(); - } - return Promise.resolve({ - err: null, - deviceType: installation.deviceType, - result: true - }) +const pushCompleted = async pushId => { + const query = new Parse.Query('_PushStatus'); + query.equalTo('objectId', pushId); + let result = await query.first({ useMasterKey: true }); + while (!(result && result.get('status') === 'succeeded')) { + await jasmine.timeout(); + result = await query.first({ useMasterKey: true }); + } +}; + +const successfulAny = function (body, installations) { + const promises = installations.map(device => { + return Promise.resolve({ + transmitted: true, + device: device, + }); + }); + + return Promise.all(promises); +}; + +const provideInstallations = function (num) { + if (!num) { + num = 2; + } + + const installations = []; + while (installations.length !== num) { + // add Android installations + const installation = new Parse.Object('_Installation'); + installation.set('installationId', 'installation_' + installations.length); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('deviceType', 'android'); + installations.push(installation); + } + + return installations; +}; + +const losingAdapter = { + send: function (body, installations) { + // simulate having lost an installation before this was called + // thus invalidating our 'count' in _PushStatus + installations.pop(); + + return successfulAny(body, installations); + }, + getValidPushTypes: function () { + return ['android']; + }, +}; + +const setup = function () { + const sendToInstallationSpy = jasmine.createSpy(); + + const pushAdapter = { + send: function (body, installations) { + const badge = body.data.badge; + const promises = installations.map(installation => { + sendToInstallationSpy(installation); + + if (installation.deviceType == 'ios') { + expect(installation.badge).toEqual(badge); + expect(installation.originalBadge + 1).toEqual(installation.badge); + } else { + expect(installation.badge).toBeUndefined(); + } + return Promise.resolve({ + err: null, + device: installation, + transmitted: true, }); - return Promise.all(promises); - }, - getValidPushTypes: function() { - return ["ios", "android"]; - } - } + }); + return Promise.all(promises); + }, + getValidPushTypes: function () { + return ['ios', 'android']; + }, + }; - return reconfigureServer({ - appId: Parse.applicationId, - masterKey: Parse.masterKey, - serverURL: Parse.serverURL, - push: { - adapter: pushAdapter - } - }) + return reconfigureServer({ + appId: Parse.applicationId, + masterKey: Parse.masterKey, + serverURL: Parse.serverURL, + push: { + adapter: pushAdapter, + }, + }) .then(() => { - var installations = []; - while(installations.length != 10) { - var installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation_"+installations.length); - installation.set("deviceToken","device_token_"+installations.length) - installation.set("badge", installations.length); - installation.set("originalBadge", installations.length); - installation.set("deviceType", "ios"); + const installations = []; + while (installations.length != 10) { + const installation = new Parse.Object('_Installation'); + installation.set('installationId', 'installation_' + installations.length); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); installations.push(installation); } return Parse.Object.saveAll(installations); + }) + .then(() => { + return { + sendToInstallationSpy, + }; }); - } +}; - it_exclude_dbs(['postgres'])('should properly send push', (done) => { - return setup().then(() => { - return Parse.Push.send({ - where: { - deviceType: 'ios' - }, - data: { - badge: 'Increment', - alert: 'Hello world!' - } - }, {useMasterKey: true}) - }) - .then(() => { - done(); - }, (err) => { - console.error(); - fail('should not fail sending push') - done(); +describe('Parse.Push', () => { + it_id('d1e591c4-2b21-466b-9ee2-5be467b6b771')(it)('should properly send push', async () => { + const { sendToInstallationSpy } = await setup(); + const pushStatusId = await Parse.Push.send({ + where: { + deviceType: 'ios', + }, + data: { + badge: 'Increment', + alert: 'Hello world!', + }, }); + await pushCompleted(pushStatusId); + expect(sendToInstallationSpy.calls.count()).toEqual(10); }); - it_exclude_dbs(['postgres'])('should properly send push with lowercaseIncrement', (done) => { - return setup().then(() => { - return Parse.Push.send({ - where: { - deviceType: 'ios' - }, - data: { - badge: 'increment', - alert: 'Hello world!' - } - }, {useMasterKey: true}) - }).then(() => { - done(); - }, (err) => { - console.error(); - fail('should not fail sending push') - done(); + it_id('2a58e3c7-b6f3-4261-a384-6c893b2ac3f3')(it)('should properly send push with lowercaseIncrement', async () => { + await setup(); + const pushStatusId = await Parse.Push.send({ + where: { + deviceType: 'ios', + }, + data: { + badge: 'increment', + alert: 'Hello world!', + }, }); + await pushCompleted(pushStatusId); }); - it_exclude_dbs(['postgres'])('should not allow clients to query _PushStatus', done => { - setup() - .then(() => Parse.Push.send({ + it_id('e21780b6-2cdd-467e-8013-81030f3288e9')(it)('should not allow clients to query _PushStatus', async () => { + await setup(); + const pushStatusId = await Parse.Push.send({ where: { - deviceType: 'ios' + deviceType: 'ios', }, data: { badge: 'increment', - alert: 'Hello world!' - } - }, {useMasterKey: true})) - .then(() => { - request.get({ + alert: 'Hello world!', + }, + }); + await pushCompleted(pushStatusId); + try { + await request({ url: 'http://localhost:8378/1/classes/_PushStatus', json: true, headers: { 'X-Parse-Application-Id': 'test', }, - }, (error, response, body) => { - expect(body.error).toEqual('unauthorized'); - done(); }); - }); + fail(); + } catch (response) { + expect(response.data.error).toEqual('unauthorized'); + } }); - it_exclude_dbs(['postgres'])('should allow master key to query _PushStatus', done => { - setup() - .then(() => Parse.Push.send({ + it_id('924cf5f5-f684-4925-978a-e52c0c457366')(it)('should allow master key to query _PushStatus', async () => { + await setup(); + const pushStatusId = await Parse.Push.send({ where: { - deviceType: 'ios' + deviceType: 'ios', }, data: { badge: 'increment', - alert: 'Hello world!' - } - }, {useMasterKey: true})) - .then(() => { - request.get({ - url: 'http://localhost:8378/1/classes/_PushStatus', - json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - }, - }, (error, response, body) => { - expect(body.results.length).toEqual(1); - expect(body.results[0].query).toEqual('{"deviceType":"ios"}'); - expect(body.results[0].payload).toEqual('{"badge":"increment","alert":"Hello world!"}'); - done(); - }); + alert: 'Hello world!', + }, + }); + await pushCompleted(pushStatusId); + const response = await request({ + url: 'http://localhost:8378/1/classes/_PushStatus', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, }); + const body = response.data; + expect(body.results.length).toEqual(1); + expect(body.results[0].query).toEqual('{"deviceType":"ios"}'); + expect(body.results[0].payload).toEqual('{"badge":"increment","alert":"Hello world!"}'); }); - it_exclude_dbs(['postgres'])('should throw error if missing push configuration', done => { - reconfigureServer({push: null}) - .then(() => { - return Parse.Push.send({ + it('should throw error if missing push configuration', async () => { + await reconfigureServer({ push: null }); + try { + await Parse.Push.send({ where: { - deviceType: 'ios' + deviceType: 'ios', }, data: { badge: 'increment', - alert: 'Hello world!' - } - }, {useMasterKey: true}) - }).then((response) => { - fail('should not succeed'); - }, (err) => { + alert: 'Hello world!', + }, + }); + fail(); + } catch (err) { expect(err.code).toEqual(Parse.Error.PUSH_MISCONFIGURED); - done(); + } + }); + + /** + * Verifies that _PushStatus cannot get stuck in a 'running' state + * Simulates a simple push where 1 installation is removed between _PushStatus + * count being set and the pushes being sent + */ + it("does not get stuck with _PushStatus 'running' on 1 installation lost", async () => { + await reconfigureServer({ + push: { adapter: losingAdapter }, + }); + await Parse.Object.saveAll(provideInstallations()); + const pushStatusId = await Parse.Push.send({ + data: { alert: 'We fixed our status!' }, + where: { deviceType: 'android' }, + }); + await pushCompleted(pushStatusId); + const result = await Parse.Push.getPushStatus(pushStatusId); + expect(result.get('status')).toEqual('succeeded'); + expect(result.get('numSent')).toEqual(1); + expect(result.get('count')).toEqual(undefined); + }); + + /** + * Verifies that _PushStatus cannot get stuck in a 'running' state + * Simulates a simple push where 1 installation is added between _PushStatus + * count being set and the pushes being sent + */ + it("does not get stuck with _PushStatus 'running' on 1 installation added", async () => { + const installations = provideInstallations(); + + // add 1 iOS installation which we will omit & add later on + const iOSInstallation = new Parse.Object('_Installation'); + iOSInstallation.set('installationId', 'installation_' + installations.length); + iOSInstallation.set('deviceToken', 'device_token_' + installations.length); + iOSInstallation.set('deviceType', 'ios'); + installations.push(iOSInstallation); + + await reconfigureServer({ + push: { + adapter: { + send: function (body, installations) { + // simulate having added an installation before this was called + // thus invalidating our 'count' in _PushStatus + installations.push(iOSInstallation); + return successfulAny(body, installations); + }, + getValidPushTypes: function () { + return ['android']; + }, + }, + }, + }); + await Parse.Object.saveAll(installations); + const pushStatusId = await Parse.Push.send({ + data: { alert: 'We fixed our status!' }, + where: { deviceType: { $ne: 'random' } }, + }); + await pushCompleted(pushStatusId); + const result = await Parse.Push.getPushStatus(pushStatusId); + expect(result.get('status')).toEqual('succeeded'); + expect(result.get('numSent')).toEqual(3); + expect(result.get('count')).toEqual(undefined); + }); + + /** + * Verifies that _PushStatus cannot get stuck in a 'running' state + * Simulates an extended push, where some installations may be removed, + * resulting in a non-zero count + */ + it("does not get stuck with _PushStatus 'running' on many installations removed", async () => { + const devices = 1000; + const installations = provideInstallations(devices); + + await reconfigureServer({ + push: { adapter: losingAdapter }, + }); + await Parse.Object.saveAll(installations); + const pushStatusId = await Parse.Push.send({ + data: { alert: 'We fixed our status!' }, + where: { deviceType: 'android' }, + }); + await pushCompleted(pushStatusId); + const result = await Parse.Push.getPushStatus(pushStatusId); + expect(result.get('status')).toEqual('succeeded'); + // expect # less than # of batches used, assuming each batch is 100 pushes + expect(result.get('numSent')).toEqual(devices - devices / 100); + expect(result.get('count')).toEqual(undefined); + }); + + /** + * Verifies that _PushStatus cannot get stuck in a 'running' state + * Simulates an extended push, where some installations may be added, + * resulting in a non-zero count + */ + it("does not get stuck with _PushStatus 'running' on many installations added", async () => { + const devices = 1000; + const installations = provideInstallations(devices); + + // add 1 iOS installation which we will omit & add later on + const iOSInstallations = []; + while (iOSInstallations.length !== devices / 100) { + const iOSInstallation = new Parse.Object('_Installation'); + iOSInstallation.set('installationId', 'installation_' + installations.length); + iOSInstallation.set('deviceToken', 'device_token_' + installations.length); + iOSInstallation.set('deviceType', 'ios'); + installations.push(iOSInstallation); + iOSInstallations.push(iOSInstallation); + } + await reconfigureServer({ + push: { + adapter: { + send: function (body, installations) { + // simulate having added an installation before this was called + // thus invalidating our 'count' in _PushStatus + installations.push(iOSInstallations.pop()); + return successfulAny(body, installations); + }, + getValidPushTypes: function () { + return ['android']; + }, + }, + }, + }); + await Parse.Object.saveAll(installations); + + const pushStatusId = await Parse.Push.send({ + data: { alert: 'We fixed our status!' }, + where: { deviceType: { $ne: 'random' } }, }); + await pushCompleted(pushStatusId); + const result = await Parse.Push.getPushStatus(pushStatusId); + expect(result.get('status')).toEqual('succeeded'); + // expect # less than # of batches used, assuming each batch is 100 pushes + expect(result.get('numSent')).toEqual(devices + devices / 100); + expect(result.get('count')).toEqual(undefined); }); }); diff --git a/spec/ParseACL.spec.js b/spec/ParseACL.spec.js index bbab57d526..f2d2cbd8b3 100644 --- a/spec/ParseACL.spec.js +++ b/spec/ParseACL.spec.js @@ -1,1233 +1,1016 @@ // This is a port of the test suite: // hungry/js/test/parse_acl_test.js -var rest = require('../src/rest'); -var Config = require('../src/Config'); -var config = new Config('test'); -var auth = require('../src/Auth'); +const rest = require('../lib/rest'); +const Config = require('../lib/Config'); +const auth = require('../lib/Auth'); describe('Parse.ACL', () => { - it("acl must be valid", (done) => { - var user = new Parse.User(); - ok(!user.setACL("Ceci n'est pas un ACL.", { - error: function(user, error) { - equal(error.code, -1); - done(); - } - }), "setACL should have returned false."); + it('acl must be valid', () => { + const user = new Parse.User(); + expect(() => user.setACL('ACL')).toThrow(new Parse.Error(Parse.Error.OTHER_CAUSE, 'ACL must be a Parse ACL.')); }); - it("refresh object with acl", (done) => { + it('refresh object with acl', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - // Refreshing the object should succeed. - object.fetch({ - success: function() { - done(); - } - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(null); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + await object.fetch(); + done(); }); - it("acl an object owned by one user and public get", (done) => { + it('acl an object owned by one user and public get', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - // Start making requests by the public, which should all fail. - Parse.User.logOut() - .then(() => { - // Get - var query = new Parse.Query(TestObject); - query.get(object.id, { - success: function(model) { - fail('Should not have retrieved the object.'); - done(); - }, - error: function(model, error) { - equal(error.code, Parse.Error.OBJECT_NOT_FOUND); - done(); - } - }); - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + await Parse.User.logOut(); + const query = new Parse.Query(TestObject); + try { + await query.get(object.id); + done.fail('Should not have retrieved the object.'); + } catch (error) { + equal(error.code, Parse.Error.OBJECT_NOT_FOUND); + done(); + } }); - it("acl an object owned by one user and public find", (done) => { + it('acl an object owned by one user and public find', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - // Start making requests by the public, which should all fail. - Parse.User.logOut() - .then(() => { - // Find - var query = new Parse.Query(TestObject); - query.find({ - success: function(results) { - equal(results.length, 0); - done(); - } - }); - }); - - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + // Start making requests by the public, which should all fail. + await Parse.User.logOut(); + // Find + const query = new Parse.Query(TestObject); + const results = await query.find(); + equal(results.length, 0); + done(); }); - it("acl an object owned by one user and public update", (done) => { + it('acl an object owned by one user and public update', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - // Start making requests by the public, which should all fail. - Parse.User.logOut() - .then(() => { - // Update - object.set("foo", "bar"); - object.save(null, { - success: function() { - fail('Should not have been able to update the object.'); - done(); - }, error: function(model, err) { - equal(err.code, Parse.Error.OBJECT_NOT_FOUND); - done(); - } - }); - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + // Start making requests by the public, which should all fail. + await Parse.User.logOut(); + // Update + object.set('foo', 'bar'); + try { + await object.save(); + done.fail('Should not have been able to update the object.'); + } catch (err) { + equal(err.code, Parse.Error.OBJECT_NOT_FOUND); + done(); + } }); - it_exclude_dbs(['postgres'])("acl an object owned by one user and public delete", (done) => { + it('acl an object owned by one user and public delete', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - // Start making requests by the public, which should all fail. - Parse.User.logOut() - .then(() => object.destroy()) - .then(() => { - fail('destroy should fail'); - done(); - }, error => { - expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - done(); - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + // Start making requests by the public, which should all fail. + await Parse.User.logOut(); + try { + await object.destroy(); + done.fail('destroy should fail'); + } catch (error) { + expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + done(); + } }); - it("acl an object owned by one user and logged in get", (done) => { + it('acl an object owned by one user and logged in get', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => { - Parse.User.logIn("alice", "wonderland", { - success: function() { - // Get - var query = new Parse.Query(TestObject); - query.get(object.id, { - success: function(result) { - ok(result); - equal(result.id, object.id); - equal(result.getACL().getReadAccess(user), true); - equal(result.getACL().getWriteAccess(user), true); - equal(result.getACL().getPublicReadAccess(), false); - equal(result.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - done(); - } - }); - } - }); - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + await Parse.User.logOut(); + await Parse.User.logIn('alice', 'wonderland'); + // Get + const query = new Parse.Query(TestObject); + const result = await query.get(object.id); + ok(result); + equal(result.id, object.id); + equal(result.getACL().getReadAccess(user), true); + equal(result.getACL().getWriteAccess(user), true); + equal(result.getACL().getPublicReadAccess(), false); + equal(result.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + done(); }); - it("acl an object owned by one user and logged in find", (done) => { + it('acl an object owned by one user and logged in find', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => { - Parse.User.logIn("alice", "wonderland", { - success: function() { - // Find - var query = new Parse.Query(TestObject); - query.find({ - success: function(results) { - equal(results.length, 1); - var result = results[0]; - ok(result); - if (!result) { - return fail(); - } - equal(result.id, object.id); - equal(result.getACL().getReadAccess(user), true); - equal(result.getACL().getWriteAccess(user), true); - equal(result.getACL().getPublicReadAccess(), false); - equal(result.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - done(); - } - }); - } - }); - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + await Parse.User.logOut(); + await Parse.User.logIn('alice', 'wonderland'); + // Find + const query = new Parse.Query(TestObject); + const results = await query.find(); + equal(results.length, 1); + const result = results[0]; + ok(result); + if (!result) { + return fail(); + } + equal(result.id, object.id); + equal(result.getACL().getReadAccess(user), true); + equal(result.getACL().getWriteAccess(user), true); + equal(result.getACL().getPublicReadAccess(), false); + equal(result.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + done(); }); - it("acl an object owned by one user and logged in update", (done) => { + it('acl an object owned by one user and logged in update', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => { - Parse.User.logIn("alice", "wonderland", { - success: function() { - // Update - object.set("foo", "bar"); - object.save(null, { - success: function() { - done(); - } - }); - } - }); - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + await Parse.User.logOut(); + await Parse.User.logIn('alice', 'wonderland'); + // Update + object.set('foo', 'bar'); + await object.save(); + done(); }); - it("acl an object owned by one user and logged in delete", (done) => { + it('acl an object owned by one user and logged in delete', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => { - Parse.User.logIn("alice", "wonderland", { - success: function() { - // Delete - object.destroy({ - success: function() { - done(); - } - }); - } - }); - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + await Parse.User.logOut(); + await Parse.User.logIn('alice', 'wonderland'); + // Delete + await object.destroy(); + done(); }); - it_exclude_dbs(['postgres'])("acl making an object publicly readable and public get", (done) => { + it('acl making an object publicly readable and public get', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - // Now make it public. - object.getACL().setPublicReadAccess(true); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), true); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => { - // Get - var query = new Parse.Query(TestObject); - query.get(object.id, { - success: function(result) { - ok(result); - equal(result.id, object.id); - done(); - } - }); - }); - } - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + // Now make it public. + object.getACL().setPublicReadAccess(true); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), true); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + await Parse.User.logOut(); + // Get + const query = new Parse.Query(TestObject); + const result = await query.get(object.id); + ok(result); + equal(result.id, object.id); + done(); }); - it_exclude_dbs(['postgres'])("acl making an object publicly readable and public find", (done) => { + it('acl making an object publicly readable and public find', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - // Now make it public. - object.getACL().setPublicReadAccess(true); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), true); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => { - // Find - var query = new Parse.Query(TestObject); - query.find({ - success: function(results) { - equal(results.length, 1); - var result = results[0]; - ok(result); - equal(result.id, object.id); - done(); - } - }); - }); - } - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + // Now make it public. + object.getACL().setPublicReadAccess(true); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), true); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + await Parse.User.logOut(); + // Find + const query = new Parse.Query(TestObject); + const results = await query.find(); + equal(results.length, 1); + const result = results[0]; + ok(result); + equal(result.id, object.id); + done(); }); - it_exclude_dbs(['postgres'])("acl making an object publicly readable and public update", (done) => { + it('acl making an object publicly readable and public update', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - // Now make it public. - object.getACL().setPublicReadAccess(true); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), true); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => { - // Update - object.set("foo", "bar"); - object.save().then(() => { - fail('the save should fail'); - }, error => { - expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - done(); - }); - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + // Now make it public. + object.getACL().setPublicReadAccess(true); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), true); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + await Parse.User.logOut(); + object.set('foo', 'bar'); + object.save().then( + () => { + fail('the save should fail'); + }, + error => { + expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + done(); } - }); + ); }); - it_exclude_dbs(['postgres'])("acl making an object publicly readable and public delete", (done) => { + it('acl making an object publicly readable and public delete', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - // Now make it public. - object.getACL().setPublicReadAccess(true); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), true); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => object.destroy()) - .then(() => { - fail('expected failure'); - }, error => { - expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - done(); - }); - } - }); - } - }); - } - }); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + // Now make it public. + object.getACL().setPublicReadAccess(true); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), true); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + Parse.User.logOut() + .then(() => object.destroy()) + .then( + () => { + fail('expected failure'); + }, + error => { + expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + done(); + } + ); }); - it_exclude_dbs(['postgres'])("acl making an object publicly writable and public get", (done) => { + it('acl making an object publicly writable and public get', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - // Now make it public. - object.getACL().setPublicWriteAccess(true); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), true); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => { - // Get - var query = new Parse.Query(TestObject); - query.get(object.id, { - error: function(model, error) { - equal(error.code, Parse.Error.OBJECT_NOT_FOUND); - done(); - } - }); - }); - } - }); - } - }); - } + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + // Now make it public. + object.getACL().setPublicWriteAccess(true); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), true); + ok(object.get('ACL')); + + await Parse.User.logOut(); + // Get + const query = new Parse.Query(TestObject); + query + .get(object.id) + .then(done.fail) + .catch(error => { + equal(error.code, Parse.Error.OBJECT_NOT_FOUND); + done(); + }); + }); + + it('acl making an object publicly writable and public find', async done => { + // Create an object owned by Alice. + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + // Now make it public. + object.getACL().setPublicWriteAccess(true); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), true); + ok(object.get('ACL')); + + await Parse.User.logOut(); + // Find + const query = new Parse.Query(TestObject); + query.find().then(function (results) { + equal(results.length, 0); + done(); }); }); - it_exclude_dbs(['postgres'])("acl making an object publicly writable and public find", (done) => { + it('acl making an object publicly writable and public update', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - // Now make it public. - object.getACL().setPublicWriteAccess(true); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), true); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => { - // Find - var query = new Parse.Query(TestObject); - query.find({ - success: function(results) { - equal(results.length, 0); - done(); - } - }); - }); - } - }); - } - }); - } + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + // Now make it public. + object.getACL().setPublicWriteAccess(true); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), true); + ok(object.get('ACL')); + + Parse.User.logOut().then(() => { + // Update + object.set('foo', 'bar'); + object.save().then(done); }); }); - it_exclude_dbs(['postgres'])("acl making an object publicly writable and public update", (done) => { + it('acl making an object publicly writable and public delete', async done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - // Now make it public. - object.getACL().setPublicWriteAccess(true); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), true); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => { - // Update - object.set("foo", "bar"); - object.save(null, { - success: function() { - done(); - } - }); - }); - } - }); - } - }); - } + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + await user.signUp(); + const object = new TestObject(); + const acl = new Parse.ACL(user); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + ok(object.get('ACL')); + + // Now make it public. + object.getACL().setPublicWriteAccess(true); + await object.save(); + equal(object.getACL().getReadAccess(user), true); + equal(object.getACL().getWriteAccess(user), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), true); + ok(object.get('ACL')); + + Parse.User.logOut().then(() => { + // Delete + object.destroy().then(done); }); }); - it_exclude_dbs(['postgres'])("acl making an object publicly writable and public delete", (done) => { + it('acl making an object privately writable (#3194)', done => { // Create an object owned by Alice. - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "wonderland"); - user.signUp(null, { - success: function() { - var object = new TestObject(); - var acl = new Parse.ACL(user); + let object; + let user2; + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'wonderland'); + user + .signUp() + .then(() => { + object = new TestObject(); + const acl = new Parse.ACL(user); + acl.setPublicWriteAccess(false); + acl.setPublicReadAccess(true); object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - ok(object.get("ACL")); - - // Now make it public. - object.getACL().setPublicWriteAccess(true); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(user), true); - equal(object.getACL().getWriteAccess(user), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), true); - ok(object.get("ACL")); - - Parse.User.logOut() - .then(() => { - // Delete - object.destroy({ - success: function() { - done(); - } - }); - }); - } - }); - } + return object.save().then(() => { + return Parse.User.logOut(); }); - } - }); + }) + .then(() => { + user2 = new Parse.User(); + user2.set('username', 'bob'); + user2.set('password', 'burger'); + return user2.signUp(); + }) + .then(() => { + return object.destroy({ sessionToken: user2.getSessionToken() }); + }) + .then( + () => { + fail('should not be able to destroy the object'); + done(); + }, + err => { + expect(err).not.toBeUndefined(); + done(); + } + ); }); - it("acl sharing with another user and get", (done) => { + it('acl sharing with another user and get', async done => { // Sign in as Bob. - Parse.User.signUp("bob", "pass", null, { - success: function(bob) { - Parse.User.logOut() - .then(() => { - // Sign in as Alice. - Parse.User.signUp("alice", "wonderland", null, { - success: function(alice) { - // Create an object shared by Bob and Alice. - var object = new TestObject(); - var acl = new Parse.ACL(alice); - acl.setWriteAccess(bob, true); - acl.setReadAccess(bob, true); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(alice), true); - equal(object.getACL().getWriteAccess(alice), true); - equal(object.getACL().getReadAccess(bob), true); - equal(object.getACL().getWriteAccess(bob), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - - // Sign in as Bob again. - Parse.User.logIn("bob", "pass", { - success: function() { - var query = new Parse.Query(TestObject); - query.get(object.id, { - success: function(result) { - ok(result); - equal(result.id, object.id); - done(); - } - }); - } - }); - } - }); - } - }); - }); - } + const bob = await Parse.User.signUp('bob', 'pass'); + await Parse.User.logOut(); + + const alice = await Parse.User.signUp('alice', 'wonderland'); + // Create an object shared by Bob and Alice. + const object = new TestObject(); + const acl = new Parse.ACL(alice); + acl.setWriteAccess(bob, true); + acl.setReadAccess(bob, true); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(alice), true); + equal(object.getACL().getWriteAccess(alice), true); + equal(object.getACL().getReadAccess(bob), true); + equal(object.getACL().getWriteAccess(bob), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + + // Sign in as Bob again. + await Parse.User.logIn('bob', 'pass'); + const query = new Parse.Query(TestObject); + query.get(object.id).then(result => { + ok(result); + equal(result.id, object.id); + done(); }); }); - it("acl sharing with another user and find", (done) => { + it('acl sharing with another user and find', async done => { // Sign in as Bob. - Parse.User.signUp("bob", "pass", null, { - success: function(bob) { - Parse.User.logOut() - .then(() => { - // Sign in as Alice. - Parse.User.signUp("alice", "wonderland", null, { - success: function(alice) { - // Create an object shared by Bob and Alice. - var object = new TestObject(); - var acl = new Parse.ACL(alice); - acl.setWriteAccess(bob, true); - acl.setReadAccess(bob, true); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(alice), true); - equal(object.getACL().getWriteAccess(alice), true); - equal(object.getACL().getReadAccess(bob), true); - equal(object.getACL().getWriteAccess(bob), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - - // Sign in as Bob again. - Parse.User.logIn("bob", "pass", { - success: function() { - var query = new Parse.Query(TestObject); - query.find({ - success: function(results) { - equal(results.length, 1); - var result = results[0]; - ok(result); - if (!result) { - fail("should have result"); - } else { - equal(result.id, object.id); - } - done(); - } - }); - } - }); - } - }); - } - }); - }); + const bob = await Parse.User.signUp('bob', 'pass'); + await Parse.User.logOut(); + // Sign in as Alice. + const alice = await Parse.User.signUp('alice', 'wonderland'); + // Create an object shared by Bob and Alice. + const object = new TestObject(); + const acl = new Parse.ACL(alice); + acl.setWriteAccess(bob, true); + acl.setReadAccess(bob, true); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(alice), true); + equal(object.getACL().getWriteAccess(alice), true); + equal(object.getACL().getReadAccess(bob), true); + equal(object.getACL().getWriteAccess(bob), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + + // Sign in as Bob again. + await Parse.User.logIn('bob', 'pass'); + const query = new Parse.Query(TestObject); + query.find().then(results => { + equal(results.length, 1); + const result = results[0]; + ok(result); + if (!result) { + fail('should have result'); + } else { + equal(result.id, object.id); } + done(); }); }); - it("acl sharing with another user and update", (done) => { + it('acl sharing with another user and update', async done => { // Sign in as Bob. - Parse.User.signUp("bob", "pass", null, { - success: function(bob) { - Parse.User.logOut() - .then(() => { - // Sign in as Alice. - Parse.User.signUp("alice", "wonderland", null, { - success: function(alice) { - // Create an object shared by Bob and Alice. - var object = new TestObject(); - var acl = new Parse.ACL(alice); - acl.setWriteAccess(bob, true); - acl.setReadAccess(bob, true); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(alice), true); - equal(object.getACL().getWriteAccess(alice), true); - equal(object.getACL().getReadAccess(bob), true); - equal(object.getACL().getWriteAccess(bob), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - - // Sign in as Bob again. - Parse.User.logIn("bob", "pass", { - success: function() { - object.set("foo", "bar"); - object.save(null, { - success: function() { - done(); - } - }); - } - }); - } - }); - } - }); - }); - } - }); + const bob = await Parse.User.signUp('bob', 'pass'); + await Parse.User.logOut(); + // Sign in as Alice. + const alice = await Parse.User.signUp('alice', 'wonderland'); + // Create an object shared by Bob and Alice. + const object = new TestObject(); + const acl = new Parse.ACL(alice); + acl.setWriteAccess(bob, true); + acl.setReadAccess(bob, true); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(alice), true); + equal(object.getACL().getWriteAccess(alice), true); + equal(object.getACL().getReadAccess(bob), true); + equal(object.getACL().getWriteAccess(bob), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + + // Sign in as Bob again. + await Parse.User.logIn('bob', 'pass'); + object.set('foo', 'bar'); + object.save().then(done); }); - it("acl sharing with another user and delete", (done) => { + it('acl sharing with another user and delete', async done => { // Sign in as Bob. - Parse.User.signUp("bob", "pass", null, { - success: function(bob) { - Parse.User.logOut() - .then(() => { - // Sign in as Alice. - Parse.User.signUp("alice", "wonderland", null, { - success: function(alice) { - // Create an object shared by Bob and Alice. - var object = new TestObject(); - var acl = new Parse.ACL(alice); - acl.setWriteAccess(bob, true); - acl.setReadAccess(bob, true); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(alice), true); - equal(object.getACL().getWriteAccess(alice), true); - equal(object.getACL().getReadAccess(bob), true); - equal(object.getACL().getWriteAccess(bob), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - - // Sign in as Bob again. - Parse.User.logIn("bob", "pass", { - success: function() { - object.set("foo", "bar"); - object.destroy({ - success: function() { - done(); - } - }); - } - }); - } - }); - } - }); - }); - } - }); + const bob = await Parse.User.signUp('bob', 'pass'); + await Parse.User.logOut(); + // Sign in as Alice. + const alice = await Parse.User.signUp('alice', 'wonderland'); + // Create an object shared by Bob and Alice. + const object = new TestObject(); + const acl = new Parse.ACL(alice); + acl.setWriteAccess(bob, true); + acl.setReadAccess(bob, true); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(alice), true); + equal(object.getACL().getWriteAccess(alice), true); + equal(object.getACL().getReadAccess(bob), true); + equal(object.getACL().getWriteAccess(bob), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + + // Sign in as Bob again. + await Parse.User.logIn('bob', 'pass'); + object.set('foo', 'bar'); + object.destroy().then(done); }); - it("acl sharing with another user and public get", (done) => { - // Sign in as Bob. - Parse.User.signUp("bob", "pass", null, { - success: function(bob) { - Parse.User.logOut() - .then(() => { - // Sign in as Alice. - Parse.User.signUp("alice", "wonderland", null, { - success: function(alice) { - // Create an object shared by Bob and Alice. - var object = new TestObject(); - var acl = new Parse.ACL(alice); - acl.setWriteAccess(bob, true); - acl.setReadAccess(bob, true); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(alice), true); - equal(object.getACL().getWriteAccess(alice), true); - equal(object.getACL().getReadAccess(bob), true); - equal(object.getACL().getWriteAccess(bob), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - - // Start making requests by the public. - Parse.User.logOut() - .then(() => { - var query = new Parse.Query(TestObject); - query.get(object.id).then((result) => { - fail(result); - }, (error) => { - expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - done(); - }); - }); - } - }); - } - }); - }); + it('acl sharing with another user and public get', async done => { + const bob = await Parse.User.signUp('bob', 'pass'); + await Parse.User.logOut(); + // Sign in as Alice. + const alice = await Parse.User.signUp('alice', 'wonderland'); + // Create an object shared by Bob and Alice. + const object = new TestObject(); + const acl = new Parse.ACL(alice); + acl.setWriteAccess(bob, true); + acl.setReadAccess(bob, true); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(alice), true); + equal(object.getACL().getWriteAccess(alice), true); + equal(object.getACL().getReadAccess(bob), true); + equal(object.getACL().getWriteAccess(bob), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + // Start making requests by the public. + await Parse.User.logOut(); + const query = new Parse.Query(TestObject); + query.get(object.id).then( + result => { + fail(result); + }, + error => { + expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + done(); } - }); + ); }); - it("acl sharing with another user and public find", (done) => { - // Sign in as Bob. - Parse.User.signUp("bob", "pass", null, { - success: function(bob) { - Parse.User.logOut() - .then(() => { - // Sign in as Alice. - Parse.User.signUp("alice", "wonderland", null, { - success: function(alice) { - // Create an object shared by Bob and Alice. - var object = new TestObject(); - var acl = new Parse.ACL(alice); - acl.setWriteAccess(bob, true); - acl.setReadAccess(bob, true); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(alice), true); - equal(object.getACL().getWriteAccess(alice), true); - equal(object.getACL().getReadAccess(bob), true); - equal(object.getACL().getWriteAccess(bob), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - - // Start making requests by the public. - Parse.User.logOut() - .then(() => { - var query = new Parse.Query(TestObject); - query.find({ - success: function(results) { - equal(results.length, 0); - done(); - } - }); - }); - } - }); - } - }); - }); - } + it('acl sharing with another user and public find', async done => { + const bob = await Parse.User.signUp('bob', 'pass'); + await Parse.User.logOut(); + // Sign in as Alice. + const alice = await Parse.User.signUp('alice', 'wonderland'); + // Create an object shared by Bob and Alice. + const object = new TestObject(); + const acl = new Parse.ACL(alice); + acl.setWriteAccess(bob, true); + acl.setReadAccess(bob, true); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(alice), true); + equal(object.getACL().getWriteAccess(alice), true); + equal(object.getACL().getReadAccess(bob), true); + equal(object.getACL().getWriteAccess(bob), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + + // Start making requests by the public. + Parse.User.logOut().then(() => { + const query = new Parse.Query(TestObject); + query.find().then(function (results) { + equal(results.length, 0); + done(); + }); }); }); - it("acl sharing with another user and public update", (done) => { + it('acl sharing with another user and public update', async done => { // Sign in as Bob. - Parse.User.signUp("bob", "pass", null, { - success: function(bob) { - Parse.User.logOut() - .then(() => { - // Sign in as Alice. - Parse.User.signUp("alice", "wonderland", null, { - success: function(alice) { - // Create an object shared by Bob and Alice. - var object = new TestObject(); - var acl = new Parse.ACL(alice); - acl.setWriteAccess(bob, true); - acl.setReadAccess(bob, true); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(alice), true); - equal(object.getACL().getWriteAccess(alice), true); - equal(object.getACL().getReadAccess(bob), true); - equal(object.getACL().getWriteAccess(bob), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - - // Start making requests by the public. - Parse.User.logOut() - .then(() => { - object.set("foo", "bar"); - object.save().then(() => { - fail('expected failure'); - }, (error) => { - expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - done(); - }); - }); - } - }); - } - }); - }); - } + const bob = await Parse.User.signUp('bob', 'pass'); + await Parse.User.logOut(); + // Sign in as Alice. + const alice = await Parse.User.signUp('alice', 'wonderland'); + // Create an object shared by Bob and Alice. + const object = new TestObject(); + const acl = new Parse.ACL(alice); + acl.setWriteAccess(bob, true); + acl.setReadAccess(bob, true); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(alice), true); + equal(object.getACL().getWriteAccess(alice), true); + equal(object.getACL().getReadAccess(bob), true); + equal(object.getACL().getWriteAccess(bob), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + + // Start making requests by the public. + Parse.User.logOut().then(() => { + object.set('foo', 'bar'); + object.save().then( + () => { + fail('expected failure'); + }, + error => { + expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + done(); + } + ); }); }); - it_exclude_dbs(['postgres'])("acl sharing with another user and public delete", (done) => { + it('acl sharing with another user and public delete', async done => { // Sign in as Bob. - Parse.User.signUp("bob", "pass", null, { - success: function(bob) { - Parse.User.logOut() - .then(() => { - // Sign in as Alice. - Parse.User.signUp("alice", "wonderland", null, { - success: function(alice) { - // Create an object shared by Bob and Alice. - var object = new TestObject(); - var acl = new Parse.ACL(alice); - acl.setWriteAccess(bob, true); - acl.setReadAccess(bob, true); - object.setACL(acl); - object.save(null, { - success: function() { - equal(object.getACL().getReadAccess(alice), true); - equal(object.getACL().getWriteAccess(alice), true); - equal(object.getACL().getReadAccess(bob), true); - equal(object.getACL().getWriteAccess(bob), true); - equal(object.getACL().getPublicReadAccess(), false); - equal(object.getACL().getPublicWriteAccess(), false); - - // Start making requests by the public. - Parse.User.logOut() - .then(() => object.destroy()) - .then(() => { - fail('expected failure'); - }, (error) => { - expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - done(); - }); - } - }); - } - }); - }); - } - }); + const bob = await Parse.User.signUp('bob', 'pass'); + await Parse.User.logOut(); + // Sign in as Alice. + const alice = await Parse.User.signUp('alice', 'wonderland'); + // Create an object shared by Bob and Alice. + const object = new TestObject(); + const acl = new Parse.ACL(alice); + acl.setWriteAccess(bob, true); + acl.setReadAccess(bob, true); + object.setACL(acl); + await object.save(); + equal(object.getACL().getReadAccess(alice), true); + equal(object.getACL().getWriteAccess(alice), true); + equal(object.getACL().getReadAccess(bob), true); + equal(object.getACL().getWriteAccess(bob), true); + equal(object.getACL().getPublicReadAccess(), false); + equal(object.getACL().getPublicWriteAccess(), false); + + // Start making requests by the public. + Parse.User.logOut() + .then(() => object.destroy()) + .then( + () => { + fail('expected failure'); + }, + error => { + expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + done(); + } + ); }); - it("acl saveAll with permissions", (done) => { - Parse.User.signUp("alice", "wonderland", null, { - success: function(alice) { - var acl = new Parse.ACL(alice); - - var object1 = new TestObject(); - var object2 = new TestObject(); - object1.setACL(acl); - object2.setACL(acl); - Parse.Object.saveAll([object1, object2], { - success: function() { - equal(object1.getACL().getReadAccess(alice), true); - equal(object1.getACL().getWriteAccess(alice), true); - equal(object1.getACL().getPublicReadAccess(), false); - equal(object1.getACL().getPublicWriteAccess(), false); - equal(object2.getACL().getReadAccess(alice), true); - equal(object2.getACL().getWriteAccess(alice), true); - equal(object2.getACL().getPublicReadAccess(), false); - equal(object2.getACL().getPublicWriteAccess(), false); - - // Save all the objects after updating them. - object1.set("foo", "bar"); - object2.set("foo", "bar"); - Parse.Object.saveAll([object1, object2], { - success: function() { - var query = new Parse.Query(TestObject); - query.equalTo("foo", "bar"); - query.find({ - success: function(results) { - equal(results.length, 2); - done(); - } - }); - } - }); - } - }); - } + it('acl saveAll with permissions', async done => { + const alice = await Parse.User.signUp('alice', 'wonderland'); + const acl = new Parse.ACL(alice); + const object1 = new TestObject(); + const object2 = new TestObject(); + object1.setACL(acl); + object2.setACL(acl); + await Parse.Object.saveAll([object1, object2]); + equal(object1.getACL().getReadAccess(alice), true); + equal(object1.getACL().getWriteAccess(alice), true); + equal(object1.getACL().getPublicReadAccess(), false); + equal(object1.getACL().getPublicWriteAccess(), false); + equal(object2.getACL().getReadAccess(alice), true); + equal(object2.getACL().getWriteAccess(alice), true); + equal(object2.getACL().getPublicReadAccess(), false); + equal(object2.getACL().getPublicWriteAccess(), false); + + // Save all the objects after updating them. + object1.set('foo', 'bar'); + object2.set('foo', 'bar'); + await Parse.Object.saveAll([object1, object2]); + const query = new Parse.Query(TestObject); + query.equalTo('foo', 'bar'); + query.find().then(function (results) { + equal(results.length, 2); + done(); }); }); - it("empty acl works", (done) => { - Parse.User.signUp("tdurden", "mayhem", { + it('empty acl works', async done => { + await Parse.User.signUp('tdurden', 'mayhem', { ACL: new Parse.ACL(), - foo: "bar" - }, { - success: function(user) { - Parse.User.logOut() - .then(() => { - Parse.User.logIn("tdurden", "mayhem", { - success: function(user) { - equal(user.get("foo"), "bar"); - done(); - }, - error: function(user, error) { - ok(null, "Error " + error.id + ": " + error.message); - done(); - } - }); - }); - }, - error: function(user, error) { - ok(null, "Error " + error.id + ": " + error.message); - done(); - } + foo: 'bar', }); + + await Parse.User.logOut(); + const user = await Parse.User.logIn('tdurden', 'mayhem'); + equal(user.get('foo'), 'bar'); + done(); }); - it("query for included object with ACL works", (done) => { - var obj1 = new Parse.Object("TestClass1"); - var obj2 = new Parse.Object("TestClass2"); - var acl = new Parse.ACL(); + it('query for included object with ACL works', async done => { + const obj1 = new Parse.Object('TestClass1'); + const obj2 = new Parse.Object('TestClass2'); + const acl = new Parse.ACL(); acl.setPublicReadAccess(true); - obj2.set("ACL", acl); - obj1.set("other", obj2); - obj1.save(null, expectSuccess({ - success: function() { - obj2._clearServerData(); - var query = new Parse.Query("TestClass1"); - query.first(expectSuccess({ - success: function(obj1Again) { - ok(!obj1Again.get("other").get("ACL")); - - query.include("other"); - query.first(expectSuccess({ - success: function(obj1AgainWithInclude) { - ok(obj1AgainWithInclude.get("other").get("ACL")); - done(); - } - })); - } - })); - } - })); + obj2.set('ACL', acl); + obj1.set('other', obj2); + await obj1.save(); + obj2._clearServerData(); + const query = new Parse.Query('TestClass1'); + const obj1Again = await query.first(); + ok(!obj1Again.get('other').get('ACL')); + + query.include('other'); + const obj1AgainWithInclude = await query.first(); + ok(obj1AgainWithInclude.get('other').get('ACL')); + done(); }); - it('restricted ACL does not have public access', (done) => { - var obj = new Parse.Object("TestClassMasterACL"); - var acl = new Parse.ACL(); + it('restricted ACL does not have public access', done => { + const obj = new Parse.Object('TestClassMasterACL'); + const acl = new Parse.ACL(); obj.set('ACL', acl); - obj.save().then(() => { - var query = new Parse.Query("TestClassMasterACL"); - return query.find(); - }).then((results) => { - ok(!results.length, 'Should not have returned object with secure ACL.'); - done(); - }); + obj + .save() + .then(() => { + const query = new Parse.Query('TestClassMasterACL'); + return query.find(); + }) + .then(results => { + ok(!results.length, 'Should not have returned object with secure ACL.'); + done(); + }); }); - it_exclude_dbs(['postgres'])('regression test #701', done => { - var anonUser = { + it('regression test #701', done => { + const config = Config.get('test'); + const anonUser = { authData: { anonymous: { - id: '00000000-0000-0000-0000-000000000001' - } - } + id: '00000000-0000-0000-0000-000000000001', + }, + }, }; - Parse.Cloud.afterSave(Parse.User, req => { + Parse.Cloud.afterSave(Parse.User, req => { if (!req.object.existed()) { - var user = req.object; - var acl = new Parse.ACL(user); + const user = req.object; + const acl = new Parse.ACL(user); user.setACL(acl); - user.save(null, {useMasterKey: true}).then(user => { - new Parse.Query('_User').get(user.objectId).then(user => { - fail('should not have fetched user without public read enabled'); - done(); - }, error => { - expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - done(); - }); - }); + user.save(null, { useMasterKey: true }).then(user => { + new Parse.Query('_User').get(user.objectId).then( + () => { + fail('should not have fetched user without public read enabled'); + done(); + }, + error => { + expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + done(); + } + ); + }, done.fail); } }); - rest.create(config, auth.nobody(config), '_User', anonUser) - }) + rest.create(config, auth.nobody(config), '_User', anonUser); + }); + + it('support defaultACL in schema', async () => { + await new Parse.Object('TestObject').save(); + const schema = await Parse.Server.database.loadSchema(); + await schema.updateClass( + 'TestObject', + {}, + { + create: { + '*': true, + }, + ACL: { + '*': { read: true }, + currentUser: { read: true, write: true }, + }, + } + ); + const acls = new Parse.ACL(); + acls.setPublicReadAccess(true); + const user = await Parse.User.signUp('testuser', 'p@ssword'); + const obj = new Parse.Object('TestObject'); + await obj.save(null, { sessionToken: user.getSessionToken() }); + expect(obj.getACL()).toBeDefined(); + const acl = obj.getACL().toJSON(); + expect(acl['*']).toEqual({ read: true }); + expect(acl[user.id].write).toBeTrue(); + expect(acl[user.id].read).toBeTrue(); + }); + + it('should not overwrite ACL with defaultACL on update', async () => { + await new Parse.Object('TestObject').save(); + const schema = await Parse.Server.database.loadSchema(); + await schema.updateClass( + 'TestObject', + {}, + { + create: { '*': true }, + update: { '*': true }, + addField: { '*': true }, + ACL: { + '*': { read: true }, + currentUser: { read: true, write: true }, + }, + } + ); + const user = await Parse.User.signUp('testuser', 'p@ssword'); + const obj = new Parse.Object('TestObject'); + await obj.save(null, { sessionToken: user.getSessionToken() }); + + const originalAcl = obj.getACL().toJSON(); + expect(originalAcl['*']).toEqual({ read: true }); + expect(originalAcl[user.id]).toEqual({ read: true, write: true }); + + obj.set('field', 'value'); + await obj.save(null, { sessionToken: user.getSessionToken() }); + + const updatedAcl = obj.getACL().toJSON(); + expect(updatedAcl).toEqual(originalAcl); + }); + + it('should allow explicit ACL modification on update', async () => { + await new Parse.Object('TestObject').save(); + const schema = await Parse.Server.database.loadSchema(); + await schema.updateClass( + 'TestObject', + {}, + { + create: { '*': true }, + update: { '*': true }, + ACL: { + '*': { read: true }, + currentUser: { read: true, write: true }, + }, + } + ); + const user = await Parse.User.signUp('testuser', 'p@ssword'); + const obj = new Parse.Object('TestObject'); + await obj.save(null, { sessionToken: user.getSessionToken() }); + + const customAcl = new Parse.ACL(); + customAcl.setPublicReadAccess(false); + customAcl.setReadAccess(user.id, true); + customAcl.setWriteAccess(user.id, true); + obj.setACL(customAcl); + await obj.save(null, { sessionToken: user.getSessionToken() }); + + const updatedAcl = obj.getACL().toJSON(); + expect(updatedAcl['*']).toBeUndefined(); + expect(updatedAcl[user.id]).toEqual({ read: true, write: true }); + }); }); diff --git a/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js index a4ea9d902f..cd0221e3ad 100644 --- a/spec/ParseAPI.spec.js +++ b/spec/ParseAPI.spec.js @@ -2,380 +2,444 @@ // It would probably be better to refactor them into different files. 'use strict'; -var DatabaseAdapter = require('../src/DatabaseAdapter'); -const MongoStorageAdapter = require('../src/Adapters/Storage/Mongo/MongoStorageAdapter'); -var request = require('request'); -const rp = require('request-promise'); -const Parse = require("parse/node"); -let Config = require('../src/Config'); -const SchemaController = require('../src/Controllers/SchemaController'); -var TestUtils = require('../src/index').TestUtils; -const deepcopy = require('deepcopy'); - -const userSchema = SchemaController.convertSchemaToAdapterSchema({ className: '_User', fields: Object.assign({}, SchemaController.defaultColumns._Default, SchemaController.defaultColumns._User) }); - -describe('miscellaneous', function() { - it('create a GameScore object', function(done) { - var obj = new Parse.Object('GameScore'); +const request = require('../lib/request'); +const Parse = require('parse/node'); +const Config = require('../lib/Config'); +const SchemaController = require('../lib/Controllers/SchemaController'); +const { destroyAllDataPermanently } = require('../lib/TestUtils'); +const Utils = require('../lib/Utils'); + +const userSchema = SchemaController.convertSchemaToAdapterSchema({ + className: '_User', + fields: Object.assign( + {}, + SchemaController.defaultColumns._Default, + SchemaController.defaultColumns._User + ), +}); +const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Installation-Id': 'yolo', +}; + +describe('miscellaneous', () => { + it('db contains document after successful save', async () => { + const obj = new Parse.Object('TestObject'); + obj.set('foo', 'bar'); + await obj.save(); + const config = Config.get(defaultConfiguration.appId); + const results = await config.database.adapter.find('TestObject', { fields: {} }, {}, {}); + expect(results.length).toEqual(1); + expect(results[0]['foo']).toEqual('bar'); + }); + + it('create a GameScore object', function (done) { + const obj = new Parse.Object('GameScore'); obj.set('score', 1337); - obj.save().then(function(obj) { + obj.save().then(function (obj) { expect(typeof obj.id).toBe('string'); expect(typeof obj.createdAt.toGMTString()).toBe('string'); done(); - }, error => { - fail(JSON.stringify(error)); - done(); - }); + }, done.fail); }); - it('get a TestObject', function(done) { - create({ 'bloop' : 'blarg' }, function(obj) { - var t2 = new TestObject({ objectId: obj.id }); - t2.fetch({ - success: function(obj2) { - expect(obj2.get('bloop')).toEqual('blarg'); - expect(obj2.id).toBeTruthy(); - expect(obj2.id).toEqual(obj.id); - done(); - }, - error: error => { - fail(JSON.stringify(error)); - done(); - } - }); + it('get a TestObject', function (done) { + create({ bloop: 'blarg' }, async function (obj) { + const t2 = new TestObject({ objectId: obj.id }); + const obj2 = await t2.fetch(); + expect(obj2.get('bloop')).toEqual('blarg'); + expect(obj2.id).toBeTruthy(); + expect(obj2.id).toEqual(obj.id); + done(); }); }); - it('create a valid parse user', function(done) { - createTestUser(function(data) { + it('create a valid parse user', function (done) { + createTestUser().then(function (data) { expect(data.id).not.toBeUndefined(); expect(data.getSessionToken()).not.toBeUndefined(); expect(data.get('password')).toBeUndefined(); done(); - }, error => { - fail(JSON.stringify(error)); - done(); - }); + }, done.fail); }); - it('fail to create a duplicate username', done => { - let numCreated = 0; + it('fail to create a duplicate username', async () => { + await reconfigureServer(); let numFailed = 0; - let p1 = createTestUser(); - p1.then(user => { - numCreated++; - expect(numCreated).toEqual(1); - }) - .catch(error => { - numFailed++; - expect(numFailed).toEqual(1); - expect(error.code).toEqual(Parse.Error.USERNAME_TAKEN); - }); - let p2 = createTestUser(); - p2.then(user => { - numCreated++; - expect(numCreated).toEqual(1); - }) - .catch(error => { - numFailed++; - expect(numFailed).toEqual(1); - expect(error.code).toEqual(Parse.Error.USERNAME_TAKEN); - }); - Parse.Promise.when([p1, p2]) - .then(() => { - fail('one of the users should not have been created'); - done(); - }) - .catch(done); + let numCreated = 0; + const p1 = request({ + method: 'POST', + url: Parse.serverURL + '/users', + body: { + password: 'asdf', + username: 'u1', + email: 'dupe@dupe.dupe', + }, + headers, + }).then( + () => { + numCreated++; + expect(numCreated).toEqual(1); + }, + response => { + numFailed++; + expect(response.data.code).toEqual(Parse.Error.USERNAME_TAKEN); + } + ); + + const p2 = request({ + method: 'POST', + url: Parse.serverURL + '/users', + body: { + password: 'otherpassword', + username: 'u1', + email: 'email@other.email', + }, + headers, + }).then( + () => { + numCreated++; + }, + ({ data }) => { + numFailed++; + expect(data.code).toEqual(Parse.Error.USERNAME_TAKEN); + } + ); + + await Promise.all([p1, p2]); + expect(numFailed).toEqual(1); + expect(numCreated).toBe(1); }); - it('ensure that email is uniquely indexed', done => { + it('ensure that email is uniquely indexed', async () => { + await reconfigureServer(); let numFailed = 0; let numCreated = 0; - let user1 = new Parse.User(); - user1.setPassword('asdf'); - user1.setUsername('u1'); - user1.setEmail('dupe@dupe.dupe'); - let p1 = user1.signUp(); - p1.then(user => { - numCreated++; - expect(numCreated).toEqual(1); - }, error => { - numFailed++; - expect(numFailed).toEqual(1); - expect(error.code).toEqual(Parse.Error.EMAIL_TAKEN); - }); - - let user2 = new Parse.User(); - user2.setPassword('asdf'); - user2.setUsername('u2'); - user2.setEmail('dupe@dupe.dupe'); - let p2 = user2.signUp(); - p2.then(user => { - numCreated++; - expect(numCreated).toEqual(1); - }, error => { - numFailed++; - expect(numFailed).toEqual(1); - expect(error.code).toEqual(Parse.Error.EMAIL_TAKEN); - }); + const p1 = request({ + method: 'POST', + url: Parse.serverURL + '/users', + body: { + password: 'asdf', + username: 'u1', + email: 'dupe@dupe.dupe', + }, + headers, + }).then( + () => { + numCreated++; + expect(numCreated).toEqual(1); + }, + ({ data }) => { + numFailed++; + expect(data.code).toEqual(Parse.Error.EMAIL_TAKEN); + } + ); + + const p2 = request({ + url: Parse.serverURL + '/users', + method: 'POST', + body: { + password: 'asdf', + username: 'u2', + email: 'dupe@dupe.dupe', + }, + headers, + }).then( + () => { + numCreated++; + expect(numCreated).toEqual(1); + }, + ({ data }) => { + numFailed++; + expect(data.code).toEqual(Parse.Error.EMAIL_TAKEN); + } + ); - Parse.Promise.when([p1, p2]) - .then(() => { - fail('one of the users should not have been created'); - done(); - }) - .catch(done); + await Promise.all([p1, p2]); + expect(numFailed).toEqual(1); + expect(numCreated).toBe(1); }); - it_exclude_dbs(['postgres'])('ensure that if people already have duplicate users, they can still sign up new users', done => { - let config = new Config('test'); + it_id('be1b9ac7-5e5f-4e91-b044-2bd8fb7622ad')(it)('ensure that if people already have duplicate users, they can still sign up new users', async done => { + try { + await Parse.User.logOut(); + } catch (e) { + /* ignore */ + } + const config = Config.get('test'); // Remove existing data to clear out unique index - TestUtils.destroyAllDataPermanently() - .then(() => config.database.adapter.createClass('_User', userSchema)) - .then(() => config.database.adapter.createObject('_User', userSchema, { objectId: 'x', username: 'u' }).catch(fail)) - .then(() => config.database.adapter.createObject('_User', userSchema, { objectId: 'y', username: 'u' }).catch(fail)) - // Create a new server to try to recreate the unique indexes - .then(reconfigureServer) - .catch(error => { - expect(error.code).toEqual(Parse.Error.DUPLICATE_VALUE); - let user = new Parse.User(); - user.setPassword('asdf'); - user.setUsername('zxcv'); - return user.signUp().catch(fail); - }) - .then(() => { - let user = new Parse.User(); - user.setPassword('asdf'); - user.setUsername('u'); - return user.signUp() - }) - .then(result => { - fail('should not have been able to sign up'); - done(); - }) - .catch(error => { - expect(error.code).toEqual(Parse.Error.USERNAME_TAKEN); - done(); - }) + destroyAllDataPermanently() + .then(() => config.database.adapter.performInitialization({ VolatileClassesSchemas: [] })) + .then(() => config.database.adapter.createClass('_User', userSchema)) + .then(() => + config.database.adapter + .createObject('_User', userSchema, { objectId: 'x', username: 'u' }) + .catch(fail) + ) + .then(() => + config.database.adapter + .createObject('_User', userSchema, { objectId: 'y', username: 'u' }) + .catch(fail) + ) + // Create a new server to try to recreate the unique indexes + .then(reconfigureServer) + .catch(error => { + expect(error.code).toEqual(Parse.Error.DUPLICATE_VALUE); + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + return user.signUp().catch(fail); + }) + .then(() => { + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('u'); + return user.signUp(); + }) + .then(() => { + fail('should not have been able to sign up'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.USERNAME_TAKEN); + done(); + }); }); - it('ensure that if people already have duplicate emails, they can still sign up new users', done => { - let config = new Config('test'); + it_id('d00f907e-41b9-40f6-8168-63e832199a8c')(it)('ensure that if people already have duplicate emails, they can still sign up new users', done => { + const config = Config.get('test'); // Remove existing data to clear out unique index - TestUtils.destroyAllDataPermanently() - .then(() => config.database.adapter.createClass('_User', userSchema)) - .then(() => config.database.adapter.createObject('_User', userSchema, { objectId: 'x', email: 'a@b.c' })) - .then(() => config.database.adapter.createObject('_User', userSchema, { objectId: 'y', email: 'a@b.c' })) - .then(reconfigureServer) - .catch(() => { - let user = new Parse.User(); - user.setPassword('asdf'); - user.setUsername('qqq'); - user.setEmail('unique@unique.unique'); - return user.signUp().catch(fail); - }) - .then(() => { - let user = new Parse.User(); - user.setPassword('asdf'); - user.setUsername('www'); - user.setEmail('a@b.c'); - return user.signUp() - }) - .catch(error => { - expect(error.code).toEqual(Parse.Error.EMAIL_TAKEN); - done(); - }); + destroyAllDataPermanently() + .then(() => config.database.adapter.performInitialization({ VolatileClassesSchemas: [] })) + .then(() => config.database.adapter.createClass('_User', userSchema)) + .then(() => + config.database.adapter.createObject('_User', userSchema, { + objectId: 'x', + email: 'a@b.c', + }) + ) + .then(() => + config.database.adapter.createObject('_User', userSchema, { + objectId: 'y', + email: 'a@b.c', + }) + ) + .then(reconfigureServer) + .catch(() => { + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('qqq'); + user.setEmail('unique@unique.unique'); + return user.signUp().catch(fail); + }) + .then(() => { + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('www'); + user.setEmail('a@b.c'); + return user.signUp(); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.EMAIL_TAKEN); + done(); + }); }); - it('ensure that if you try to sign up a user with a unique username and email, but duplicates in some other field that has a uniqueness constraint, you get a regular duplicate value error', done => { - let config = new Config('test'); - config.database.adapter.addFieldIfNotExists('_User', 'randomField', { type: 'String' }) - .then(() => config.database.adapter.ensureUniqueness('_User', userSchema, ['randomField'])) - .then(() => { - let user = new Parse.User(); - user.setPassword('asdf'); - user.setUsername('1'); - user.setEmail('1@b.c'); - user.set('randomField', 'a'); - return user.signUp() - }) - .then(() => { - let user = new Parse.User(); - user.setPassword('asdf'); - user.setUsername('2'); - user.setEmail('2@b.c'); - user.set('randomField', 'a'); - return user.signUp() - }) - .catch(error => { - expect(error.code).toEqual(Parse.Error.DUPLICATE_VALUE); - done(); - }); + it('ensure that if you try to sign up a user with a unique username and email, but duplicates in some other field that has a uniqueness constraint, you get a regular duplicate value error', async done => { + await reconfigureServer(); + const config = Config.get('test'); + config.database.adapter + .addFieldIfNotExists('_User', 'randomField', { type: 'String' }) + .then(() => config.database.adapter.ensureUniqueness('_User', userSchema, ['randomField'])) + .then(() => { + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('1'); + user.setEmail('1@b.c'); + user.set('randomField', 'a'); + return user.signUp(); + }) + .then(() => { + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('2'); + user.setEmail('2@b.c'); + user.set('randomField', 'a'); + return user.signUp(); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.DUPLICATE_VALUE); + done(); + }); }); - it('succeed in logging in', function(done) { - createTestUser(function(u) { + it('succeed in logging in', function (done) { + createTestUser().then(async function (u) { expect(typeof u.id).toEqual('string'); - Parse.User.logIn('test', 'moon-y', { - success: function(user) { - expect(typeof user.id).toEqual('string'); - expect(user.get('password')).toBeUndefined(); - expect(user.getSessionToken()).not.toBeUndefined(); + const user = await Parse.User.logIn('test', 'moon-y'); + expect(typeof user.id).toEqual('string'); + expect(user.get('password')).toBeUndefined(); + expect(user.getSessionToken()).not.toBeUndefined(); + await Parse.User.logOut(); + done(); + }, fail); + }); + + it_id('33db6efe-7c02-496c-8595-0ef627a94103')(it)('increment with a user object', function (done) { + createTestUser() + .then(user => { + user.increment('foo'); + return user.save(); + }) + .then(() => { + return Parse.User.logIn('test', 'moon-y'); + }) + .then(user => { + expect(user.get('foo')).toEqual(1); + user.increment('foo'); + return user.save(); + }) + .then(() => Parse.User.logOut()) + .then(() => Parse.User.logIn('test', 'moon-y')) + .then( + user => { + expect(user.get('foo')).toEqual(2); Parse.User.logOut().then(done); - }, error: error => { + }, + error => { fail(JSON.stringify(error)); done(); } - }); - }, fail); - }); - - it('increment with a user object', function(done) { - createTestUser().then((user) => { - user.increment('foo'); - return user.save(); - }).then(() => { - return Parse.User.logIn('test', 'moon-y'); - }).then((user) => { - expect(user.get('foo')).toEqual(1); - user.increment('foo'); - return user.save(); - }).then(() => Parse.User.logOut()) - .then(() => Parse.User.logIn('test', 'moon-y')) - .then((user) => { - expect(user.get('foo')).toEqual(2); - Parse.User.logOut() - .then(done); - }, (error) => { - fail(JSON.stringify(error)); - done(); - }); + ); }); - it('save various data types', function(done) { - var obj = new TestObject(); + it_id('bef99522-bcfd-4f79-ba9e-3c3845550401')(it)('save various data types', function (done) { + const obj = new TestObject(); obj.set('date', new Date()); obj.set('array', [1, 2, 3]); - obj.set('object', {one: 1, two: 2}); - obj.save().then(() => { - var obj2 = new TestObject({objectId: obj.id}); - return obj2.fetch(); - }).then((obj2) => { - expect(obj2.get('date') instanceof Date).toBe(true); - expect(obj2.get('array') instanceof Array).toBe(true); - expect(obj2.get('object') instanceof Array).toBe(false); - expect(obj2.get('object') instanceof Object).toBe(true); - done(); - }); + obj.set('object', { one: 1, two: 2 }); + obj + .save() + .then(() => { + const obj2 = new TestObject({ objectId: obj.id }); + return obj2.fetch(); + }) + .then(obj2 => { + expect(Utils.isDate(obj2.get('date'))).toBe(true); + expect(Array.isArray(obj2.get('array'))).toBe(true); + expect(Array.isArray(obj2.get('object'))).toBe(false); + expect(Utils.isObject(obj2.get('object'))).toBe(true); + done(); + }); }); - it('query with limit', function(done) { - var baz = new TestObject({ foo: 'baz' }); - var qux = new TestObject({ foo: 'qux' }); - baz.save().then(() => { - return qux.save(); - }).then(() => { - var query = new Parse.Query(TestObject); - query.limit(1); - return query.find(); - }).then((results) => { - expect(results.length).toEqual(1); - done(); - }, (error) => { - fail(JSON.stringify(error)); - done(); - }); + it('query with limit', function (done) { + const baz = new TestObject({ foo: 'baz' }); + const qux = new TestObject({ foo: 'qux' }); + baz + .save() + .then(() => { + return qux.save(); + }) + .then(() => { + const query = new Parse.Query(TestObject); + query.limit(1); + return query.find(); + }) + .then( + results => { + expect(results.length).toEqual(1); + done(); + }, + error => { + fail(JSON.stringify(error)); + done(); + } + ); }); - it('query without limit get default 100 records', function(done) { - var objects = []; - for (var i = 0; i < 150; i++) { - objects.push(new TestObject({name: 'name' + i})); + it('query without limit get default 100 records', function (done) { + const objects = []; + for (let i = 0; i < 150; i++) { + objects.push(new TestObject({ name: 'name' + i })); } - Parse.Object.saveAll(objects).then(() => { - return new Parse.Query(TestObject).find(); - }).then((results) => { - expect(results.length).toEqual(100); - done(); - }, error => { - fail(JSON.stringify(error)); - done(); - }); + Parse.Object.saveAll(objects) + .then(() => { + return new Parse.Query(TestObject).find(); + }) + .then( + results => { + expect(results.length).toEqual(100); + done(); + }, + error => { + fail(JSON.stringify(error)); + done(); + } + ); }); - it('basic saveAll', function(done) { - var alpha = new TestObject({ letter: 'alpha' }); - var beta = new TestObject({ letter: 'beta' }); - Parse.Object.saveAll([alpha, beta]).then(() => { - expect(alpha.id).toBeTruthy(); - expect(beta.id).toBeTruthy(); - return new Parse.Query(TestObject).find(); - }).then((results) => { - expect(results.length).toEqual(2); - done(); - }, (error) => { - fail(error); - done(); - }); + it('basic saveAll', function (done) { + const alpha = new TestObject({ letter: 'alpha' }); + const beta = new TestObject({ letter: 'beta' }); + Parse.Object.saveAll([alpha, beta]) + .then(() => { + expect(alpha.id).toBeTruthy(); + expect(beta.id).toBeTruthy(); + return new Parse.Query(TestObject).find(); + }) + .then( + results => { + expect(results.length).toEqual(2); + done(); + }, + error => { + fail(error); + done(); + } + ); }); - it('test beforeSave set object acl success', function(done) { - var acl = new Parse.ACL({ - '*': { read: true, write: false } + it('test beforeSave set object acl success', function (done) { + const acl = new Parse.ACL({ + '*': { read: true, write: false }, }); - Parse.Cloud.beforeSave('BeforeSaveAddACL', function(req, res) { + Parse.Cloud.beforeSave('BeforeSaveAddACL', function (req) { req.object.setACL(acl); - res.success(); }); - var obj = new Parse.Object('BeforeSaveAddACL'); + const obj = new Parse.Object('BeforeSaveAddACL'); obj.set('lol', true); - obj.save().then(function() { - var query = new Parse.Query('BeforeSaveAddACL'); - query.get(obj.id).then(function(objAgain) { - expect(objAgain.get('lol')).toBeTruthy(); - expect(objAgain.getACL().equals(acl)); - done(); - }, function(error) { - fail(error); + obj.save().then( + function () { + const query = new Parse.Query('BeforeSaveAddACL'); + query.get(obj.id).then( + function (objAgain) { + expect(objAgain.get('lol')).toBeTruthy(); + expect(objAgain.getACL().equals(acl)); + done(); + }, + function (error) { + fail(error); + done(); + } + ); + }, + error => { + fail(JSON.stringify(error)); done(); - }); - }, error => { - fail(JSON.stringify(error)); - done(); - }); - }); - - it('test rest_create_app', function(done) { - var appId; - Parse._request('POST', 'rest_create_app').then((res) => { - expect(typeof res.application_id).toEqual('string'); - expect(res.master_key).toEqual('master'); - appId = res.application_id; - Parse.initialize(appId, 'unused'); - var obj = new Parse.Object('TestObject'); - obj.set('foo', 'bar'); - return obj.save(); - }).then(() => { - let config = new Config(appId); - return config.database.adapter.find('TestObject', { fields: {} }, {}, {}); - }).then((results) => { - expect(results.length).toEqual(1); - expect(results[0]['foo']).toEqual('bar'); - done(); - }).fail(error => { - fail(JSON.stringify(error)); - done(); - }) + } + ); }); it('object is set on create and update', done => { let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.beforeSave('GameScore', (req, res) => { - let object = req.object; + Parse.Cloud.beforeSave('GameScore', req => { + const object = req.object; expect(object instanceof Parse.Object).toBeTruthy(); expect(object.get('fooAgain')).toEqual('barAgain'); if (triggerTime == 0) { @@ -392,58 +456,66 @@ describe('miscellaneous', function() { expect(object.createdAt).not.toBeUndefined(); expect(object.updatedAt).not.toBeUndefined(); } else { - res.error(); + throw new Error(); } triggerTime++; - res.success(); }); - let obj = new Parse.Object('GameScore'); + const obj = new Parse.Object('GameScore'); obj.set('foo', 'bar'); obj.set('fooAgain', 'barAgain'); - obj.save().then(() => { - // We only update foo - obj.set('foo', 'baz'); - return obj.save(); - }).then(() => { - // Make sure the checking has been triggered - expect(triggerTime).toBe(2); - done(); - }, error => { - fail(error); - done(); - }); + obj + .save() + .then(() => { + // We only update foo + obj.set('foo', 'baz'); + return obj.save(); + }) + .then( + () => { + // Make sure the checking has been triggered + expect(triggerTime).toBe(2); + done(); + }, + error => { + fail(error); + done(); + } + ); }); it('works when object is passed to success', done => { let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.beforeSave('GameScore', (req, res) => { - let object = req.object; + Parse.Cloud.beforeSave('GameScore', req => { + const object = req.object; object.set('foo', 'bar'); triggerTime++; - res.success(object); + return object; }); - let obj = new Parse.Object('GameScore'); + const obj = new Parse.Object('GameScore'); obj.set('foo', 'baz'); - obj.save().then(() => { - expect(triggerTime).toBe(1); - expect(obj.get('foo')).toEqual('bar'); - done(); - }, error => { - fail(error); - done(); - }); + obj.save().then( + () => { + expect(triggerTime).toBe(1); + expect(obj.get('foo')).toEqual('bar'); + done(); + }, + error => { + fail(error); + done(); + } + ); }); it('original object is set on update', done => { let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.beforeSave('GameScore', (req, res) => { - let object = req.object; + Parse.Cloud.beforeSave('GameScore', req => { + const object = req.object; expect(object instanceof Parse.Object).toBeTruthy(); expect(object.get('fooAgain')).toEqual('barAgain'); - let originalObject = req.original; + const originalObject = req.original; if (triggerTime == 0) { // No id/createdAt/updatedAt expect(object.id).toBeUndefined(); @@ -467,106 +539,139 @@ describe('miscellaneous', function() { expect(originalObject.updatedAt).not.toBeUndefined(); expect(originalObject.get('foo')).toEqual('bar'); } else { - res.error(); + throw new Error(); } triggerTime++; - res.success(); }); - let obj = new Parse.Object('GameScore'); + const obj = new Parse.Object('GameScore'); obj.set('foo', 'bar'); obj.set('fooAgain', 'barAgain'); - obj.save().then(() => { - // We only update foo - obj.set('foo', 'baz'); - return obj.save(); - }).then(() => { - // Make sure the checking has been triggered - expect(triggerTime).toBe(2); - done(); - }, error => { - fail(error); - done(); - }); + obj + .save() + .then(() => { + // We only update foo + obj.set('foo', 'baz'); + return obj.save(); + }) + .then( + () => { + // Make sure the checking has been triggered + expect(triggerTime).toBe(2); + done(); + }, + error => { + fail(error); + done(); + } + ); }); it('pointer mutation properly saves object', done => { - let className = 'GameScore'; + const className = 'GameScore'; - Parse.Cloud.beforeSave(className, (req, res) => { - let object = req.object; + Parse.Cloud.beforeSave(className, req => { + const object = req.object; expect(object instanceof Parse.Object).toBeTruthy(); - let child = object.get('child'); + const child = object.get('child'); expect(child instanceof Parse.Object).toBeTruthy(); child.set('a', 'b'); - child.save().then(() => { - res.success(); - }); + return child.save(); }); - let obj = new Parse.Object(className); + const obj = new Parse.Object(className); obj.set('foo', 'bar'); - let child = new Parse.Object('Child'); - child.save().then(() => { - obj.set('child', child); - return obj.save(); - }).then(() => { - let query = new Parse.Query(className); - query.include('child'); - return query.get(obj.id).then(objAgain => { - expect(objAgain.get('foo')).toEqual('bar'); + const child = new Parse.Object('Child'); + child + .save() + .then(() => { + obj.set('child', child); + return obj.save(); + }) + .then(() => { + const query = new Parse.Query(className); + query.include('child'); + return query.get(obj.id).then(objAgain => { + expect(objAgain.get('foo')).toEqual('bar'); - let childAgain = objAgain.get('child'); - expect(childAgain instanceof Parse.Object).toBeTruthy(); - expect(childAgain.get('a')).toEqual('b'); + const childAgain = objAgain.get('child'); + expect(childAgain instanceof Parse.Object).toBeTruthy(); + expect(childAgain.get('a')).toEqual('b'); - return Promise.resolve(); - }); - }).then(() => { - done(); - }, error => { - fail(error); - done(); - }); + return Promise.resolve(); + }); + }) + .then( + () => { + done(); + }, + error => { + fail(error); + done(); + } + ); }); - it('pointer reassign is working properly (#1288)', (done) => { - Parse.Cloud.beforeSave('GameScore', (req, res) => { - - var obj = req.object; + it('pointer reassign is working properly (#1288)', done => { + Parse.Cloud.beforeSave('GameScore', req => { + const obj = req.object; if (obj.get('point')) { - return res.success(); + return; } - var TestObject1 = Parse.Object.extend('TestObject1'); - var newObj = new TestObject1({'key1': 1}); + const TestObject1 = Parse.Object.extend('TestObject1'); + const newObj = new TestObject1({ key1: 1 }); - return newObj.save().then((newObj) => { - obj.set('point' , newObj); - res.success(); - }); + return newObj.save().then(newObj => { + obj.set('point', newObj); + }); }); - var pointId; - var obj = new Parse.Object('GameScore'); + let pointId; + const obj = new Parse.Object('GameScore'); obj.set('foo', 'bar'); - obj.save().then(() => { - expect(obj.get('point')).not.toBeUndefined(); - pointId = obj.get('point').id; - expect(pointId).not.toBeUndefined(); - obj.set('foo', 'baz'); - return obj.save(); - }).then((obj) => { - expect(obj.get('point').id).toEqual(pointId); - done(); - }) + obj + .save() + .then(() => { + expect(obj.get('point')).not.toBeUndefined(); + pointId = obj.get('point').id; + expect(pointId).not.toBeUndefined(); + obj.set('foo', 'baz'); + return obj.save(); + }) + .then(obj => { + expect(obj.get('point').id).toEqual(pointId); + done(); + }); + }); + + it_only_db('mongo')('pointer reassign on nested fields is working properly (#7391)', async () => { + const obj = new Parse.Object('GameScore'); // This object will include nested pointers + const ptr1 = new Parse.Object('GameScore'); + await ptr1.save(); // Obtain a unique id + const ptr2 = new Parse.Object('GameScore'); + await ptr2.save(); // Obtain a unique id + obj.set('data', { ptr: ptr1 }); + await obj.save(); + + obj.set('data.ptr', ptr2); + await obj.save(); + + const obj2 = await new Parse.Query('GameScore').get(obj.id); + expect(obj2.get('data').ptr.id).toBe(ptr2.id); + + const query = new Parse.Query('GameScore'); + query.equalTo('data.ptr', ptr2); + const res = await query.find(); + expect(res.length).toBe(1); + expect(res[0].get('data').ptr.id).toBe(ptr2.id); }); - it('test afterSave get full object on create and update', function(done) { - var triggerTime = 0; + it('test afterSave get full object on create and update', function (done) { + let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.afterSave('GameScore', function(req, res) { - var object = req.object; + Parse.Cloud.afterSave('GameScore', function (req) { + const object = req.object; expect(object instanceof Parse.Object).toBeTruthy(); expect(object.id).not.toBeUndefined(); expect(object.createdAt).not.toBeUndefined(); @@ -579,41 +684,46 @@ describe('miscellaneous', function() { // Update expect(object.get('foo')).toEqual('baz'); } else { - res.error(); + throw new Error(); } triggerTime++; - res.success(); }); - var obj = new Parse.Object('GameScore'); + const obj = new Parse.Object('GameScore'); obj.set('foo', 'bar'); obj.set('fooAgain', 'barAgain'); - obj.save().then(function() { - // We only update foo - obj.set('foo', 'baz'); - return obj.save(); - }).then(function() { - // Make sure the checking has been triggered - expect(triggerTime).toBe(2); - done(); - }, function(error) { - fail(error); - done(); - }); + obj + .save() + .then(function () { + // We only update foo + obj.set('foo', 'baz'); + return obj.save(); + }) + .then( + function () { + // Make sure the checking has been triggered + expect(triggerTime).toBe(2); + done(); + }, + function (error) { + fail(error); + done(); + } + ); }); - it('test afterSave get original object on update', function(done) { - var triggerTime = 0; + it('test afterSave get original object on update', function (done) { + let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.afterSave('GameScore', function(req, res) { - var object = req.object; + Parse.Cloud.afterSave('GameScore', function (req) { + const object = req.object; expect(object instanceof Parse.Object).toBeTruthy(); expect(object.get('fooAgain')).toEqual('barAgain'); expect(object.id).not.toBeUndefined(); expect(object.createdAt).not.toBeUndefined(); expect(object.updatedAt).not.toBeUndefined(); - var originalObject = req.original; + const originalObject = req.original; if (triggerTime == 0) { // Create expect(object.get('foo')).toEqual('bar'); @@ -630,36 +740,40 @@ describe('miscellaneous', function() { expect(originalObject.updatedAt).not.toBeUndefined(); expect(originalObject.get('foo')).toEqual('bar'); } else { - res.error(); + throw new Error(); } triggerTime++; - res.success(); }); - var obj = new Parse.Object('GameScore'); + const obj = new Parse.Object('GameScore'); obj.set('foo', 'bar'); obj.set('fooAgain', 'barAgain'); - obj.save().then(function() { - // We only update foo - obj.set('foo', 'baz'); - return obj.save(); - }).then(function() { - // Make sure the checking has been triggered - expect(triggerTime).toBe(2); - done(); - }, function(error) { - console.error(error); - fail(error); - done(); - }); + obj + .save() + .then(function () { + // We only update foo + obj.set('foo', 'baz'); + return obj.save(); + }) + .then( + function () { + // Make sure the checking has been triggered + expect(triggerTime).toBe(2); + done(); + }, + function (error) { + jfail(error); + done(); + } + ); }); - it('test afterSave get full original object even req auth can not query it', (done) => { - var triggerTime = 0; + it('test afterSave get full original object even req auth can not query it', done => { + let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.afterSave('GameScore', function(req, res) { - var object = req.object; - var originalObject = req.original; + Parse.Cloud.afterSave('GameScore', function (req) { + const object = req.object; + const originalObject = req.original; if (triggerTime == 0) { // Create } else if (triggerTime == 1) { @@ -673,42 +787,46 @@ describe('miscellaneous', function() { expect(originalObject.updatedAt).not.toBeUndefined(); expect(originalObject.get('foo')).toEqual('bar'); } else { - res.error(); + throw new Error(); } triggerTime++; - res.success(); }); - var obj = new Parse.Object('GameScore'); + const obj = new Parse.Object('GameScore'); obj.set('foo', 'bar'); obj.set('fooAgain', 'barAgain'); - var acl = new Parse.ACL(); + const acl = new Parse.ACL(); // Make sure our update request can not query the object acl.setPublicReadAccess(false); acl.setPublicWriteAccess(true); obj.setACL(acl); - obj.save().then(function() { - // We only update foo - obj.set('foo', 'baz'); - return obj.save(); - }).then(function() { - // Make sure the checking has been triggered - expect(triggerTime).toBe(2); - done(); - }, function(error) { - console.error(error); - fail(error); - done(); - }); + obj + .save() + .then(function () { + // We only update foo + obj.set('foo', 'baz'); + return obj.save(); + }) + .then( + function () { + // Make sure the checking has been triggered + expect(triggerTime).toBe(2); + done(); + }, + function (error) { + jfail(error); + done(); + } + ); }); it('afterSave flattens custom operations', done => { - var triggerTime = 0; + let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.afterSave('GameScore', function(req, res) { - let object = req.object; + Parse.Cloud.afterSave('GameScore', function (req) { + const object = req.object; expect(object instanceof Parse.Object).toBeTruthy(); - let originalObject = req.original; + const originalObject = req.original; if (triggerTime == 0) { // Create expect(object.get('yolo')).toEqual(1); @@ -718,558 +836,708 @@ describe('miscellaneous', function() { // Check the originalObject expect(originalObject.get('yolo')).toEqual(1); } else { - res.error(); + throw new Error(); } triggerTime++; - res.success(); }); - var obj = new Parse.Object('GameScore'); + const obj = new Parse.Object('GameScore'); obj.increment('yolo', 1); - obj.save().then(() => { - obj.increment('yolo', 1); - return obj.save(); - }).then(() => { - // Make sure the checking has been triggered - expect(triggerTime).toBe(2); - done(); - }, error => { - console.error(error); - fail(error); - done(); - }); + obj + .save() + .then(() => { + obj.increment('yolo', 1); + return obj.save(); + }) + .then( + () => { + // Make sure the checking has been triggered + expect(triggerTime).toBe(2); + done(); + }, + error => { + jfail(error); + done(); + } + ); }); - it_exclude_dbs(['postgres'])('beforeSave receives ACL', done => { + it('beforeSave receives ACL', done => { let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.beforeSave('GameScore', function(req, res) { - let object = req.object; + Parse.Cloud.beforeSave('GameScore', function (req) { + const object = req.object; if (triggerTime == 0) { - let acl = object.getACL(); + const acl = object.getACL(); expect(acl.getPublicReadAccess()).toBeTruthy(); expect(acl.getPublicWriteAccess()).toBeTruthy(); } else if (triggerTime == 1) { - let acl = object.getACL(); + const acl = object.getACL(); expect(acl.getPublicReadAccess()).toBeFalsy(); expect(acl.getPublicWriteAccess()).toBeTruthy(); } else { - res.error(); + throw new Error(); } triggerTime++; - res.success(); }); - let obj = new Parse.Object('GameScore'); - let acl = new Parse.ACL(); + const obj = new Parse.Object('GameScore'); + const acl = new Parse.ACL(); acl.setPublicReadAccess(true); acl.setPublicWriteAccess(true); obj.setACL(acl); - obj.save().then(() => { - acl.setPublicReadAccess(false); - obj.setACL(acl); - return obj.save(); - }).then(() => { - // Make sure the checking has been triggered - expect(triggerTime).toBe(2); - done(); - }, error => { - console.error(error); - fail(error); - done(); - }); + obj + .save() + .then(() => { + acl.setPublicReadAccess(false); + obj.setACL(acl); + return obj.save(); + }) + .then( + () => { + // Make sure the checking has been triggered + expect(triggerTime).toBe(2); + done(); + }, + error => { + jfail(error); + done(); + } + ); }); - it_exclude_dbs(['postgres'])('afterSave receives ACL', done => { + it('afterSave receives ACL', done => { let triggerTime = 0; // Register a mock beforeSave hook - Parse.Cloud.afterSave('GameScore', function(req, res) { - let object = req.object; + Parse.Cloud.afterSave('GameScore', function (req) { + const object = req.object; if (triggerTime == 0) { - let acl = object.getACL(); + const acl = object.getACL(); expect(acl.getPublicReadAccess()).toBeTruthy(); expect(acl.getPublicWriteAccess()).toBeTruthy(); } else if (triggerTime == 1) { - let acl = object.getACL(); + const acl = object.getACL(); expect(acl.getPublicReadAccess()).toBeFalsy(); expect(acl.getPublicWriteAccess()).toBeTruthy(); } else { - res.error(); + throw new Error(); } triggerTime++; - res.success(); }); - let obj = new Parse.Object('GameScore'); - let acl = new Parse.ACL(); + const obj = new Parse.Object('GameScore'); + const acl = new Parse.ACL(); acl.setPublicReadAccess(true); acl.setPublicWriteAccess(true); obj.setACL(acl); - obj.save().then(() => { - acl.setPublicReadAccess(false); - obj.setACL(acl); - return obj.save(); - }).then(() => { - // Make sure the checking has been triggered - expect(triggerTime).toBe(2); - done(); - }, error => { - console.error(error); - fail(error); - done(); - }); + obj + .save() + .then(() => { + acl.setPublicReadAccess(false); + obj.setACL(acl); + return obj.save(); + }) + .then( + () => { + // Make sure the checking has been triggered + expect(triggerTime).toBe(2); + done(); + }, + error => { + jfail(error); + done(); + } + ); }); - it_exclude_dbs(['postgres'])('should return the updated fields on PUT', done => { - let obj = new Parse.Object('GameScore'); - obj.save({a:'hello', c: 1, d: ['1'], e:['1'], f:['1','2']}).then(( ) => { - var headers = { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Installation-Id': 'yolo' - }; - request.put({ - headers: headers, - url: 'http://localhost:8378/1/classes/GameScore/'+obj.id, - body: JSON.stringify({ - a: 'b', - c: {"__op":"Increment","amount":2}, - d: {"__op":"Add", objects: ['2']}, - e: {"__op":"AddUnique", objects: ['1', '2']}, - f: {"__op":"Remove", objects: ['2']}, - selfThing: {"__type":"Pointer","className":"GameScore","objectId":obj.id}, - }) - }, (error, response, body) => { - body = JSON.parse(body); - expect(body.a).toBeUndefined(); - expect(body.c).toEqual(3); // 2+1 - expect(body.d.length).toBe(2); - expect(body.d.indexOf('1') > -1).toBe(true); - expect(body.d.indexOf('2') > -1).toBe(true); - expect(body.e.length).toBe(2); - expect(body.e.indexOf('1') > -1).toBe(true); - expect(body.e.indexOf('2') > -1).toBe(true); - expect(body.f.length).toBe(1); - expect(body.f.indexOf('1') > -1).toBe(true); - // return nothing on other self - expect(body.selfThing).toBeUndefined(); - // updatedAt is always set - expect(body.updatedAt).not.toBeUndefined(); - done(); - }); - }).fail((err) => { - fail('Should not fail'); - done(); - }) - }) + it_id('e9e718a9-4465-4158-b13e-f146855a8892')(it)('return the updated fields on PUT', async () => { + const obj = new Parse.Object('GameScore'); + const pointer = new Parse.Object('Child'); + await pointer.save(); + obj.set( + 'point', + new Parse.GeoPoint({ + latitude: 37.4848, + longitude: -122.1483, + }) + ); + obj.set('array', ['obj1', 'obj2']); + obj.set('objects', { a: 'b' }); + obj.set('string', 'abc'); + obj.set('bool', true); + obj.set('number', 1); + obj.set('date', new Date()); + obj.set('pointer', pointer); + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Installation-Id': 'yolo', + }; + const saveResponse = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/classes/GameScore', + body: JSON.stringify({ + a: 'hello', + c: 1, + d: ['1'], + e: ['1'], + f: ['1', '2'], + ...obj.toJSON(), + }), + }); + expect(Object.keys(saveResponse.data).sort()).toEqual(['createdAt', 'objectId']); + obj.id = saveResponse.data.objectId; + const response = await request({ + method: 'PUT', + headers: headers, + url: 'http://localhost:8378/1/classes/GameScore/' + obj.id, + body: JSON.stringify({ + a: 'b', + c: { __op: 'Increment', amount: 2 }, + d: { __op: 'Add', objects: ['2'] }, + e: { __op: 'AddUnique', objects: ['1', '2'] }, + f: { __op: 'Remove', objects: ['2'] }, + selfThing: { + __type: 'Pointer', + className: 'GameScore', + objectId: obj.id, + }, + }), + }); + const body = response.data; + expect(Object.keys(body).sort()).toEqual(['c', 'd', 'e', 'f', 'updatedAt']); + expect(body.a).toBeUndefined(); + expect(body.c).toEqual(3); // 2+1 + expect(body.d.length).toBe(2); + expect(body.d.indexOf('1') > -1).toBe(true); + expect(body.d.indexOf('2') > -1).toBe(true); + expect(body.e.length).toBe(2); + expect(body.e.indexOf('1') > -1).toBe(true); + expect(body.e.indexOf('2') > -1).toBe(true); + expect(body.f.length).toBe(1); + expect(body.f.indexOf('1') > -1).toBe(true); + expect(body.selfThing).toBeUndefined(); + expect(body.updatedAt).not.toBeUndefined(); + }); + + it_id('ea358b59-03c0-45c9-abc7-1fdd67573029')(it)('should response should not change with triggers', async () => { + const obj = new Parse.Object('GameScore'); + const pointer = new Parse.Object('Child'); + Parse.Cloud.beforeSave('GameScore', request => { + return request.object; + }); + Parse.Cloud.afterSave('GameScore', request => { + return request.object; + }); + await pointer.save(); + obj.set( + 'point', + new Parse.GeoPoint({ + latitude: 37.4848, + longitude: -122.1483, + }) + ); + obj.set('array', ['obj1', 'obj2']); + obj.set('objects', { a: 'b' }); + obj.set('string', 'abc'); + obj.set('bool', true); + obj.set('number', 1); + obj.set('date', new Date()); + obj.set('pointer', pointer); + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Installation-Id': 'yolo', + }; + const saveResponse = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/classes/GameScore', + body: JSON.stringify({ + a: 'hello', + c: 1, + d: ['1'], + e: ['1'], + f: ['1', '2'], + ...obj.toJSON(), + }), + }); + expect(Object.keys(saveResponse.data).sort()).toEqual(['createdAt', 'objectId']); + obj.id = saveResponse.data.objectId; + const response = await request({ + method: 'PUT', + headers: headers, + url: 'http://localhost:8378/1/classes/GameScore/' + obj.id, + body: JSON.stringify({ + a: 'b', + c: { __op: 'Increment', amount: 2 }, + d: { __op: 'Add', objects: ['2'] }, + e: { __op: 'AddUnique', objects: ['1', '2'] }, + f: { __op: 'Remove', objects: ['2'] }, + selfThing: { + __type: 'Pointer', + className: 'GameScore', + objectId: obj.id, + }, + }), + }); + const body = response.data; + expect(Object.keys(body).sort()).toEqual(['c', 'd', 'e', 'f', 'updatedAt']); + expect(body.a).toBeUndefined(); + expect(body.c).toEqual(3); // 2+1 + expect(body.d.length).toBe(2); + expect(body.d.indexOf('1') > -1).toBe(true); + expect(body.d.indexOf('2') > -1).toBe(true); + expect(body.e.length).toBe(2); + expect(body.e.indexOf('1') > -1).toBe(true); + expect(body.e.indexOf('2') > -1).toBe(true); + expect(body.f.length).toBe(1); + expect(body.f.indexOf('1') > -1).toBe(true); + expect(body.selfThing).toBeUndefined(); + expect(body.updatedAt).not.toBeUndefined(); + }); - it('test cloud function error handling', (done) => { + it('test cloud function error handling', done => { // Register a function which will fail - Parse.Cloud.define('willFail', (req, res) => { - res.error('noway'); - }); - Parse.Cloud.run('willFail').then((s) => { - fail('Should not have succeeded.'); - done(); - }, (e) => { - expect(e.code).toEqual(141); - expect(e.message).toEqual('noway'); - done(); + Parse.Cloud.define('willFail', () => { + throw new Error('noway'); }); + Parse.Cloud.run('willFail').then( + () => { + fail('Should not have succeeded.'); + done(); + }, + e => { + expect(e.code).toEqual(Parse.Error.SCRIPT_FAILED); + expect(e.message).toEqual('noway'); + done(); + } + ); }); - it('test cloud function error handling with custom error code', (done) => { + it('test cloud function error handling with custom error code', done => { // Register a function which will fail - Parse.Cloud.define('willFail', (req, res) => { - res.error(999, 'noway'); - }); - Parse.Cloud.run('willFail').then((s) => { - fail('Should not have succeeded.'); - done(); - }, (e) => { - expect(e.code).toEqual(999); - expect(e.message).toEqual('noway'); - done(); + Parse.Cloud.define('willFail', () => { + throw new Parse.Error(999, 'noway'); }); + Parse.Cloud.run('willFail').then( + () => { + fail('Should not have succeeded.'); + done(); + }, + e => { + expect(e.code).toEqual(999); + expect(e.message).toEqual('noway'); + done(); + } + ); }); - it('test cloud function error handling with standard error code', (done) => { + it('test cloud function error handling with standard error code', done => { // Register a function which will fail - Parse.Cloud.define('willFail', (req, res) => { - res.error('noway'); - }); - Parse.Cloud.run('willFail').then((s) => { - fail('Should not have succeeded.'); - done(); - }, (e) => { - expect(e.code).toEqual(Parse.Error.SCRIPT_FAILED); - expect(e.message).toEqual('noway'); - done(); + Parse.Cloud.define('willFail', () => { + throw new Error('noway'); }); + Parse.Cloud.run('willFail').then( + () => { + fail('Should not have succeeded.'); + done(); + }, + e => { + expect(e.code).toEqual(Parse.Error.SCRIPT_FAILED); + expect(e.message).toEqual('noway'); + done(); + } + ); }); - it('test beforeSave/afterSave get installationId', function(done) { + it('test beforeSave/afterSave get installationId', function (done) { let triggerTime = 0; - Parse.Cloud.beforeSave('GameScore', function(req, res) { + Parse.Cloud.beforeSave('GameScore', function (req) { triggerTime++; expect(triggerTime).toEqual(1); expect(req.installationId).toEqual('yolo'); - res.success(); }); - Parse.Cloud.afterSave('GameScore', function(req) { + Parse.Cloud.afterSave('GameScore', function (req) { triggerTime++; expect(triggerTime).toEqual(2); expect(req.installationId).toEqual('yolo'); }); - var headers = { + const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'test', 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Installation-Id': 'yolo' + 'X-Parse-Installation-Id': 'yolo', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/classes/GameScore', - body: JSON.stringify({ a: 'b' }) - }, (error, response, body) => { - expect(error).toBe(null); + body: JSON.stringify({ a: 'b' }), + }).then(() => { expect(triggerTime).toEqual(2); done(); }); }); - it('test beforeDelete/afterDelete get installationId', function(done) { + it('test beforeDelete/afterDelete get installationId', function (done) { let triggerTime = 0; - Parse.Cloud.beforeDelete('GameScore', function(req, res) { + Parse.Cloud.beforeDelete('GameScore', function (req) { triggerTime++; expect(triggerTime).toEqual(1); expect(req.installationId).toEqual('yolo'); - res.success(); }); - Parse.Cloud.afterDelete('GameScore', function(req) { + Parse.Cloud.afterDelete('GameScore', function (req) { triggerTime++; expect(triggerTime).toEqual(2); expect(req.installationId).toEqual('yolo'); }); - var headers = { + const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'test', 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Installation-Id': 'yolo' + 'X-Parse-Installation-Id': 'yolo', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/classes/GameScore', - body: JSON.stringify({ a: 'b' }) - }, (error, response, body) => { - expect(error).toBe(null); - request.del({ + body: JSON.stringify({ a: 'b' }), + }).then(response => { + request({ + method: 'DELETE', headers: headers, - url: 'http://localhost:8378/1/classes/GameScore/' + JSON.parse(body).objectId - }, (error, response, body) => { - expect(error).toBe(null); + url: 'http://localhost:8378/1/classes/GameScore/' + response.data.objectId, + }).then(() => { expect(triggerTime).toEqual(2); done(); }); }); }); - it('test cloud function query parameters', (done) => { - Parse.Cloud.define('echoParams', (req, res) => { - res.success(req.params); + it('test beforeDelete with locked down ACL', async () => { + let called = false; + Parse.Cloud.beforeDelete('GameScore', () => { + called = true; + }); + const object = new Parse.Object('GameScore'); + object.setACL(new Parse.ACL()); + await object.save(); + const objects = await new Parse.Query('GameScore').find(); + expect(objects.length).toBe(0); + try { + await object.destroy(); + } catch (e) { + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + } + expect(called).toBe(false); + }); + + it('test cloud function query parameters', done => { + Parse.Cloud.define('echoParams', req => { + return req.params; }); - var headers = { + const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'test', - 'X-Parse-Javascript-Key': 'test' + 'X-Parse-Javascript-Key': 'test', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/functions/echoParams', //?option=1&other=2 qs: { option: 1, - other: 2 + other: 2, }, - body: '{"foo":"bar", "other": 1}' - }, (error, response, body) => { - expect(error).toBe(null); - var res = JSON.parse(body).result; + body: '{"foo":"bar", "other": 1}', + }).then(response => { + const res = response.data.result; expect(res.option).toEqual('1'); // Make sure query string params override body params expect(res.other).toEqual('2'); - expect(res.foo).toEqual("bar"); + expect(res.foo).toEqual('bar'); done(); }); }); - it('test cloud function parameter validation', (done) => { - // Register a function with validation - Parse.Cloud.define('functionWithParameterValidationFailure', (req, res) => { - res.success('noway'); - }, (request) => { - return request.params.success === 100; + it('test cloud function query parameters with array of pointers', async () => { + Parse.Cloud.define('echoParams', req => { + return req.params; }); - - Parse.Cloud.run('functionWithParameterValidationFailure', {"success":500}).then((s) => { - fail('Validation should not have succeeded'); - done(); - }, (e) => { - expect(e.code).toEqual(142); - expect(e.message).toEqual('Validation failed.'); - done(); + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }; + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/functions/echoParams', + body: '{"arr": [{ "__type": "Pointer", "className": "PointerTest", "objectId": "test123" }]}', }); + const res = response.data.result; + expect(res.arr.length).toEqual(1); }); it('can handle null params in cloud functions (regression test for #1742)', done => { - Parse.Cloud.define('func', (request, response) => { + Parse.Cloud.define('func', request => { expect(request.params.nullParam).toEqual(null); - response.success('yay'); + return 'yay'; }); - Parse.Cloud.run('func', {nullParam: null}) - .then(() => { - done() - }, e => { - fail('cloud code call failed'); - done(); - }); + Parse.Cloud.run('func', { nullParam: null }).then( + () => { + done(); + }, + () => { + fail('cloud code call failed'); + done(); + } + ); }); it('can handle date params in cloud functions (#2214)', done => { - let date = new Date(); - Parse.Cloud.define('dateFunc', (request, response) => { + const date = new Date(); + Parse.Cloud.define('dateFunc', request => { expect(request.params.date.__type).toEqual('Date'); expect(request.params.date.iso).toEqual(date.toISOString()); - response.success('yay'); + return 'yay'; }); - Parse.Cloud.run('dateFunc', {date: date}) - .then(() => { - done() - }, e => { - fail('cloud code call failed'); - done(); - }); + Parse.Cloud.run('dateFunc', { date: date }).then( + () => { + done(); + }, + () => { + fail('cloud code call failed'); + done(); + } + ); }); it('fails on invalid client key', done => { - var headers = { + const headers = { 'Content-Type': 'application/octet-stream', 'X-Parse-Application-Id': 'test', - 'X-Parse-Client-Key': 'notclient' + 'X-Parse-Client-Key': 'notclient', }; - request.get({ + request({ headers: headers, - url: 'http://localhost:8378/1/classes/TestObject' - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); + url: 'http://localhost:8378/1/classes/TestObject', + }).then(fail, response => { + const b = response.data; expect(b.error).toEqual('unauthorized'); done(); }); }); it('fails on invalid windows key', done => { - var headers = { + const headers = { 'Content-Type': 'application/octet-stream', 'X-Parse-Application-Id': 'test', - 'X-Parse-Windows-Key': 'notwindows' + 'X-Parse-Windows-Key': 'notwindows', }; - request.get({ + request({ headers: headers, - url: 'http://localhost:8378/1/classes/TestObject' - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); + url: 'http://localhost:8378/1/classes/TestObject', + }).then(fail, response => { + const b = response.data; expect(b.error).toEqual('unauthorized'); done(); }); }); it('fails on invalid javascript key', done => { - var headers = { + const headers = { 'Content-Type': 'application/octet-stream', 'X-Parse-Application-Id': 'test', - 'X-Parse-Javascript-Key': 'notjavascript' + 'X-Parse-Javascript-Key': 'notjavascript', }; - request.get({ + request({ headers: headers, - url: 'http://localhost:8378/1/classes/TestObject' - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); + url: 'http://localhost:8378/1/classes/TestObject', + }).then(fail, response => { + const b = response.data; expect(b.error).toEqual('unauthorized'); done(); }); }); it('fails on invalid rest api key', done => { - var headers = { + const headers = { 'Content-Type': 'application/octet-stream', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'notrest' + 'X-Parse-REST-API-Key': 'notrest', }; - request.get({ + request({ headers: headers, - url: 'http://localhost:8378/1/classes/TestObject' - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); + url: 'http://localhost:8378/1/classes/TestObject', + }).then(fail, response => { + const b = response.data; expect(b.error).toEqual('unauthorized'); done(); }); }); it('fails on invalid function', done => { - Parse.Cloud.run('somethingThatDoesDefinitelyNotExist').then((s) => { - fail('This should have never suceeded'); - done(); - }, (e) => { - expect(e.code).toEqual(Parse.Error.SCRIPT_FAILED); - expect(e.message).toEqual('Invalid function.'); - done(); - }); + Parse.Cloud.run('somethingThatDoesDefinitelyNotExist').then( + () => { + fail('This should have never suceeded'); + done(); + }, + e => { + expect(e.code).toEqual(Parse.Error.SCRIPT_FAILED); + expect(e.message).toEqual('Invalid function: "somethingThatDoesDefinitelyNotExist"'); + done(); + } + ); }); - it('dedupes an installation properly and returns updatedAt', (done) => { - let headers = { + it('dedupes an installation properly and returns updatedAt', done => { + const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - let data = { - 'installationId': 'lkjsahdfkjhsdfkjhsdfkjhsdf', - 'deviceType': 'embedded' + const data = { + installationId: 'lkjsahdfkjhsdfkjhsdfkjhsdf', + deviceType: 'embedded', }; - let requestOptions = { + const requestOptions = { headers: headers, + method: 'POST', url: 'http://localhost:8378/1/installations', - body: JSON.stringify(data) + body: JSON.stringify(data), }; - request.post(requestOptions, (error, response, body) => { - expect(error).toBe(null); - let b = JSON.parse(body); + request(requestOptions).then(response => { + const b = response.data; expect(typeof b.objectId).toEqual('string'); - request.post(requestOptions, (error, response, body) => { - expect(error).toBe(null); - let b = JSON.parse(body); + request(requestOptions).then(response => { + const b = response.data; expect(typeof b.updatedAt).toEqual('string'); done(); }); }); }); - it('android login providing empty authData block works', (done) => { - let headers = { + it('android login providing empty authData block works', done => { + const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - let data = { + const data = { username: 'pulse1989', password: 'password1234', - authData: {} + authData: {}, }; - let requestOptions = { + const requestOptions = { + method: 'POST', headers: headers, url: 'http://localhost:8378/1/users', - body: JSON.stringify(data) + body: JSON.stringify(data), }; - request.post(requestOptions, (error, response, body) => { - expect(error).toBe(null); + request(requestOptions).then(() => { requestOptions.url = 'http://localhost:8378/1/login'; - request.get(requestOptions, (error, response, body) => { - expect(error).toBe(null); - let b = JSON.parse(body); + request(requestOptions).then(response => { + const b = response.data; expect(typeof b['sessionToken']).toEqual('string'); done(); }); }); }); - it_exclude_dbs(['postgres'])('gets relation fields', (done) => { - let object = new Parse.Object('AnObject'); - let relatedObject = new Parse.Object('RelatedObject'); - Parse.Object.saveAll([object, relatedObject]).then(() => { - object.relation('related').add(relatedObject); - return object.save(); - }).then(() => { - let headers = { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - let requestOptions = { - headers: headers, - url: 'http://localhost:8378/1/classes/AnObject', - json: true - }; - request.get(requestOptions, (err, res, body) => { - expect(body.results.length).toBe(1); - let result = body.results[0]; - expect(result.related).toEqual({ - __type: "Relation", - className: 'RelatedObject' - }) + it('gets relation fields', done => { + const object = new Parse.Object('AnObject'); + const relatedObject = new Parse.Object('RelatedObject'); + Parse.Object.saveAll([object, relatedObject]) + .then(() => { + object.relation('related').add(relatedObject); + return object.save(); + }) + .then(() => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const requestOptions = { + headers: headers, + url: 'http://localhost:8378/1/classes/AnObject', + json: true, + }; + request(requestOptions).then(res => { + const body = res.data; + expect(body.results.length).toBe(1); + const result = body.results[0]; + expect(result.related).toEqual({ + __type: 'Relation', + className: 'RelatedObject', + }); + done(); + }); + }) + .catch(err => { + jfail(err); done(); }); - }) }); - it('properly returns incremented values (#1554)', (done) => { - let headers = { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - let requestOptions = { - headers: headers, - url: 'http://localhost:8378/1/classes/AnObject', - json: true - }; - let object = new Parse.Object('AnObject');; - - function runIncrement(amount) { - let options = Object.assign({}, requestOptions, { - body: { - "key": { + it_id('b2cd9cf2-13fa-4acd-aaa9-6f81fc1858db')(it)('properly returns incremented values (#1554)', done => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const requestOptions = { + headers: headers, + url: 'http://localhost:8378/1/classes/AnObject', + json: true, + }; + const object = new Parse.Object('AnObject'); + + function runIncrement(amount) { + const options = Object.assign({}, requestOptions, { + body: { + key: { __op: 'Increment', - amount: amount - } + amount: amount, }, - url: 'http://localhost:8378/1/classes/AnObject/'+object.id - }) - return new Promise((resolve, reject) => { - request.put(options, (err, res, body) => { - if (err) { - reject(err); - } else { - resolve(body); - } - }); - }) - } - - object.save().then(() => { - return runIncrement(1); - }).then((res) => { - expect(res.key).toBe(1); - return runIncrement(-1); - }).then((res) => { - expect(res.key).toBe(0); - done(); - }) - }) - - it('ignores _RevocableSession "header" send by JS SDK', (done) => { - let object = new Parse.Object('AnObject'); + }, + url: 'http://localhost:8378/1/classes/AnObject/' + object.id, + method: 'PUT', + }); + return request(options).then(res => res.data); + } + + object + .save() + .then(() => { + return runIncrement(1); + }) + .then(res => { + expect(res.key).toBe(1); + return runIncrement(-1); + }) + .then(res => { + expect(res.key).toBe(0); + done(); + }); + }); + + it('ignores _RevocableSession "header" send by JS SDK', done => { + const object = new Parse.Object('AnObject'); object.set('a', 'b'); object.save().then(() => { - request.post({ - headers: {'Content-Type': 'application/json'}, + request({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, url: 'http://localhost:8378/1/classes/AnObject', body: { _method: 'GET', @@ -1277,206 +1545,347 @@ describe('miscellaneous', function() { _JavaScriptKey: 'test', _ClientVersion: 'js1.8.3', _InstallationId: 'iid', - _RevocableSession: "1", + _RevocableSession: '1', }, - json: true - }, (err, res, body) => { + }).then(res => { + const body = res.data; expect(body.error).toBeUndefined(); expect(body.results).not.toBeUndefined(); expect(body.results.length).toBe(1); - let result = body.results[0]; + const result = body.results[0]; expect(result.a).toBe('b'); done(); - }) + }); }); }); it('doesnt convert interior keys of objects that use special names', done => { - let obj = new Parse.Object('Obj'); + const obj = new Parse.Object('Obj'); obj.set('val', { createdAt: 'a', updatedAt: 1 }); - obj.save() - .then(obj => new Parse.Query('Obj').get(obj.id)) - .then(obj => { - expect(obj.get('val').createdAt).toEqual('a'); - expect(obj.get('val').updatedAt).toEqual(1); - done(); - }); + obj + .save() + .then(obj => new Parse.Query('Obj').get(obj.id)) + .then(obj => { + expect(obj.get('val').createdAt).toEqual('a'); + expect(obj.get('val').updatedAt).toEqual(1); + done(); + }); }); - it_exclude_dbs(['postgres'])('bans interior keys containing . or $', done => { - new Parse.Object('Obj').save({innerObj: {'key with a $': 'fails'}}) - .catch(error => { - expect(error.code).toEqual(Parse.Error.INVALID_NESTED_KEY); - return new Parse.Object('Obj').save({innerObj: {'key with a .': 'fails'}}); - }) - .catch(error => { - expect(error.code).toEqual(Parse.Error.INVALID_NESTED_KEY); - return new Parse.Object('Obj').save({innerObj: {innerInnerObj: {'key with $': 'fails'}}}); - }) - .catch(error => { - expect(error.code).toEqual(Parse.Error.INVALID_NESTED_KEY); - return new Parse.Object('Obj').save({innerObj: {innerInnerObj: {'key with .': 'fails'}}}); - }) - .catch(error => { - expect(error.code).toEqual(Parse.Error.INVALID_NESTED_KEY); - done(); - }) + it('bans interior keys containing . or $', done => { + new Parse.Object('Obj') + .save({ innerObj: { 'key with a $': 'fails' } }) + .then( + () => { + fail('should not succeed'); + }, + error => { + expect(error.code).toEqual(Parse.Error.INVALID_NESTED_KEY); + return new Parse.Object('Obj').save({ + innerObj: { 'key with a .': 'fails' }, + }); + } + ) + .then( + () => { + fail('should not succeed'); + }, + error => { + expect(error.code).toEqual(Parse.Error.INVALID_NESTED_KEY); + return new Parse.Object('Obj').save({ + innerObj: { innerInnerObj: { 'key with $': 'fails' } }, + }); + } + ) + .then( + () => { + fail('should not succeed'); + }, + error => { + expect(error.code).toEqual(Parse.Error.INVALID_NESTED_KEY); + return new Parse.Object('Obj').save({ + innerObj: { innerInnerObj: { 'key with .': 'fails' } }, + }); + } + ) + .then( + () => { + fail('should not succeed'); + done(); + }, + error => { + expect(error.code).toEqual(Parse.Error.INVALID_NESTED_KEY); + done(); + } + ); }); - it_exclude_dbs(['postgres'])('does not change inner object keys named _auth_data_something', done => { - new Parse.Object('O').save({ innerObj: {_auth_data_facebook: 7}}) - .then(object => new Parse.Query('O').get(object.id)) - .then(object => { - expect(object.get('innerObj')).toEqual({_auth_data_facebook: 7}); - done(); - }); + it('does not change inner object keys named _auth_data_something', done => { + new Parse.Object('O') + .save({ innerObj: { _auth_data_facebook: 7 } }) + .then(object => new Parse.Query('O').get(object.id)) + .then(object => { + expect(object.get('innerObj')).toEqual({ _auth_data_facebook: 7 }); + done(); + }); }); it('does not change inner object key names _p_somethign', done => { - new Parse.Object('O').save({ innerObj: {_p_data: 7}}) - .then(object => new Parse.Query('O').get(object.id)) - .then(object => { - expect(object.get('innerObj')).toEqual({_p_data: 7}); - done(); - }); + new Parse.Object('O') + .save({ innerObj: { _p_data: 7 } }) + .then(object => new Parse.Query('O').get(object.id)) + .then(object => { + expect(object.get('innerObj')).toEqual({ _p_data: 7 }); + done(); + }); }); it('does not change inner object key names _rperm, _wperm', done => { - new Parse.Object('O').save({ innerObj: {_rperm: 7, _wperm: 8}}) - .then(object => new Parse.Query('O').get(object.id)) - .then(object => { - expect(object.get('innerObj')).toEqual({_rperm: 7, _wperm: 8}); - done(); - }); + new Parse.Object('O') + .save({ innerObj: { _rperm: 7, _wperm: 8 } }) + .then(object => new Parse.Query('O').get(object.id)) + .then(object => { + expect(object.get('innerObj')).toEqual({ _rperm: 7, _wperm: 8 }); + done(); + }); }); - it_exclude_dbs(['postgres'])('does not change inner objects if the key has the same name as a geopoint field on the class, and the value is an array of length 2, or if the key has the same name as a file field on the class, and the value is a string', done => { - let file = new Parse.File('myfile.txt', { base64: 'eAo=' }); - file.save() - .then(f => { - let obj = new Parse.Object('O'); - obj.set('fileField', f); - obj.set('geoField', new Parse.GeoPoint(0, 0)); - obj.set('innerObj', { - fileField: "data", - geoField: [1,2], - }); - return obj.save(); - }) - .then(object => object.fetch()) - .then(object => { - expect(object.get('innerObj')).toEqual({ - fileField: "data", - geoField: [1,2], + it('does not change inner objects if the key has the same name as a geopoint field on the class, and the value is an array of length 2, or if the key has the same name as a file field on the class, and the value is a string', done => { + const file = new Parse.File('myfile.txt', { base64: 'eAo=' }); + file + .save() + .then(f => { + const obj = new Parse.Object('O'); + obj.set('fileField', f); + obj.set('geoField', new Parse.GeoPoint(0, 0)); + obj.set('innerObj', { + fileField: 'data', + geoField: [1, 2], + }); + return obj.save(); + }) + .then(object => object.fetch()) + .then(object => { + expect(object.get('innerObj')).toEqual({ + fileField: 'data', + geoField: [1, 2], + }); + done(); + }) + .catch(e => { + jfail(e); + done(); }); - done(); - }); }); - it_exclude_dbs(['postgres'])('purge all objects in class', (done) => { - let object = new Parse.Object('TestObject'); + it_id('8f99ee20-3da7-45ec-b867-ea0eb87524a9')(it)('purge all objects in class', done => { + const object = new Parse.Object('TestObject'); object.set('foo', 'bar'); - let object2 = new Parse.Object('TestObject'); + const object2 = new Parse.Object('TestObject'); object2.set('alice', 'wonderland'); Parse.Object.saveAll([object, object2]) - .then(() => { - let query = new Parse.Query(TestObject); - return query.count() - }).then((count) => { - expect(count).toBe(2); - let headers = { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test' - }; - request.del({ - headers: headers, - url: 'http://localhost:8378/1/purge/TestObject', - json: true - }, (err, res, body) => { - expect(err).toBe(null); - let query = new Parse.Query(TestObject); - return query.count().then((count) => { - expect(count).toBe(0); - done(); + .then(() => { + const query = new Parse.Query(TestObject); + return query.count(); + }) + .then(count => { + expect(count).toBe(2); + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }; + request({ + method: 'DELETE', + headers: headers, + url: 'http://localhost:8378/1/purge/TestObject', + }).then(() => { + const query = new Parse.Query(TestObject); + return query.count().then(count => { + expect(count).toBe(0); + done(); + }); }); }); - }); }); - it('fail on purge all objects in class without master key', (done) => { - let headers = { + it('fail on purge all objects in class without master key', done => { + const logger = require('../lib/logger').default; + const loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + + const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - rp({ + loggerErrorSpy.calls.reset(); + request({ method: 'DELETE', headers: headers, - uri: 'http://localhost:8378/1/purge/TestObject', - json: true - }).then(body => { - fail('Should not succeed'); - }).catch(err => { - expect(err.error.error).toEqual('unauthorized: master key is required'); - done(); - }); + url: 'http://localhost:8378/1/purge/TestObject', + }) + .then(() => { + fail('Should not succeed'); + }) + .catch(response => { + expect(response.data.error).toEqual('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('unauthorized: master key is required')); + done(); + }); }); - it_exclude_dbs(['postgres'])('purge all objects in _Role also purge cache', (done) => { - let headers = { + it('purge all objects in _Role also purge cache', done => { + const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test' + 'X-Parse-Master-Key': 'test', }; - var user, object; - createTestUser().then((x) => { - user = x; - let acl = new Parse.ACL(); - acl.setPublicReadAccess(true); - acl.setPublicWriteAccess(false); - let role = new Parse.Object('_Role'); - role.set('name', 'TestRole'); - role.setACL(acl); - let users = role.relation('users'); - users.add(user); - return role.save({}, { useMasterKey: true }); - }).then((x) => { - let query = new Parse.Query('_Role'); - return query.find({ useMasterKey: true }); - }).then((x) => { - expect(x.length).toEqual(1); - let relation = x[0].relation('users').query(); - return relation.first({ useMasterKey: true }); - }).then((x) => { - expect(x.id).toEqual(user.id); - object = new Parse.Object('TestObject'); - let acl = new Parse.ACL(); - acl.setPublicReadAccess(false); - acl.setPublicWriteAccess(false); - acl.setRoleReadAccess('TestRole', true); - acl.setRoleWriteAccess('TestRole', true); - object.setACL(acl); - return object.save(); - }).then((x) => { - let query = new Parse.Query('TestObject'); - return query.find({ sessionToken: user.getSessionToken() }); - }).then((x) => { - expect(x.length).toEqual(1); - return rp({ - method: 'DELETE', - headers: headers, - uri: 'http://localhost:8378/1/purge/_Role', - json: true - }); - }).then((x) => { - let query = new Parse.Query('TestObject'); - return query.get(object.id, { sessionToken: user.getSessionToken() }); - }).then((x) => { - fail('Should not succeed'); - }, (e) => { - expect(e.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - done(); + let user, object; + createTestUser() + .then(x => { + user = x; + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + acl.setPublicWriteAccess(false); + const role = new Parse.Object('_Role'); + role.set('name', 'TestRole'); + role.setACL(acl); + const users = role.relation('users'); + users.add(user); + return role.save({}, { useMasterKey: true }); + }) + .then(() => { + const query = new Parse.Query('_Role'); + return query.find({ useMasterKey: true }); + }) + .then(x => { + expect(x.length).toEqual(1); + const relation = x[0].relation('users').query(); + return relation.first({ useMasterKey: true }); + }) + .then(x => { + expect(x.id).toEqual(user.id); + object = new Parse.Object('TestObject'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(false); + acl.setPublicWriteAccess(false); + acl.setRoleReadAccess('TestRole', true); + acl.setRoleWriteAccess('TestRole', true); + object.setACL(acl); + return object.save(); + }) + .then(() => { + const query = new Parse.Query('TestObject'); + return query.find({ sessionToken: user.getSessionToken() }); + }) + .then(x => { + expect(x.length).toEqual(1); + return request({ + method: 'DELETE', + headers: headers, + url: 'http://localhost:8378/1/purge/_Role', + json: true, + }); + }) + .then(() => { + const query = new Parse.Query('TestObject'); + return query.get(object.id, { sessionToken: user.getSessionToken() }); + }) + .then( + () => { + fail('Should not succeed'); + }, + e => { + expect(e.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + done(); + } + ); + }); + + it('purge empty class', done => { + const testSchema = new Parse.Schema('UnknownClass'); + testSchema.purge().then(done).catch(done.fail); + }); + + it('should not update schema beforeSave #2672', done => { + Parse.Cloud.beforeSave('MyObject', request => { + if (request.object.get('secret')) { + throw 'cannot set secret here'; + } }); + + const object = new Parse.Object('MyObject'); + object.set('key', 'value'); + object + .save() + .then(() => { + return object.save({ secret: 'should not update schema' }); + }) + .then( + () => { + fail(); + done(); + }, + () => { + return request({ + method: 'GET', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + url: 'http://localhost:8378/1/schemas/MyObject', + json: true, + }); + } + ) + .then( + res => { + const fields = res.data.fields; + expect(fields.secret).toBeUndefined(); + done(); + }, + err => { + jfail(err); + done(); + } + ); + }); +}); + +describe_only_db('mongo')('legacy _acl', () => { + it('should have _acl when locking down (regression for #2465)', done => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/classes/Report', + body: { + ACL: {}, + name: 'My Report', + }, + json: true, + }) + .then(() => { + const config = Config.get('test'); + const adapter = config.database.adapter; + return adapter._adaptiveCollection('Report').then(collection => collection.find({})); + }) + .then(results => { + expect(results.length).toBe(1); + const result = results[0]; + expect(result.name).toEqual('My Report'); + expect(result._wperm).toEqual([]); + expect(result._rperm).toEqual([]); + expect(result._acl).toEqual({}); + done(); + }) + .catch(err => { + fail(JSON.stringify(err)); + done(); + }); }); }); diff --git a/spec/ParseCloudCodePublisher.spec.js b/spec/ParseCloudCodePublisher.spec.js index c1fe03649d..3435d44bde 100644 --- a/spec/ParseCloudCodePublisher.spec.js +++ b/spec/ParseCloudCodePublisher.spec.js @@ -1,69 +1,76 @@ -var ParseCloudCodePublisher = require('../src/LiveQuery/ParseCloudCodePublisher').ParseCloudCodePublisher; -var Parse = require('parse/node'); +const ParseCloudCodePublisher = require('../lib/LiveQuery/ParseCloudCodePublisher') + .ParseCloudCodePublisher; +const Parse = require('parse/node'); -describe('ParseCloudCodePublisher', function() { - beforeEach(function(done) { +describe('ParseCloudCodePublisher', function () { + beforeEach(function (done) { // Mock ParsePubSub - var mockParsePubSub = { + const mockParsePubSub = { createPublisher: jasmine.createSpy('publish').and.returnValue({ publish: jasmine.createSpy('publish'), - on: jasmine.createSpy('on') + on: jasmine.createSpy('on'), }), createSubscriber: jasmine.createSpy('publish').and.returnValue({ subscribe: jasmine.createSpy('subscribe'), - on: jasmine.createSpy('on') - }) + on: jasmine.createSpy('on'), + }), }; - jasmine.mockLibrary('../src/LiveQuery/ParsePubSub', 'ParsePubSub', mockParsePubSub); + jasmine.mockLibrary('../lib/LiveQuery/ParsePubSub', 'ParsePubSub', mockParsePubSub); done(); }); - it('can initialize', function() { - var config = {} - var publisher = new ParseCloudCodePublisher(config); + it('can initialize', function () { + const config = {}; + new ParseCloudCodePublisher(config); - var ParsePubSub = require('../src/LiveQuery/ParsePubSub').ParsePubSub; + const ParsePubSub = require('../lib/LiveQuery/ParsePubSub').ParsePubSub; expect(ParsePubSub.createPublisher).toHaveBeenCalledWith(config); }); - it('can handle cloud code afterSave request', function() { - var publisher = new ParseCloudCodePublisher({}); + it('can handle cloud code afterSave request', function () { + const publisher = new ParseCloudCodePublisher({}); publisher._onCloudCodeMessage = jasmine.createSpy('onCloudCodeMessage'); - var request = {}; + const request = {}; publisher.onCloudCodeAfterSave(request); - expect(publisher._onCloudCodeMessage).toHaveBeenCalledWith('afterSave', request); + expect(publisher._onCloudCodeMessage).toHaveBeenCalledWith( + Parse.applicationId + 'afterSave', + request + ); }); - it('can handle cloud code afterDelete request', function() { - var publisher = new ParseCloudCodePublisher({}); + it('can handle cloud code afterDelete request', function () { + const publisher = new ParseCloudCodePublisher({}); publisher._onCloudCodeMessage = jasmine.createSpy('onCloudCodeMessage'); - var request = {}; + const request = {}; publisher.onCloudCodeAfterDelete(request); - expect(publisher._onCloudCodeMessage).toHaveBeenCalledWith('afterDelete', request); + expect(publisher._onCloudCodeMessage).toHaveBeenCalledWith( + Parse.applicationId + 'afterDelete', + request + ); }); - it('can handle cloud code request', function() { - var publisher = new ParseCloudCodePublisher({}); - var currentParseObject = new Parse.Object('Test'); + it('can handle cloud code request', function () { + const publisher = new ParseCloudCodePublisher({}); + const currentParseObject = new Parse.Object('Test'); currentParseObject.set('key', 'value'); - var originalParseObject = new Parse.Object('Test'); + const originalParseObject = new Parse.Object('Test'); originalParseObject.set('key', 'originalValue'); - var request = { + const request = { object: currentParseObject, - original: originalParseObject + original: originalParseObject, }; publisher._onCloudCodeMessage('afterSave', request); - var args = publisher.parsePublisher.publish.calls.mostRecent().args; + const args = publisher.parsePublisher.publish.calls.mostRecent().args; expect(args[0]).toBe('afterSave'); - var message = JSON.parse(args[1]); + const message = JSON.parse(args[1]); expect(message.currentParseObject).toEqual(request.object._toFullJSON()); expect(message.originalParseObject).toEqual(request.original._toFullJSON()); }); - afterEach(function(){ - jasmine.restoreLibrary('../src/LiveQuery/ParsePubSub', 'ParsePubSub'); + afterEach(function () { + jasmine.restoreLibrary('../lib/LiveQuery/ParsePubSub', 'ParsePubSub'); }); }); diff --git a/spec/ParseConfigKey.spec.js b/spec/ParseConfigKey.spec.js new file mode 100644 index 0000000000..2b6881e775 --- /dev/null +++ b/spec/ParseConfigKey.spec.js @@ -0,0 +1,144 @@ +const Config = require('../lib/Config'); + +describe('Config Keys', () => { + const invalidKeyErrorMessage = 'Invalid key\\(s\\) found in Parse Server configuration'; + let loggerErrorSpy; + + beforeEach(async () => { + const logger = require('../lib/logger').logger; + loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + spyOn(Config, 'validateOptions').and.callFake(() => {}); + }); + + it('recognizes invalid keys in root', async () => { + await expectAsync(reconfigureServer({ + invalidKey: 1, + })).toBeResolved(); + const error = loggerErrorSpy.calls.all().reduce((s, call) => s += call.args[0], ''); + expect(error).toMatch(invalidKeyErrorMessage); + }); + + it('recognizes invalid keys in pages.customUrls', async () => { + await expectAsync(reconfigureServer({ + pages: { + customUrls: { + invalidKey: 1, + EmailVerificationSendFail: 1, + } + } + })).toBeResolved(); + const error = loggerErrorSpy.calls.all().reduce((s, call) => s += call.args[0], ''); + expect(error).toMatch(invalidKeyErrorMessage); + expect(error).toMatch(`invalidKey`); + expect(error).toMatch(`EmailVerificationSendFail`); + }); + + it('recognizes invalid keys in liveQueryServerOptions', async () => { + await expectAsync(reconfigureServer({ + liveQueryServerOptions: { + invalidKey: 1, + MasterKey: 1, + } + })).toBeResolved(); + const error = loggerErrorSpy.calls.all().reduce((s, call) => s += call.args[0], ''); + expect(error).toMatch(invalidKeyErrorMessage); + expect(error).toMatch(`MasterKey`); + }); + + it('recognizes invalid keys in rateLimit', async () => { + await expectAsync(reconfigureServer({ + rateLimit: [ + { invalidKey: 1 }, + { RequestPath: 1 }, + { RequestTimeWindow: 1 }, + ] + })).toBeRejected(); + const error = loggerErrorSpy.calls.all().reduce((s, call) => s += call.args[0], ''); + expect(error).toMatch(invalidKeyErrorMessage); + expect(error).toMatch('rateLimit\\[0\\]\\.invalidKey'); + expect(error).toMatch('rateLimit\\[1\\]\\.RequestPath'); + expect(error).toMatch('rateLimit\\[2\\]\\.RequestTimeWindow'); + }); + + it_only_db('mongo')('recognizes valid keys in default configuration', async () => { + await expectAsync(reconfigureServer({ + ...defaultConfiguration, + })).toBeResolved(); + expect(loggerErrorSpy.calls.all().reduce((s, call) => s += call.args[0], '')).not.toMatch(invalidKeyErrorMessage); + }); + + it_only_db('mongo')('recognizes valid keys in databaseOptions (MongoDB)', async () => { + await expectAsync(reconfigureServer({ + databaseURI: 'mongodb://localhost:27017/parse', + filesAdapter: null, + databaseAdapter: null, + databaseOptions: { + appName: 'MyParseApp', + + // Cannot be tested as it requires authentication setup + // authMechanism: 'SCRAM-SHA-256', + // authMechanismProperties: { SERVICE_NAME: 'mongodb' }, + + authSource: 'admin', + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout: 3000, + compressors: ['zlib'], + connectTimeoutMS: 5000, + directConnection: false, + disableIndexFieldValidation: true, + forceServerObjectId: false, + heartbeatFrequencyMS: 10000, + localThresholdMS: 15, + maxConnecting: 2, + maxIdleTimeMS: 60000, + maxPoolSize: 10, + maxStalenessSeconds: 90, + maxTimeMS: 1000, + minPoolSize: 5, + + // Cannot be tested as it requires a proxy setup + // proxyHost: 'proxy.example.com', + // proxyPassword: 'proxypass', + // proxyPort: 1080, + // proxyUsername: 'proxyuser', + + readConcernLevel: 'majority', + readPreference: 'secondaryPreferred', + readPreferenceTags: [{ dc: 'east' }], + + // Cannot be tested as it requires a replica set setup + // replicaSet: 'myReplicaSet', + + retryReads: true, + retryWrites: true, + serverMonitoringMode: 'auto', + serverSelectionTimeoutMS: 5000, + socketTimeoutMS: 5000, + + // Cannot be tested as it requires a replica cluster setup + // srvMaxHosts: 0, + // srvServiceName: 'mongodb', + + ssl: false, + tls: false, + tlsAllowInvalidCertificates: false, + tlsAllowInvalidHostnames: false, + tlsCAFile: __dirname + '/support/cert/cert.pem', + tlsCertificateKeyFile: __dirname + '/support/cert/cert.pem', + tlsCertificateKeyFilePassword: 'password', + waitQueueTimeoutMS: 5000, + zlibCompressionLevel: 6, + }, + })).toBeResolved(); + await expectAsync(reconfigureServer({ + databaseURI: 'mongodb://localhost:27017/parse', + filesAdapter: null, + databaseAdapter: null, + databaseOptions: { + // The following option needs to be tested separately due to driver config rules + tlsInsecure: false, + }, + })).toBeResolved(); + expect(loggerErrorSpy.calls.all().reduce((s, call) => s += call.args[0], '')).not.toMatch(invalidKeyErrorMessage); + }); +}); diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js index 90731c0941..5fa13ce9a9 100644 --- a/spec/ParseFile.spec.js +++ b/spec/ParseFile.spec.js @@ -1,550 +1,2824 @@ // This is a port of the test suite: // hungry/js/test/parse_file_test.js -"use strict"; +'use strict'; -var request = require('request'); +const { FilesController } = require('../lib/Controllers/FilesController'); +const request = require('../lib/request'); -var str = "Hello World!"; -var data = []; -for (var i = 0; i < str.length; i++) { +const str = 'Hello World!'; +const data = []; +for (let i = 0; i < str.length; i++) { data.push(str.charCodeAt(i)); } describe('Parse.File testing', () => { + let loggerErrorSpy; + + beforeEach(() => { + const logger = require('../lib/logger').default; + loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + }); + describe('creating files', () => { it('works with Content-Type', done => { - var headers = { + const headers = { 'Content-Type': 'application/octet-stream', 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/files/file.txt', body: 'argle bargle', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); + }).then(response => { + const b = response.data; expect(b.name).toMatch(/_file.txt$/); expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.txt$/); - request.get(b.url, (error, response, body) => { - expect(error).toBe(null); + request({ url: b.url }).then(response => { + const body = response.text; expect(body).toEqual('argle bargle'); done(); }); }); }); - - it('works with _ContentType', done => { - - request.post({ + it('works with _ContentType', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + fileExtensions: ['*'], + }, + }); + let response = await request({ + method: 'POST', url: 'http://localhost:8378/1/files/file', body: JSON.stringify({ _ApplicationId: 'test', _JavaScriptKey: 'test', _ContentType: 'text/html', - base64: 'PGh0bWw+PC9odG1sPgo=' - }) - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(b.name).toMatch(/_file.html/); - expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.html$/); - request.get(b.url, (error, response, body) => { - expect(response.headers['content-type']).toMatch('^text/html'); - expect(error).toBe(null); - expect(body).toEqual('\n'); - done(); - }); + base64: 'PGh0bWw+PC9odG1sPgo=', + }), }); + const b = response.data; + expect(b.name).toMatch(/_file.html/); + expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.html$/); + response = await request({ url: b.url }); + const body = response.text; + try { + expect(response.headers['content-type']).toMatch('^text/html'); + expect(body).toEqual('\n'); + } catch (e) { + jfail(e); + } }); it('works without Content-Type', done => { - var headers = { + const headers = { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }; - request.post({ + request({ + method: 'POST', headers: headers, url: 'http://localhost:8378/1/files/file.txt', body: 'argle bargle', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); + }).then(response => { + const b = response.data; expect(b.name).toMatch(/_file.txt$/); expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.txt$/); - request.get(b.url, (error, response, body) => { - expect(error).toBe(null); - expect(body).toEqual('argle bargle'); + request({ url: b.url }).then(response => { + expect(response.text).toEqual('argle bargle'); done(); }); }); }); - }); - it('supports REST end-to-end file create, read, delete, read', done => { - var headers = { - 'Content-Type': 'image/jpeg', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - request.post({ - headers: headers, - url: 'http://localhost:8378/1/files/testfile.txt', - body: 'check one two', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(b.name).toMatch(/_testfile.txt$/); - expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*testfile.txt$/); - request.get(b.url, (error, response, body) => { - expect(error).toBe(null); - expect(body).toEqual('check one two'); - request.del({ + it('supports REST end-to-end file create, read, delete, read', done => { + const headers = { + 'Content-Type': 'image/jpeg', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/testfile.txt', + body: 'check one two', + }).then(response => { + const b = response.data; + expect(b.name).toMatch(/_testfile.txt$/); + expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*testfile.txt$/); + request({ url: b.url }).then(response => { + const body = response.text; + expect(body).toEqual('check one two'); + request({ + method: 'DELETE', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + }, + url: 'http://localhost:8378/1/files/' + b.name, + }).then(response => { + expect(response.status).toEqual(200); + request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + url: b.url, + }).then(fail, response => { + expect(response.status).toEqual(404); + done(); + }); + }); + }); + }); + }); + + it('blocks file deletions with missing or incorrect master-key header', done => { + const headers = { + 'Content-Type': 'image/jpeg', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/thefile.jpg', + body: 'the file body', + }).then(response => { + const b = response.data; + expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*thefile.jpg$/); + // missing X-Parse-Master-Key header + loggerErrorSpy.calls.reset(); + request({ + method: 'DELETE', headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Master-Key': 'test' }, - url: 'http://localhost:8378/1/files/' + b.name - }, (error, response, body) => { - expect(error).toBe(null); - expect(response.statusCode).toEqual(200); - request.get({ + url: 'http://localhost:8378/1/files/' + b.name, + }).then(fail, response => { + const del_b = response.data; + expect(response.status).toEqual(403); + expect(del_b.error).toBe('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('unauthorized: master key is required')); + // incorrect X-Parse-Master-Key header + loggerErrorSpy.calls.reset(); + request({ + method: 'DELETE', headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'tryagain', }, - url: b.url - }, (error, response, body) => { - expect(error).toBe(null); - expect(response.statusCode).toEqual(404); + url: 'http://localhost:8378/1/files/' + b.name, + }).then(fail, response => { + const del_b2 = response.data; + expect(response.status).toEqual(403); + expect(del_b2.error).toBe('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('unauthorized: master key is required')); done(); }); }); }); }); - }); - it('blocks file deletions with missing or incorrect master-key header', done => { - var headers = { - 'Content-Type': 'image/jpeg', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - request.post({ - headers: headers, - url: 'http://localhost:8378/1/files/thefile.jpg', - body: 'the file body' - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*thefile.jpg$/); - // missing X-Parse-Master-Key header - request.del({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }, - url: 'http://localhost:8378/1/files/' + b.name - }, (error, response, body) => { - expect(error).toBe(null); - var del_b = JSON.parse(body); - expect(response.statusCode).toEqual(403); - expect(del_b.error).toMatch(/unauthorized/); - // incorrect X-Parse-Master-Key header - request.del({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Master-Key': 'tryagain' - }, - url: 'http://localhost:8378/1/files/' + b.name - }, (error, response, body) => { - expect(error).toBe(null); - var del_b2 = JSON.parse(body); - expect(response.statusCode).toEqual(403); - expect(del_b2.error).toMatch(/unauthorized/); + it('handles other filetypes', done => { + const headers = { + 'Content-Type': 'image/jpeg', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/file.jpg', + body: 'argle bargle', + }).then(response => { + const b = response.data; + expect(b.name).toMatch(/_file.jpg$/); + expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/.*file.jpg$/); + request({ url: b.url }).then(response => { + const body = response.text; + expect(body).toEqual('argle bargle'); done(); }); }); }); - }); - it('handles other filetypes', done => { - var headers = { - 'Content-Type': 'image/jpeg', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - request.post({ - headers: headers, - url: 'http://localhost:8378/1/files/file.jpg', - body: 'argle bargle', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(b.name).toMatch(/_file.jpg$/); - expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/.*file.jpg$/); - request.get(b.url, (error, response, body) => { - expect(error).toBe(null); - expect(body).toEqual('argle bargle'); - done(); + it('save file', async () => { + const file = new Parse.File('hello.txt', data, 'text/plain'); + ok(!file.url()); + const result = await file.save(); + strictEqual(result, file); + ok(file.name()); + ok(file.url()); + notEqual(file.name(), 'hello.txt'); + }); + + it('saves the file with tags', async () => { + spyOn(FilesController.prototype, 'createFile').and.callThrough(); + const file = new Parse.File('hello.txt', data, 'text/plain'); + const tags = { hello: 'world' }; + file.setTags(tags); + expect(file.url()).toBeUndefined(); + const result = await file.save(); + expect(file.name()).toBeDefined(); + expect(file.url()).toBeDefined(); + expect(result.tags()).toEqual(tags); + expect(FilesController.prototype.createFile.calls.argsFor(0)[4]).toEqual({ + tags: tags, + metadata: {}, }); }); - }); - it("save file", done => { - var file = new Parse.File("hello.txt", data, "text/plain"); - ok(!file.url()); - file.save(expectSuccess({ - success: function(result) { - strictEqual(result, file); - ok(file.name()); - ok(file.url()); - notEqual(file.name(), "hello.txt"); - done(); - } - })); - }); + it('does not pass empty file tags while saving', async () => { + spyOn(FilesController.prototype, 'createFile').and.callThrough(); + const file = new Parse.File('hello.txt', data, 'text/plain'); + expect(file.url()).toBeUndefined(); + expect(file.name()).toBeDefined(); + await file.save(); + expect(file.url()).toBeDefined(); + expect(FilesController.prototype.createFile.calls.argsFor(0)[4]).toEqual({ + metadata: {}, + }); + }); - it_exclude_dbs(['postgres'])("save file in object", done => { - var file = new Parse.File("hello.txt", data, "text/plain"); - ok(!file.url()); - file.save(expectSuccess({ - success: function(result) { - strictEqual(result, file); - ok(file.name()); - ok(file.url()); - notEqual(file.name(), "hello.txt"); - - var object = new Parse.Object("TestObject"); - object.save({ - file: file - }, expectSuccess({ - success: function(object) { - (new Parse.Query("TestObject")).get(object.id, expectSuccess({ - success: function(objectAgain) { - ok(objectAgain.get("file") instanceof Parse.File); - done(); - } - })); - } - })); - } - })); - }); + it('save file in object', async done => { + const file = new Parse.File('hello.txt', data, 'text/plain'); + ok(!file.url()); + const result = await file.save(); + strictEqual(result, file); + ok(file.name()); + ok(file.url()); + notEqual(file.name(), 'hello.txt'); - it_exclude_dbs(['postgres'])("save file in object with escaped characters in filename", done => { - var file = new Parse.File("hello . txt", data, "text/plain"); - ok(!file.url()); - file.save(expectSuccess({ - success: function(result) { - strictEqual(result, file); - ok(file.name()); - ok(file.url()); - notEqual(file.name(), "hello . txt"); - - var object = new Parse.Object("TestObject"); - object.save({ - file: file - }, expectSuccess({ - success: function(object) { - (new Parse.Query("TestObject")).get(object.id, expectSuccess({ - success: function(objectAgain) { - ok(objectAgain.get("file") instanceof Parse.File); - - done(); - } - })); - } - })); - } - })); - }); + const object = new Parse.Object('TestObject'); + await object.save({ file: file }); + const objectAgain = await new Parse.Query('TestObject').get(object.id); + ok(objectAgain.get('file') instanceof Parse.File); + done(); + }); - it_exclude_dbs(['postgres'])("autosave file in object", done => { - var file = new Parse.File("hello.txt", data, "text/plain"); - ok(!file.url()); - var object = new Parse.Object("TestObject"); - object.save({ - file: file - }, expectSuccess({ - success: function(object) { - (new Parse.Query("TestObject")).get(object.id, expectSuccess({ - success: function(objectAgain) { - file = objectAgain.get("file"); - ok(file instanceof Parse.File); - ok(file.name()); - ok(file.url()); - notEqual(file.name(), "hello.txt"); - done(); - } - })); - } - })); - }); + it('save file in object with escaped characters in filename', async () => { + const file = new Parse.File('hello . txt', data, 'text/plain'); + ok(!file.url()); + const result = await file.save(); + strictEqual(result, file); + ok(file.name()); + ok(file.url()); + notEqual(file.name(), 'hello . txt'); - it_exclude_dbs(['postgres'])("autosave file in object in object", done => { - var file = new Parse.File("hello.txt", data, "text/plain"); - ok(!file.url()); - - var child = new Parse.Object("Child"); - child.set("file", file); - - var parent = new Parse.Object("Parent"); - parent.set("child", child); - - parent.save(expectSuccess({ - success: function(parent) { - var query = new Parse.Query("Parent"); - query.include("child"); - query.get(parent.id, expectSuccess({ - success: function(parentAgain) { - var childAgain = parentAgain.get("child"); - file = childAgain.get("file"); - ok(file instanceof Parse.File); - ok(file.name()); - ok(file.url()); - notEqual(file.name(), "hello.txt"); - done(); - } - })); - } - })); - }); + const object = new Parse.Object('TestObject'); + await object.save({ file }); + const objectAgain = await new Parse.Query('TestObject').get(object.id); + ok(objectAgain.get('file') instanceof Parse.File); + }); - it("saving an already saved file", done => { - var file = new Parse.File("hello.txt", data, "text/plain"); - ok(!file.url()); - file.save(expectSuccess({ - success: function(result) { - strictEqual(result, file); - ok(file.name()); - ok(file.url()); - notEqual(file.name(), "hello.txt"); - var previousName = file.name(); - - file.save(expectSuccess({ - success: function() { - equal(file.name(), previousName); - done(); - } - })); - } - })); - }); + it('autosave file in object', async done => { + let file = new Parse.File('hello.txt', data, 'text/plain'); + ok(!file.url()); + const object = new Parse.Object('TestObject'); + await object.save({ file }); + const objectAgain = await new Parse.Query('TestObject').get(object.id); + file = objectAgain.get('file'); + ok(file instanceof Parse.File); + ok(file.name()); + ok(file.url()); + notEqual(file.name(), 'hello.txt'); + done(); + }); - it("two saves at the same time", done => { - var file = new Parse.File("hello.txt", data, "text/plain"); + it('autosave file in object in object', async done => { + let file = new Parse.File('hello.txt', data, 'text/plain'); + ok(!file.url()); - var firstName; - var secondName; + const child = new Parse.Object('Child'); + child.set('file', file); - var firstSave = file.save().then(function() { firstName = file.name(); }); - var secondSave = file.save().then(function() { secondName = file.name(); }); + const parent = new Parse.Object('Parent'); + parent.set('child', child); - Parse.Promise.when(firstSave, secondSave).then(function() { - equal(firstName, secondName); - done(); - }, function(error) { - ok(false, error); + await parent.save(); + const query = new Parse.Query('Parent'); + query.include('child'); + const parentAgain = await query.get(parent.id); + const childAgain = parentAgain.get('child'); + file = childAgain.get('file'); + ok(file instanceof Parse.File); + ok(file.name()); + ok(file.url()); + notEqual(file.name(), 'hello.txt'); done(); }); - }); - it_exclude_dbs(['postgres'])("file toJSON testing", done => { - var file = new Parse.File("hello.txt", data, "text/plain"); - ok(!file.url()); - var object = new Parse.Object("TestObject"); - object.save({ - file: file - }, expectSuccess({ - success: function(obj) { - ok(object.toJSON().file.url); - done(); - } - })); - }); + it('saving an already saved file', async () => { + const file = new Parse.File('hello.txt', data, 'text/plain'); + ok(!file.url()); + const result = await file.save(); + strictEqual(result, file); + ok(file.name()); + ok(file.url()); + notEqual(file.name(), 'hello.txt'); + const previousName = file.name(); + + await file.save(); + equal(file.name(), previousName); + }); + + it('two saves at the same time', done => { + const file = new Parse.File('hello.txt', data, 'text/plain'); + + let firstName; + let secondName; + + const firstSave = file.save().then(function () { + firstName = file.name(); + }); + const secondSave = file.save().then(function () { + secondName = file.name(); + }); + + Promise.all([firstSave, secondSave]).then( + function () { + equal(firstName, secondName); + done(); + }, + function (error) { + ok(false, error); + done(); + } + ); + }); + + it('file toJSON testing', async () => { + const file = new Parse.File('hello.txt', data, 'text/plain'); + ok(!file.url()); + const object = new Parse.Object('TestObject'); + await object.save({ + file: file, + }); + ok(object.toJSON().file.url); + }); - it("content-type used with no extension", done => { - var headers = { - 'Content-Type': 'text/html', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - request.post({ - headers: headers, - url: 'http://localhost:8378/1/files/file', - body: 'fee fi fo', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); + it('content-type used with no extension', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + fileExtensions: ['*'], + }, + }); + const headers = { + 'Content-Type': 'text/html', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + let response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/file', + body: 'fee fi fo', + }); + const b = response.data; expect(b.name).toMatch(/\.html$/); - request.get(b.url, (error, response, body) => { - expect(response.headers['content-type']).toMatch(/^text\/html/); + response = await request({ url: b.url }); + expect(response.headers['content-type']).toMatch(/^text\/html/); + }); + + it('works without Content-Type and extension', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const result = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/file', + body: '\n', + }); + expect(result.data.url.includes('file.txt')).toBeTrue(); + expect(result.data.name.includes('file.txt')).toBeTrue(); + }); + + it('filename is url encoded', done => { + const headers = { + 'Content-Type': 'text/html', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/hello world.txt', + body: 'oh emm gee', + }).then(response => { + const b = response.data; + expect(b.url).toMatch(/hello%20world/); done(); }); }); - }); - it("filename is url encoded", done => { - var headers = { - 'Content-Type': 'text/html', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - request.post({ - headers: headers, - url: 'http://localhost:8378/1/files/hello world.txt', - body: 'oh emm gee', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(b.url).toMatch(/hello%20world/); - done(); - }) - }); + it('supports array of files', done => { + const file = { + __type: 'File', + url: 'http://meep.meep', + name: 'meep', + }; + const files = [file, file]; + const obj = new Parse.Object('FilesArrayTest'); + obj.set('files', files); + obj + .save() + .then(() => { + const query = new Parse.Query('FilesArrayTest'); + return query.first(); + }) + .then(result => { + const filesAgain = result.get('files'); + expect(filesAgain.length).toEqual(2); + expect(filesAgain[0].name()).toEqual('meep'); + expect(filesAgain[0].url()).toEqual('http://meep.meep'); + done(); + }); + }); - it('supports array of files', done => { - var file = { - __type: 'File', - url: 'http://meep.meep', - name: 'meep' - }; - var files = [file, file]; - var obj = new Parse.Object('FilesArrayTest'); - obj.set('files', files); - obj.save().then(() => { - var query = new Parse.Query('FilesArrayTest'); - return query.first(); - }).then((result) => { - var filesAgain = result.get('files'); - expect(filesAgain.length).toEqual(2); - expect(filesAgain[0].name()).toEqual('meep'); - expect(filesAgain[0].url()).toEqual('http://meep.meep'); - done(); + it('validates filename characters', done => { + const headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/di$avowed.txt', + body: 'will fail', + }).then(fail, response => { + const b = response.data; + expect(b.code).toEqual(122); + done(); + }); }); - }); - it('validates filename characters', done => { - var headers = { - 'Content-Type': 'text/plain', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - request.post({ - headers: headers, - url: 'http://localhost:8378/1/files/di$avowed.txt', - body: 'will fail', - }, (error, response, body) => { - var b = JSON.parse(body); - expect(b.code).toEqual(122); - done(); + it('validates filename length', done => { + const headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const fileName = + 'Onceuponamidnightdrearywhileiponderedweak' + + 'andwearyOveramanyquaintandcuriousvolumeof' + + 'forgottenloreWhileinoddednearlynappingsud' + + 'denlytherecameatapping'; + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/' + fileName, + body: 'will fail', + }).then(fail, response => { + const b = response.data; + expect(b.code).toEqual(122); + done(); + }); }); - }); - it('validates filename length', done => { - var headers = { - 'Content-Type': 'text/plain', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - var fileName = 'Onceuponamidnightdrearywhileiponderedweak' + - 'andwearyOveramanyquaintandcuriousvolumeof' + - 'forgottenloreWhileinoddednearlynappingsud' + - 'denlytherecameatapping'; - request.post({ - headers: headers, - url: 'http://localhost:8378/1/files/' + fileName, - body: 'will fail', - }, (error, response, body) => { - var b = JSON.parse(body); - expect(b.code).toEqual(122); - done(); + it('supports a dictionary with file', done => { + const file = { + __type: 'File', + url: 'http://meep.meep', + name: 'meep', + }; + const dict = { + file: file, + }; + const obj = new Parse.Object('FileObjTest'); + obj.set('obj', dict); + obj + .save() + .then(() => { + const query = new Parse.Query('FileObjTest'); + return query.first(); + }) + .then(result => { + const dictAgain = result.get('obj'); + expect(typeof dictAgain).toEqual('object'); + const fileAgain = dictAgain['file']; + expect(fileAgain.name()).toEqual('meep'); + expect(fileAgain.url()).toEqual('http://meep.meep'); + done(); + }) + .catch(e => { + jfail(e); + done(); + }); }); - }); - it('supports a dictionary with file', done => { - var file = { - __type: 'File', - url: 'http://meep.meep', - name: 'meep' - }; - var dict = { - file: file - }; - var obj = new Parse.Object('FileObjTest'); - obj.set('obj', dict); - obj.save().then(() => { - var query = new Parse.Query('FileObjTest'); - return query.first(); - }).then((result) => { - var dictAgain = result.get('obj'); - expect(typeof dictAgain).toEqual('object'); - var fileAgain = dictAgain['file']; - expect(fileAgain.name()).toEqual('meep'); - expect(fileAgain.url()).toEqual('http://meep.meep'); - done(); + it('creates correct url for old files hosted on files.parsetfss.com', done => { + const file = { + __type: 'File', + url: 'http://irrelevant.elephant/', + name: 'tfss-123.txt', + }; + const obj = new Parse.Object('OldFileTest'); + obj.set('oldfile', file); + obj + .save() + .then(() => { + const query = new Parse.Query('OldFileTest'); + return query.first(); + }) + .then(result => { + const fileAgain = result.get('oldfile'); + expect(fileAgain.url()).toEqual('http://files.parsetfss.com/test/tfss-123.txt'); + done(); + }) + .catch(e => { + jfail(e); + done(); + }); + }); + + it('creates correct url for old files hosted on files.parse.com', done => { + const file = { + __type: 'File', + url: 'http://irrelevant.elephant/', + name: 'd6e80979-a128-4c57-a167-302f874700dc-123.txt', + }; + const obj = new Parse.Object('OldFileTest'); + obj.set('oldfile', file); + obj + .save() + .then(() => { + const query = new Parse.Query('OldFileTest'); + return query.first(); + }) + .then(result => { + const fileAgain = result.get('oldfile'); + expect(fileAgain.url()).toEqual( + 'http://files.parse.com/test/d6e80979-a128-4c57-a167-302f874700dc-123.txt' + ); + done(); + }) + .catch(e => { + jfail(e); + done(); + }); + }); + + it('supports files in objects without urls', done => { + const file = { + __type: 'File', + name: '123.txt', + }; + const obj = new Parse.Object('FileTest'); + obj.set('file', file); + obj + .save() + .then(() => { + const query = new Parse.Query('FileTest'); + return query.first(); + }) + .then(result => { + const fileAgain = result.get('file'); + expect(fileAgain.url()).toMatch(/123.txt$/); + done(); + }) + .catch(e => { + jfail(e); + done(); + }); + }); + + it('return with publicServerURL when provided', done => { + reconfigureServer({ + publicServerURL: 'https://mydomain/parse', + }) + .then(() => { + const file = { + __type: 'File', + name: '123.txt', + }; + const obj = new Parse.Object('FileTest'); + obj.set('file', file); + return obj.save(); + }) + .then(() => { + const query = new Parse.Query('FileTest'); + return query.first(); + }) + .then(result => { + const fileAgain = result.get('file'); + expect(fileAgain.url().indexOf('https://mydomain/parse')).toBe(0); + done(); + }) + .catch(e => { + jfail(e); + done(); + }); + }); + + it('fails to upload an empty file', done => { + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/file.txt', + body: '', + }).then(fail, response => { + expect(response.status).toBe(400); + const body = response.text; + expect(body).toEqual('{"code":130,"error":"Invalid file upload."}'); + done(); + }); + }); + + it('fails to upload without a file name', done => { + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/', + body: 'yolo', + }).then(fail, response => { + expect(response.status).toBe(400); + const body = response.text; + expect(body).toEqual('{"code":122,"error":"Filename not provided."}'); + done(); + }); + }); + + describe('URI-backed file upload is disabled to prevent SSRF attack', () => { + const express = require('express'); + let testServer; + let testServerPort; + let requestsMade; + + beforeEach(async () => { + requestsMade = []; + const app = express(); + app.use((req, res) => { + requestsMade.push({ url: req.url, method: req.method }); + res.status(200).send('test file content'); + }); + testServer = app.listen(0); + testServerPort = testServer.address().port; + }); + + afterEach(async () => { + if (testServer) { + await new Promise(resolve => testServer.close(resolve)); + } + Parse.Cloud._removeAllHooks(); + }); + + it('does not access URI when file upload attempted over REST', async () => { + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestClass', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: { + file: { + __type: 'File', + name: 'test.txt', + _source: { + format: 'uri', + uri: `http://127.0.0.1:${testServerPort}/secret-file.txt`, + }, + }, + }, + }); + expect(response.status).toBe(201); + // Verify no HTTP request was made to the URI + expect(requestsMade.length).toBe(0); + }); + + it('does not access URI when file created in beforeSave trigger', async () => { + Parse.Cloud.beforeSave(Parse.File, () => { + return new Parse.File('trigger-file.txt', { + uri: `http://127.0.0.1:${testServerPort}/secret-file.txt`, + }); + }); + await expectAsync( + request({ + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + url: 'http://localhost:8378/1/files/test.txt', + body: 'test content', + }) + ).toBeRejectedWith(jasmine.objectContaining({ + status: 400 + })); + // Verify no HTTP request was made to the URI + expect(requestsMade.length).toBe(0); + }); }); }); - it_exclude_dbs(['postgres'])('creates correct url for old files hosted on files.parsetfss.com', done => { - var file = { - __type: 'File', - url: 'http://irrelevant.elephant/', - name: 'tfss-123.txt' - }; - var obj = new Parse.Object('OldFileTest'); - obj.set('oldfile', file); - obj.save().then(() => { - var query = new Parse.Query('OldFileTest'); - return query.first(); - }).then((result) => { - var fileAgain = result.get('oldfile'); - expect(fileAgain.url()).toEqual( - 'http://files.parsetfss.com/test/tfss-123.txt' - ); - done(); + describe('deleting files', () => { + it('fails to delete an unkown file', done => { + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + }; + request({ + method: 'DELETE', + headers: headers, + url: 'http://localhost:8378/1/files/file.txt', + }).then(fail, response => { + expect(response.status).toBe(400); + const body = response.text; + expect(typeof body).toBe('string'); + const { code, error } = JSON.parse(body); + expect(code).toBe(153); + expect(typeof error).toBe('string'); + expect(error.length).toBeGreaterThan(0); + done(); + }); }); }); - it_exclude_dbs(['postgres'])('creates correct url for old files hosted on files.parse.com', done => { - var file = { - __type: 'File', - url: 'http://irrelevant.elephant/', - name: 'd6e80979-a128-4c57-a167-302f874700dc-123.txt' - }; - var obj = new Parse.Object('OldFileTest'); - obj.set('oldfile', file); - obj.save().then(() => { - var query = new Parse.Query('OldFileTest'); - return query.first(); - }).then((result) => { - var fileAgain = result.get('oldfile'); - expect(fileAgain.url()).toEqual( - 'http://files.parse.com/test/d6e80979-a128-4c57-a167-302f874700dc-123.txt' - ); - done(); + describe('getting files', () => { + it('does not crash on file request with invalid app ID', async () => { + const res1 = await request({ + url: 'http://localhost:8378/1/files/invalid-id/invalid-file.txt', + }).catch(e => e); + expect(res1.status).toBe(403); + expect(res1.data).toEqual({ error: 'Permission denied' }); + // Ensure server did not crash + const res2 = await request({ url: 'http://localhost:8378/1/health' }); + expect(res2.status).toEqual(200); + expect(res2.data).toEqual({ status: 'ok' }); + }); + + it('does not crash on file request with invalid path', async () => { + const res1 = await request({ + url: 'http://localhost:8378/1/files/invalid-id//invalid-path/%20/invalid-file.txt', + }).catch(e => e); + expect(res1.status).toBe(403); + expect(res1.data).toEqual({ error: 'Permission denied' }); + // Ensure server did not crash + const res2 = await request({ url: 'http://localhost:8378/1/health' }); + expect(res2.status).toEqual(200); + expect(res2.data).toEqual({ status: 'ok' }); + }); + + it('does not crash on file metadata request with invalid app ID', async () => { + const res1 = await request({ + url: `http://localhost:8378/1/files/invalid-id/metadata/invalid-file.txt`, + }); + expect(res1.status).toBe(200); + expect(res1.data).toEqual({}); + // Ensure server did not crash + const res2 = await request({ url: 'http://localhost:8378/1/health' }); + expect(res2.status).toEqual(200); + expect(res2.data).toEqual({ status: 'ok' }); }); }); - it_exclude_dbs(['postgres'])('supports files in objects without urls', done => { - var file = { - __type: 'File', - name: '123.txt' - }; - var obj = new Parse.Object('FileTest'); - obj.set('file', file); - obj.save().then(() => { - var query = new Parse.Query('FileTest'); - return query.first(); - }).then(result => { - let fileAgain = result.get('file'); - expect(fileAgain.url()).toMatch(/123.txt$/); - done(); + describe_only_db('mongo')('Gridstore Range', () => { + it('supports bytes range out of range', async () => { + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1//files/file.txt ', + body: repeat('argle bargle', 100), + }); + const b = response.data; + const file = await request({ + url: b.url, + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + Range: 'bytes=15000-18000', + }, + }); + expect(file.headers['content-range']).toBe('bytes 1212-1212/1212'); + }); + + it('supports bytes range if end greater than start', async () => { + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1//files/file.txt ', + body: repeat('argle bargle', 100), + }); + const b = response.data; + const file = await request({ + url: b.url, + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + Range: 'bytes=15000-100', + }, + }); + expect(file.headers['content-range']).toBe('bytes 100-1212/1212'); + }); + + it('supports bytes range if end is undefined', async () => { + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1//files/file.txt ', + body: repeat('argle bargle', 100), + }); + const b = response.data; + const file = await request({ + url: b.url, + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + Range: 'bytes=100-', + }, + }); + expect(file.headers['content-range']).toBe('bytes 100-1212/1212'); + }); + + it('supports bytes range if start and end undefined', async () => { + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1//files/file.txt ', + body: repeat('argle bargle', 100), + }); + const b = response.data; + const file = await request({ + url: b.url, + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + }, + }).catch(e => e); + expect(file.headers['content-range']).toBeUndefined(); + }); + + it('supports bytes range if end is greater than size', async () => { + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1//files/file.txt ', + body: repeat('argle bargle', 100), + }); + const b = response.data; + const file = await request({ + url: b.url, + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + Range: 'bytes=0-2000', + }, + }).catch(e => e); + expect(file.headers['content-range']).toBe('bytes 0-1212/1212'); + }); + + it('supports bytes range with 0 length', async () => { + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1//files/file.txt ', + body: 'a', + }).catch(e => e); + const b = response.data; + const file = await request({ + url: b.url, + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + Range: 'bytes=-2000', + }, + }).catch(e => e); + expect(file.headers['content-range']).toBe('bytes 0-1/1'); + }); + + it('supports range requests', done => { + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/file.txt', + body: 'argle bargle', + }).then(response => { + const b = response.data; + request({ + url: b.url, + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + Range: 'bytes=0-5', + }, + }).then(response => { + const body = response.text; + expect(body).toEqual('argle '); + done(); + }); + }); + }); + + it('supports small range requests', done => { + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/file.txt', + body: 'argle bargle', + }).then(response => { + const b = response.data; + request({ + url: b.url, + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + Range: 'bytes=0-2', + }, + }).then(response => { + const body = response.text; + expect(body).toEqual('arg'); + done(); + }); + }); + }); + + // See specs https://www.greenbytes.de/tech/webdav/draft-ietf-httpbis-p5-range-latest.html#byte.ranges + it('supports getting one byte', done => { + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/file.txt', + body: 'argle bargle', + }).then(response => { + const b = response.data; + request({ + url: b.url, + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + Range: 'bytes=2-2', + }, + }).then(response => { + const body = response.text; + expect(body).toEqual('g'); + done(); + }); + }); + }); + + it('supports getting last n bytes', done => { + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/file.txt', + body: 'something different', + }).then(response => { + const b = response.data; + request({ + url: b.url, + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + Range: 'bytes=-4', + }, + }).then(response => { + const body = response.text; + expect(body.length).toBe(4); + expect(body).toEqual('rent'); + done(); + }); + }); + }); + + it('supports getting first n bytes', done => { + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/file.txt', + body: 'something different', + }).then(response => { + const b = response.data; + request({ + url: b.url, + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + Range: 'bytes=10-', + }, + }).then(response => { + const body = response.text; + expect(body).toEqual('different'); + done(); + }); + }); + }); + + function repeat(string, count) { + let s = string; + while (count > 0) { + s += string; + count--; + } + return s; + } + + it('supports large range requests', done => { + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/file.txt', + body: repeat('argle bargle', 100), + }).then(response => { + const b = response.data; + request({ + url: b.url, + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + Range: 'bytes=13-240', + }, + }).then(response => { + const body = response.text; + expect(body.length).toEqual(228); + expect(body.indexOf('rgle barglea')).toBe(0); + done(); + }); + }); + }); + + it('fails to stream unknown file', async () => { + const response = await request({ + url: 'http://localhost:8378/1/files/test/file.txt', + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + Range: 'bytes=13-240', + }, + }).catch(e => e); + expect(response.status).toBe(404); + const body = response.text; + expect(body).toEqual('File not found.'); + }); + }); + + // Because GridStore is not loaded on PG, those are perfect + // for fallback tests + describe_only_db('postgres')('Default Range tests', () => { + it('fallback to regular request', async done => { + await reconfigureServer(); + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/file.txt', + body: 'argle bargle', + }).then(response => { + const b = response.data; + request({ + url: b.url, + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + Range: 'bytes=0-5', + }, + }).then(response => { + const body = response.text; + expect(body).toEqual('argle bargle'); + done(); + }); + }); + }); + }); + + describe('file upload configuration', () => { + it('allows file upload only for authenticated user by default', async () => { + await reconfigureServer({ + fileUpload: {}, + }); + let file = new Parse.File('hello.txt', data, 'text/plain'); + await expectAsync(file.save()).toBeRejectedWith( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'File upload by public is disabled.') + ); + file = new Parse.File('hello.txt', data, 'text/plain'); + const anonUser = await Parse.AnonymousUtils.logIn(); + await expectAsync(file.save({ sessionToken: anonUser.getSessionToken() })).toBeRejectedWith( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'File upload by anonymous user is disabled.') + ); + file = new Parse.File('hello.txt', data, 'text/plain'); + const authUser = await Parse.User.signUp('user', 'password'); + await expectAsync(file.save({ sessionToken: authUser.getSessionToken() })).toBeResolved(); + }); + + it('allows file upload with master key', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: false, + enableForAnonymousUser: false, + enableForAuthenticatedUser: false, + }, + }); + const file = new Parse.File('hello.txt', data, 'text/plain'); + await expectAsync(file.save({ useMasterKey: true })).toBeResolved(); + }); + + it('rejects all file uploads', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: false, + enableForAnonymousUser: false, + enableForAuthenticatedUser: false, + }, + }); + let file = new Parse.File('hello.txt', data, 'text/plain'); + await expectAsync(file.save()).toBeRejectedWith( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'File upload by public is disabled.') + ); + file = new Parse.File('hello.txt', data, 'text/plain'); + const anonUser = await Parse.AnonymousUtils.logIn(); + await expectAsync(file.save({ sessionToken: anonUser.getSessionToken() })).toBeRejectedWith( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'File upload by anonymous user is disabled.') + ); + file = new Parse.File('hello.txt', data, 'text/plain'); + const authUser = await Parse.User.signUp('user', 'password'); + await expectAsync(file.save({ sessionToken: authUser.getSessionToken() })).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + 'File upload by authenticated user is disabled.' + ) + ); + }); + + it('allows all file uploads', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + enableForAnonymousUser: true, + enableForAuthenticatedUser: true, + }, + }); + let file = new Parse.File('hello.txt', data, 'text/plain'); + await expectAsync(file.save()).toBeResolved(); + file = new Parse.File('hello.txt', data, 'text/plain'); + const anonUser = await Parse.AnonymousUtils.logIn(); + await expectAsync(file.save({ sessionToken: anonUser.getSessionToken() })).toBeResolved(); + file = new Parse.File('hello.txt', data, 'text/plain'); + const authUser = await Parse.User.signUp('user', 'password'); + await expectAsync(file.save({ sessionToken: authUser.getSessionToken() })).toBeResolved(); + }); + + it('allows file upload only for public', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + enableForAnonymousUser: false, + enableForAuthenticatedUser: false, + }, + }); + let file = new Parse.File('hello.txt', data, 'text/plain'); + await expectAsync(file.save()).toBeResolved(); + file = new Parse.File('hello.txt', data, 'text/plain'); + const anonUser = await Parse.AnonymousUtils.logIn(); + await expectAsync(file.save({ sessionToken: anonUser.getSessionToken() })).toBeRejectedWith( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'File upload by anonymous user is disabled.') + ); + file = new Parse.File('hello.txt', data, 'text/plain'); + const authUser = await Parse.User.signUp('user', 'password'); + await expectAsync(file.save({ sessionToken: authUser.getSessionToken() })).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + 'File upload by authenticated user is disabled.' + ) + ); + }); + + it('allows file upload only for anonymous user', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: false, + enableForAnonymousUser: true, + enableForAuthenticatedUser: false, + }, + }); + let file = new Parse.File('hello.txt', data, 'text/plain'); + await expectAsync(file.save()).toBeRejectedWith( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'File upload by public is disabled.') + ); + file = new Parse.File('hello.txt', data, 'text/plain'); + const anonUser = await Parse.AnonymousUtils.logIn(); + await expectAsync(file.save({ sessionToken: anonUser.getSessionToken() })).toBeResolved(); + file = new Parse.File('hello.txt', data, 'text/plain'); + const authUser = await Parse.User.signUp('user', 'password'); + await expectAsync(file.save({ sessionToken: authUser.getSessionToken() })).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + 'File upload by authenticated user is disabled.' + ) + ); + }); + + it('allows file upload only for authenticated user', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: false, + enableForAnonymousUser: false, + enableForAuthenticatedUser: true, + }, + }); + let file = new Parse.File('hello.txt', data, 'text/plain'); + await expectAsync(file.save()).toBeRejectedWith( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'File upload by public is disabled.') + ); + file = new Parse.File('hello.txt', data, 'text/plain'); + const anonUser = await Parse.AnonymousUtils.logIn(); + await expectAsync(file.save({ sessionToken: anonUser.getSessionToken() })).toBeRejectedWith( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'File upload by anonymous user is disabled.') + ); + file = new Parse.File('hello.txt', data, 'text/plain'); + const authUser = await Parse.User.signUp('user', 'password'); + await expectAsync(file.save({ sessionToken: authUser.getSessionToken() })).toBeResolved(); + }); + + it('rejects invalid fileUpload configuration', async () => { + const invalidConfigs = [ + { fileUpload: undefined }, + { fileUpload: null }, + { fileUpload: [] }, + { fileUpload: 1 }, + { fileUpload: 'string' }, + ]; + const validConfigs = [{ fileUpload: {} }]; + const keys = ['enableForPublic', 'enableForAnonymousUser', 'enableForAuthenticatedUser']; + const invalidValues = [[], {}, 1, 'string', null]; + const validValues = [undefined, true, false]; + for (const config of invalidConfigs) { + await expectAsync(reconfigureServer(config)).toBeRejectedWith( + 'fileUpload must be an object value.' + ); + } + for (const config of validConfigs) { + await expectAsync(reconfigureServer(config)).toBeResolved(); + } + for (const key of keys) { + for (const value of invalidValues) { + await expectAsync(reconfigureServer({ fileUpload: { [key]: value } })).toBeRejectedWith( + `fileUpload.${key} must be a boolean value.` + ); + } + for (const value of validValues) { + await expectAsync(reconfigureServer({ fileUpload: { [key]: value } })).toBeResolved(); + } + } + await expectAsync( + reconfigureServer({ + fileUpload: { + fileExtensions: 1, + }, + }) + ).toBeRejectedWith('fileUpload.fileExtensions must be an array.'); + await expectAsync( + reconfigureServer({ + fileUpload: { + allowedFileUrlDomains: 'not-an-array', + }, + }) + ).toBeRejectedWith('fileUpload.allowedFileUrlDomains must be an array.'); + await expectAsync( + reconfigureServer({ + fileUpload: { + allowedFileUrlDomains: [123], + }, + }) + ).toBeRejectedWith('fileUpload.allowedFileUrlDomains must contain only non-empty strings.'); + await expectAsync( + reconfigureServer({ + fileUpload: { + allowedFileUrlDomains: [''], + }, + }) + ).toBeRejectedWith('fileUpload.allowedFileUrlDomains must contain only non-empty strings.'); + await expectAsync( + reconfigureServer({ + fileUpload: { + allowedFileUrlDomains: ['example.com'], + }, + }) + ).toBeResolved(); + }); + }); + + describe('fileExtensions', () => { + it('works with _ContentType', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + fileExtensions: ['png'], + }, + }); + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/files/file', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'text/html', + base64: 'PGh0bWw+PC9odG1sPgo=', + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension html is disabled.`) + ); + }); + + it('works without Content-Type', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + await expectAsync( + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/file.html', + body: '\n', + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension html is disabled.`) + ); + }); + + it('default should allow common types', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + for (const type of ['plain', 'txt', 'png', 'jpg', 'gif', 'doc']) { + const file = new Parse.File(`parse-server-logo.${type}`, { base64: 'ParseA==' }); + await file.save(); + } + }); + + it('default should block SVG files', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const svgContent = Buffer.from('').toString('base64'); + for (const extension of ['svg', 'SVG', 'Svg']) { + await expectAsync( + request({ + method: 'POST', + headers: headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'image/svg+xml', + base64: svgContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension ${extension} is disabled.`) + ); + } + }); + + it('default should block SVG content type without file extension', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + const svgContent = Buffer.from('').toString('base64'); + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/files/file', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'image/svg+xml', + base64: svgContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension svg+xml is disabled.`) + ); + }); + + it('works with a period in the file name', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + fileExtensions: ['^[^hH][^tT][^mM][^lL]?$'], + }, + }); + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + const values = ['file.png.html', 'file.txt.png.html', 'file.png.txt.html']; + + for (const value of values) { + await expectAsync( + request({ + method: 'POST', + headers: headers, + url: `http://localhost:8378/1/files/${value}`, + body: '\n', + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension html is disabled.`) + ); + } + }); + + it('works to stop invalid filenames', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + fileExtensions: ['^[^hH][^tT][^mM][^lL]?$'], + }, + }); + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + const values = [ + '!invalid.png', + '.png', + '.html', + ' .html', + '.png.html', + '~invalid.png', + '-invalid.png', + ]; + + for (const value of values) { + await expectAsync( + request({ + method: 'POST', + headers: headers, + url: `http://localhost:8378/1/files/${value}`, + body: '\n', + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.INVALID_FILE_NAME, `Filename contains invalid characters.`) + ); + } + }); + + it('allows file without extension', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + fileExtensions: ['^[^hH][^tT][^mM][^lL]?$'], + }, + }); + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + const values = ['filenamewithoutextension']; + + for (const value of values) { + await expectAsync( + request({ + method: 'POST', + headers: headers, + url: `http://localhost:8378/1/files/${value}`, + body: '\n', + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeResolved(); + } + }); + + it('works with array', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + fileExtensions: ['jpg', 'wav'], + }, + }); + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/files/file', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'text/html', + base64: 'PGh0bWw+PC9odG1sPgo=', + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension html is disabled.`) + ); + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/files/file', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'image/jpg', + base64: 'PGh0bWw+PC9odG1sPgo=', + }), + }) + ).toBeResolved(); + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/files/file', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'audio/wav', + base64: 'UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAEA', + }), + }) + ).toBeResolved(); + }); + + it('works with array without Content-Type', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + fileExtensions: ['jpg'], + }, + }); + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + await expectAsync( + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/file.html', + body: '\n', + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension html is disabled.`) + ); + }); + + it('works with array with correct file type', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + fileExtensions: ['html'], + }, + }); + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/files/file', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'text/html', + base64: 'PGh0bWw+PC9odG1sPgo=', + }), + }); + const b = response.data; + expect(b.name).toMatch(/_file.html$/); + expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.html$/); + }); + }); + + describe('File URL domain validation for SSRF prevention', () => { + it('rejects cloud function call with disallowed file URL', async () => { + await reconfigureServer({ + fileUpload: { + allowedFileUrlDomains: [], + }, + }); + + Parse.Cloud.define('setUserIcon', () => {}); + + await expectAsync( + Parse.Cloud.run('setUserIcon', { + file: { __type: 'File', name: 'file.txt', url: 'http://malicious.example.com/leak' }, + }) + ).toBeRejectedWith( + jasmine.objectContaining({ message: jasmine.stringMatching(/not allowed/) }) + ); + }); + + it('rejects REST API create with disallowed file URL', async () => { + await reconfigureServer({ + fileUpload: { + allowedFileUrlDomains: [], + }, + }); + + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: { + file: { + __type: 'File', + name: 'test.txt', + url: 'http://malicious.example.com/file', + }, + }, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('rejects REST API update with disallowed file URL', async () => { + const obj = new Parse.Object('TestObject'); + await obj.save(); + + await reconfigureServer({ + fileUpload: { + allowedFileUrlDomains: [], + }, + }); + + await expectAsync( + request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/TestObject/${obj.id}`, + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: { + file: { + __type: 'File', + name: 'test.txt', + url: 'http://malicious.example.com/file', + }, + }, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('allows file URLs matching configured domains', async () => { + await reconfigureServer({ + fileUpload: { + allowedFileUrlDomains: ['cdn.example.com'], + }, + }); + + Parse.Cloud.define('setUserIcon', () => 'ok'); + + const result = await Parse.Cloud.run('setUserIcon', { + file: { __type: 'File', name: 'file.txt', url: 'http://cdn.example.com/file.txt' }, + }); + expect(result).toBe('ok'); + }); + + it('allows file URLs when default wildcard is used', async () => { + Parse.Cloud.define('setUserIcon', () => 'ok'); + + const result = await Parse.Cloud.run('setUserIcon', { + file: { __type: 'File', name: 'file.txt', url: 'http://example.com/file.txt' }, + }); + expect(result).toBe('ok'); + }); + + it('allows files with server-hosted URLs even when domains are restricted', async () => { + const file = new Parse.File('test.txt', [1, 2, 3]); + await file.save(); + + await reconfigureServer({ + fileUpload: { + allowedFileUrlDomains: ['localhost'], + }, + }); + + const result = await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: { + file: { + __type: 'File', + name: file.name(), + url: file.url(), + }, + }, + }); + expect(result.status).toBe(201); + }); + + it('allows REST API create with file URL when default wildcard is used', async () => { + const result = await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: { + file: { + __type: 'File', + name: 'test.txt', + url: 'http://example.com/file.txt', + }, + }, + }); + expect(result.status).toBe(201); + }); + + it('allows cloud function with name-only file when domains are restricted', async () => { + await reconfigureServer({ + fileUpload: { + allowedFileUrlDomains: [], + }, + }); + + Parse.Cloud.define('processFile', req => req.params.file.name()); + + const result = await Parse.Cloud.run('processFile', { + file: { __type: 'File', name: 'test.txt' }, + }); + expect(result).toBe('test.txt'); + }); + + it('rejects disallowed file URL in array field', async () => { + await reconfigureServer({ + fileUpload: { + allowedFileUrlDomains: [], + }, + }); + + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: { + files: [ + { + __type: 'File', + name: 'test.txt', + url: 'http://malicious.example.com/file', + }, + ], + }, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('rejects disallowed file URL nested in object', async () => { + await reconfigureServer({ + fileUpload: { + allowedFileUrlDomains: [], + }, + }); + + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: { + data: { + nested: { + file: { + __type: 'File', + name: 'test.txt', + url: 'http://malicious.example.com/file', + }, + }, + }, + }, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + }); + + describe('streaming binary uploads', () => { + afterEach(() => { + Parse.Cloud._removeAllHooks(); + }); + + describe('createSizeLimitedStream', () => { + const { createSizeLimitedStream } = require('../lib/Routers/FilesRouter'); + const { Readable } = require('stream'); + + it('passes data through when under limit', async () => { + const input = Readable.from(Buffer.from('hello')); + const limited = createSizeLimitedStream(input, 100); + const chunks = []; + for await (const chunk of limited) { + chunks.push(chunk); + } + expect(Buffer.concat(chunks).toString()).toBe('hello'); + }); + + it('destroys stream when data exceeds limit', async () => { + const input = Readable.from(Buffer.from('hello world, this is too long')); + const limited = createSizeLimitedStream(input, 5); + const chunks = []; + try { + for await (const chunk of limited) { + chunks.push(chunk); + } + fail('should have thrown'); + } catch (e) { + expect(e.message).toContain('exceeds'); + } + }); + + }); + + it('streams binary upload with X-Parse-Upload-Mode header', async () => { + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Upload-Mode': 'stream', + }; + let response; + try { + response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/stream-test.txt', + body: 'streaming file content', + }); + } catch (e) { + fail('Request failed: status=' + e.status + ' text=' + e.text + ' data=' + JSON.stringify(e.data)); + return; + } + const b = response.data; + expect(b.name).toMatch(/_stream-test.txt$/); + expect(b.url).toMatch(/stream-test\.txt$/); + const getResponse = await request({ url: b.url }); + expect(getResponse.text).toEqual('streaming file content'); + }); + + it('infers content type from extension when Content-Type header is missing', async () => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Upload-Mode': 'stream', + }; + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/inferred.txt', + body: 'inferred content type', + }); + const b = response.data; + expect(b.name).toMatch(/_inferred.txt$/); + const getResponse = await request({ url: b.url }); + expect(getResponse.text).toEqual('inferred content type'); + }); + + it('uses buffered path without X-Parse-Upload-Mode header', async () => { + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/buffered-test.txt', + body: 'buffered file content', + }); + const b = response.data; + expect(b.name).toMatch(/_buffered-test.txt$/); + const getResponse = await request({ url: b.url }); + expect(getResponse.text).toEqual('buffered file content'); + }); + + it('rejects streaming upload exceeding size limit', async () => { + await reconfigureServer({ maxUploadSize: '10b' }); + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Upload-Mode': 'stream', + }; + try { + await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/big-file.txt', + body: 'this content is definitely longer than 10 bytes', + }); + fail('should have thrown'); + } catch (response) { + expect(response.data.code).toBe(Parse.Error.FILE_SAVE_ERROR); + expect(response.data.error).toContain('exceeds'); + } + }); + + it('rejects streaming upload with Content-Length exceeding limit', async () => { + await reconfigureServer({ maxUploadSize: '10b' }); + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Upload-Mode': 'stream', + 'Content-Length': '99999', + }; + try { + await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/big-file.txt', + body: 'hi', + }); + fail('should have thrown'); + } catch (response) { + expect(response.data.code).toBe(Parse.Error.FILE_SAVE_ERROR); + expect(response.data.error).toContain('exceeds'); + } + }); + + describe('maxUploadSize override', () => { + it('allows streaming upload exceeding server limit with maxUploadSize override and master key', async () => { + await reconfigureServer({ maxUploadSize: '10b' }); + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Max-Upload-Size': '1mb', + }; + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/override-stream.txt', + body: 'this content is definitely longer than 10 bytes', + }); + expect(response.data.name).toContain('override-stream'); + expect(response.data.url).toBeDefined(); + }); + + it('allows buffered upload exceeding server limit with maxUploadSize override and master key', async () => { + await reconfigureServer({ maxUploadSize: '10b' }); + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'X-Parse-File-Max-Upload-Size': '1mb', + }; + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/override-buffer.txt', + body: 'this content is definitely longer than 10 bytes', + }); + expect(response.data.name).toContain('override-buffer'); + expect(response.data.url).toBeDefined(); + }); + + it('rejects maxUploadSize override without master key', async () => { + await reconfigureServer({ maxUploadSize: '10b' }); + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Max-Upload-Size': '1mb', + }; + try { + await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/no-master.txt', + body: 'this content is longer than 10 bytes', + }); + fail('should have thrown'); + } catch (response) { + expect(response.status).toBe(403); + } + }); + + it('rejects invalid maxUploadSize override value', async () => { + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Max-Upload-Size': 'notasize', + }; + try { + await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/bad-value.txt', + body: 'some data', + }); + fail('should have thrown'); + } catch (response) { + expect(response.data.code).toBe(Parse.Error.FILE_SAVE_ERROR); + expect(response.data.error).toContain('Invalid maxUploadSize override'); + } + }); + + it('rejects streaming upload exceeding the overridden maxUploadSize', async () => { + await reconfigureServer({ maxUploadSize: '5b' }); + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Max-Upload-Size': '10b', + }; + try { + await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/still-too-big.txt', + body: 'this content is definitely longer than 10 bytes', + }); + fail('should have thrown'); + } catch (response) { + expect(response.data.code).toBe(Parse.Error.FILE_SAVE_ERROR); + expect(response.data.error).toContain('exceeds'); + } + }); + + it('rejects maxUploadSize override with wrong master key', async () => { + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'wrong-key', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Max-Upload-Size': '1mb', + }; + try { + await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/wrong-key.txt', + body: 'some data', + }); + fail('should have thrown'); + } catch (response) { + expect(response.status).toBe(403); + } + }); + + it('rejects maxUploadSize override with invalid application ID', async () => { + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'invalid-app-id', + 'X-Parse-Master-Key': 'test', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Max-Upload-Size': '1mb', + }; + try { + await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/bad-app.txt', + body: 'some data', + }); + fail('should have thrown'); + } catch (response) { + expect(response.status).toBe(403); + } + }); + + it('rejects maxUploadSize override when masterKeyIps blocks the IP', async () => { + await reconfigureServer({ masterKeyIps: ['10.0.0.1'] }); + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Max-Upload-Size': '1mb', + }; + try { + await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/blocked-ip.txt', + body: 'some data', + }); + fail('should have thrown'); + } catch (response) { + expect(response.status).toBe(403); + } + }); + + }); + + describe('maxUploadSize override via SDK', () => { + it('saves buffer file with maxUploadSize override and master key', async () => { + await reconfigureServer({ maxUploadSize: '10b' }); + const data = Buffer.alloc(100, 'a'); + const file = new Parse.File('sdk-buffer-override.txt', data, 'text/plain'); + const result = await file.save({ useMasterKey: true, maxUploadSize: '1mb' }); + expect(result.url()).toBeDefined(); + expect(result.name()).toContain('sdk-buffer-override'); + }); + + it('saves stream file with maxUploadSize override and master key', async () => { + await reconfigureServer({ maxUploadSize: '10b' }); + const { Readable } = require('stream'); + const stream = Readable.from(Buffer.alloc(100, 'b')); + const file = new Parse.File('sdk-stream-override.txt', stream, 'text/plain'); + const result = await file.save({ useMasterKey: true, maxUploadSize: '1mb' }); + expect(result.url()).toBeDefined(); + expect(result.name()).toContain('sdk-stream-override'); + }); + + it('rejects maxUploadSize override without master key', async () => { + await reconfigureServer({ maxUploadSize: '10b' }); + const data = Buffer.alloc(100, 'c'); + const file = new Parse.File('sdk-no-master.txt', data, 'text/plain'); + try { + await file.save({ maxUploadSize: '1mb' }); + fail('should have thrown'); + } catch (error) { + expect(error.error).toBeDefined(); + } + }); + }); + + it('fires beforeSave trigger with request.stream = true on streaming upload', async () => { + let receivedStream; + let receivedData; + Parse.Cloud.beforeSave(Parse.File, (request) => { + receivedStream = request.stream; + receivedData = request.file._data; + request.file.addMetadata('source', 'stream'); + request.file.addTag('env', 'test'); + }); + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Upload-Mode': 'stream', + }; + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/trigger-test.txt', + body: 'trigger test content', + }); + expect(response.data.name).toMatch(/_trigger-test.txt$/); + expect(receivedStream).toBe(true); + expect(receivedData).toBeFalsy(); + const getResponse = await request({ url: response.data.url }); + expect(getResponse.text).toEqual('trigger test content'); + }); + + it('rejects streaming upload when beforeSave trigger throws', async () => { + Parse.Cloud.beforeSave(Parse.File, () => { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Upload rejected'); + }); + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Upload-Mode': 'stream', + }; + try { + await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/rejected.txt', + body: 'rejected content', + }); + fail('should have thrown'); + } catch (response) { + expect(response.data.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(response.data.error).toBe('Upload rejected'); + } + }); + + it('skips save when beforeSave trigger returns Parse.File with URL on streaming upload', async () => { + Parse.Cloud.beforeSave(Parse.File, () => { + return Parse.File.fromJSON({ + __type: 'File', + name: 'existing.txt', + url: 'http://example.com/existing.txt', + }); + }); + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Upload-Mode': 'stream', + }; + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/skip-save.txt', + body: 'should not be saved', + }); + expect(response.data.url).toBe('http://example.com/existing.txt'); + expect(response.data.name).toBe('existing.txt'); + }); + + it('fires afterSave trigger with request.stream = true on streaming upload', async () => { + let afterSaveStream; + let afterSaveData; + let afterSaveUrl; + Parse.Cloud.afterSave(Parse.File, (request) => { + afterSaveStream = request.stream; + afterSaveData = request.file._data; + afterSaveUrl = request.file._url; + }); + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Upload-Mode': 'stream', + }; + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/after-save.txt', + body: 'after save content', + }); + expect(response.data.name).toMatch(/_after-save.txt$/); + expect(afterSaveStream).toBe(true); + expect(afterSaveData).toBeFalsy(); + expect(afterSaveUrl).toBeTruthy(); + }); + + it('verifies FilesAdapter default supportsStreaming is false', () => { + const { FilesAdapter } = require('../lib/Adapters/Files/FilesAdapter'); + const adapter = new FilesAdapter(); + expect(adapter.supportsStreaming).toBe(false); + }); + + it('legacy JSON-wrapped upload still works', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + fileExtensions: ['*'], + }, + }); + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/files/legacy.txt', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'text/plain', + base64: Buffer.from('legacy content').toString('base64'), + }), + }); + const b = response.data; + expect(b.name).toMatch(/_legacy.txt$/); + const getResponse = await request({ url: b.url }); + expect(getResponse.text).toEqual('legacy content'); + }); + }); + + describe('file directory', () => { + it('saves file with directory using master key', async () => { + spyOn(FilesController.prototype, 'createFile').and.callThrough(); + const file = new Parse.File('hello.txt', data, 'text/plain'); + file.setDirectory('user-uploads/avatars'); + const result = await file.save({ useMasterKey: true }); + expect(result.name()).toMatch(/^user-uploads\/avatars\/.*_hello.txt$/); + expect(result.url()).toBeDefined(); + // directory is consumed (deleted) from options by FilesController.createFile + // and prepended to the filename, which is verified above via result.name() + expect(FilesController.prototype.createFile.calls.argsFor(0)[4]).toEqual({ + metadata: {}, + }); + }); + + it('rejects directory without master key', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + try { + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/files/hello.txt', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'text/plain', + base64: Buffer.from('Hello World!').toString('base64'), + fileData: { + directory: 'some-dir', + metadata: {}, + tags: {}, + }, + }), + }); + fail('should have thrown'); + expect(response).toBeUndefined(); + } catch (error) { + expect(error.data.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); + expect(error.data.error).toEqual('Directory can only be set using the Master Key.'); + } + }); + + it('validates directory - rejects path traversal', async () => { + const file = new Parse.File('hello.txt', data, 'text/plain'); + file.setDirectory('some/../etc'); + try { + await file.save({ useMasterKey: true }); + fail('should have thrown'); + } catch (error) { + expect(error.code).toEqual(Parse.Error.INVALID_FILE_NAME); + expect(error.message).toContain('..'); + } + }); + + it('validates directory - rejects leading slash', async () => { + const file = new Parse.File('hello.txt', data, 'text/plain'); + file.setDirectory('/absolute-path'); + try { + await file.save({ useMasterKey: true }); + fail('should have thrown'); + } catch (error) { + expect(error.code).toEqual(Parse.Error.INVALID_FILE_NAME); + } + }); + + it('validates directory - rejects invalid characters', async () => { + const invalidDirs = ['dir with spaces', '~root', '$HOME/files', 'dir%00name', '.hidden', 'foo\\bar']; + for (const dir of invalidDirs) { + const file = new Parse.File('hello.txt', data, 'text/plain'); + file.setDirectory(dir); + try { + await file.save({ useMasterKey: true }); + fail(`should have thrown for directory: ${dir}`); + } catch (error) { + expect(error.code).toEqual(Parse.Error.INVALID_FILE_NAME); + expect(error.message).toContain('invalid characters'); + } + } + }); + + it('validates directory - rejects consecutive slashes', async () => { + const file = new Parse.File('hello.txt', data, 'text/plain'); + file.setDirectory('dir//subdir'); + try { + await file.save({ useMasterKey: true }); + fail('should have thrown'); + } catch (error) { + expect(error.code).toEqual(Parse.Error.INVALID_FILE_NAME); + expect(error.message).toContain('consecutive slashes'); + } + }); + + it('saves and retrieves file with nested directory', async () => { + const file = new Parse.File('hello.txt', data, 'text/plain'); + file.setDirectory('photos/2024/january'); + const result = await file.save({ useMasterKey: true }); + expect(result.name()).toMatch(/^photos\/2024\/january\/.*_hello.txt$/); + expect(result.url()).toBeDefined(); + // Retrieve the file via its URL + const response = await request({ url: result.url() }); + expect(response.text).toEqual(str); + }); + + it('allows beforeSaveFile trigger to set directory', async () => { + Parse.Cloud.beforeSave(Parse.File, req => { + req.file.setDirectory('trigger-dir'); + }); + spyOn(FilesController.prototype, 'createFile').and.callThrough(); + const file = new Parse.File('hello.txt', data, 'text/plain'); + const result = await file.save(); + expect(result.name()).toMatch(/^trigger-dir\/.*_hello.txt$/); + // directory is consumed (deleted) from options by FilesController.createFile + // and prepended to the filename, which is verified above via result.name() + expect(FilesController.prototype.createFile.calls.argsFor(0)[4]).toEqual({ + metadata: {}, + }); + }); + + it('deletes file with directory path', async () => { + const file = new Parse.File('hello.txt', data, 'text/plain'); + file.setDirectory('delete-test'); + const result = await file.save({ useMasterKey: true }); + expect(result.name()).toMatch(/^delete-test\/.*_hello.txt$/); + await result.destroy({ useMasterKey: true }); + // Verify file is gone + try { + await request({ url: result.url() }); + fail('should have thrown'); + } catch (error) { + expect(error.status).toBe(404); + } + }); + + it('saves file with directory via streaming upload (trigger)', async () => { + Parse.Cloud.beforeSave(Parse.File, req => { + req.file.setDirectory('stream-uploads'); + }); + const headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Upload-Mode': 'stream', + }; + const response = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/files/stream-dir.txt', + body: 'stream directory content', + }); + const b = response.data; + expect(b.name).toMatch(/^stream-uploads\/.*_stream-dir.txt$/); + expect(b.url).toBeDefined(); + }); + + it('saves file with directory via streaming upload (header)', async () => { + const headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Directory': 'stream-dir-test', + }; + const response = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/files/stream-header.txt', + body: 'stream directory header content', + }); + const b = response.data; + expect(b.name).toMatch(/^stream-dir-test\/.*_stream-header.txt$/); + expect(b.url).toBeDefined(); + }); + + it('rejects directory header without master key for streaming upload', async () => { + const headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Directory': 'no-master', + }; + try { + await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/files/stream-header.txt', + body: 'should fail', + }); + fail('should have thrown'); + } catch (error) { + expect(error.data.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it('validates directory header for streaming upload', async () => { + const headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Directory': '../etc', + }; + try { + await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/files/stream-header.txt', + body: 'should fail', + }); + fail('should have thrown'); + } catch (error) { + expect(error.data.code).toEqual(Parse.Error.INVALID_FILE_NAME); + } + }); + + it('saves file with metadata and tags via streaming upload headers', async () => { + spyOn(FilesController.prototype, 'createFile').and.callThrough(); + const headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Metadata': JSON.stringify({ key1: 'value1' }), + 'X-Parse-File-Tags': JSON.stringify({ tag1: 'tagValue1' }), + }; + const response = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/files/stream-meta.txt', + body: 'stream with metadata content', + }); + const b = response.data; + expect(b.name).toMatch(/_stream-meta.txt$/); + expect(b.url).toBeDefined(); + const options = FilesController.prototype.createFile.calls.argsFor(0)[4]; + expect(options.metadata).toEqual({ key1: 'value1' }); + expect(options.tags).toEqual({ tag1: 'tagValue1' }); + }); + + it('saves file with directory, metadata, and tags via streaming upload headers', async () => { + spyOn(FilesController.prototype, 'createFile').and.callThrough(); + const headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Directory': 'uploads', + 'X-Parse-File-Metadata': JSON.stringify({ author: 'test' }), + 'X-Parse-File-Tags': JSON.stringify({ env: 'test' }), + }; + const response = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/files/stream-all.txt', + body: 'stream with all file data', + }); + const b = response.data; + expect(b.name).toMatch(/^uploads\/.*_stream-all.txt$/); + expect(b.url).toBeDefined(); + const options = FilesController.prototype.createFile.calls.argsFor(0)[4]; + expect(options.metadata).toEqual({ author: 'test' }); + expect(options.tags).toEqual({ env: 'test' }); + }); + + it('rejects invalid JSON in metadata header', async () => { + const headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Metadata': 'not-json', + }; + try { + await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/files/stream-bad.txt', + body: 'should fail', + }); + fail('should have thrown'); + } catch (error) { + expect(error.data.code).toEqual(Parse.Error.INVALID_JSON); + } + }); + + it('rejects invalid JSON in tags header', async () => { + const headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Tags': '{bad', + }; + try { + await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/files/stream-bad.txt', + body: 'should fail', + }); + fail('should have thrown'); + } catch (error) { + expect(error.data.code).toEqual(Parse.Error.INVALID_JSON); + } + }); + + it('rejects non-object metadata header', async () => { + const invalidValues = ['"a string"', '[1,2]', 'null', '42', 'true']; + for (const value of invalidValues) { + const headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Metadata': value, + }; + try { + await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/files/stream-bad.txt', + body: 'should fail', + }); + fail(`should have thrown for metadata: ${value}`); + } catch (error) { + expect(error.data.code).toEqual(Parse.Error.INVALID_JSON); + expect(error.data.error).toBe('Invalid JSON in X-Parse-File-Metadata header.'); + } + } + }); + + it('rejects non-object tags header', async () => { + const invalidValues = ['"a string"', '[1,2]', 'null', '42', 'true']; + for (const value of invalidValues) { + const headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Tags': value, + }; + try { + await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/files/stream-bad.txt', + body: 'should fail', + }); + fail(`should have thrown for tags: ${value}`); + } catch (error) { + expect(error.data.code).toEqual(Parse.Error.INVALID_JSON); + expect(error.data.error).toBe('Invalid JSON in X-Parse-File-Tags header.'); + } + } + }); + + it('validates directory - rejects trailing slash', async () => { + const file = new Parse.File('hello.txt', data, 'text/plain'); + file.setDirectory('trailing/'); + try { + await file.save({ useMasterKey: true }); + fail('should have thrown'); + } catch (error) { + expect(error.code).toEqual(Parse.Error.INVALID_FILE_NAME); + expect(error.message).toContain('start or end with'); + } + }); + + it('validates directory - rejects too long path', async () => { + const file = new Parse.File('hello.txt', data, 'text/plain'); + file.setDirectory('a'.repeat(257)); + try { + await file.save({ useMasterKey: true }); + fail('should have thrown'); + } catch (error) { + expect(error.code).toEqual(Parse.Error.INVALID_FILE_NAME); + expect(error.message).toContain('too long'); + } + }); + + it('validates directory - rejects reserved segment "metadata"', async () => { + const file = new Parse.File('hello.txt', data, 'text/plain'); + file.setDirectory('metadata/docs'); + try { + await file.save({ useMasterKey: true }); + fail('should have thrown'); + } catch (error) { + expect(error.code).toEqual(Parse.Error.INVALID_FILE_NAME); + expect(error.message).toContain('reserved segment'); + } + }); + + it('saves file without directory (no change to existing behavior)', async () => { + spyOn(FilesController.prototype, 'createFile').and.callThrough(); + const file = new Parse.File('hello.txt', data, 'text/plain'); + const result = await file.save(); + expect(result.name()).not.toContain('/'); + expect(result.url()).toBeDefined(); + expect(FilesController.prototype.createFile.calls.argsFor(0)[4]).toEqual({ + metadata: {}, + }); }); }); }); diff --git a/spec/ParseGeoPoint.spec.js b/spec/ParseGeoPoint.spec.js index 54d193a3f2..f154f0048e 100644 --- a/spec/ParseGeoPoint.spec.js +++ b/spec/ParseGeoPoint.spec.js @@ -1,333 +1,789 @@ // This is a port of the test suite: // hungry/js/test/parse_geo_point_test.js -var TestObject = Parse.Object.extend('TestObject'); +const request = require('../lib/request'); +const TestObject = Parse.Object.extend('TestObject'); describe('Parse.GeoPoint testing', () => { - it_exclude_dbs(['postgres'])('geo point roundtrip', (done) => { - var point = new Parse.GeoPoint(44.0, -11.0); - var obj = new TestObject(); + it('geo point roundtrip', async () => { + const point = new Parse.GeoPoint(44.0, -11.0); + const obj = new TestObject(); obj.set('location', point); obj.set('name', 'Ferndale'); - obj.save(null, { - success: function() { - var query = new Parse.Query(TestObject); - query.find({ - success: function(results) { - equal(results.length, 1); - var pointAgain = results[0].get('location'); - ok(pointAgain); - equal(pointAgain.latitude, 44.0); - equal(pointAgain.longitude, -11.0); - done(); - } - }); - } + await obj.save(); + const result = await new Parse.Query(TestObject).get(obj.id); + const pointAgain = result.get('location'); + ok(pointAgain); + equal(pointAgain.latitude, 44.0); + equal(pointAgain.longitude, -11.0); + }); + + it('update geopoint', done => { + const oldPoint = new Parse.GeoPoint(44.0, -11.0); + const newPoint = new Parse.GeoPoint(24.0, 19.0); + const obj = new TestObject(); + obj.set('location', oldPoint); + obj + .save() + .then(() => { + obj.set('location', newPoint); + return obj.save(); + }) + .then(() => { + const query = new Parse.Query(TestObject); + return query.get(obj.id); + }) + .then(result => { + const point = result.get('location'); + equal(point.latitude, newPoint.latitude); + equal(point.longitude, newPoint.longitude); + done(); + }); + }); + + it('has the correct __type field in the json response', async done => { + const point = new Parse.GeoPoint(44.0, -11.0); + const obj = new TestObject(); + obj.set('location', point); + obj.set('name', 'Zhoul'); + await obj.save(); + request({ + url: 'http://localhost:8378/1/classes/TestObject/' + obj.id, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }).then(response => { + equal(response.data.location.__type, 'GeoPoint'); + done(); }); }); - it_exclude_dbs(['postgres'])('geo point exception two fields', (done) => { - var point = new Parse.GeoPoint(20, 20); - var obj = new TestObject(); + it('creating geo point exception two fields', done => { + const point = new Parse.GeoPoint(20, 20); + const obj = new TestObject(); obj.set('locationOne', point); obj.set('locationTwo', point); - obj.save().then(() => { - fail('expected error'); - }, (err) => { - equal(err.code, Parse.Error.INCORRECT_TYPE); - done(); - }); + obj.save().then( + () => { + fail('expected error'); + }, + err => { + equal(err.code, Parse.Error.INCORRECT_TYPE); + done(); + } + ); + }); + + // TODO: This should also have support in postgres, or higher level database agnostic support. + it_exclude_dbs(['postgres'])('updating geo point exception two fields', async done => { + const point = new Parse.GeoPoint(20, 20); + const obj = new TestObject(); + obj.set('locationOne', point); + await obj.save(); + obj.set('locationTwo', point); + obj.save().then( + () => { + fail('expected error'); + }, + err => { + equal(err.code, Parse.Error.INCORRECT_TYPE); + done(); + } + ); }); - it_exclude_dbs(['postgres'])('geo line', (done) => { - var line = []; - for (var i = 0; i < 10; ++i) { - var obj = new TestObject(); - var point = new Parse.GeoPoint(i * 4.0 - 12.0, i * 3.2 - 11.0); + it_id('bbd9e2f6-7f61-458f-98f2-4a563586cd8d')(it)('geo line', async done => { + const line = []; + for (let i = 0; i < 10; ++i) { + const obj = new TestObject(); + const point = new Parse.GeoPoint(i * 4.0 - 12.0, i * 3.2 - 11.0); obj.set('location', point); obj.set('construct', 'line'); obj.set('seq', i); line.push(obj); } - Parse.Object.saveAll(line, { - success: function() { - var query = new Parse.Query(TestObject); - var point = new Parse.GeoPoint(24, 19); - query.equalTo('construct', 'line'); - query.withinMiles('location', point, 10000); - query.find({ - success: function(results) { - equal(results.length, 10); - equal(results[0].get('seq'), 9); - equal(results[3].get('seq'), 6); - done(); - } - }); - } - }); + await Parse.Object.saveAll(line); + const query = new Parse.Query(TestObject); + const point = new Parse.GeoPoint(24, 19); + query.equalTo('construct', 'line'); + query.withinMiles('location', point, 10000); + const results = await query.find(); + equal(results.length, 10); + equal(results[0].get('seq'), 9); + equal(results[3].get('seq'), 6); + done(); }); - it_exclude_dbs(['postgres'])('geo max distance large', (done) => { - var objects = []; - [0, 1, 2].map(function(i) { - var obj = new TestObject(); - var point = new Parse.GeoPoint(0.0, i * 45.0); + it('geo max distance large', done => { + const objects = []; + [0, 1, 2].map(function (i) { + const obj = new TestObject(); + const point = new Parse.GeoPoint(0.0, i * 45.0); obj.set('location', point); obj.set('index', i); objects.push(obj); }); - Parse.Object.saveAll(objects).then((list) => { - var query = new Parse.Query(TestObject); - var point = new Parse.GeoPoint(1.0, -1.0); - query.withinRadians('location', point, 3.14); - return query.find(); - }).then((results) => { - equal(results.length, 3); - done(); - }, (err) => { - fail("Couldn't query GeoPoint"); - fail(err) - }); + Parse.Object.saveAll(objects) + .then(() => { + const query = new Parse.Query(TestObject); + const point = new Parse.GeoPoint(1.0, -1.0); + query.withinRadians('location', point, 3.14); + return query.find(); + }) + .then( + results => { + equal(results.length, 3); + done(); + }, + err => { + fail("Couldn't query GeoPoint"); + jfail(err); + } + ); }); - it_exclude_dbs(['postgres'])('geo max distance medium', (done) => { - var objects = []; - [0, 1, 2].map(function(i) { - var obj = new TestObject(); - var point = new Parse.GeoPoint(0.0, i * 45.0); + it_id('e1e86b38-b8a4-4109-8330-a324fe628e0c')(it)('geo max distance medium', async () => { + const objects = []; + [0, 1, 2].map(function (i) { + const obj = new TestObject(); + const point = new Parse.GeoPoint(0.0, i * 45.0); obj.set('location', point); obj.set('index', i); objects.push(obj); }); - Parse.Object.saveAll(objects, function(list) { - var query = new Parse.Query(TestObject); - var point = new Parse.GeoPoint(1.0, -1.0); - query.withinRadians('location', point, 3.14 * 0.5); - query.find({ - success: function(results) { - equal(results.length, 2); - equal(results[0].get('index'), 0); - equal(results[1].get('index'), 1); - done(); - } - }); - }); + await Parse.Object.saveAll(objects); + const query = new Parse.Query(TestObject); + const point = new Parse.GeoPoint(1.0, -1.0); + query.withinRadians('location', point, 3.14 * 0.5); + const results = await query.find(); + equal(results.length, 2); + equal(results[0].get('index'), 0); + equal(results[1].get('index'), 1); }); - it_exclude_dbs(['postgres'])('geo max distance small', (done) => { - var objects = []; - [0, 1, 2].map(function(i) { - var obj = new TestObject(); - var point = new Parse.GeoPoint(0.0, i * 45.0); + it('geo max distance small', async () => { + const objects = []; + [0, 1, 2].map(function (i) { + const obj = new TestObject(); + const point = new Parse.GeoPoint(0.0, i * 45.0); obj.set('location', point); obj.set('index', i); objects.push(obj); }); - Parse.Object.saveAll(objects, function(list) { - var query = new Parse.Query(TestObject); - var point = new Parse.GeoPoint(1.0, -1.0); - query.withinRadians('location', point, 3.14 * 0.25); - query.find({ - success: function(results) { - equal(results.length, 1); - equal(results[0].get('index'), 0); - done(); - } - }); - }); + await Parse.Object.saveAll(objects); + const query = new Parse.Query(TestObject); + const point = new Parse.GeoPoint(1.0, -1.0); + query.withinRadians('location', point, 3.14 * 0.25); + const results = await query.find(); + equal(results.length, 1); + equal(results[0].get('index'), 0); }); - var makeSomeGeoPoints = function(callback) { - var sacramento = new TestObject(); - sacramento.set('location', new Parse.GeoPoint(38.52, -121.50)); + const makeSomeGeoPoints = function () { + const sacramento = new TestObject(); + sacramento.set('location', new Parse.GeoPoint(38.52, -121.5)); sacramento.set('name', 'Sacramento'); - var honolulu = new TestObject(); + const honolulu = new TestObject(); honolulu.set('location', new Parse.GeoPoint(21.35, -157.93)); honolulu.set('name', 'Honolulu'); - var sf = new TestObject(); + const sf = new TestObject(); sf.set('location', new Parse.GeoPoint(37.75, -122.68)); sf.set('name', 'San Francisco'); - Parse.Object.saveAll([sacramento, sf, honolulu], callback); + return Parse.Object.saveAll([sacramento, sf, honolulu]); }; - it_exclude_dbs(['postgres'])('geo max distance in km everywhere', (done) => { - makeSomeGeoPoints(function(list) { - var sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - var query = new Parse.Query(TestObject); - query.withinKilometers('location', sfo, 4000.0); - query.find({ - success: function(results) { - equal(results.length, 3); - done(); - } + it('geo max distance in km everywhere', async done => { + await makeSomeGeoPoints(); + const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); + const query = new Parse.Query(TestObject); + // Honolulu is 4300 km away from SFO on a sphere ;) + query.withinKilometers('location', sfo, 4800.0); + const results = await query.find(); + equal(results.length, 3); + done(); + }); + + it_id('05f1a454-56b1-4f2e-908e-408a9222cbae')(it)('geo max distance in km california', async () => { + await makeSomeGeoPoints(); + const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); + const query = new Parse.Query(TestObject); + query.withinKilometers('location', sfo, 3700.0); + const results = await query.find(); + equal(results.length, 2); + equal(results[0].get('name'), 'San Francisco'); + equal(results[1].get('name'), 'Sacramento'); + }); + + it('geo max distance in km bay area', async () => { + await makeSomeGeoPoints(); + const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); + const query = new Parse.Query(TestObject); + query.withinKilometers('location', sfo, 100.0); + const results = await query.find(); + equal(results.length, 1); + equal(results[0].get('name'), 'San Francisco'); + }); + + it('geo max distance in km mid peninsula', async () => { + await makeSomeGeoPoints(); + const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); + const query = new Parse.Query(TestObject); + query.withinKilometers('location', sfo, 10.0); + const results = await query.find(); + equal(results.length, 0); + }); + + it('geo max distance in miles everywhere', async () => { + await makeSomeGeoPoints(); + const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); + const query = new Parse.Query(TestObject); + query.withinMiles('location', sfo, 2600.0); + const results = await query.find(); + equal(results.length, 3); + }); + + it_id('9ee376ad-dd6c-4c17-ad28-c7899a4411f1')(it)('geo max distance in miles california', async () => { + await makeSomeGeoPoints(); + const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); + const query = new Parse.Query(TestObject); + query.withinMiles('location', sfo, 2200.0); + const results = await query.find(); + equal(results.length, 2); + equal(results[0].get('name'), 'San Francisco'); + equal(results[1].get('name'), 'Sacramento'); + }); + + it('geo max distance in miles bay area', async () => { + await makeSomeGeoPoints(); + const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); + const query = new Parse.Query(TestObject); + query.withinMiles('location', sfo, 62.0); + const results = await query.find(); + equal(results.length, 1); + equal(results[0].get('name'), 'San Francisco'); + }); + + it('geo max distance in miles mid peninsula', async () => { + await makeSomeGeoPoints(); + const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); + const query = new Parse.Query(TestObject); + query.withinMiles('location', sfo, 10.0); + const results = await query.find(); + equal(results.length, 0); + }); + + it_id('9e35a89e-bc2c-4ec5-b25a-8d1890a55233')(it)('returns nearest location', async () => { + await makeSomeGeoPoints(); + const sfo = new Parse.GeoPoint(37.6189722, -122.3748889); + const query = new Parse.Query(TestObject); + query.near('location', sfo); + const results = await query.find(); + equal(results[0].get('name'), 'San Francisco'); + equal(results[1].get('name'), 'Sacramento'); + }); + + it_id('6df434b0-142d-4302-bbc6-a6ec5a9d9c68')(it)('works with geobox queries', done => { + const inbound = new Parse.GeoPoint(1.5, 1.5); + const onbound = new Parse.GeoPoint(10, 10); + const outbound = new Parse.GeoPoint(20, 20); + const obj1 = new Parse.Object('TestObject', { location: inbound }); + const obj2 = new Parse.Object('TestObject', { location: onbound }); + const obj3 = new Parse.Object('TestObject', { location: outbound }); + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const sw = new Parse.GeoPoint(0, 0); + const ne = new Parse.GeoPoint(10, 10); + const query = new Parse.Query(TestObject); + query.withinGeoBox('location', sw, ne); + return query.find(); + }) + .then(results => { + equal(results.length, 2); + done(); }); - }); }); - it_exclude_dbs(['postgres'])('geo max distance in km california', (done) => { - makeSomeGeoPoints(function(list) { - var sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - var query = new Parse.Query(TestObject); - query.withinKilometers('location', sfo, 3700.0); - query.find({ - success: function(results) { - equal(results.length, 2); - equal(results[0].get('name'), 'San Francisco'); - equal(results[1].get('name'), 'Sacramento'); - done(); - } + it('supports a sub-object with a geo point', async () => { + const point = new Parse.GeoPoint(44.0, -11.0); + const obj = new TestObject(); + obj.set('subobject', { location: point }); + await obj.save(); + const query = new Parse.Query(TestObject); + const results = await query.find(); + equal(results.length, 1); + const pointAgain = results[0].get('subobject')['location']; + ok(pointAgain); + equal(pointAgain.latitude, 44.0); + equal(pointAgain.longitude, -11.0); + }); + + it('supports array of geo points', async () => { + const point1 = new Parse.GeoPoint(44.0, -11.0); + const point2 = new Parse.GeoPoint(22.0, -55.0); + const obj = new TestObject(); + obj.set('locations', [point1, point2]); + await obj.save(); + const query = new Parse.Query(TestObject); + const results = await query.find(); + equal(results.length, 1); + const locations = results[0].get('locations'); + expect(locations.length).toEqual(2); + expect(locations[0]).toEqual(point1); + expect(locations[1]).toEqual(point2); + }); + + it('equalTo geopoint', done => { + const point = new Parse.GeoPoint(44.0, -11.0); + const obj = new TestObject(); + obj.set('location', point); + obj + .save() + .then(() => { + const query = new Parse.Query(TestObject); + query.equalTo('location', point); + return query.find(); + }) + .then(results => { + equal(results.length, 1); + const loc = results[0].get('location'); + equal(loc.latitude, point.latitude); + equal(loc.longitude, point.longitude); + done(); }); - }); }); - it_exclude_dbs(['postgres'])('geo max distance in km bay area', (done) => { - makeSomeGeoPoints(function(list) { - var sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - var query = new Parse.Query(TestObject); - query.withinKilometers('location', sfo, 100.0); - query.find({ - success: function(results) { - equal(results.length, 1); - equal(results[0].get('name'), 'San Francisco'); - done(); - } + it_id('d9fbc5c6-f767-47d6-bb44-3858eb9df15a')(it)('supports withinPolygon open path', done => { + const inbound = new Parse.GeoPoint(1.5, 1.5); + const onbound = new Parse.GeoPoint(10, 10); + const outbound = new Parse.GeoPoint(20, 20); + const obj1 = new Parse.Object('Polygon', { location: inbound }); + const obj2 = new Parse.Object('Polygon', { location: onbound }); + const obj3 = new Parse.Object('Polygon', { location: outbound }); + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const where = { + location: { + $geoWithin: { + $polygon: [ + { __type: 'GeoPoint', latitude: 0, longitude: 0 }, + { __type: 'GeoPoint', latitude: 0, longitude: 10 }, + { __type: 'GeoPoint', latitude: 10, longitude: 10 }, + { __type: 'GeoPoint', latitude: 10, longitude: 0 }, + ], + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/Polygon', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + expect(resp.data.results.length).toBe(2); + done(); + }, done.fail); + }); + + it_id('3ec537bd-839a-4c93-a48b-b4a249820074')(it)('supports withinPolygon closed path', done => { + const inbound = new Parse.GeoPoint(1.5, 1.5); + const onbound = new Parse.GeoPoint(10, 10); + const outbound = new Parse.GeoPoint(20, 20); + const obj1 = new Parse.Object('Polygon', { location: inbound }); + const obj2 = new Parse.Object('Polygon', { location: onbound }); + const obj3 = new Parse.Object('Polygon', { location: outbound }); + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const where = { + location: { + $geoWithin: { + $polygon: [ + { __type: 'GeoPoint', latitude: 0, longitude: 0 }, + { __type: 'GeoPoint', latitude: 0, longitude: 10 }, + { __type: 'GeoPoint', latitude: 10, longitude: 10 }, + { __type: 'GeoPoint', latitude: 10, longitude: 0 }, + { __type: 'GeoPoint', latitude: 0, longitude: 0 }, + ], + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/Polygon', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + expect(resp.data.results.length).toBe(2); + done(); + }, done.fail); + }); + + it_id('0a248e11-3598-480a-9ab5-8a0b259258e4')(it)('supports withinPolygon Polygon object', done => { + const inbound = new Parse.GeoPoint(1.5, 1.5); + const onbound = new Parse.GeoPoint(10, 10); + const outbound = new Parse.GeoPoint(20, 20); + const obj1 = new Parse.Object('Polygon', { location: inbound }); + const obj2 = new Parse.Object('Polygon', { location: onbound }); + const obj3 = new Parse.Object('Polygon', { location: outbound }); + const polygon = { + __type: 'Polygon', + coordinates: [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + [0, 0], + ], + }; + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const where = { + location: { + $geoWithin: { + $polygon: polygon, + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/Polygon', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + expect(resp.data.results.length).toBe(2); + done(); + }, done.fail); + }); + + it('invalid Polygon object withinPolygon', done => { + const point = new Parse.GeoPoint(1.5, 1.5); + const obj = new Parse.Object('Polygon', { location: point }); + const polygon = { + __type: 'Polygon', + coordinates: [ + [0, 0], + [10, 0], + ], + }; + obj + .save() + .then(() => { + const where = { + location: { + $geoWithin: { + $polygon: polygon, + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/Polygon', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + fail(`no request should succeed: ${JSON.stringify(resp)}`); + done(); + }) + .catch(err => { + expect(err.data.code).toEqual(Parse.Error.INVALID_JSON); + done(); }); - }); }); - it_exclude_dbs(['postgres'])('geo max distance in km mid peninsula', (done) => { - makeSomeGeoPoints(function(list) { - var sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - var query = new Parse.Query(TestObject); - query.withinKilometers('location', sfo, 10.0); - query.find({ - success: function(results) { - equal(results.length, 0); - done(); - } + it('out of bounds Polygon object withinPolygon', done => { + const point = new Parse.GeoPoint(1.5, 1.5); + const obj = new Parse.Object('Polygon', { location: point }); + const polygon = { + __type: 'Polygon', + coordinates: [ + [0, 0], + [181, 0], + [0, 10], + ], + }; + obj + .save() + .then(() => { + const where = { + location: { + $geoWithin: { + $polygon: polygon, + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/Polygon', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + fail(`no request should succeed: ${JSON.stringify(resp)}`); + done(); + }) + .catch(err => { + expect(err.data.code).toEqual(1); + done(); }); - }); }); - it_exclude_dbs(['postgres'])('geo max distance in miles everywhere', (done) => { - makeSomeGeoPoints(function(list) { - var sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - var query = new Parse.Query(TestObject); - query.withinMiles('location', sfo, 2500.0); - query.find({ - success: function(results) { - equal(results.length, 3); - done(); - } + it('invalid input withinPolygon', done => { + const point = new Parse.GeoPoint(1.5, 1.5); + const obj = new Parse.Object('Polygon', { location: point }); + obj + .save() + .then(() => { + const where = { + location: { + $geoWithin: { + $polygon: 1234, + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/Polygon', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + fail(`no request should succeed: ${JSON.stringify(resp)}`); + done(); + }) + .catch(err => { + expect(err.data.code).toEqual(Parse.Error.INVALID_JSON); + done(); }); - }); }); - it_exclude_dbs(['postgres'])('geo max distance in miles california', (done) => { - makeSomeGeoPoints(function(list) { - var sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - var query = new Parse.Query(TestObject); - query.withinMiles('location', sfo, 2200.0); - query.find({ - success: function(results) { - equal(results.length, 2); - equal(results[0].get('name'), 'San Francisco'); - equal(results[1].get('name'), 'Sacramento'); - done(); - } + it('invalid geoPoint withinPolygon', done => { + const point = new Parse.GeoPoint(1.5, 1.5); + const obj = new Parse.Object('Polygon', { location: point }); + obj + .save() + .then(() => { + const where = { + location: { + $geoWithin: { + $polygon: [{}], + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/Polygon', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + fail(`no request should succeed: ${JSON.stringify(resp)}`); + done(); + }) + .catch(err => { + expect(err.data.code).toEqual(Parse.Error.INVALID_JSON); + done(); }); - }); }); - it_exclude_dbs(['postgres'])('geo max distance in miles bay area', (done) => { - makeSomeGeoPoints(function(list) { - var sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - var query = new Parse.Query(TestObject); - query.withinMiles('location', sfo, 75.0); - query.find({ - success: function(results) { - equal(results.length, 1); - equal(results[0].get('name'), 'San Francisco'); - done(); - } + it('invalid latitude withinPolygon', done => { + const point = new Parse.GeoPoint(1.5, 1.5); + const obj = new Parse.Object('Polygon', { location: point }); + obj + .save() + .then(() => { + const where = { + location: { + $geoWithin: { + $polygon: [ + { __type: 'GeoPoint', latitude: 0, longitude: 0 }, + { __type: 'GeoPoint', latitude: 181, longitude: 0 }, + { __type: 'GeoPoint', latitude: 0, longitude: 0 }, + ], + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/Polygon', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + fail(`no request should succeed: ${JSON.stringify(resp)}`); + done(); + }) + .catch(err => { + expect(err.data.code).toEqual(1); + done(); }); - }); }); - it_exclude_dbs(['postgres'])('geo max distance in miles mid peninsula', (done) => { - makeSomeGeoPoints(function(list) { - var sfo = new Parse.GeoPoint(37.6189722, -122.3748889); - var query = new Parse.Query(TestObject); - query.withinMiles('location', sfo, 10.0); - query.find({ - success: function(results) { - equal(results.length, 0); - done(); - } + it('invalid longitude withinPolygon', done => { + const point = new Parse.GeoPoint(1.5, 1.5); + const obj = new Parse.Object('Polygon', { location: point }); + obj + .save() + .then(() => { + const where = { + location: { + $geoWithin: { + $polygon: [ + { __type: 'GeoPoint', latitude: 0, longitude: 0 }, + { __type: 'GeoPoint', latitude: 0, longitude: 181 }, + { __type: 'GeoPoint', latitude: 0, longitude: 0 }, + ], + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/Polygon', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + fail(`no request should succeed: ${JSON.stringify(resp)}`); + done(); + }) + .catch(err => { + expect(err.data.code).toEqual(1); + done(); }); - }); }); - it_exclude_dbs(['postgres'])('works with geobox queries', (done) => { - var inSF = new Parse.GeoPoint(37.75, -122.4); - var southwestOfSF = new Parse.GeoPoint(37.708813, -122.526398); - var northeastOfSF = new Parse.GeoPoint(37.822802, -122.373962); + it('minimum 3 points withinPolygon', done => { + const point = new Parse.GeoPoint(1.5, 1.5); + const obj = new Parse.Object('Polygon', { location: point }); + obj + .save() + .then(() => { + const where = { + location: { + $geoWithin: { + $polygon: [], + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/Polygon', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + fail(`no request should succeed: ${JSON.stringify(resp)}`); + done(); + }) + .catch(err => { + expect(err.data.code).toEqual(107); + done(); + }); + }); - var object = new TestObject(); - object.set('point', inSF); - object.save().then(() => { - var query = new Parse.Query(TestObject); - query.withinGeoBox('point', southwestOfSF, northeastOfSF); - return query.find(); - }).then((results) => { - equal(results.length, 1); - done(); - }); + it('withinKilometers supports count', async () => { + const inside = new Parse.GeoPoint(10, 10); + const outside = new Parse.GeoPoint(20, 20); + + const obj1 = new Parse.Object('TestObject', { location: inside }); + const obj2 = new Parse.Object('TestObject', { location: outside }); + + await Parse.Object.saveAll([obj1, obj2]); + + const q = new Parse.Query(TestObject).withinKilometers('location', inside, 5); + const count = await q.count(); + + equal(count, 1); }); - it('supports a sub-object with a geo point', done => { - var point = new Parse.GeoPoint(44.0, -11.0); - var obj = new TestObject(); - obj.set('subobject', { location: point }); - obj.save(null, { - success: function() { - var query = new Parse.Query(TestObject); - query.find({ - success: function(results) { - equal(results.length, 1); - var pointAgain = results[0].get('subobject')['location']; - ok(pointAgain); - equal(pointAgain.latitude, 44.0); - equal(pointAgain.longitude, -11.0); - done(); - } - }); - } - }); + it_id('0b073d31-0d41-41e7-bd60-f636ffb759dc')(it)('withinKilometers complex supports count', async () => { + const inside = new Parse.GeoPoint(10, 10); + const middle = new Parse.GeoPoint(20, 20); + const outside = new Parse.GeoPoint(30, 30); + const obj1 = new Parse.Object('TestObject', { location: inside }); + const obj2 = new Parse.Object('TestObject', { location: middle }); + const obj3 = new Parse.Object('TestObject', { location: outside }); + + await Parse.Object.saveAll([obj1, obj2, obj3]); + + const q1 = new Parse.Query(TestObject).withinKilometers('location', inside, 5); + const q2 = new Parse.Query(TestObject).withinKilometers('location', middle, 5); + const query = Parse.Query.or(q1, q2); + const count = await query.count(); + + equal(count, 2); }); - it('supports array of geo points', done => { - var point1 = new Parse.GeoPoint(44.0, -11.0); - var point2 = new Parse.GeoPoint(22.0, -55.0); - var obj = new TestObject(); - obj.set('locations', [ point1, point2 ]); - obj.save(null, { - success: function() { - var query = new Parse.Query(TestObject); - query.find({ - success: function(results) { - equal(results.length, 1); - var locations = results[0].get('locations'); - expect(locations.length).toEqual(2); - expect(locations[0]).toEqual(point1); - expect(locations[1]).toEqual(point2); - done(); - } - }); - } + it_id('26c9a13d-3d71-452e-a91c-9a4589be021c')(it)('fails to fetch geopoints that are specifically not at (0,0)', async () => { + const tmp = new TestObject({ + location: new Parse.GeoPoint({ latitude: 0, longitude: 0 }), + }); + const tmp2 = new TestObject({ + location: new Parse.GeoPoint({ + latitude: 49.2577142, + longitude: -123.1941149, + }), }); + await Parse.Object.saveAll([tmp, tmp2]); + const query = new Parse.Query(TestObject); + query.notEqualTo('location', new Parse.GeoPoint({ latitude: 0, longitude: 0 })); + const results = await query.find(); + expect(results.length).toEqual(1); }); }); diff --git a/spec/ParseGlobalConfig.spec.js b/spec/ParseGlobalConfig.spec.js index 7f57cb7ab5..1b3a9adc0d 100644 --- a/spec/ParseGlobalConfig.spec.js +++ b/spec/ParseGlobalConfig.spec.js @@ -1,115 +1,270 @@ 'use strict'; -var request = require('request'); -var Parse = require('parse/node').Parse; -let Config = require('../src/Config'); +const request = require('../lib/request'); +const Config = require('../lib/Config'); describe('a GlobalConfig', () => { - beforeEach(done => { - let config = new Config('test'); - config.database.adapter.upsertOneObject( - '_GlobalConfig', - { fields: {} }, - { objectId: 1 }, - { params: { companies: ['US', 'DK'] } } - ).then(done); + beforeEach(async () => { + const config = Config.get('test'); + const query = on_db( + 'mongo', + () => { + // Legacy is with an int... + return { objectId: 1 }; + }, + () => { + return { objectId: '1' }; + } + ); + await config.database.adapter + .upsertOneObject( + '_GlobalConfig', + { + fields: { + objectId: { type: 'Number' }, + params: { type: 'Object' }, + masterKeyOnly: { type: 'Object' }, + }, + }, + query, + { + params: { companies: ['US', 'DK'], counter: 20, internalParam: 'internal' }, + masterKeyOnly: { internalParam: true }, + } + ); }); - it_exclude_dbs(['postgres'])('can be retrieved', (done) => { - request.get({ - url : 'http://localhost:8378/1/config', - json : true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key' : 'test' + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }; + + it('can be retrieved', done => { + request({ + url: 'http://localhost:8378/1/config', + json: true, + headers, + }).then(response => { + const body = response.data; + try { + expect(response.status).toEqual(200); + expect(body.params.companies).toEqual(['US', 'DK']); + } catch (e) { + jfail(e); } - }, (error, response, body) => { - expect(response.statusCode).toEqual(200); - expect(body.params.companies).toEqual(['US', 'DK']); done(); }); }); - it_exclude_dbs(['postgres'])('can be updated when a master key exists', (done) => { - request.put({ - url : 'http://localhost:8378/1/config', - json : true, - body : { params: { companies: ['US', 'DK', 'SE'] } }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key' : 'test' + it('internal parameter can be retrieved with master key', done => { + request({ + url: 'http://localhost:8378/1/config', + json: true, + headers, + }).then(response => { + const body = response.data; + try { + expect(response.status).toEqual(200); + expect(body.params.internalParam).toEqual('internal'); + } catch (e) { + jfail(e); } - }, (error, response, body) => { - expect(response.statusCode).toEqual(200); - expect(body.result).toEqual(true); done(); }); }); - it_exclude_dbs(['postgres'])('properly handles delete op', (done) => { - request.put({ - url : 'http://localhost:8378/1/config', - json : true, - body : { params: { companies: {__op: 'Delete'}, foo: 'bar' } }, + it('internal parameter cannot be retrieved without master key', done => { + request({ + url: 'http://localhost:8378/1/config', + json: true, headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key' : 'test' + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).then(response => { + const body = response.data; + try { + expect(response.status).toEqual(200); + expect(body.params.internalParam).toBeUndefined(); + } catch (e) { + jfail(e); } - }, (error, response, body) => { - expect(response.statusCode).toEqual(200); + done(); + }); + }); + + it('can be updated when a master key exists', done => { + request({ + method: 'PUT', + url: 'http://localhost:8378/1/config', + json: true, + body: { params: { companies: ['US', 'DK', 'SE'] } }, + headers, + }).then(response => { + const body = response.data; + expect(response.status).toEqual(200); + expect(body.result).toEqual(true); + done(); + }); + }); + + it_only_db('mongo')('can addUnique', async () => { + await Parse.Config.save({ companies: { __op: 'AddUnique', objects: ['PA', 'RS', 'E'] } }); + const config = await Parse.Config.get(); + const companies = config.get('companies'); + expect(companies).toEqual(['US', 'DK', 'PA', 'RS', 'E']); + }); + + it_only_db('mongo')('can add to array', async () => { + await Parse.Config.save({ companies: { __op: 'Add', objects: ['PA'] } }); + const config = await Parse.Config.get(); + const companies = config.get('companies'); + expect(companies).toEqual(['US', 'DK', 'PA']); + }); + + it_only_db('mongo')('can remove from array', async () => { + await Parse.Config.save({ companies: { __op: 'Remove', objects: ['US'] } }); + const config = await Parse.Config.get(); + const companies = config.get('companies'); + expect(companies).toEqual(['DK']); + }); + + it('can increment', async () => { + await Parse.Config.save({ counter: { __op: 'Increment', amount: 49 } }); + const config = await Parse.Config.get(); + const counter = config.get('counter'); + expect(counter).toEqual(69); + }); + + it('can add and retrive files', done => { + request({ + method: 'PUT', + url: 'http://localhost:8378/1/config', + json: true, + body: { + params: { file: { __type: 'File', name: 'name', url: 'http://url' } }, + }, + headers, + }).then(response => { + const body = response.data; + expect(response.status).toEqual(200); + expect(body.result).toEqual(true); + Parse.Config.get().then(res => { + const file = res.get('file'); + expect(file.name()).toBe('name'); + expect(file.url()).toBe('http://url'); + done(); + }); + }); + }); + + it('can add and retrive Geopoints', done => { + const geopoint = new Parse.GeoPoint(10, -20); + request({ + method: 'PUT', + url: 'http://localhost:8378/1/config', + json: true, + body: { params: { point: geopoint.toJSON() } }, + headers, + }).then(response => { + const body = response.data; + expect(response.status).toEqual(200); + expect(body.result).toEqual(true); + Parse.Config.get().then(res => { + const point = res.get('point'); + expect(point.latitude).toBe(10); + expect(point.longitude).toBe(-20); + done(); + }); + }); + }); + + it_id('5ebbd0cf-d1a5-49d9-aac7-5216abc5cb62')(it)('properly handles delete op', done => { + request({ + method: 'PUT', + url: 'http://localhost:8378/1/config', + json: true, + body: { + params: { + companies: { __op: 'Delete' }, + counter: { __op: 'Delete' }, + internalParam: { __op: 'Delete' }, + foo: 'bar', + }, + }, + headers, + }).then(response => { + const body = response.data; + expect(response.status).toEqual(200); expect(body.result).toEqual(true); - request.get({ - url : 'http://localhost:8378/1/config', - json : true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key' : 'test' + request({ + url: 'http://localhost:8378/1/config', + json: true, + headers, + }).then(response => { + const body = response.data; + try { + expect(response.status).toEqual(200); + expect(body.params.companies).toBeUndefined(); + expect(body.params.counter).toBeUndefined(); + expect(body.params.foo).toBe('bar'); + expect(Object.keys(body.params).length).toBe(1); + } catch (e) { + jfail(e); } - }, (error, response, body) => { - expect(response.statusCode).toEqual(200); - expect(body.params.companies).toBeUndefined(); - expect(body.params.foo).toBe('bar'); - expect(Object.keys(body.params).length).toBe(1); done(); }); }); }); - it_exclude_dbs(['postgres'])('fail to update if master key is missing', (done) => { - request.put({ - url : 'http://localhost:8378/1/config', - json : true, - body : { params: { companies: [] } }, + it('fail to update if master key is missing', done => { + const logger = require('../lib/logger').default; + const loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + loggerErrorSpy.calls.reset(); + request({ + method: 'PUT', + url: 'http://localhost:8378/1/config', + json: true, + body: { params: { companies: [] } }, headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key' : 'rest' - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(403); - expect(body.error).toEqual('unauthorized: master key is required'); + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).then(fail, response => { + const body = response.data; + expect(response.status).toEqual(403); + expect(body.error).toEqual('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('unauthorized: master key is required')); done(); }); }); - it_exclude_dbs(['postgres'])('failed getting config when it is missing', (done) => { - let config = new Config('test'); - config.database.adapter.deleteObjectsByQuery( - '_GlobalConfig', - { fields: { params: { __type: 'String' } } }, - { objectId: 1 } - ).then(() => { - request.get({ - url : 'http://localhost:8378/1/config', - json : true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key' : 'test' - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(200); - expect(body.params).toEqual({}); + it('failed getting config when it is missing', done => { + const config = Config.get('test'); + config.database.adapter + .deleteObjectsByQuery( + '_GlobalConfig', + { fields: { params: { __type: 'String' } } }, + { objectId: '1' } + ) + .then(() => { + request({ + url: 'http://localhost:8378/1/config', + json: true, + headers, + }).then(response => { + const body = response.data; + expect(response.status).toEqual(200); + expect(body.params).toEqual({}); + done(); + }); + }) + .catch(e => { + jfail(e); done(); }); - }); }); }); diff --git a/spec/ParseGraphQLClassNameTransformer.spec.js b/spec/ParseGraphQLClassNameTransformer.spec.js new file mode 100644 index 0000000000..d8a4dd6020 --- /dev/null +++ b/spec/ParseGraphQLClassNameTransformer.spec.js @@ -0,0 +1,12 @@ +const { transformClassNameToGraphQL } = require('../lib/GraphQL/transformers/className'); + +describe('transformClassNameToGraphQL', () => { + it('should remove starting _ and tansform first letter to upper case', () => { + expect(['_User', '_user', 'User', 'user'].map(transformClassNameToGraphQL)).toEqual([ + 'User', + 'User', + 'User', + 'User', + ]); + }); +}); diff --git a/spec/ParseGraphQLController.spec.js b/spec/ParseGraphQLController.spec.js new file mode 100644 index 0000000000..15bbb48ab7 --- /dev/null +++ b/spec/ParseGraphQLController.spec.js @@ -0,0 +1,1048 @@ +const { + default: ParseGraphQLController, + GraphQLConfigClassName, + GraphQLConfigId, + GraphQLConfigKey, +} = require('../lib/Controllers/ParseGraphQLController'); +const { isEqual } = require('lodash'); + +describe('ParseGraphQLController', () => { + let parseServer; + let databaseController; + let cacheController; + let databaseUpdateArgs; + let originalDbFind; + let originalDbUpdate; + + // Holds the graphQLConfig in memory instead of using the db + let graphQLConfigRecord; + + const setConfigOnDb = graphQLConfigData => { + graphQLConfigRecord = { + objectId: GraphQLConfigId, + [GraphQLConfigKey]: graphQLConfigData, + }; + }; + const removeConfigFromDb = () => { + graphQLConfigRecord = null; + }; + const getConfigFromDb = () => { + return graphQLConfigRecord; + }; + + beforeEach(async () => { + if (!parseServer) { + parseServer = await global.reconfigureServer(); + databaseController = parseServer.config.databaseController; + cacheController = parseServer.config.cacheController; + + originalDbFind = databaseController.find.bind(databaseController); + originalDbUpdate = databaseController.update.bind(databaseController); + + databaseController.find = async (className, query, ...args) => { + if (className === GraphQLConfigClassName && isEqual(query, { objectId: GraphQLConfigId })) { + const graphQLConfigRecord = getConfigFromDb(); + return graphQLConfigRecord ? [graphQLConfigRecord] : []; + } else { + return originalDbFind(className, query, ...args); + } + }; + + databaseController.update = async (className, query, update, fullQueryOptions) => { + databaseUpdateArgs = [className, query, update, fullQueryOptions]; + if ( + className === GraphQLConfigClassName && + isEqual(query, { objectId: GraphQLConfigId }) && + update && + !!update[GraphQLConfigKey] && + fullQueryOptions && + isEqual(fullQueryOptions, { upsert: true }) + ) { + setConfigOnDb(update[GraphQLConfigKey]); + } else { + return originalDbUpdate(...databaseUpdateArgs); + } + }; + } + databaseUpdateArgs = null; + }); + + afterAll(() => { + if (databaseController) { + databaseController.find = originalDbFind; + databaseController.update = originalDbUpdate; + } + }); + + describe('constructor', () => { + it('should require a databaseController', () => { + expect(() => new ParseGraphQLController()).toThrow( + 'ParseGraphQLController requires a "databaseController" to be instantiated.' + ); + expect(() => new ParseGraphQLController({ cacheController })).toThrow( + 'ParseGraphQLController requires a "databaseController" to be instantiated.' + ); + expect( + () => + new ParseGraphQLController({ + cacheController, + mountGraphQL: false, + }) + ).toThrow('ParseGraphQLController requires a "databaseController" to be instantiated.'); + }); + it('should construct without a cacheController', () => { + expect( + () => + new ParseGraphQLController({ + databaseController, + }) + ).not.toThrow(); + expect( + () => + new ParseGraphQLController({ + databaseController, + mountGraphQL: true, + }) + ).not.toThrow(); + }); + it('should set isMounted to true if config.mountGraphQL is true', () => { + const mountedController = new ParseGraphQLController({ + databaseController, + mountGraphQL: true, + }); + expect(mountedController.isMounted).toBe(true); + const unmountedController = new ParseGraphQLController({ + databaseController, + mountGraphQL: false, + }); + expect(unmountedController.isMounted).toBe(false); + const unmountedController2 = new ParseGraphQLController({ + databaseController, + }); + expect(unmountedController2.isMounted).toBe(false); + }); + }); + + describe('getGraphQLConfig', () => { + it('should return an empty graphQLConfig if collection has none', async () => { + removeConfigFromDb(); + + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + mountGraphQL: false, + }); + + const graphQLConfig = await parseGraphQLController.getGraphQLConfig(); + expect(graphQLConfig).toEqual({}); + }); + it('should return an existing graphQLConfig', async () => { + setConfigOnDb({ enabledForClasses: ['_User'] }); + + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + mountGraphQL: false, + }); + const graphQLConfig = await parseGraphQLController.getGraphQLConfig(); + expect(graphQLConfig).toEqual({ enabledForClasses: ['_User'] }); + }); + it('should use the cache if mounted, and return the stored graphQLConfig', async () => { + removeConfigFromDb(); + cacheController.graphQL.clear(); + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + cacheController, + mountGraphQL: true, + }); + cacheController.graphQL.put(parseGraphQLController.configCacheKey, { + enabledForClasses: ['SuperCar'], + }); + + const graphQLConfig = await parseGraphQLController.getGraphQLConfig(); + expect(graphQLConfig).toEqual({ enabledForClasses: ['SuperCar'] }); + }); + it('should use the database when mounted and cache is empty', async () => { + setConfigOnDb({ disabledForClasses: ['SuperCar'] }); + cacheController.graphQL.clear(); + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + cacheController, + mountGraphQL: true, + }); + const graphQLConfig = await parseGraphQLController.getGraphQLConfig(); + expect(graphQLConfig).toEqual({ disabledForClasses: ['SuperCar'] }); + }); + it('should store the graphQLConfig in cache if mounted', async () => { + setConfigOnDb({ enabledForClasses: ['SuperCar'] }); + cacheController.graphQL.clear(); + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + cacheController, + mountGraphQL: true, + }); + const cachedValueBefore = await cacheController.graphQL.get( + parseGraphQLController.configCacheKey + ); + expect(cachedValueBefore).toBeNull(); + await parseGraphQLController.getGraphQLConfig(); + const cachedValueAfter = await cacheController.graphQL.get( + parseGraphQLController.configCacheKey + ); + expect(cachedValueAfter).toEqual({ enabledForClasses: ['SuperCar'] }); + }); + }); + + describe('updateGraphQLConfig', () => { + const successfulUpdateResponse = { response: { result: true } }; + + it('should throw if graphQLConfig is not provided', async function () { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync(parseGraphQLController.updateGraphQLConfig()).toBeRejectedWith( + 'You must provide a graphQLConfig!' + ); + }); + + it('should correct update the graphQLConfig object using the databaseController', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + const graphQLConfig = { + enabledForClasses: ['ClassA', 'ClassB'], + disabledForClasses: [], + classConfigs: [ + { className: 'ClassA', query: { get: false } }, + { className: 'ClassB', mutation: { destroy: false }, type: {} }, + ], + }; + + await parseGraphQLController.updateGraphQLConfig(graphQLConfig); + + expect(databaseUpdateArgs).toBeTruthy(); + const [className, query, update, op] = databaseUpdateArgs; + expect(className).toBe(GraphQLConfigClassName); + expect(query).toEqual({ objectId: GraphQLConfigId }); + expect(update).toEqual({ + [GraphQLConfigKey]: graphQLConfig, + }); + expect(op).toEqual({ upsert: true }); + }); + + it('should throw if graphQLConfig is not an object', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync(parseGraphQLController.updateGraphQLConfig([])).toBeRejected(); + expectAsync(parseGraphQLController.updateGraphQLConfig(function () {})).toBeRejected(); + expectAsync(parseGraphQLController.updateGraphQLConfig(Promise.resolve({}))).toBeRejected(); + expectAsync(parseGraphQLController.updateGraphQLConfig('')).toBeRejected(); + expectAsync(parseGraphQLController.updateGraphQLConfig({})).toBeResolvedTo( + successfulUpdateResponse + ); + }); + it('should throw if graphQLConfig has an invalid root key', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync(parseGraphQLController.updateGraphQLConfig({ invalidKey: true })).toBeRejected(); + expectAsync(parseGraphQLController.updateGraphQLConfig({})).toBeResolvedTo( + successfulUpdateResponse + ); + }); + it('should throw if graphQLConfig has invalid class filters', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ enabledForClasses: {} }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + enabledForClasses: [undefined], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + disabledForClasses: [null], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + enabledForClasses: ['_User', null], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ disabledForClasses: [''] }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + enabledForClasses: [], + disabledForClasses: ['_User'], + }) + ).toBeResolvedTo(successfulUpdateResponse); + }); + it('should throw if classConfigs array is invalid', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync(parseGraphQLController.updateGraphQLConfig({ classConfigs: {} })).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ classConfigs: [null] }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [undefined], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [{ className: 'ValidClass' }, null], + }) + ).toBeRejected(); + expectAsync(parseGraphQLController.updateGraphQLConfig({ classConfigs: [] })).toBeResolvedTo( + successfulUpdateResponse + ); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + }); + it('should throw if a classConfig has invalid type settings', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: [], + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + invalidKey: true, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: {}, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + }); + it('should throw if a classConfig has invalid type.inputFields settings', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + type: { + inputFields: [], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + type: { + inputFields: { + invalidKey: true, + }, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + type: { + inputFields: { + create: {}, + }, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + type: { + inputFields: { + update: [null], + }, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + type: { + inputFields: { + create: [], + update: [], + }, + }, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + type: { + inputFields: { + create: ['make', 'model'], + update: [], + }, + }, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + }); + it('should throw if a classConfig has invalid type.outputFields settings', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + outputFields: {}, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + outputFields: [null], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + outputFields: ['name', undefined], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + outputFields: [''], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + outputFields: [], + }, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + outputFields: ['name'], + }, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + }); + it('should throw if a classConfig has invalid type.constraintFields settings', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + constraintFields: {}, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + constraintFields: [null], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + constraintFields: ['name', undefined], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + constraintFields: [''], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + constraintFields: [], + }, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + constraintFields: ['name'], + }, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + }); + it('should throw if a classConfig has invalid type.sortFields settings', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + sortFields: {}, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + sortFields: [null], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + sortFields: [ + { + field: undefined, + asc: true, + desc: true, + }, + ], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + sortFields: [ + { + field: '', + asc: true, + desc: false, + }, + ], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + sortFields: [ + { + field: 'name', + asc: true, + desc: 'false', + }, + ], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + sortFields: [ + { + field: 'name', + asc: true, + desc: true, + }, + null, + ], + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + sortFields: [], + }, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + sortFields: [ + { + field: 'name', + asc: true, + desc: true, + }, + ], + }, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + }); + it('should throw if a classConfig has invalid query params', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + query: [], + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + query: { + invalidKey: true, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + query: { + get: 1, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + query: { + find: 'true', + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + query: { + get: false, + find: true, + }, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + query: {}, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + }); + it('should throw if a classConfig has invalid mutation params', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + mutation: [], + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + mutation: { + invalidKey: true, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + mutation: { + destroy: 1, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + mutation: { + update: 'true', + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + mutation: {}, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + mutation: { + create: true, + update: true, + destroy: false, + }, + }, + ], + }) + ).toBeResolvedTo(successfulUpdateResponse); + }); + + it('should throw if _User create fields is missing username or password', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + inputFields: { + create: ['username', 'no-password'], + }, + }, + }, + ], + }) + ).toBeRejected(); + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: '_User', + type: { + inputFields: { + create: ['username', 'password'], + }, + }, + }, + ], + }) + ).toBeResolved(successfulUpdateResponse); + }); + it('should update the cache if mounted', async () => { + removeConfigFromDb(); + cacheController.graphQL.clear(); + const mountedController = new ParseGraphQLController({ + databaseController, + cacheController, + mountGraphQL: true, + }); + const unmountedController = new ParseGraphQLController({ + databaseController, + cacheController, + mountGraphQL: false, + }); + + let cacheBeforeValue; + let cacheAfterValue; + + cacheBeforeValue = await cacheController.graphQL.get(mountedController.configCacheKey); + expect(cacheBeforeValue).toBeNull(); + + await mountedController.updateGraphQLConfig({ + enabledForClasses: ['SuperCar'], + }); + cacheAfterValue = await cacheController.graphQL.get(mountedController.configCacheKey); + expect(cacheAfterValue).toEqual({ enabledForClasses: ['SuperCar'] }); + + // reset + removeConfigFromDb(); + cacheController.graphQL.clear(); + + cacheBeforeValue = await cacheController.graphQL.get(unmountedController.configCacheKey); + expect(cacheBeforeValue).toBeNull(); + + await unmountedController.updateGraphQLConfig({ + enabledForClasses: ['SuperCar'], + }); + cacheAfterValue = await cacheController.graphQL.get(unmountedController.configCacheKey); + expect(cacheAfterValue).toBeNull(); + }); + }); + + describe('alias', () => { + it('should fail if query alias is not a string', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + + const className = 'Bar'; + + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className, + query: { + get: true, + getAlias: 1, + }, + }, + ], + }) + ).toBeRejected( + `Invalid graphQLConfig: classConfig:${className} is invalid because "query.getAlias" must be a string` + ); + + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className, + query: { + find: true, + findAlias: { not: 'valid' }, + }, + }, + ], + }) + ).toBeRejected( + `Invalid graphQLConfig: classConfig:${className} is invalid because "query.findAlias" must be a string` + ); + }); + + it('should fail if mutation alias is not a string', async () => { + const parseGraphQLController = new ParseGraphQLController({ + databaseController, + }); + + const className = 'Bar'; + + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className, + mutation: { + create: true, + createAlias: true, + }, + }, + ], + }) + ).toBeRejected( + `Invalid graphQLConfig: classConfig:${className} is invalid because "mutation.createAlias" must be a string` + ); + + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className, + mutation: { + update: true, + updateAlias: 1, + }, + }, + ], + }) + ).toBeRejected( + `Invalid graphQLConfig: classConfig:${className} is invalid because "mutation.updateAlias" must be a string` + ); + + expectAsync( + parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className, + mutation: { + destroy: true, + destroyAlias: { not: 'valid' }, + }, + }, + ], + }) + ).toBeRejected( + `Invalid graphQLConfig: classConfig:${className} is invalid because "mutation.destroyAlias" must be a string` + ); + }); + }); +}); diff --git a/spec/ParseGraphQLSchema.spec.js b/spec/ParseGraphQLSchema.spec.js new file mode 100644 index 0000000000..0b3d9a9007 --- /dev/null +++ b/spec/ParseGraphQLSchema.spec.js @@ -0,0 +1,576 @@ +const { GraphQLObjectType } = require('graphql'); +const defaultLogger = require('../lib/logger').default; +const { ParseGraphQLSchema } = require('../lib/GraphQL/ParseGraphQLSchema'); + +describe('ParseGraphQLSchema', () => { + let parseServer; + let databaseController; + let parseGraphQLController; + let parseGraphQLSchema; + const appId = 'test'; + + beforeEach(async () => { + parseServer = await global.reconfigureServer(); + databaseController = parseServer.config.databaseController; + parseGraphQLController = parseServer.config.parseGraphQLController; + parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: defaultLogger, + appId, + }); + }); + + describe('constructor', () => { + it('should require a parseGraphQLController, databaseController, a log instance, and the appId', () => { + expect(() => new ParseGraphQLSchema()).toThrow( + 'You must provide a parseGraphQLController instance!' + ); + expect(() => new ParseGraphQLSchema({ parseGraphQLController: {} })).toThrow( + 'You must provide a databaseController instance!' + ); + expect( + () => + new ParseGraphQLSchema({ + parseGraphQLController: {}, + databaseController: {}, + }) + ).toThrow('You must provide a log instance!'); + expect( + () => + new ParseGraphQLSchema({ + parseGraphQLController: {}, + databaseController: {}, + log: {}, + }) + ).toThrow('You must provide the appId!'); + }); + }); + + describe('load', () => { + it('should cache schema', async () => { + const graphQLSchema = await parseGraphQLSchema.load(); + const updatedGraphQLSchema = await parseGraphQLSchema.load(); + expect(graphQLSchema).toBe(updatedGraphQLSchema); + }); + + it('should load a brand new GraphQL Schema if Parse Schema changes', async () => { + await parseGraphQLSchema.load(); + const parseClasses = parseGraphQLSchema.parseClasses; + const parseClassTypes = parseGraphQLSchema.parseClassTypes; + const graphQLSchema = parseGraphQLSchema.graphQLSchema; + const graphQLTypes = parseGraphQLSchema.graphQLTypes; + const graphQLQueries = parseGraphQLSchema.graphQLQueries; + const graphQLMutations = parseGraphQLSchema.graphQLMutations; + const graphQLSubscriptions = parseGraphQLSchema.graphQLSubscriptions; + const newClassObject = new Parse.Object('NewClass'); + await newClassObject.save(); + await parseServer.config.schemaCache.clear(); + await new Promise(resolve => setTimeout(resolve, 200)); + await parseGraphQLSchema.load(); + expect(parseClasses).not.toBe(parseGraphQLSchema.parseClasses); + expect(parseClassTypes).not.toBe(parseGraphQLSchema.parseClassTypes); + expect(graphQLSchema).not.toBe(parseGraphQLSchema.graphQLSchema); + expect(graphQLTypes).not.toBe(parseGraphQLSchema.graphQLTypes); + expect(graphQLQueries).not.toBe(parseGraphQLSchema.graphQLQueries); + expect(graphQLMutations).not.toBe(parseGraphQLSchema.graphQLMutations); + expect(graphQLSubscriptions).not.toBe(parseGraphQLSchema.graphQLSubscriptions); + }); + + it('should load a brand new GraphQL Schema if graphQLConfig changes', async () => { + const parseGraphQLController = { + graphQLConfig: { enabledForClasses: [] }, + getGraphQLConfig() { + return this.graphQLConfig; + }, + }; + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: defaultLogger, + appId, + }); + await parseGraphQLSchema.load(); + const parseClasses = parseGraphQLSchema.parseClasses; + const parseClassTypes = parseGraphQLSchema.parseClassTypes; + const graphQLSchema = parseGraphQLSchema.graphQLSchema; + const graphQLTypes = parseGraphQLSchema.graphQLTypes; + const graphQLQueries = parseGraphQLSchema.graphQLQueries; + const graphQLMutations = parseGraphQLSchema.graphQLMutations; + const graphQLSubscriptions = parseGraphQLSchema.graphQLSubscriptions; + + parseGraphQLController.graphQLConfig = { + enabledForClasses: ['_User'], + }; + + await new Promise(resolve => setTimeout(resolve, 200)); + await parseGraphQLSchema.load(); + expect(parseClasses).not.toBe(parseGraphQLSchema.parseClasses); + expect(parseClassTypes).not.toBe(parseGraphQLSchema.parseClassTypes); + expect(graphQLSchema).not.toBe(parseGraphQLSchema.graphQLSchema); + expect(graphQLTypes).not.toBe(parseGraphQLSchema.graphQLTypes); + expect(graphQLQueries).not.toBe(parseGraphQLSchema.graphQLQueries); + expect(graphQLMutations).not.toBe(parseGraphQLSchema.graphQLMutations); + expect(graphQLSubscriptions).not.toBe(parseGraphQLSchema.graphQLSubscriptions); + }); + }); + + describe('addGraphQLType', () => { + it('should not load and warn duplicated types', async () => { + let logged = false; + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: message => { + logged = true; + expect(message).toEqual( + 'Type SomeClass could not be added to the auto schema because it collided with an existing type.' + ); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + const type = new GraphQLObjectType({ name: 'SomeClass' }); + expect(parseGraphQLSchema.addGraphQLType(type)).toBe(type); + expect(parseGraphQLSchema.graphQLTypes).toContain(type); + expect( + parseGraphQLSchema.addGraphQLType(new GraphQLObjectType({ name: 'SomeClass' })) + ).toBeUndefined(); + expect(logged).toBeTruthy(); + }); + + it('should throw error when required', async () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: () => { + fail('Should not warn'); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + const type = new GraphQLObjectType({ name: 'SomeClass' }); + expect(parseGraphQLSchema.addGraphQLType(type, true)).toBe(type); + expect(parseGraphQLSchema.graphQLTypes).toContain(type); + expect(() => + parseGraphQLSchema.addGraphQLType(new GraphQLObjectType({ name: 'SomeClass' }), true) + ).toThrowError( + 'Type SomeClass could not be added to the auto schema because it collided with an existing type.' + ); + }); + + it('should warn reserved name collision', async () => { + let logged = false; + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: message => { + logged = true; + expect(message).toEqual( + 'Type String could not be added to the auto schema because it collided with an existing type.' + ); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + expect( + parseGraphQLSchema.addGraphQLType(new GraphQLObjectType({ name: 'String' })) + ).toBeUndefined(); + expect(logged).toBeTruthy(); + }); + + it('should ignore collision when necessary', async () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: () => { + fail('Should not warn'); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + const type = new GraphQLObjectType({ name: 'String' }); + expect(parseGraphQLSchema.addGraphQLType(type, true, true)).toBe(type); + expect(parseGraphQLSchema.graphQLTypes).toContain(type); + }); + }); + + describe('addGraphQLQuery', () => { + it('should not load and warn duplicated queries', async () => { + let logged = false; + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: message => { + logged = true; + expect(message).toEqual( + 'Query someClasses could not be added to the auto schema because it collided with an existing field.' + ); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + const field = {}; + expect(parseGraphQLSchema.addGraphQLQuery('someClasses', field)).toBe(field); + expect(parseGraphQLSchema.graphQLQueries['someClasses']).toBe(field); + expect(parseGraphQLSchema.addGraphQLQuery('someClasses', {})).toBeUndefined(); + expect(logged).toBeTruthy(); + }); + + it('should throw error when required', async () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: () => { + fail('Should not warn'); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + const field = {}; + expect(parseGraphQLSchema.addGraphQLQuery('someClasses', field)).toBe(field); + expect(parseGraphQLSchema.graphQLQueries['someClasses']).toBe(field); + expect(() => parseGraphQLSchema.addGraphQLQuery('someClasses', {}, true)).toThrowError( + 'Query someClasses could not be added to the auto schema because it collided with an existing field.' + ); + }); + + it('should warn reserved name collision', async () => { + let logged = false; + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: message => { + logged = true; + expect(message).toEqual( + 'Query viewer could not be added to the auto schema because it collided with an existing field.' + ); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + expect(parseGraphQLSchema.addGraphQLQuery('viewer', {})).toBeUndefined(); + expect(logged).toBeTruthy(); + }); + + it('should ignore collision when necessary', async () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: () => { + fail('Should not warn'); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + delete parseGraphQLSchema.graphQLQueries.viewer; + const field = {}; + expect(parseGraphQLSchema.addGraphQLQuery('viewer', field, true, true)).toBe(field); + expect(parseGraphQLSchema.graphQLQueries['viewer']).toBe(field); + }); + }); + + describe('addGraphQLMutation', () => { + it('should not load and warn duplicated mutations', async () => { + let logged = false; + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: message => { + logged = true; + expect(message).toEqual( + 'Mutation createSomeClass could not be added to the auto schema because it collided with an existing field.' + ); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + const field = {}; + expect(parseGraphQLSchema.addGraphQLMutation('createSomeClass', field)).toBe(field); + expect(parseGraphQLSchema.graphQLMutations['createSomeClass']).toBe(field); + expect(parseGraphQLSchema.addGraphQLMutation('createSomeClass', {})).toBeUndefined(); + expect(logged).toBeTruthy(); + }); + + it('should throw error when required', async () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: () => { + fail('Should not warn'); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + const field = {}; + expect(parseGraphQLSchema.addGraphQLMutation('createSomeClass', field)).toBe(field); + expect(parseGraphQLSchema.graphQLMutations['createSomeClass']).toBe(field); + expect(() => parseGraphQLSchema.addGraphQLMutation('createSomeClass', {}, true)).toThrowError( + 'Mutation createSomeClass could not be added to the auto schema because it collided with an existing field.' + ); + }); + + it('should warn reserved name collision', async () => { + let logged = false; + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: message => { + logged = true; + expect(message).toEqual( + 'Mutation signUp could not be added to the auto schema because it collided with an existing field.' + ); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + expect(parseGraphQLSchema.addGraphQLMutation('signUp', {})).toBeUndefined(); + expect(logged).toBeTruthy(); + }); + + it('should ignore collision when necessary', async () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: () => { + fail('Should not warn'); + }, + }, + appId, + }); + await parseGraphQLSchema.load(); + delete parseGraphQLSchema.graphQLMutations.signUp; + const field = {}; + expect(parseGraphQLSchema.addGraphQLMutation('signUp', field, true, true)).toBe(field); + expect(parseGraphQLSchema.graphQLMutations['signUp']).toBe(field); + }); + }); + + describe('_getParseClassesWithConfig', () => { + it('should sort classes', () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: { + warn: () => { + fail('Should not warn'); + }, + }, + appId, + }); + expect( + parseGraphQLSchema + ._getParseClassesWithConfig( + [ + { className: 'b' }, + { className: '_b' }, + { className: 'B' }, + { className: '_B' }, + { className: 'a' }, + { className: '_a' }, + { className: 'A' }, + { className: '_A' }, + ], + { + classConfigs: [], + } + ) + .map(item => item[0]) + ).toEqual([ + { className: '_A' }, + { className: '_B' }, + { className: '_a' }, + { className: '_b' }, + { className: 'A' }, + { className: 'B' }, + { className: 'a' }, + { className: 'b' }, + ]); + }); + }); + + describe('name collision', () => { + it('should not generate duplicate types when colliding to default classes', async () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: defaultLogger, + appId, + }); + await parseGraphQLSchema.schemaCache.clear(); + const schema1 = await parseGraphQLSchema.load(); + const types1 = parseGraphQLSchema.graphQLTypes; + const queries1 = parseGraphQLSchema.graphQLQueries; + const mutations1 = parseGraphQLSchema.graphQLMutations; + const user = new Parse.Object('User'); + await user.save(); + await parseGraphQLSchema.schemaCache.clear(); + const schema2 = await parseGraphQLSchema.load(); + const types2 = parseGraphQLSchema.graphQLTypes; + const queries2 = parseGraphQLSchema.graphQLQueries; + const mutations2 = parseGraphQLSchema.graphQLMutations; + expect(schema1).not.toBe(schema2); + expect(types1).not.toBe(types2); + expect(types1.map(type => type.name).sort()).toEqual(types2.map(type => type.name).sort()); + expect(queries1).not.toBe(queries2); + expect(Object.keys(queries1).sort()).toEqual(Object.keys(queries2).sort()); + expect(mutations1).not.toBe(mutations2); + expect(Object.keys(mutations1).sort()).toEqual(Object.keys(mutations2).sort()); + }); + + it('should not generate duplicate types when colliding the same name', async () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: defaultLogger, + appId, + }); + const car1 = new Parse.Object('Car'); + await car1.save(); + await parseGraphQLSchema.schemaCache.clear(); + const schema1 = await parseGraphQLSchema.load(); + const types1 = parseGraphQLSchema.graphQLTypes; + const queries1 = parseGraphQLSchema.graphQLQueries; + const mutations1 = parseGraphQLSchema.graphQLMutations; + const car2 = new Parse.Object('car'); + await car2.save(); + await parseGraphQLSchema.schemaCache.clear(); + const schema2 = await parseGraphQLSchema.load(); + const types2 = parseGraphQLSchema.graphQLTypes; + const queries2 = parseGraphQLSchema.graphQLQueries; + const mutations2 = parseGraphQLSchema.graphQLMutations; + expect(schema1).not.toBe(schema2); + expect(types1).not.toBe(types2); + expect(types1.map(type => type.name).sort()).toEqual(types2.map(type => type.name).sort()); + expect(queries1).not.toBe(queries2); + expect(Object.keys(queries1).sort()).toEqual(Object.keys(queries2).sort()); + expect(mutations1).not.toBe(mutations2); + expect(Object.keys(mutations1).sort()).toEqual(Object.keys(mutations2).sort()); + }); + + it('should not generate duplicate queries when query name collide', async () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: defaultLogger, + appId, + }); + const car = new Parse.Object('Car'); + await car.save(); + await parseGraphQLSchema.schemaCache.clear(); + const schema1 = await parseGraphQLSchema.load(); + const queries1 = parseGraphQLSchema.graphQLQueries; + const mutations1 = parseGraphQLSchema.graphQLMutations; + const cars = new Parse.Object('cars'); + await cars.save(); + await parseGraphQLSchema.schemaCache.clear(); + const schema2 = await parseGraphQLSchema.load(); + const queries2 = parseGraphQLSchema.graphQLQueries; + const mutations2 = parseGraphQLSchema.graphQLMutations; + expect(schema1).not.toBe(schema2); + expect(queries1).not.toBe(queries2); + expect(Object.keys(queries1).sort()).toEqual(Object.keys(queries2).sort()); + expect(mutations1).not.toBe(mutations2); + expect( + Object.keys(mutations1).concat('createCars', 'updateCars', 'deleteCars').sort() + ).toEqual(Object.keys(mutations2).sort()); + }); + }); + describe('alias', () => { + it_id('45282d26-f4c7-4d2d-a7b6-cd8741d5322f')(it)('Should be able to define alias for get and find query', async () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: defaultLogger, + appId, + }); + + await parseGraphQLSchema.parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: 'Data', + query: { + get: true, + getAlias: 'precious_data', + find: true, + findAlias: 'data_results', + }, + }, + ], + }); + + const data = new Parse.Object('Data'); + + await data.save(); + + await parseGraphQLSchema.schemaCache.clear(); + await parseGraphQLSchema.load(); + + const queries1 = parseGraphQLSchema.graphQLQueries; + + expect(Object.keys(queries1)).toContain('data_results'); + expect(Object.keys(queries1)).toContain('precious_data'); + }); + + it_id('f04b46e3-a25d-401d-a315-3298cfee1df8')(it)('Should be able to define alias for mutation', async () => { + const parseGraphQLSchema = new ParseGraphQLSchema({ + databaseController, + parseGraphQLController, + log: defaultLogger, + appId, + }); + + await parseGraphQLSchema.parseGraphQLController.updateGraphQLConfig({ + classConfigs: [ + { + className: 'Track', + mutation: { + create: true, + createAlias: 'addTrack', + update: true, + updateAlias: 'modifyTrack', + destroy: true, + destroyAlias: 'eraseTrack', + }, + }, + ], + }); + + const data = new Parse.Object('Track'); + + await data.save(); + + await parseGraphQLSchema.schemaCache.clear(); + await parseGraphQLSchema.load(); + + const mutations = parseGraphQLSchema.graphQLMutations; + + expect(Object.keys(mutations)).toContain('addTrack'); + expect(Object.keys(mutations)).toContain('modifyTrack'); + expect(Object.keys(mutations)).toContain('eraseTrack'); + }); + }); +}); diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js new file mode 100644 index 0000000000..07bcd4efdf --- /dev/null +++ b/spec/ParseGraphQLServer.spec.js @@ -0,0 +1,12275 @@ +const http = require('http'); +const express = require('express'); +const req = require('../lib/request'); +const fetch = (...args) => + import('node-fetch').then(({ default: fetch }) => { + const [url, options = {}] = args; + return fetch(url, { agent: new http.Agent({ keepAlive: false }), ...options }); + }); +const FormData = require('form-data'); +require('./helper'); +const { updateCLP } = require('./support/dev'); +const Utils = require('../lib/Utils'); + +const pluralize = require('pluralize'); +const createUploadLink = (...args) => import('apollo-upload-client/createUploadLink.mjs').then(({ default: fn }) => fn(...args)); +const { mergeSchemas } = require('@graphql-tools/schema'); +const { + ApolloClient, + InMemoryCache, + ApolloLink, + createHttpLink, +} = require('@apollo/client/core'); +const gql = require('graphql-tag'); +const { toGlobalId } = require('graphql-relay'); +const { + GraphQLObjectType, + GraphQLString, + GraphQLNonNull, + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLSchema, + GraphQLList, +} = require('graphql'); +const { ParseServer } = require('../'); +const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer'); +const { ReadPreference, Collection } = require('mongodb'); +const { randomUUID: uuidv4 } = require('crypto'); + +function handleError(e) { + if (e && e.networkError && e.networkError.result && e.networkError.result.errors) { + fail(e.networkError.result.errors); + } else { + fail(e); + } +} + +describe('ParseGraphQLServer', () => { + let parseServer; + let parseGraphQLServer; + let loggerErrorSpy; + + beforeEach(async () => { + parseServer = await global.reconfigureServer({ + maintenanceKey: 'test2', + maxUploadSize: '1kb', + }); + parseGraphQLServer = new ParseGraphQLServer(parseServer, { + graphQLPath: '/graphql', + playgroundPath: '/playground', + }); + + const logger = require('../lib/logger').default; + loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + }); + + describe('constructor', () => { + it('should require a parseServer instance', () => { + expect(() => new ParseGraphQLServer()).toThrow('You must provide a parseServer instance!'); + }); + + it('should require config.graphQLPath', () => { + expect(() => new ParseGraphQLServer(parseServer)).toThrow( + 'You must provide a config.graphQLPath!' + ); + expect(() => new ParseGraphQLServer(parseServer, {})).toThrow( + 'You must provide a config.graphQLPath!' + ); + }); + + it('should only require parseServer and config.graphQLPath args', () => { + let parseGraphQLServer; + expect(() => { + parseGraphQLServer = new ParseGraphQLServer(parseServer, { + graphQLPath: 'graphql', + }); + }).not.toThrow(); + expect(parseGraphQLServer.parseGraphQLSchema).toBeDefined(); + expect(parseGraphQLServer.parseGraphQLSchema.databaseController).toEqual( + parseServer.config.databaseController + ); + }); + + it('should initialize parseGraphQLSchema with a log controller', async () => { + const loggerAdapter = { + log: () => { }, + error: () => { }, + }; + const parseServer = await global.reconfigureServer({ + loggerAdapter, + }); + const parseGraphQLServer = new ParseGraphQLServer(parseServer, { + graphQLPath: 'graphql', + }); + expect(parseGraphQLServer.parseGraphQLSchema.log.adapter).toBe(loggerAdapter); + }); + }); + + describe('_getServer', () => { + it('should only return new server on schema changes', async () => { + parseGraphQLServer.server = undefined; + const server1 = await parseGraphQLServer._getServer(); + const server2 = await parseGraphQLServer._getServer(); + expect(server1).toBe(server2); + + // Trigger a schema change + const obj = new Parse.Object('SomeClass'); + await obj.save(); + + const server3 = await parseGraphQLServer._getServer(); + const server4 = await parseGraphQLServer._getServer(); + expect(server3).not.toBe(server2); + expect(server3).toBe(server4); + }); + + it('should return same server reference when called 100 times in parallel', async () => { + parseGraphQLServer.server = undefined; + + // Call _getServer 100 times in parallel + const promises = Array.from({ length: 100 }, () => parseGraphQLServer._getServer()); + const servers = await Promise.all(promises); + + // All resolved servers should be the same reference + const firstServer = servers[0]; + servers.forEach((server, index) => { + expect(server).toBe(firstServer); + }); + }); + }); + + describe('_getGraphQLOptions', () => { + const req = { + info: new Object(), + config: new Object(), + auth: new Object(), + get: () => { }, + }; + const res = { + set: () => { }, + }; + + it_id('0696675e-060f-414f-bc77-9d57f31807f5')(it)('should return schema and context with req\'s info, config and auth', async () => { + const options = await parseGraphQLServer._getGraphQLOptions(); + expect(options.schema).toEqual(parseGraphQLServer.parseGraphQLSchema.graphQLSchema); + const contextResponse = await options.context({ req, res }); + expect(contextResponse.info).toEqual(req.info); + expect(contextResponse.config).toEqual(req.config); + expect(contextResponse.auth).toEqual(req.auth); + }); + + it('should load GraphQL schema in every call', async () => { + const originalLoad = parseGraphQLServer.parseGraphQLSchema.load; + let counter = 0; + parseGraphQLServer.parseGraphQLSchema.load = () => ++counter; + expect((await parseGraphQLServer._getGraphQLOptions(req)).schema).toEqual(1); + expect((await parseGraphQLServer._getGraphQLOptions(req)).schema).toEqual(2); + expect((await parseGraphQLServer._getGraphQLOptions(req)).schema).toEqual(3); + parseGraphQLServer.parseGraphQLSchema.load = originalLoad; + }); + }); + + describe('_transformMaxUploadSizeToBytes', () => { + it('should transform to bytes', () => { + expect(parseGraphQLServer._transformMaxUploadSizeToBytes('20mb')).toBe(20971520); + expect(parseGraphQLServer._transformMaxUploadSizeToBytes('333Gb')).toBe(357556027392); + expect(parseGraphQLServer._transformMaxUploadSizeToBytes('123456KB')).toBe(126418944); + }); + }); + + describe('applyGraphQL', () => { + it('should require an Express.js app instance', () => { + expect(() => parseGraphQLServer.applyGraphQL()).toThrow( + 'You must provide an Express.js app instance!' + ); + expect(() => parseGraphQLServer.applyGraphQL({})).toThrow( + 'You must provide an Express.js app instance!' + ); + expect(() => parseGraphQLServer.applyGraphQL(new express())).not.toThrow(); + }); + + it('should apply middlewares at config.graphQLPath', () => { + let useCount = 0; + expect(() => + new ParseGraphQLServer(parseServer, { + graphQLPath: 'somepath', + }).applyGraphQL({ + use: path => { + useCount++; + expect(path).toEqual('somepath'); + }, + }) + ).not.toThrow(); + expect(useCount).toBeGreaterThan(0); + }); + }); + + describe('applyPlayground', () => { + it('should require an Express.js app instance', () => { + expect(() => parseGraphQLServer.applyPlayground()).toThrow( + 'You must provide an Express.js app instance!' + ); + expect(() => parseGraphQLServer.applyPlayground({})).toThrow( + 'You must provide an Express.js app instance!' + ); + expect(() => parseGraphQLServer.applyPlayground(new express())).not.toThrow(); + }); + + it('should require initialization with config.playgroundPath', () => { + expect(() => + new ParseGraphQLServer(parseServer, { + graphQLPath: 'graphql', + }).applyPlayground(new express()) + ).toThrow('You must provide a config.playgroundPath to applyPlayground!'); + }); + + it('should apply middlewares at config.playgroundPath', () => { + let useCount = 0; + expect(() => + new ParseGraphQLServer(parseServer, { + graphQLPath: 'graphQL', + playgroundPath: 'somepath', + }).applyPlayground({ + get: path => { + useCount++; + expect(path).toEqual('somepath'); + }, + }) + ).not.toThrow(); + expect(useCount).toBeGreaterThan(0); + }); + }); + + describe('setGraphQLConfig', () => { + let parseGraphQLServer; + beforeEach(() => { + parseGraphQLServer = new ParseGraphQLServer(parseServer, { + graphQLPath: 'graphql', + }); + }); + it('should pass the graphQLConfig onto the parseGraphQLController', async () => { + let received; + parseGraphQLServer.parseGraphQLController = { + async updateGraphQLConfig(graphQLConfig) { + received = graphQLConfig; + return {}; + }, + }; + const graphQLConfig = { enabledForClasses: [] }; + await parseGraphQLServer.setGraphQLConfig(graphQLConfig); + expect(received).toBe(graphQLConfig); + }); + it('should not absorb exceptions from parseGraphQLController', async () => { + parseGraphQLServer.parseGraphQLController = { + async updateGraphQLConfig() { + throw new Error('Network request failed'); + }, + }; + await expectAsync(parseGraphQLServer.setGraphQLConfig({})).toBeRejectedWith( + new Error('Network request failed') + ); + }); + it('should return the response from parseGraphQLController', async () => { + parseGraphQLServer.parseGraphQLController = { + async updateGraphQLConfig() { + return { response: { result: true } }; + }, + }; + await expectAsync(parseGraphQLServer.setGraphQLConfig({})).toBeResolvedTo({ + response: { result: true }, + }); + }); + }); + + describe('Auto API', () => { + let httpServer; + let parseLiveQueryServer; + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }; + + let apolloClient; + + let user1; + let user2; + let user3; + let user4; + let user5; + let role; + let object1; + let object2; + let object3; + let object4; + let objects = []; + + async function prepareData() { + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + user1 = new Parse.User(); + user1.setUsername('user1'); + user1.setPassword('user1'); + user1.setEmail('user1@user1.user1'); + user1.setACL(acl); + await user1.signUp(); + + user2 = new Parse.User(); + user2.setUsername('user2'); + user2.setPassword('user2'); + user2.setACL(acl); + await user2.signUp(); + + user3 = new Parse.User(); + user3.setUsername('user3'); + user3.setPassword('user3'); + user3.setACL(acl); + await user3.signUp(); + + user4 = new Parse.User(); + user4.setUsername('user4'); + user4.setPassword('user4'); + user4.setACL(acl); + await user4.signUp(); + + user5 = new Parse.User(); + user5.setUsername('user5'); + user5.setPassword('user5'); + user5.setACL(acl); + await user5.signUp(); + + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + role = new Parse.Role(); + role.setName('role'); + role.setACL(roleACL); + role.getUsers().add(user1); + role.getUsers().add(user3); + role = await role.save(); + + const schemaController = await parseServer.config.databaseController.loadSchema(); + try { + await schemaController.addClassIfNotExists( + 'GraphQLClass', + { + someField: { type: 'String' }, + pointerToUser: { type: 'Pointer', targetClass: '_User' }, + }, + { + find: { + 'role:role': true, + [user1.id]: true, + [user2.id]: true, + }, + create: { + 'role:role': true, + [user1.id]: true, + [user2.id]: true, + }, + get: { + 'role:role': true, + [user1.id]: true, + [user2.id]: true, + }, + update: { + 'role:role': true, + [user1.id]: true, + [user2.id]: true, + }, + addField: { + 'role:role': true, + [user1.id]: true, + [user2.id]: true, + }, + delete: { + 'role:role': true, + [user1.id]: true, + [user2.id]: true, + }, + readUserFields: ['pointerToUser'], + writeUserFields: ['pointerToUser'], + }, + {} + ); + } catch (err) { + if (!(err instanceof Parse.Error) || err.message !== 'Class GraphQLClass already exists.') { + throw err; + } + } + + object1 = new Parse.Object('GraphQLClass'); + object1.set('someField', 'someValue1'); + object1.set('someOtherField', 'A'); + const object1ACL = new Parse.ACL(); + object1ACL.setPublicReadAccess(false); + object1ACL.setPublicWriteAccess(false); + object1ACL.setRoleReadAccess(role, true); + object1ACL.setRoleWriteAccess(role, true); + object1ACL.setReadAccess(user1.id, true); + object1ACL.setWriteAccess(user1.id, true); + object1ACL.setReadAccess(user2.id, true); + object1ACL.setWriteAccess(user2.id, true); + object1.setACL(object1ACL); + await object1.save(undefined, { useMasterKey: true }); + + object2 = new Parse.Object('GraphQLClass'); + object2.set('someField', 'someValue2'); + object2.set('someOtherField', 'A'); + const object2ACL = new Parse.ACL(); + object2ACL.setPublicReadAccess(false); + object2ACL.setPublicWriteAccess(false); + object2ACL.setReadAccess(user1.id, true); + object2ACL.setWriteAccess(user1.id, true); + object2ACL.setReadAccess(user2.id, true); + object2ACL.setWriteAccess(user2.id, true); + object2ACL.setReadAccess(user5.id, true); + object2ACL.setWriteAccess(user5.id, true); + object2.setACL(object2ACL); + await object2.save(undefined, { useMasterKey: true }); + + object3 = new Parse.Object('GraphQLClass'); + object3.set('someField', 'someValue3'); + object3.set('someOtherField', 'B'); + object3.set('pointerToUser', user5); + await object3.save(undefined, { useMasterKey: true }); + + object4 = new Parse.Object('PublicClass'); + object4.set('someField', 'someValue4'); + await object4.save(); + + objects = []; + objects.push(object1, object2, object3, object4); + } + + async function createGQLFromParseServer(_parseServer, parseGraphQLServerOptions) { + if (parseLiveQueryServer) { + await parseLiveQueryServer.server.close(); + } + if (httpServer) { + await httpServer.close(); + } + const expressApp = express(); + httpServer = http.createServer(expressApp); + expressApp.use('/parse', _parseServer.app); + parseLiveQueryServer = await ParseServer.createLiveQueryServer(httpServer, { + port: 1338, + }); + parseGraphQLServer = new ParseGraphQLServer(_parseServer, { + graphQLPath: '/graphql', + playgroundPath: '/playground', + ...parseGraphQLServerOptions, + }); + parseGraphQLServer.applyGraphQL(expressApp); + parseGraphQLServer.applyPlayground(expressApp); + await new Promise(resolve => httpServer.listen({ port: 13377 }, resolve)); + } + + beforeEach(async () => { + await createGQLFromParseServer(parseServer); + + const httpLink = await createUploadLink({ + uri: 'http://localhost:13377/graphql', + fetch, + headers, + }); + apolloClient = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + defaultOptions: { + query: { + fetchPolicy: 'no-cache', + }, + }, + }); + spyOn(console, 'warn').and.callFake(() => { }); + spyOn(console, 'error').and.callFake(() => { }); + }); + + afterEach(async () => { + await parseLiveQueryServer.server.close(); + await httpServer.close(); + }); + + describe('GraphQL', () => { + it('should be healthy', async () => { + try { + const health = ( + await apolloClient.query({ + query: gql` + query Health { + health + } + `, + }) + ).data.health; + expect(health).toBeTruthy(); + } catch (e) { + handleError(e); + } + }); + + it('should be cors enabled', async () => { + let checked = false; + const apolloClient = new ApolloClient({ + link: new ApolloLink((operation, forward) => { + return forward(operation).map(response => { + const context = operation.getContext(); + const { + response: { headers }, + } = context; + expect(headers.get('access-control-allow-origin')).toEqual('*'); + checked = true; + return response; + }); + }).concat( + createHttpLink({ + uri: 'http://localhost:13377/graphql', + fetch, + headers: { + ...headers, + Origin: 'http://example.com', + }, + }) + ), + cache: new InMemoryCache(), + }); + const healthResponse = await apolloClient.query({ + query: gql` + query Health { + health + } + `, + }); + expect(healthResponse.data.health).toBeTruthy(); + expect(checked).toBeTruthy(); + }); + + it('should handle Parse headers', async () => { + const test = { + context: ({ req: { info, config, auth } }) => { + expect(req.info).toBeDefined(); + expect(req.config).toBeDefined(); + expect(req.auth).toBeDefined(); + return { + info, + config, + auth, + }; + }, + }; + const contextSpy = spyOn(test, 'context'); + const originalGetGraphQLOptions = parseGraphQLServer._getGraphQLOptions; + parseGraphQLServer._getGraphQLOptions = async () => { + return { + schema: await parseGraphQLServer.parseGraphQLSchema.load(), + context: test.context, + }; + }; + const health = ( + await apolloClient.query({ + query: gql` + query Health { + health + } + `, + }) + ).data.health; + expect(health).toBeTruthy(); + expect(contextSpy).toHaveBeenCalledTimes(1); + parseGraphQLServer._getGraphQLOptions = originalGetGraphQLOptions; + }); + }); + + describe('Playground', () => { + it('should mount playground', async () => { + const res = await req({ + method: 'GET', + url: 'http://localhost:13377/playground', + }); + expect(res.status).toEqual(200); + }); + }); + + describe('Schema', () => { + const resetGraphQLCache = async () => { + await Promise.all([ + parseGraphQLServer.parseGraphQLController.cacheController.graphQL.clear(), + parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(), + ]); + }; + + describe('Context', () => { + it('should support dependency injection on graphql api', async () => { + const requestContextMiddleware = (req, res, next) => { + req.config.aCustomController = 'aCustomController'; + next(); + }; + + let called; + const parseServer = await reconfigureServer({ requestContextMiddleware }); + await createGQLFromParseServer(parseServer); + Parse.Cloud.beforeSave('_User', request => { + expect(request.config.aCustomController).toEqual('aCustomController'); + called = true; + }); + + await apolloClient.query({ + query: gql` + mutation { + createUser(input: { fields: { username: "test", password: "test" } }) { + user { + objectId + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + } + }) + expect(called).toBe(true); + }) + }) + + describe('Introspection', () => { + it('should have public introspection disabled by default without master key', async () => { + + try { + await apolloClient.query({ + query: gql` + query Introspection { + __schema { + types { + name + } + } + } + `, + }) + + fail('should have thrown an error'); + + } catch (e) { + expect(e.message).toEqual('Response not successful: Received status code 403'); + expect(e.networkError.result.errors[0].message).toEqual('Introspection is not allowed'); + } + }); + + it('should always work with master key in node environment production', async () => { + const originalNodeEnv = process.env.NODE_ENV; + try { + // Apollo Server have changing behavior based on the NODE_ENV variable + // so we need to set it to production to get the expected behavior + // and cover correctly the introspection cases + process.env.NODE_ENV = 'production'; + await createGQLFromParseServer(parseServer); + + const introspection = await apolloClient.query({ + query: gql` + query Introspection { + __schema { + types { + name + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + expect(introspection.data).toBeDefined(); + expect(introspection.errors).not.toBeDefined(); + } finally { + process.env.NODE_ENV = originalNodeEnv; + } + }); + + it('should always work with master key in node environment development', async () => { + const originalNodeEnv = process.env.NODE_ENV; + try { + // Apollo Server have changing behavior based on the NODE_ENV variable + // so we need to set it to development to get the expected behavior + // and cover correctly the introspection cases + process.env.NODE_ENV = 'development'; + await createGQLFromParseServer(parseServer); + + const introspection = await apolloClient.query({ + query: gql` + query Introspection { + __schema { + types { + name + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + expect(introspection.data).toBeDefined(); + expect(introspection.errors).not.toBeDefined(); + } finally { + process.env.NODE_ENV = originalNodeEnv; + } + }); + + it('should always work with maintenance key', async () => { + const introspection = + await apolloClient.query({ + query: gql` + query Introspection { + __schema { + types { + name + } + } + } + `, + context: { + headers: { + 'X-Parse-Maintenance-Key': 'test2', + }, + } + },) + expect(introspection.data).toBeDefined(); + expect(introspection.errors).not.toBeDefined(); + }); + + it('should have public introspection enabled if enabled', async () => { + + const parseServer = await reconfigureServer(); + await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true }); + + const introspection = + await apolloClient.query({ + query: gql` + query Introspection { + __schema { + types { + name + } + } + } + `, + }) + expect(introspection.data).toBeDefined(); + }); + + it('should block __type introspection without master key', async () => { + try { + await apolloClient.query({ + query: gql` + query TypeIntrospection { + __type(name: "User") { + name + kind + } + } + `, + }); + + fail('should have thrown an error'); + } catch (e) { + expect(e.message).toEqual('Response not successful: Received status code 403'); + expect(e.networkError.result.errors[0].message).toEqual('Introspection is not allowed'); + } + }); + + it('should block aliased __type introspection without master key', async () => { + try { + await apolloClient.query({ + query: gql` + query AliasedTypeIntrospection { + myAlias: __type(name: "User") { + name + kind + } + } + `, + }); + + fail('should have thrown an error'); + } catch (e) { + expect(e.message).toEqual('Response not successful: Received status code 403'); + expect(e.networkError.result.errors[0].message).toEqual('Introspection is not allowed'); + } + }); + + it('should block __type introspection in fragments without master key', async () => { + try { + await apolloClient.query({ + query: gql` + fragment TypeIntrospectionFields on Query { + typeInfo: __type(name: "User") { + name + kind + } + } + + query FragmentTypeIntrospection { + ...TypeIntrospectionFields + } + `, + }); + + fail('should have thrown an error'); + } catch (e) { + expect(e.message).toEqual('Response not successful: Received status code 403'); + expect(e.networkError.result.errors[0].message).toEqual('Introspection is not allowed'); + } + }); + + it('should block __type introspection through nested fragment spreads without master key', async () => { + try { + await apolloClient.query({ + query: gql` + fragment InnerFragment on Query { + __type(name: "User") { + name + fields { + name + } + } + } + + fragment OuterFragment on Query { + ...InnerFragment + } + + query NestedFragmentIntrospection { + ...OuterFragment + } + `, + }); + + fail('should have thrown an error'); + } catch (e) { + expect(e.message).toEqual('Response not successful: Received status code 403'); + expect(e.networkError.result.errors[0].message).toEqual('Introspection is not allowed'); + } + }); + + it('should block __type introspection hidden in fragment with valid field without master key', async () => { + try { + // First create a test object to query + const object = new Parse.Object('SomeClass'); + await object.save(); + + await apolloClient.query({ + query: gql` + fragment MixedFragment on Query { + someClasses { + edges { + node { + objectId + } + } + } + __type(name: "User") { + name + kind + } + } + + query MixedQuery { + ...MixedFragment + } + `, + }); + + fail('should have thrown an error'); + } catch (e) { + expect(e.message).toEqual('Response not successful: Received status code 403'); + expect(e.networkError.result.errors[0].message).toEqual('Introspection is not allowed'); + } + }); + + it('should block __type introspection inside inline fragment without master key', async () => { + try { + await apolloClient.query({ + query: gql` + query InlineFragmentBypass { + ... on Query { + __type(name: "User") { + name + kind + } + } + } + `, + }); + + fail('should have thrown an error'); + } catch (e) { + expect(e.message).toEqual('Response not successful: Received status code 403'); + expect(e.networkError.result.errors[0].message).toEqual('Introspection is not allowed'); + } + }); + + it('should block __type introspection inside nested inline fragments without master key', async () => { + try { + await apolloClient.query({ + query: gql` + query NestedInlineFragmentBypass { + ... on Query { + ... { + __type(name: "User") { + name + kind + } + } + } + } + `, + }); + + fail('should have thrown an error'); + } catch (e) { + expect(e.message).toEqual('Response not successful: Received status code 403'); + expect(e.networkError.result.errors[0].message).toEqual('Introspection is not allowed'); + } + }); + + it('should allow __type introspection with master key', async () => { + const introspection = await apolloClient.query({ + query: gql` + query TypeIntrospection { + __type(name: "User") { + name + kind + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + expect(introspection.data).toBeDefined(); + expect(introspection.data.__type).toBeDefined(); + expect(introspection.errors).not.toBeDefined(); + }); + + it('should allow aliased __type introspection with master key', async () => { + const introspection = await apolloClient.query({ + query: gql` + query AliasedTypeIntrospection { + myAlias: __type(name: "User") { + name + kind + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + expect(introspection.data).toBeDefined(); + expect(introspection.data.myAlias).toBeDefined(); + expect(introspection.errors).not.toBeDefined(); + }); + + it('should allow __type introspection with maintenance key', async () => { + const introspection = await apolloClient.query({ + query: gql` + query TypeIntrospection { + __type(name: "User") { + name + kind + } + } + `, + context: { + headers: { + 'X-Parse-Maintenance-Key': 'test2', + }, + }, + }); + expect(introspection.data).toBeDefined(); + expect(introspection.data.__type).toBeDefined(); + expect(introspection.errors).not.toBeDefined(); + }); + + it('should allow __type introspection when public introspection is enabled', async () => { + const parseServer = await reconfigureServer(); + await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true }); + + const introspection = await apolloClient.query({ + query: gql` + query TypeIntrospection { + __type(name: "User") { + name + kind + } + } + `, + }); + expect(introspection.data).toBeDefined(); + expect(introspection.data.__type).toBeDefined(); + }); + }); + + + describe('Default Types', () => { + beforeEach(async () => { + await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true }); + }); + it('should have Object scalar type', async () => { + const objectType = ( + await apolloClient.query({ + query: gql` + query ObjectType { + __type(name: "Object") { + kind + } + } + `, + }) + ).data['__type']; + expect(objectType.kind).toEqual('SCALAR'); + }); + + it('should have Date scalar type', async () => { + const dateType = ( + await apolloClient.query({ + query: gql` + query DateType { + __type(name: "Date") { + kind + } + } + `, + }) + ).data['__type']; + expect(dateType.kind).toEqual('SCALAR'); + }); + + it('should have ArrayResult type', async () => { + const arrayResultType = ( + await apolloClient.query({ + query: gql` + query ArrayResultType { + __type(name: "ArrayResult") { + kind + } + } + `, + }) + ).data['__type']; + expect(arrayResultType.kind).toEqual('UNION'); + }); + + it('should have File object type', async () => { + const fileType = ( + await apolloClient.query({ + query: gql` + query FileType { + __type(name: "FileInfo") { + kind + fields { + name + } + } + } + `, + }) + ).data['__type']; + expect(fileType.kind).toEqual('OBJECT'); + expect(fileType.fields.map(field => field.name).sort()).toEqual(['name', 'url']); + }); + + it('should have Class interface type', async () => { + const classType = ( + await apolloClient.query({ + query: gql` + query ClassType { + __type(name: "ParseObject") { + kind + fields { + name + } + } + } + `, + }) + ).data['__type']; + expect(classType.kind).toEqual('INTERFACE'); + expect(classType.fields.map(field => field.name).sort()).toEqual([ + 'ACL', + 'createdAt', + 'objectId', + 'updatedAt', + ]); + }); + + it('should have ReadPreference enum type', async () => { + const readPreferenceType = ( + await apolloClient.query({ + query: gql` + query ReadPreferenceType { + __type(name: "ReadPreference") { + kind + enumValues { + name + } + } + } + `, + }) + ).data['__type']; + expect(readPreferenceType.kind).toEqual('ENUM'); + expect(readPreferenceType.enumValues.map(value => value.name).sort()).toEqual([ + 'NEAREST', + 'PRIMARY', + 'PRIMARY_PREFERRED', + 'SECONDARY', + 'SECONDARY_PREFERRED', + ]); + }); + + it('should have GraphQLUpload object type', async () => { + const graphQLUploadType = ( + await apolloClient.query({ + query: gql` + query GraphQLUploadType { + __type(name: "Upload") { + kind + fields { + name + } + } + } + `, + }) + ).data['__type']; + expect(graphQLUploadType.kind).toEqual('SCALAR'); + }); + + it('should have all expected types', async () => { + const schemaTypes = ( + await apolloClient.query({ + query: gql` + query SchemaTypes { + __schema { + types { + name + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + } + }) + ).data['__schema'].types.map(type => type.name); + + const expectedTypes = ['ParseObject', 'Date', 'FileInfo', 'ReadPreference', 'Upload']; + expect(expectedTypes.every(type => schemaTypes.indexOf(type) !== -1)).toBeTruthy( + JSON.stringify(schemaTypes.types) + ); + }); + }); + + describe('Relay Specific Types', () => { + beforeEach(async () => { + await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true }); + }); + + let clearCache; + beforeEach(async () => { + if (!clearCache) { + await resetGraphQLCache(); + clearCache = true; + } + }); + + it('should have Node interface', async () => { + const schemaTypes = ( + await apolloClient.query({ + query: gql` + query SchemaTypes { + __schema { + types { + name + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + } + }) + ).data['__schema'].types.map(type => type.name); + + expect(schemaTypes).toContain('Node'); + }); + + it('should have node query', async () => { + const queryFields = ( + await apolloClient.query({ + query: gql` + query UserType { + __type(name: "Query") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields.map(field => field.name); + + expect(queryFields).toContain('node'); + }); + + it('should return global id', async () => { + const userFields = ( + await apolloClient.query({ + query: gql` + query UserType { + __type(name: "User") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields.map(field => field.name); + + expect(userFields).toContain('id'); + expect(userFields).toContain('objectId'); + }); + + it('should have clientMutationId in create file input', async () => { + const createFileInputFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "CreateFileInput") { + inputFields { + name + } + } + } + `, + }) + ).data['__type'].inputFields + .map(field => field.name) + .sort(); + + expect(createFileInputFields).toEqual(['clientMutationId', 'upload']); + }); + + it('should have clientMutationId in create file payload', async () => { + const createFilePayloadFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "CreateFilePayload") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields + .map(field => field.name) + .sort(); + + expect(createFilePayloadFields).toEqual(['clientMutationId', 'fileInfo']); + }); + + it('should have clientMutationId in call function input', async () => { + Parse.Cloud.define('hello', () => { }); + + const callFunctionInputFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "CallCloudCodeInput") { + inputFields { + name + } + } + } + `, + }) + ).data['__type'].inputFields + .map(field => field.name) + .sort(); + + expect(callFunctionInputFields).toEqual(['clientMutationId', 'functionName', 'params']); + }); + + it('should have clientMutationId in call function payload', async () => { + Parse.Cloud.define('hello', () => { }); + + const callFunctionPayloadFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "CallCloudCodePayload") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields + .map(field => field.name) + .sort(); + + expect(callFunctionPayloadFields).toEqual(['clientMutationId', 'result']); + }); + + it('should have clientMutationId in sign up mutation input', async () => { + const inputFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "SignUpInput") { + inputFields { + name + } + } + } + `, + }) + ).data['__type'].inputFields + .map(field => field.name) + .sort(); + + expect(inputFields).toEqual(['clientMutationId', 'fields']); + }); + + it('should have clientMutationId in sign up mutation payload', async () => { + const payloadFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "SignUpPayload") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields + .map(field => field.name) + .sort(); + + expect(payloadFields).toEqual(['clientMutationId', 'viewer']); + }); + + it('should have clientMutationId in log in mutation input', async () => { + const inputFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "LogInInput") { + inputFields { + name + } + } + } + `, + }) + ).data['__type'].inputFields + .map(field => field.name) + .sort(); + expect(inputFields).toEqual(['authData', 'clientMutationId', 'password', 'username']); + }); + + it('should have clientMutationId in log in mutation payload', async () => { + const payloadFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "LogInPayload") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields + .map(field => field.name) + .sort(); + + expect(payloadFields).toEqual(['clientMutationId', 'viewer']); + }); + + it('should have clientMutationId in log out mutation input', async () => { + const inputFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "LogOutInput") { + inputFields { + name + } + } + } + `, + }) + ).data['__type'].inputFields + .map(field => field.name) + .sort(); + + expect(inputFields).toEqual(['clientMutationId']); + }); + + it('should have clientMutationId in log out mutation payload', async () => { + const payloadFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "LogOutPayload") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields + .map(field => field.name) + .sort(); + + expect(payloadFields).toEqual(['clientMutationId', 'ok']); + }); + + it('should have clientMutationId in createClass mutation input', async () => { + const inputFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "CreateClassInput") { + inputFields { + name + } + } + } + `, + }) + ).data['__type'].inputFields + .map(field => field.name) + .sort(); + + expect(inputFields).toEqual(['clientMutationId', 'name', 'schemaFields']); + }); + + it('should have clientMutationId in createClass mutation payload', async () => { + const payloadFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "CreateClassPayload") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields + .map(field => field.name) + .sort(); + + expect(payloadFields).toEqual(['class', 'clientMutationId']); + }); + + it('should have clientMutationId in updateClass mutation input', async () => { + const inputFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "UpdateClassInput") { + inputFields { + name + } + } + } + `, + }) + ).data['__type'].inputFields + .map(field => field.name) + .sort(); + + expect(inputFields).toEqual(['clientMutationId', 'name', 'schemaFields']); + }); + + it('should have clientMutationId in updateClass mutation payload', async () => { + const payloadFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "UpdateClassPayload") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields + .map(field => field.name) + .sort(); + + expect(payloadFields).toEqual(['class', 'clientMutationId']); + }); + + it('should have clientMutationId in deleteClass mutation input', async () => { + const inputFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "DeleteClassInput") { + inputFields { + name + } + } + } + `, + }) + ).data['__type'].inputFields + .map(field => field.name) + .sort(); + + expect(inputFields).toEqual(['clientMutationId', 'name']); + }); + + it('should have clientMutationId in deleteClass mutation payload', async () => { + const payloadFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "UpdateClassPayload") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields + .map(field => field.name) + .sort(); + + expect(payloadFields).toEqual(['class', 'clientMutationId']); + }); + + it('should have clientMutationId in custom create object mutation input', async () => { + const obj = new Parse.Object('SomeClass'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const createObjectInputFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "CreateSomeClassInput") { + inputFields { + name + } + } + } + `, + }) + ).data['__type'].inputFields + .map(field => field.name) + .sort(); + + expect(createObjectInputFields).toEqual(['clientMutationId', 'fields']); + }); + + it('should have clientMutationId in custom create object mutation payload', async () => { + const obj = new Parse.Object('SomeClass'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const createObjectPayloadFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "CreateSomeClassPayload") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields + .map(field => field.name) + .sort(); + + expect(createObjectPayloadFields).toEqual(['clientMutationId', 'someClass']); + }); + + it('should have clientMutationId in custom update object mutation input', async () => { + const obj = new Parse.Object('SomeClass'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const createObjectInputFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "UpdateSomeClassInput") { + inputFields { + name + } + } + } + `, + }) + ).data['__type'].inputFields + .map(field => field.name) + .sort(); + + expect(createObjectInputFields).toEqual(['clientMutationId', 'fields', 'id']); + }); + + it('should have clientMutationId in custom update object mutation payload', async () => { + const obj = new Parse.Object('SomeClass'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const createObjectPayloadFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "UpdateSomeClassPayload") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields + .map(field => field.name) + .sort(); + + expect(createObjectPayloadFields).toEqual(['clientMutationId', 'someClass']); + }); + + it('should have clientMutationId in custom delete object mutation input', async () => { + const obj = new Parse.Object('SomeClass'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const createObjectInputFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "DeleteSomeClassInput") { + inputFields { + name + } + } + } + `, + }) + ).data['__type'].inputFields + .map(field => field.name) + .sort(); + + expect(createObjectInputFields).toEqual(['clientMutationId', 'id']); + }); + + it('should have clientMutationId in custom delete object mutation payload', async () => { + const obj = new Parse.Object('SomeClass'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const createObjectPayloadFields = ( + await apolloClient.query({ + query: gql` + query { + __type(name: "DeleteSomeClassPayload") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields + .map(field => field.name) + .sort(); + + expect(createObjectPayloadFields).toEqual(['clientMutationId', 'someClass']); + }); + }); + + describe('Parse Class Types', () => { + beforeEach(async () => { + await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true }); + }); + it('should have all expected types', async () => { + await parseServer.config.databaseController.loadSchema(); + + const schemaTypes = ( + await apolloClient.query({ + query: gql` + query SchemaTypes { + __schema { + types { + name + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + } + }) + ).data['__schema'].types.map(type => type.name); + + const expectedTypes = [ + 'Role', + 'RoleWhereInput', + 'CreateRoleFieldsInput', + 'UpdateRoleFieldsInput', + 'RoleConnection', + 'User', + 'UserWhereInput', + 'UserConnection', + 'CreateUserFieldsInput', + 'UpdateUserFieldsInput', + ]; + expect(expectedTypes.every(type => schemaTypes.indexOf(type) !== -1)).toBeTruthy( + JSON.stringify(schemaTypes) + ); + }); + + it('should ArrayResult contains all types', async () => { + const objectType = ( + await apolloClient.query({ + query: gql` + query ObjectType { + __type(name: "ArrayResult") { + kind + possibleTypes { + name + } + } + } + `, + }) + ).data['__type']; + const possibleTypes = objectType.possibleTypes.map(o => o.name); + expect(possibleTypes).toContain('User'); + expect(possibleTypes).toContain('Role'); + expect(possibleTypes).toContain('Element'); + }); + + it('should update schema when it changes', async () => { + const schemaController = await parseServer.config.databaseController.loadSchema(); + await schemaController.updateClass('_User', { + foo: { type: 'String' }, + }); + + const userFields = ( + await apolloClient.query({ + query: gql` + query UserType { + __type(name: "User") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields.map(field => field.name); + expect(userFields.indexOf('foo') !== -1).toBeTruthy(); + }); + + it('should not contain password field from _User class', async () => { + const userFields = ( + await apolloClient.query({ + query: gql` + query UserType { + __type(name: "User") { + fields { + name + } + } + } + `, + }) + ).data['__type'].fields.map(field => field.name); + expect(userFields.includes('password')).toBeFalsy(); + }); + }); + + describe('Configuration', function () { + const resetGraphQLCache = async () => { + await Promise.all([ + parseGraphQLServer.parseGraphQLController.cacheController.graphQL.clear(), + parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(), + ]); + }; + + beforeEach(async () => { + await parseGraphQLServer.setGraphQLConfig({}); + await resetGraphQLCache(); + await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true }); + }); + + it_id('d6a23a2f-ca18-4b15-bc73-3e636f99e6bc')(it)('should only include types in the enabledForClasses list', async () => { + const schemaController = await parseServer.config.databaseController.loadSchema(); + await schemaController.addClassIfNotExists('SuperCar', { + foo: { type: 'String' }, + }); + + const graphQLConfig = { + enabledForClasses: ['SuperCar'], + }; + await parseGraphQLServer.setGraphQLConfig(graphQLConfig); + await resetGraphQLCache(); + + const { data } = await apolloClient.query({ + query: gql` + query UserType { + userType: __type(name: "User") { + fields { + name + } + } + superCarType: __type(name: "SuperCar") { + fields { + name + } + } + } + `, + }); + expect(data.userType).toBeNull(); + expect(data.superCarType).toBeTruthy(); + }); + it_id('1db2aceb-d24e-4929-ba43-8dbb5d0395e1')(it)('should not include types in the disabledForClasses list', async () => { + const schemaController = await parseServer.config.databaseController.loadSchema(); + await schemaController.addClassIfNotExists('SuperCar', { + foo: { type: 'String' }, + }); + + const graphQLConfig = { + disabledForClasses: ['SuperCar'], + }; + await parseGraphQLServer.setGraphQLConfig(graphQLConfig); + await resetGraphQLCache(); + + const { data } = await apolloClient.query({ + query: gql` + query UserType { + userType: __type(name: "User") { + fields { + name + } + } + superCarType: __type(name: "SuperCar") { + fields { + name + } + } + } + `, + }); + expect(data.superCarType).toBeNull(); + expect(data.userType).toBeTruthy(); + }); + it_id('85c2e02f-0239-4819-b66e-392e0125f6c5')(it)('should remove query operations when disabled', async () => { + const superCar = new Parse.Object('SuperCar'); + await superCar.save({ foo: 'bar' }); + const customer = new Parse.Object('Customer'); + await customer.save({ foo: 'bar' }); + + await expectAsync( + apolloClient.query({ + query: gql` + query GetSuperCar($id: ID!) { + superCar(id: $id) { + id + } + } + `, + variables: { + id: superCar.id, + }, + }) + ).toBeResolved(); + + await expectAsync( + apolloClient.query({ + query: gql` + query FindCustomer { + customers { + count + } + } + `, + }) + ).toBeResolved(); + + const graphQLConfig = { + classConfigs: [ + { + className: 'SuperCar', + query: { + get: false, + find: true, + }, + }, + { + className: 'Customer', + query: { + get: true, + find: false, + }, + }, + ], + }; + await parseGraphQLServer.setGraphQLConfig(graphQLConfig); + await resetGraphQLCache(); + + await expectAsync( + apolloClient.query({ + query: gql` + query GetSuperCar($id: ID!) { + superCar(id: $id) { + id + } + } + `, + variables: { + id: superCar.id, + }, + }) + ).toBeRejected(); + await expectAsync( + apolloClient.query({ + query: gql` + query GetCustomer($id: ID!) { + customer(id: $id) { + id + } + } + `, + variables: { + id: customer.id, + }, + }) + ).toBeResolved(); + await expectAsync( + apolloClient.query({ + query: gql` + query FindSuperCar { + superCars { + count + } + } + `, + }) + ).toBeResolved(); + await expectAsync( + apolloClient.query({ + query: gql` + query FindCustomer { + customers { + count + } + } + `, + }) + ).toBeRejected(); + }); + + it_id('972161a6-8108-4e99-a1a5-71d0267d26c2')(it)('should remove mutation operations, create, update and delete, when disabled', async () => { + const superCar1 = new Parse.Object('SuperCar'); + await superCar1.save({ foo: 'bar' }); + const customer1 = new Parse.Object('Customer'); + await customer1.save({ foo: 'bar' }); + + await expectAsync( + apolloClient.query({ + query: gql` + mutation UpdateSuperCar($id: ID!, $foo: String!) { + updateSuperCar(input: { id: $id, fields: { foo: $foo } }) { + clientMutationId + } + } + `, + variables: { + id: superCar1.id, + foo: 'lah', + }, + }) + ).toBeResolved(); + + await expectAsync( + apolloClient.query({ + query: gql` + mutation DeleteCustomer($id: ID!) { + deleteCustomer(input: { id: $id }) { + clientMutationId + } + } + `, + variables: { + id: customer1.id, + }, + }) + ).toBeResolved(); + + const { data: customerData } = await apolloClient.query({ + query: gql` + mutation CreateCustomer($foo: String!) { + createCustomer(input: { fields: { foo: $foo } }) { + customer { + id + } + } + } + `, + variables: { + foo: 'rah', + }, + }); + expect(customerData.createCustomer.customer).toBeTruthy(); + + // used later + const customer2Id = customerData.createCustomer.customer.id; + + await parseGraphQLServer.setGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + mutation: { + create: true, + update: false, + destroy: true, + }, + }, + { + className: 'Customer', + mutation: { + create: false, + update: true, + destroy: false, + }, + }, + ], + }); + await resetGraphQLCache(); + + const { data: superCarData } = await apolloClient.query({ + query: gql` + mutation CreateSuperCar($foo: String!) { + createSuperCar(input: { fields: { foo: $foo } }) { + superCar { + id + } + } + } + `, + variables: { + foo: 'mah', + }, + }); + expect(superCarData.createSuperCar).toBeTruthy(); + const superCar3Id = superCarData.createSuperCar.superCar.id; + + await expectAsync( + apolloClient.query({ + query: gql` + mutation UpdateSupercar($id: ID!, $foo: String!) { + updateSuperCar(input: { id: $id, fields: { foo: $foo } }) { + clientMutationId + } + } + `, + variables: { + id: superCar3Id, + }, + }) + ).toBeRejected(); + + await expectAsync( + apolloClient.query({ + query: gql` + mutation DeleteSuperCar($id: ID!) { + deleteSuperCar(input: { id: $id }) { + clientMutationId + } + } + `, + variables: { + id: superCar3Id, + }, + }) + ).toBeResolved(); + + await expectAsync( + apolloClient.query({ + query: gql` + mutation CreateCustomer($foo: String!) { + createCustomer(input: { fields: { foo: $foo } }) { + customer { + id + } + } + } + `, + variables: { + foo: 'rah', + }, + }) + ).toBeRejected(); + await expectAsync( + apolloClient.query({ + query: gql` + mutation UpdateCustomer($id: ID!, $foo: String!) { + updateCustomer(input: { id: $id, fields: { foo: $foo } }) { + clientMutationId + } + } + `, + variables: { + id: customer2Id, + foo: 'tah', + }, + }) + ).toBeResolved(); + await expectAsync( + apolloClient.query({ + query: gql` + mutation DeleteCustomer($id: ID!, $foo: String!) { + deleteCustomer(input: { id: $id }) { + clientMutationId + } + } + `, + variables: { + id: customer2Id, + }, + }) + ).toBeRejected(); + }); + + it_id('4af763b1-ff86-43c7-ba30-060a1c07e730')(it)('should only allow the supplied create and update fields for a class', async () => { + const schemaController = await parseServer.config.databaseController.loadSchema(); + await schemaController.addClassIfNotExists('SuperCar', { + engine: { type: 'String' }, + doors: { type: 'Number' }, + price: { type: 'String' }, + mileage: { type: 'Number' }, + }); + + await parseGraphQLServer.setGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + type: { + inputFields: { + create: ['engine', 'doors', 'price'], + update: ['price', 'mileage'], + }, + }, + }, + ], + }); + + await resetGraphQLCache(); + + await expectAsync( + apolloClient.query({ + query: gql` + mutation InvalidCreateSuperCar { + createSuperCar(input: { fields: { engine: "diesel", mileage: 1000 } }) { + superCar { + id + } + } + } + `, + }) + ).toBeRejected(); + const { id: superCarId } = ( + await apolloClient.query({ + query: gql` + mutation ValidCreateSuperCar { + createSuperCar( + input: { fields: { engine: "diesel", doors: 5, price: "ÂŖ10000" } } + ) { + superCar { + id + } + } + } + `, + }) + ).data.createSuperCar.superCar; + + expect(superCarId).toBeTruthy(); + + await expectAsync( + apolloClient.query({ + query: gql` + mutation InvalidUpdateSuperCar($id: ID!) { + updateSuperCar(input: { id: $id, fields: { engine: "petrol" } }) { + clientMutationId + } + } + `, + variables: { + id: superCarId, + }, + }) + ).toBeRejected(); + + const updatedSuperCar = ( + await apolloClient.query({ + query: gql` + mutation ValidUpdateSuperCar($id: ID!) { + updateSuperCar(input: { id: $id, fields: { mileage: 2000 } }) { + clientMutationId + } + } + `, + variables: { + id: superCarId, + }, + }) + ).data.updateSuperCar; + expect(updatedSuperCar).toBeTruthy(); + }); + + it_id('fc9237e9-3e63-4b55-9c1d-e6269f613a93')(it)('should handle required fields from the Parse class', async () => { + const schemaController = await parseServer.config.databaseController.loadSchema(); + await schemaController.addClassIfNotExists('SuperCar', { + engine: { type: 'String', required: true }, + doors: { type: 'Number', required: true }, + price: { type: 'String' }, + mileage: { type: 'Number' }, + }); + + await resetGraphQLCache(); + + const { + data: { __type }, + } = await apolloClient.query({ + query: gql` + query requiredFields { + __type(name: "CreateSuperCarFieldsInput") { + inputFields { + name + type { + kind + } + } + } + } + `, + }); + expect(__type.inputFields.find(o => o.name === 'price').type.kind).toEqual('SCALAR'); + expect(__type.inputFields.find(o => o.name === 'engine').type.kind).toEqual('NON_NULL'); + expect(__type.inputFields.find(o => o.name === 'doors').type.kind).toEqual('NON_NULL'); + + const { + data: { __type: __type2 }, + } = await apolloClient.query({ + query: gql` + query requiredFields { + __type(name: "SuperCar") { + fields { + name + type { + kind + } + } + } + } + `, + }); + expect(__type2.fields.find(o => o.name === 'price').type.kind).toEqual('SCALAR'); + expect(__type2.fields.find(o => o.name === 'engine').type.kind).toEqual('NON_NULL'); + expect(__type2.fields.find(o => o.name === 'doors').type.kind).toEqual('NON_NULL'); + }); + + it_id('83b6895a-7dfd-4e3b-a5ce-acdb1fa39705')(it)('should only allow the supplied output fields for a class', async () => { + const schemaController = await parseServer.config.databaseController.loadSchema(); + + await schemaController.addClassIfNotExists('SuperCar', { + engine: { type: 'String' }, + doors: { type: 'Number' }, + price: { type: 'String' }, + mileage: { type: 'Number' }, + insuranceClaims: { type: 'Number' }, + }); + + const superCar = await new Parse.Object('SuperCar').save({ + engine: 'petrol', + doors: 3, + price: 'ÂŖ7500', + mileage: 0, + insuranceCertificate: 'private-file.pdf', + }); + + await parseGraphQLServer.setGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + type: { + outputFields: ['engine', 'doors', 'price', 'mileage'], + }, + }, + ], + }); + + await resetGraphQLCache(); + + await expectAsync( + apolloClient.query({ + query: gql` + query GetSuperCar($id: ID!) { + superCar(id: $id) { + id + objectId + engine + doors + price + mileage + insuranceCertificate + } + } + `, + variables: { + id: superCar.id, + }, + }) + ).toBeRejected(); + let getSuperCar = ( + await apolloClient.query({ + query: gql` + query GetSuperCar($id: ID!) { + superCar(id: $id) { + id + objectId + engine + doors + price + mileage + } + } + `, + variables: { + id: superCar.id, + }, + }) + ).data.superCar; + expect(getSuperCar).toBeTruthy(); + + await parseGraphQLServer.setGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + type: { + outputFields: [], + }, + }, + ], + }); + + await resetGraphQLCache(); + await expectAsync( + apolloClient.query({ + query: gql` + query GetSuperCar($id: ID!) { + superCar(id: $id) { + engine + } + } + `, + variables: { + id: superCar.id, + }, + }) + ).toBeRejected(); + getSuperCar = ( + await apolloClient.query({ + query: gql` + query GetSuperCar($id: ID!) { + superCar(id: $id) { + id + objectId + } + } + `, + variables: { + id: superCar.id, + }, + }) + ).data.superCar; + expect(getSuperCar.objectId).toBe(superCar.id); + }); + + it_id('67dfcf94-92fb-45a3-a012-3b22c81899ba')(it)('should only allow the supplied constraint fields for a class', async () => { + try { + const schemaController = await parseServer.config.databaseController.loadSchema(); + + await schemaController.addClassIfNotExists('SuperCar', { + model: { type: 'String' }, + engine: { type: 'String' }, + doors: { type: 'Number' }, + price: { type: 'String' }, + mileage: { type: 'Number' }, + insuranceCertificate: { type: 'String' }, + }); + + await new Parse.Object('SuperCar').save({ + model: 'McLaren', + engine: 'petrol', + doors: 3, + price: 'ÂŖ7500', + mileage: 0, + insuranceCertificate: 'private-file.pdf', + }); + + await parseGraphQLServer.setGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + type: { + constraintFields: ['engine', 'doors', 'price'], + }, + }, + ], + }); + + await resetGraphQLCache(); + + await expectAsync( + apolloClient.query({ + query: gql` + query FindSuperCar { + superCars(where: { insuranceCertificate: { equalTo: "private-file.pdf" } }) { + count + } + } + `, + }) + ).toBeRejected(); + + await expectAsync( + apolloClient.query({ + query: gql` + query FindSuperCar { + superCars(where: { mileage: { equalTo: 0 } }) { + count + } + } + `, + }) + ).toBeRejected(); + + await expectAsync( + apolloClient.query({ + query: gql` + query FindSuperCar { + superCars(where: { engine: { equalTo: "petrol" } }) { + count + } + } + `, + }) + ).toBeResolved(); + } catch (e) { + handleError(e); + } + }); + + it_id('a3bdbd5d-8779-42fe-91a1-7a7f90a6177b')(it)('should only allow the supplied sort fields for a class', async () => { + const schemaController = await parseServer.config.databaseController.loadSchema(); + + await schemaController.addClassIfNotExists('SuperCar', { + engine: { type: 'String' }, + doors: { type: 'Number' }, + price: { type: 'String' }, + mileage: { type: 'Number' }, + }); + + await new Parse.Object('SuperCar').save({ + engine: 'petrol', + doors: 3, + price: 'ÂŖ7500', + mileage: 0, + }); + + await parseGraphQLServer.setGraphQLConfig({ + classConfigs: [ + { + className: 'SuperCar', + type: { + sortFields: [ + { + field: 'doors', + asc: true, + desc: true, + }, + { + field: 'price', + asc: true, + desc: true, + }, + { + field: 'mileage', + asc: true, + desc: false, + }, + ], + }, + }, + ], + }); + + await resetGraphQLCache(); + + await expectAsync( + apolloClient.query({ + query: gql` + query FindSuperCar { + superCars(order: [engine_ASC]) { + edges { + node { + id + } + } + } + } + `, + }) + ).toBeRejected(); + await expectAsync( + apolloClient.query({ + query: gql` + query FindSuperCar { + superCars(order: [engine_DESC]) { + edges { + node { + id + } + } + } + } + `, + }) + ).toBeRejected(); + await expectAsync( + apolloClient.query({ + query: gql` + query FindSuperCar { + superCars(order: [mileage_DESC]) { + edges { + node { + id + } + } + } + } + `, + }) + ).toBeRejected(); + + await expectAsync( + apolloClient.query({ + query: gql` + query FindSuperCar { + superCars(order: [mileage_ASC]) { + edges { + node { + id + } + } + } + } + `, + }) + ).toBeResolved(); + await expectAsync( + apolloClient.query({ + query: gql` + query FindSuperCar { + superCars(order: [doors_ASC]) { + edges { + node { + id + } + } + } + } + `, + }) + ).toBeResolved(); + await expectAsync( + apolloClient.query({ + query: gql` + query FindSuperCar { + superCars(order: [price_DESC]) { + edges { + node { + id + } + } + } + } + `, + }) + ).toBeResolved(); + await expectAsync( + apolloClient.query({ + query: gql` + query FindSuperCar { + superCars(order: [price_ASC, doors_DESC]) { + edges { + node { + id + } + } + } + } + `, + }) + ).toBeResolved(); + }); + }); + + describe('Relay Spec', () => { + beforeEach(async () => { + await resetGraphQLCache(); + }); + + describe('Object Identification', () => { + it('Class get custom method should return valid gobal id', async () => { + const obj = new Parse.Object('SomeClass'); + obj.set('someField', 'some value'); + await obj.save(); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeClass($objectId: ID!) { + someClass(id: $objectId) { + id + objectId + } + } + `, + variables: { + objectId: obj.id, + }, + }); + + expect(getResult.data.someClass.objectId).toBe(obj.id); + + const nodeResult = await apolloClient.query({ + query: gql` + query Node($id: ID!) { + node(id: $id) { + id + ... on SomeClass { + objectId + someField + } + } + } + `, + variables: { + id: getResult.data.someClass.id, + }, + }); + + expect(nodeResult.data.node.id).toBe(getResult.data.someClass.id); + expect(nodeResult.data.node.objectId).toBe(obj.id); + expect(nodeResult.data.node.someField).toBe('some value'); + }); + + it('Class find custom method should return valid gobal id', async () => { + const obj1 = new Parse.Object('SomeClass'); + obj1.set('someField', 'some value 1'); + await obj1.save(); + + const obj2 = new Parse.Object('SomeClass'); + obj2.set('someField', 'some value 2'); + await obj2.save(); + + const findResult = await apolloClient.query({ + query: gql` + query FindSomeClass { + someClasses(order: [createdAt_ASC]) { + edges { + node { + id + objectId + } + } + } + } + `, + }); + + expect(findResult.data.someClasses.edges[0].node.objectId).toBe(obj1.id); + expect(findResult.data.someClasses.edges[1].node.objectId).toBe(obj2.id); + + const nodeResult = await apolloClient.query({ + query: gql` + query Node($id1: ID!, $id2: ID!) { + node1: node(id: $id1) { + id + ... on SomeClass { + objectId + someField + } + } + node2: node(id: $id2) { + id + ... on SomeClass { + objectId + someField + } + } + } + `, + variables: { + id1: findResult.data.someClasses.edges[0].node.id, + id2: findResult.data.someClasses.edges[1].node.id, + }, + }); + + expect(nodeResult.data.node1.id).toBe(findResult.data.someClasses.edges[0].node.id); + expect(nodeResult.data.node1.objectId).toBe(obj1.id); + expect(nodeResult.data.node1.someField).toBe('some value 1'); + expect(nodeResult.data.node2.id).toBe(findResult.data.someClasses.edges[1].node.id); + expect(nodeResult.data.node2.objectId).toBe(obj2.id); + expect(nodeResult.data.node2.someField).toBe('some value 2'); + }); + it('Id inputs should work either with global id or object id', async () => { + try { + await apolloClient.mutate({ + mutation: gql` + mutation CreateClasses { + secondaryObject: createClass( + input: { + name: "SecondaryObject" + schemaFields: { addStrings: [{ name: "someField" }] } + } + ) { + clientMutationId + } + primaryObject: createClass( + input: { + name: "PrimaryObject" + schemaFields: { + addStrings: [{ name: "stringField" }] + addArrays: [{ name: "arrayField" }] + addPointers: [ + { name: "pointerField", targetClassName: "SecondaryObject" } + ] + addRelations: [ + { name: "relationField", targetClassName: "SecondaryObject" } + ] + } + } + ) { + clientMutationId + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await resetGraphQLCache(); + + const createSecondaryObjectsResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSecondaryObjects { + secondaryObject1: createSecondaryObject( + input: { fields: { someField: "some value 1" } } + ) { + secondaryObject { + id + objectId + someField + } + } + secondaryObject2: createSecondaryObject( + input: { fields: { someField: "some value 2" } } + ) { + secondaryObject { + id + someField + } + } + secondaryObject3: createSecondaryObject( + input: { fields: { someField: "some value 3" } } + ) { + secondaryObject { + objectId + someField + } + } + secondaryObject4: createSecondaryObject( + input: { fields: { someField: "some value 4" } } + ) { + secondaryObject { + id + objectId + } + } + secondaryObject5: createSecondaryObject( + input: { fields: { someField: "some value 5" } } + ) { + secondaryObject { + id + } + } + secondaryObject6: createSecondaryObject( + input: { fields: { someField: "some value 6" } } + ) { + secondaryObject { + objectId + } + } + secondaryObject7: createSecondaryObject( + input: { fields: { someField: "some value 7" } } + ) { + secondaryObject { + someField + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + const updateSecondaryObjectsResult = await apolloClient.mutate({ + mutation: gql` + mutation UpdateSecondaryObjects( + $id1: ID! + $id2: ID! + $id3: ID! + $id4: ID! + $id5: ID! + $id6: ID! + ) { + secondaryObject1: updateSecondaryObject( + input: { id: $id1, fields: { someField: "some value 11" } } + ) { + secondaryObject { + id + objectId + someField + } + } + secondaryObject2: updateSecondaryObject( + input: { id: $id2, fields: { someField: "some value 22" } } + ) { + secondaryObject { + id + someField + } + } + secondaryObject3: updateSecondaryObject( + input: { id: $id3, fields: { someField: "some value 33" } } + ) { + secondaryObject { + objectId + someField + } + } + secondaryObject4: updateSecondaryObject( + input: { id: $id4, fields: { someField: "some value 44" } } + ) { + secondaryObject { + id + objectId + } + } + secondaryObject5: updateSecondaryObject( + input: { id: $id5, fields: { someField: "some value 55" } } + ) { + secondaryObject { + id + } + } + secondaryObject6: updateSecondaryObject( + input: { id: $id6, fields: { someField: "some value 66" } } + ) { + secondaryObject { + objectId + } + } + } + `, + variables: { + id1: createSecondaryObjectsResult.data.secondaryObject1.secondaryObject.id, + id2: createSecondaryObjectsResult.data.secondaryObject2.secondaryObject.id, + id3: createSecondaryObjectsResult.data.secondaryObject3.secondaryObject.objectId, + id4: createSecondaryObjectsResult.data.secondaryObject4.secondaryObject.objectId, + id5: createSecondaryObjectsResult.data.secondaryObject5.secondaryObject.id, + id6: createSecondaryObjectsResult.data.secondaryObject6.secondaryObject.objectId, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + const deleteSecondaryObjectsResult = await apolloClient.mutate({ + mutation: gql` + mutation DeleteSecondaryObjects($id1: ID!, $id3: ID!, $id5: ID!, $id6: ID!) { + secondaryObject1: deleteSecondaryObject(input: { id: $id1 }) { + secondaryObject { + id + objectId + someField + } + } + secondaryObject3: deleteSecondaryObject(input: { id: $id3 }) { + secondaryObject { + objectId + someField + } + } + secondaryObject5: deleteSecondaryObject(input: { id: $id5 }) { + secondaryObject { + id + } + } + secondaryObject6: deleteSecondaryObject(input: { id: $id6 }) { + secondaryObject { + objectId + } + } + } + `, + variables: { + id1: updateSecondaryObjectsResult.data.secondaryObject1.secondaryObject.id, + id3: updateSecondaryObjectsResult.data.secondaryObject3.secondaryObject.objectId, + id5: updateSecondaryObjectsResult.data.secondaryObject5.secondaryObject.id, + id6: updateSecondaryObjectsResult.data.secondaryObject6.secondaryObject.objectId, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + const getSecondaryObjectsResult = await apolloClient.query({ + query: gql` + query GetSecondaryObjects($id2: ID!, $id4: ID!) { + secondaryObject2: secondaryObject(id: $id2) { + id + objectId + someField + } + secondaryObject4: secondaryObject(id: $id4) { + objectId + someField + } + } + `, + variables: { + id2: updateSecondaryObjectsResult.data.secondaryObject2.secondaryObject.id, + id4: updateSecondaryObjectsResult.data.secondaryObject4.secondaryObject.objectId, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + const findSecondaryObjectsResult = await apolloClient.query({ + query: gql` + query FindSecondaryObjects( + $id1: ID! + $id2: ID! + $id3: ID! + $id4: ID! + $id5: ID! + $id6: ID! + ) { + secondaryObjects( + where: { + AND: [ + { + OR: [ + { id: { equalTo: $id2 } } + { AND: [{ id: { equalTo: $id4 } }, { objectId: { equalTo: $id4 } }] } + ] + } + { id: { notEqualTo: $id1 } } + { id: { notEqualTo: $id3 } } + { objectId: { notEqualTo: $id2 } } + { objectId: { notIn: [$id5, $id6] } } + { id: { in: [$id2, $id4] } } + ] + } + order: [id_ASC, objectId_ASC] + ) { + edges { + node { + id + objectId + someField + } + } + count + } + } + `, + variables: { + id1: deleteSecondaryObjectsResult.data.secondaryObject1.secondaryObject.objectId, + id2: getSecondaryObjectsResult.data.secondaryObject2.id, + id3: deleteSecondaryObjectsResult.data.secondaryObject3.secondaryObject.objectId, + id4: getSecondaryObjectsResult.data.secondaryObject4.objectId, + id5: deleteSecondaryObjectsResult.data.secondaryObject5.secondaryObject.id, + id6: deleteSecondaryObjectsResult.data.secondaryObject6.secondaryObject.objectId, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(findSecondaryObjectsResult.data.secondaryObjects.count).toEqual(2); + expect( + findSecondaryObjectsResult.data.secondaryObjects.edges + .map(value => value.node.someField) + .sort() + ).toEqual(['some value 22', 'some value 44']); + // NOTE: Here @davimacedo tried to test RelayID order, but the test is wrong since + // "objectId1" < "objectId2" do not always keep the order when objectId is transformed + // to base64 by Relay + // "SecondaryObject:bBRgmzIRRM" < "SecondaryObject:nTMcuVbATY" true + // base64("SecondaryObject:bBRgmzIRRM"") < base64(""SecondaryObject:nTMcuVbATY"") false + // "U2Vjb25kYXJ5T2JqZWN0OmJCUmdteklSUk0=" < "U2Vjb25kYXJ5T2JqZWN0Om5UTWN1VmJBVFk=" false + const originalIds = [ + getSecondaryObjectsResult.data.secondaryObject2.objectId, + getSecondaryObjectsResult.data.secondaryObject4.objectId, + ]; + expect( + findSecondaryObjectsResult.data.secondaryObjects.edges[0].node.objectId + ).not.toBe(findSecondaryObjectsResult.data.secondaryObjects.edges[1].node.objectId); + expect( + originalIds.includes( + findSecondaryObjectsResult.data.secondaryObjects.edges[0].node.objectId + ) + ).toBeTrue(); + expect( + originalIds.includes( + findSecondaryObjectsResult.data.secondaryObjects.edges[1].node.objectId + ) + ).toBeTrue(); + + const createPrimaryObjectResult = await apolloClient.mutate({ + mutation: gql` + mutation CreatePrimaryObject( + $pointer: Any + $secondaryObject2: ID! + $secondaryObject4: ID! + ) { + createPrimaryObject( + input: { + fields: { + stringField: "some value" + arrayField: [1, "abc", $pointer] + pointerField: { link: $secondaryObject2 } + relationField: { add: [$secondaryObject2, $secondaryObject4] } + } + } + ) { + primaryObject { + id + stringField + arrayField { + ... on Element { + value + } + ... on SecondaryObject { + someField + } + } + pointerField { + id + objectId + someField + } + relationField { + edges { + node { + id + objectId + someField + } + } + } + } + } + } + `, + variables: { + pointer: { + __type: 'Pointer', + className: 'SecondaryObject', + objectId: getSecondaryObjectsResult.data.secondaryObject4.objectId, + }, + secondaryObject2: getSecondaryObjectsResult.data.secondaryObject2.id, + secondaryObject4: getSecondaryObjectsResult.data.secondaryObject4.objectId, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + const updatePrimaryObjectResult = await apolloClient.mutate({ + mutation: gql` + mutation UpdatePrimaryObject( + $id: ID! + $secondaryObject2: ID! + $secondaryObject4: ID! + ) { + updatePrimaryObject( + input: { + id: $id + fields: { + pointerField: { link: $secondaryObject4 } + relationField: { remove: [$secondaryObject2, $secondaryObject4] } + } + } + ) { + primaryObject { + id + stringField + arrayField { + ... on Element { + value + } + ... on SecondaryObject { + someField + } + } + pointerField { + id + objectId + someField + } + relationField { + edges { + node { + id + objectId + someField + } + } + } + } + } + } + `, + variables: { + id: createPrimaryObjectResult.data.createPrimaryObject.primaryObject.id, + secondaryObject2: getSecondaryObjectsResult.data.secondaryObject2.id, + secondaryObject4: getSecondaryObjectsResult.data.secondaryObject4.objectId, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect( + createPrimaryObjectResult.data.createPrimaryObject.primaryObject.stringField + ).toEqual('some value'); + expect( + createPrimaryObjectResult.data.createPrimaryObject.primaryObject.arrayField + ).toEqual([ + { __typename: 'Element', value: 1 }, + { __typename: 'Element', value: 'abc' }, + { __typename: 'SecondaryObject', someField: 'some value 44' }, + ]); + expect( + createPrimaryObjectResult.data.createPrimaryObject.primaryObject.pointerField + .someField + ).toEqual('some value 22'); + expect( + createPrimaryObjectResult.data.createPrimaryObject.primaryObject.relationField.edges + .map(value => value.node.someField) + .sort() + ).toEqual(['some value 22', 'some value 44']); + expect( + updatePrimaryObjectResult.data.updatePrimaryObject.primaryObject.stringField + ).toEqual('some value'); + expect( + updatePrimaryObjectResult.data.updatePrimaryObject.primaryObject.arrayField + ).toEqual([ + { __typename: 'Element', value: 1 }, + { __typename: 'Element', value: 'abc' }, + { __typename: 'SecondaryObject', someField: 'some value 44' }, + ]); + expect( + updatePrimaryObjectResult.data.updatePrimaryObject.primaryObject.pointerField + .someField + ).toEqual('some value 44'); + expect( + updatePrimaryObjectResult.data.updatePrimaryObject.primaryObject.relationField.edges + ).toEqual([]); + } catch (e) { + handleError(e); + } + }); + it('Id inputs should work either with global id or object id with objectId higher than 19', async () => { + const parseServer = await reconfigureServer({ objectIdSize: 20 }); + await createGQLFromParseServer(parseServer); + const obj = new Parse.Object('SomeClass'); + await obj.save({ name: 'aname', type: 'robot' }); + const result = await apolloClient.query({ + query: gql` + query getSomeClass($id: ID!) { + someClass(id: $id) { + objectId + id + } + } + `, + variables: { id: obj.id }, + }); + expect(result.data.someClass.objectId).toEqual(obj.id); + }); + }); + }); + + describe('Class Schema Mutations', () => { + it('should create a new class', async () => { + try { + const result = await apolloClient.mutate({ + mutation: gql` + mutation { + class1: createClass(input: { name: "Class1", clientMutationId: "cmid1" }) { + clientMutationId + class { + name + schemaFields { + name + __typename + } + } + } + class2: createClass( + input: { name: "Class2", schemaFields: null, clientMutationId: "cmid2" } + ) { + clientMutationId + class { + name + schemaFields { + name + __typename + } + } + } + class3: createClass( + input: { name: "Class3", schemaFields: {}, clientMutationId: "cmid3" } + ) { + clientMutationId + class { + name + schemaFields { + name + __typename + } + } + } + class4: createClass( + input: { + name: "Class4" + schemaFields: { + addStrings: null + addNumbers: null + addBooleans: null + addArrays: null + addObjects: null + addDates: null + addFiles: null + addGeoPoint: null + addPolygons: null + addBytes: null + addPointers: null + addRelations: null + } + clientMutationId: "cmid4" + } + ) { + clientMutationId + class { + name + schemaFields { + name + __typename + } + } + } + class5: createClass( + input: { + name: "Class5" + schemaFields: { + addStrings: [] + addNumbers: [] + addBooleans: [] + addArrays: [] + addObjects: [] + addDates: [] + addFiles: [] + addPolygons: [] + addBytes: [] + addPointers: [] + addRelations: [] + } + clientMutationId: "cmid5" + } + ) { + clientMutationId + class { + name + schemaFields { + name + __typename + } + } + } + class6: createClass( + input: { + name: "Class6" + schemaFields: { + addStrings: [ + { name: "stringField1" } + { name: "stringField2" } + { name: "stringField3" } + ] + addNumbers: [ + { name: "numberField1" } + { name: "numberField2" } + { name: "numberField3" } + ] + addBooleans: [ + { name: "booleanField1" } + { name: "booleanField2" } + { name: "booleanField3" } + ] + addArrays: [ + { name: "arrayField1" } + { name: "arrayField2" } + { name: "arrayField3" } + ] + addObjects: [ + { name: "objectField1" } + { name: "objectField2" } + { name: "objectField3" } + ] + addDates: [ + { name: "dateField1" } + { name: "dateField2" } + { name: "dateField3" } + ] + addFiles: [ + { name: "fileField1" } + { name: "fileField2" } + { name: "fileField3" } + ] + addGeoPoint: { name: "geoPointField" } + addPolygons: [ + { name: "polygonField1" } + { name: "polygonField2" } + { name: "polygonField3" } + ] + addBytes: [ + { name: "bytesField1" } + { name: "bytesField2" } + { name: "bytesField3" } + ] + addPointers: [ + { name: "pointerField1", targetClassName: "Class1" } + { name: "pointerField2", targetClassName: "Class6" } + { name: "pointerField3", targetClassName: "Class2" } + ] + addRelations: [ + { name: "relationField1", targetClassName: "Class1" } + { name: "relationField2", targetClassName: "Class6" } + { name: "relationField3", targetClassName: "Class2" } + ] + remove: [ + { name: "stringField3" } + { name: "numberField3" } + { name: "booleanField3" } + { name: "arrayField3" } + { name: "objectField3" } + { name: "dateField3" } + { name: "fileField3" } + { name: "polygonField3" } + { name: "bytesField3" } + { name: "pointerField3" } + { name: "relationField3" } + { name: "doesNotExist" } + ] + } + clientMutationId: "cmid6" + } + ) { + clientMutationId + class { + name + schemaFields { + name + __typename + ... on SchemaPointerField { + targetClassName + } + ... on SchemaRelationField { + targetClassName + } + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + const classes = Object.keys(result.data).map(fieldName => ({ + clientMutationId: result.data[fieldName].clientMutationId, + class: { + name: result.data[fieldName].class.name, + schemaFields: result.data[fieldName].class.schemaFields.sort((a, b) => + a.name > b.name ? 1 : -1 + ), + __typename: result.data[fieldName].class.__typename, + }, + __typename: result.data[fieldName].__typename, + })); + expect(classes).toEqual([ + { + clientMutationId: 'cmid1', + class: { + name: 'Class1', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + __typename: 'CreateClassPayload', + }, + { + clientMutationId: 'cmid2', + class: { + name: 'Class2', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + __typename: 'CreateClassPayload', + }, + { + clientMutationId: 'cmid3', + class: { + name: 'Class3', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + __typename: 'CreateClassPayload', + }, + { + clientMutationId: 'cmid4', + class: { + name: 'Class4', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + __typename: 'CreateClassPayload', + }, + { + clientMutationId: 'cmid5', + class: { + name: 'Class5', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + __typename: 'CreateClassPayload', + }, + { + clientMutationId: 'cmid6', + class: { + name: 'Class6', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'arrayField1', __typename: 'SchemaArrayField' }, + { name: 'arrayField2', __typename: 'SchemaArrayField' }, + { name: 'booleanField1', __typename: 'SchemaBooleanField' }, + { name: 'booleanField2', __typename: 'SchemaBooleanField' }, + { name: 'bytesField1', __typename: 'SchemaBytesField' }, + { name: 'bytesField2', __typename: 'SchemaBytesField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'dateField1', __typename: 'SchemaDateField' }, + { name: 'dateField2', __typename: 'SchemaDateField' }, + { name: 'fileField1', __typename: 'SchemaFileField' }, + { name: 'fileField2', __typename: 'SchemaFileField' }, + { + name: 'geoPointField', + __typename: 'SchemaGeoPointField', + }, + { name: 'numberField1', __typename: 'SchemaNumberField' }, + { name: 'numberField2', __typename: 'SchemaNumberField' }, + { name: 'objectField1', __typename: 'SchemaObjectField' }, + { name: 'objectField2', __typename: 'SchemaObjectField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { + name: 'pointerField1', + __typename: 'SchemaPointerField', + targetClassName: 'Class1', + }, + { + name: 'pointerField2', + __typename: 'SchemaPointerField', + targetClassName: 'Class6', + }, + { name: 'polygonField1', __typename: 'SchemaPolygonField' }, + { name: 'polygonField2', __typename: 'SchemaPolygonField' }, + { + name: 'relationField1', + __typename: 'SchemaRelationField', + targetClassName: 'Class1', + }, + { + name: 'relationField2', + __typename: 'SchemaRelationField', + targetClassName: 'Class6', + }, + { name: 'stringField1', __typename: 'SchemaStringField' }, + { name: 'stringField2', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + __typename: 'CreateClassPayload', + }, + ]); + + const findResult = await apolloClient.query({ + query: gql` + query { + classes { + name + schemaFields { + name + __typename + ... on SchemaPointerField { + targetClassName + } + ... on SchemaRelationField { + targetClassName + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + findResult.data.classes = findResult.data.classes + .filter(schemaClass => !schemaClass.name.startsWith('_')) + .sort((a, b) => (a.name > b.name ? 1 : -1)); + findResult.data.classes.forEach(schemaClass => { + schemaClass.schemaFields = schemaClass.schemaFields.sort((a, b) => + a.name > b.name ? 1 : -1 + ); + }); + expect(findResult.data.classes).toEqual([ + { + name: 'Class1', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + { + name: 'Class2', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + { + name: 'Class3', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + { + name: 'Class4', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + { + name: 'Class5', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + { + name: 'Class6', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'arrayField1', __typename: 'SchemaArrayField' }, + { name: 'arrayField2', __typename: 'SchemaArrayField' }, + { name: 'booleanField1', __typename: 'SchemaBooleanField' }, + { name: 'booleanField2', __typename: 'SchemaBooleanField' }, + { name: 'bytesField1', __typename: 'SchemaBytesField' }, + { name: 'bytesField2', __typename: 'SchemaBytesField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'dateField1', __typename: 'SchemaDateField' }, + { name: 'dateField2', __typename: 'SchemaDateField' }, + { name: 'fileField1', __typename: 'SchemaFileField' }, + { name: 'fileField2', __typename: 'SchemaFileField' }, + { + name: 'geoPointField', + __typename: 'SchemaGeoPointField', + }, + { name: 'numberField1', __typename: 'SchemaNumberField' }, + { name: 'numberField2', __typename: 'SchemaNumberField' }, + { name: 'objectField1', __typename: 'SchemaObjectField' }, + { name: 'objectField2', __typename: 'SchemaObjectField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { + name: 'pointerField1', + __typename: 'SchemaPointerField', + targetClassName: 'Class1', + }, + { + name: 'pointerField2', + __typename: 'SchemaPointerField', + targetClassName: 'Class6', + }, + { name: 'polygonField1', __typename: 'SchemaPolygonField' }, + { name: 'polygonField2', __typename: 'SchemaPolygonField' }, + { + name: 'relationField1', + __typename: 'SchemaRelationField', + targetClassName: 'Class1', + }, + { + name: 'relationField2', + __typename: 'SchemaRelationField', + targetClassName: 'Class6', + }, + { name: 'stringField1', __typename: 'SchemaStringField' }, + { name: 'stringField2', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + ]); + } catch (e) { + handleError(e); + } + }); + + it('should require master key to create a new class', async () => { + loggerErrorSpy.calls.reset(); + try { + await apolloClient.mutate({ + mutation: gql` + mutation { + createClass(input: { name: "SomeClass" }) { + clientMutationId + } + } + `, + }); + fail('should fail'); + } catch (e) { + expect(e.graphQLErrors[0].extensions.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); + expect(e.graphQLErrors[0].message).toEqual('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('unauthorized: master key is required')); + } + }); + + it('should not allow duplicated field names when creating', async () => { + try { + await apolloClient.mutate({ + mutation: gql` + mutation { + createClass( + input: { + name: "SomeClass" + schemaFields: { + addStrings: [{ name: "someField" }] + addNumbers: [{ name: "someField" }] + } + } + ) { + clientMutationId + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + fail('should fail'); + } catch (e) { + expect(e.graphQLErrors[0].extensions.code).toEqual(Parse.Error.INVALID_KEY_NAME); + expect(e.graphQLErrors[0].message).toEqual('Duplicated field name: someField'); + } + }); + + it('should update an existing class', async () => { + try { + const clientMutationId = uuidv4(); + const result = await apolloClient.mutate({ + mutation: gql` + mutation { + createClass( + input: { + name: "MyNewClass" + schemaFields: { addStrings: [{ name: "willBeRemoved" }] } + } + ) { + class { + name + schemaFields { + name + __typename + } + } + } + updateClass(input: { + clientMutationId: "${clientMutationId}" + name: "MyNewClass" + schemaFields: { + addStrings: [ + { name: "stringField1" } + { name: "stringField2" } + { name: "stringField3" } + ] + addNumbers: [ + { name: "numberField1" } + { name: "numberField2" } + { name: "numberField3" } + ] + addBooleans: [ + { name: "booleanField1" } + { name: "booleanField2" } + { name: "booleanField3" } + ] + addArrays: [ + { name: "arrayField1" } + { name: "arrayField2" } + { name: "arrayField3" } + ] + addObjects: [ + { name: "objectField1" } + { name: "objectField2" } + { name: "objectField3" } + ] + addDates: [ + { name: "dateField1" } + { name: "dateField2" } + { name: "dateField3" } + ] + addFiles: [ + { name: "fileField1" } + { name: "fileField2" } + { name: "fileField3" } + ] + addGeoPoint: { name: "geoPointField" } + addPolygons: [ + { name: "polygonField1" } + { name: "polygonField2" } + { name: "polygonField3" } + ] + addBytes: [ + { name: "bytesField1" } + { name: "bytesField2" } + { name: "bytesField3" } + ] + addPointers: [ + { name: "pointerField1", targetClassName: "Class1" } + { name: "pointerField2", targetClassName: "Class6" } + { name: "pointerField3", targetClassName: "Class2" } + ] + addRelations: [ + { name: "relationField1", targetClassName: "Class1" } + { name: "relationField2", targetClassName: "Class6" } + { name: "relationField3", targetClassName: "Class2" } + ] + remove: [ + { name: "willBeRemoved" } + { name: "stringField3" } + { name: "numberField3" } + { name: "booleanField3" } + { name: "arrayField3" } + { name: "objectField3" } + { name: "dateField3" } + { name: "fileField3" } + { name: "polygonField3" } + { name: "bytesField3" } + { name: "pointerField3" } + { name: "relationField3" } + { name: "doesNotExist" } + ] + } + }) { + clientMutationId + class { + name + schemaFields { + name + __typename + ... on SchemaPointerField { + targetClassName + } + ... on SchemaRelationField { + targetClassName + } + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + result.data.createClass.class.schemaFields = result.data.createClass.class.schemaFields.sort( + (a, b) => (a.name > b.name ? 1 : -1) + ); + result.data.updateClass.class.schemaFields = result.data.updateClass.class.schemaFields.sort( + (a, b) => (a.name > b.name ? 1 : -1) + ); + expect(result).toEqual({ + data: { + createClass: { + class: { + name: 'MyNewClass', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + { + name: 'willBeRemoved', + __typename: 'SchemaStringField', + }, + ], + __typename: 'Class', + }, + __typename: 'CreateClassPayload', + }, + updateClass: { + clientMutationId, + class: { + name: 'MyNewClass', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'arrayField1', __typename: 'SchemaArrayField' }, + { name: 'arrayField2', __typename: 'SchemaArrayField' }, + { + name: 'booleanField1', + __typename: 'SchemaBooleanField', + }, + { + name: 'booleanField2', + __typename: 'SchemaBooleanField', + }, + { name: 'bytesField1', __typename: 'SchemaBytesField' }, + { name: 'bytesField2', __typename: 'SchemaBytesField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'dateField1', __typename: 'SchemaDateField' }, + { name: 'dateField2', __typename: 'SchemaDateField' }, + { name: 'fileField1', __typename: 'SchemaFileField' }, + { name: 'fileField2', __typename: 'SchemaFileField' }, + { + name: 'geoPointField', + __typename: 'SchemaGeoPointField', + }, + { name: 'numberField1', __typename: 'SchemaNumberField' }, + { name: 'numberField2', __typename: 'SchemaNumberField' }, + { name: 'objectField1', __typename: 'SchemaObjectField' }, + { name: 'objectField2', __typename: 'SchemaObjectField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { + name: 'pointerField1', + __typename: 'SchemaPointerField', + targetClassName: 'Class1', + }, + { + name: 'pointerField2', + __typename: 'SchemaPointerField', + targetClassName: 'Class6', + }, + { + name: 'polygonField1', + __typename: 'SchemaPolygonField', + }, + { + name: 'polygonField2', + __typename: 'SchemaPolygonField', + }, + { + name: 'relationField1', + __typename: 'SchemaRelationField', + targetClassName: 'Class1', + }, + { + name: 'relationField2', + __typename: 'SchemaRelationField', + targetClassName: 'Class6', + }, + { name: 'stringField1', __typename: 'SchemaStringField' }, + { name: 'stringField2', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + __typename: 'UpdateClassPayload', + }, + }, + }); + + const getResult = await apolloClient.query({ + query: gql` + query { + class(name: "MyNewClass") { + name + schemaFields { + name + __typename + ... on SchemaPointerField { + targetClassName + } + ... on SchemaRelationField { + targetClassName + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + getResult.data.class.schemaFields = getResult.data.class.schemaFields.sort((a, b) => + a.name > b.name ? 1 : -1 + ); + expect(getResult.data).toEqual({ + class: { + name: 'MyNewClass', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'arrayField1', __typename: 'SchemaArrayField' }, + { name: 'arrayField2', __typename: 'SchemaArrayField' }, + { name: 'booleanField1', __typename: 'SchemaBooleanField' }, + { name: 'booleanField2', __typename: 'SchemaBooleanField' }, + { name: 'bytesField1', __typename: 'SchemaBytesField' }, + { name: 'bytesField2', __typename: 'SchemaBytesField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'dateField1', __typename: 'SchemaDateField' }, + { name: 'dateField2', __typename: 'SchemaDateField' }, + { name: 'fileField1', __typename: 'SchemaFileField' }, + { name: 'fileField2', __typename: 'SchemaFileField' }, + { + name: 'geoPointField', + __typename: 'SchemaGeoPointField', + }, + { name: 'numberField1', __typename: 'SchemaNumberField' }, + { name: 'numberField2', __typename: 'SchemaNumberField' }, + { name: 'objectField1', __typename: 'SchemaObjectField' }, + { name: 'objectField2', __typename: 'SchemaObjectField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { + name: 'pointerField1', + __typename: 'SchemaPointerField', + targetClassName: 'Class1', + }, + { + name: 'pointerField2', + __typename: 'SchemaPointerField', + targetClassName: 'Class6', + }, + { name: 'polygonField1', __typename: 'SchemaPolygonField' }, + { name: 'polygonField2', __typename: 'SchemaPolygonField' }, + { + name: 'relationField1', + __typename: 'SchemaRelationField', + targetClassName: 'Class1', + }, + { + name: 'relationField2', + __typename: 'SchemaRelationField', + targetClassName: 'Class6', + }, + { name: 'stringField1', __typename: 'SchemaStringField' }, + { name: 'stringField2', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + ], + __typename: 'Class', + }, + }); + } catch (e) { + handleError(e); + } + }); + + it('should require master key to update an existing class', async () => { + try { + await apolloClient.mutate({ + mutation: gql` + mutation { + createClass(input: { name: "SomeClass" }) { + clientMutationId + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + } catch (e) { + handleError(e); + } + + loggerErrorSpy.calls.reset(); + try { + await apolloClient.mutate({ + mutation: gql` + mutation { + updateClass(input: { name: "SomeClass" }) { + clientMutationId + } + } + `, + }); + fail('should fail'); + } catch (e) { + expect(e.graphQLErrors[0].extensions.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); + expect(e.graphQLErrors[0].message).toEqual('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('unauthorized: master key is required')); + } + }); + + it('should not allow duplicated field names when updating', async () => { + try { + await apolloClient.mutate({ + mutation: gql` + mutation { + createClass( + input: { + name: "SomeClass" + schemaFields: { addStrings: [{ name: "someField" }] } + } + ) { + clientMutationId + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + } catch (e) { + handleError(e); + } + + try { + await apolloClient.mutate({ + mutation: gql` + mutation { + updateClass( + input: { + name: "SomeClass" + schemaFields: { addNumbers: [{ name: "someField" }] } + } + ) { + clientMutationId + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + fail('should fail'); + } catch (e) { + expect(e.graphQLErrors[0].extensions.code).toEqual(Parse.Error.INVALID_KEY_NAME); + expect(e.graphQLErrors[0].message).toEqual('Duplicated field name: someField'); + } + }); + + it('should fail if updating an inexistent class', async () => { + try { + await apolloClient.mutate({ + mutation: gql` + mutation { + updateClass( + input: { + name: "SomeInexistentClass" + schemaFields: { addNumbers: [{ name: "someField" }] } + } + ) { + clientMutationId + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + fail('should fail'); + } catch (e) { + expect(e.graphQLErrors[0].extensions.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(e.graphQLErrors[0].message).toEqual('Class SomeInexistentClass does not exist.'); + } + }); + + it('should delete an existing class', async () => { + try { + const clientMutationId = uuidv4(); + const result = await apolloClient.mutate({ + mutation: gql` + mutation { + createClass( + input: { + name: "MyNewClass" + schemaFields: { addStrings: [{ name: "willBeRemoved" }] } + } + ) { + class { + name + schemaFields { + name + __typename + } + } + } + deleteClass(input: { clientMutationId: "${clientMutationId}" name: "MyNewClass" }) { + clientMutationId + class { + name + schemaFields { + name + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + result.data.createClass.class.schemaFields = result.data.createClass.class.schemaFields.sort( + (a, b) => (a.name > b.name ? 1 : -1) + ); + result.data.deleteClass.class.schemaFields = result.data.deleteClass.class.schemaFields.sort( + (a, b) => (a.name > b.name ? 1 : -1) + ); + expect(result).toEqual({ + data: { + createClass: { + class: { + name: 'MyNewClass', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + { + name: 'willBeRemoved', + __typename: 'SchemaStringField', + }, + ], + __typename: 'Class', + }, + __typename: 'CreateClassPayload', + }, + deleteClass: { + clientMutationId, + class: { + name: 'MyNewClass', + schemaFields: [ + { name: 'ACL', __typename: 'SchemaACLField' }, + { name: 'createdAt', __typename: 'SchemaDateField' }, + { name: 'objectId', __typename: 'SchemaStringField' }, + { name: 'updatedAt', __typename: 'SchemaDateField' }, + { + name: 'willBeRemoved', + __typename: 'SchemaStringField', + }, + ], + __typename: 'Class', + }, + __typename: 'DeleteClassPayload', + }, + }, + }); + + try { + await apolloClient.query({ + query: gql` + query { + class(name: "MyNewClass") { + name + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + fail('should fail'); + } catch (e) { + expect(e.graphQLErrors[0].extensions.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(e.graphQLErrors[0].message).toEqual('Class MyNewClass does not exist.'); + } + } catch (e) { + handleError(e); + } + }); + + it('should require master key to delete an existing class', async () => { + try { + await apolloClient.mutate({ + mutation: gql` + mutation { + createClass(input: { name: "SomeClass" }) { + clientMutationId + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + } catch (e) { + handleError(e); + } + + loggerErrorSpy.calls.reset(); + try { + await apolloClient.mutate({ + mutation: gql` + mutation { + deleteClass(input: { name: "SomeClass" }) { + clientMutationId + } + } + `, + }); + fail('should fail'); + } catch (e) { + expect(e.graphQLErrors[0].extensions.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); + expect(e.graphQLErrors[0].message).toEqual('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('unauthorized: master key is required')); + } + }); + + it('should fail if deleting an inexistent class', async () => { + try { + await apolloClient.mutate({ + mutation: gql` + mutation { + deleteClass(input: { name: "SomeInexistentClass" }) { + clientMutationId + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + fail('should fail'); + } catch (e) { + expect(e.graphQLErrors[0].extensions.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(e.graphQLErrors[0].message).toEqual('Class SomeInexistentClass does not exist.'); + } + }); + + it('should require master key to get an existing class', async () => { + loggerErrorSpy.calls.reset(); + try { + await apolloClient.query({ + query: gql` + query { + class(name: "_User") { + name + } + } + `, + }); + fail('should fail'); + } catch (e) { + expect(e.graphQLErrors[0].extensions.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); + expect(e.graphQLErrors[0].message).toEqual('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('unauthorized: master key is required')); + } + }); + + it('should require master key to find the existing classes', async () => { + loggerErrorSpy.calls.reset(); + try { + await apolloClient.query({ + query: gql` + query { + classes { + name + } + } + `, + }); + fail('should fail'); + } catch (e) { + expect(e.graphQLErrors[0].extensions.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); + expect(e.graphQLErrors[0].message).toEqual('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('unauthorized: master key is required')); + } + }); + }); + + describe('Objects Queries', () => { + describe('Get', () => { + it('should return a class object using class specific query', async () => { + const obj = new Parse.Object('Customer'); + obj.set('someField', 'someValue'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const result = ( + await apolloClient.query({ + query: gql` + query GetCustomer($id: ID!) { + customer(id: $id) { + id + objectId + someField + createdAt + updatedAt + } + } + `, + variables: { + id: obj.id, + }, + }) + ).data.customer; + + expect(result.objectId).toEqual(obj.id); + expect(result.someField).toEqual('someValue'); + expect(new Date(result.createdAt)).toEqual(obj.createdAt); + expect(new Date(result.updatedAt)).toEqual(obj.updatedAt); + }); + + it_only_db('mongo')('should return child objects in array fields', async () => { + const obj1 = new Parse.Object('Customer'); + const obj2 = new Parse.Object('SomeClass'); + const obj3 = new Parse.Object('Customer'); + + obj1.set('someCustomerField', 'imCustomerOne'); + const arrayField = [42.42, 42, 'string', true]; + obj1.set('arrayField', arrayField); + await obj1.save(); + + obj2.set('someClassField', 'imSomeClassTwo'); + await obj2.save(); + + obj3.set('manyRelations', [obj1, obj2]); + await obj3.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const result = ( + await apolloClient.query({ + query: gql` + query GetCustomer($id: ID!) { + customer(id: $id) { + objectId + manyRelations { + ... on Customer { + objectId + someCustomerField + arrayField { + ... on Element { + value + } + } + } + ... on SomeClass { + objectId + someClassField + } + } + createdAt + updatedAt + } + } + `, + variables: { + id: obj3.id, + }, + }) + ).data.customer; + + expect(result.objectId).toEqual(obj3.id); + expect(result.manyRelations.length).toEqual(2); + + const customerSubObject = result.manyRelations.find(o => o.objectId === obj1.id); + const someClassSubObject = result.manyRelations.find(o => o.objectId === obj2.id); + + expect(customerSubObject).toBeDefined(); + expect(someClassSubObject).toBeDefined(); + expect(customerSubObject.someCustomerField).toEqual('imCustomerOne'); + const formatedArrayField = customerSubObject.arrayField.map(elem => elem.value); + expect(formatedArrayField).toEqual(arrayField); + expect(someClassSubObject.someClassField).toEqual('imSomeClassTwo'); + }); + + it('should return many child objects in allow cyclic query', async () => { + const obj1 = new Parse.Object('Employee'); + const obj2 = new Parse.Object('Team'); + const obj3 = new Parse.Object('Company'); + const obj4 = new Parse.Object('Country'); + + obj1.set('name', 'imAnEmployee'); + await obj1.save(); + + obj2.set('name', 'imATeam'); + obj2.set('employees', [obj1]); + await obj2.save(); + + obj3.set('name', 'imACompany'); + obj3.set('teams', [obj2]); + obj3.set('employees', [obj1]); + await obj3.save(); + + obj4.set('name', 'imACountry'); + obj4.set('companies', [obj3]); + await obj4.save(); + + obj1.set('country', obj4); + await obj1.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const result = ( + await apolloClient.query({ + query: gql` + query DeepComplexGraphQLQuery($id: ID!) { + country(id: $id) { + objectId + name + companies { + ... on Company { + objectId + name + employees { + ... on Employee { + objectId + name + } + } + teams { + ... on Team { + objectId + name + employees { + ... on Employee { + objectId + name + country { + objectId + name + } + } + } + } + } + } + } + } + } + `, + variables: { + id: obj4.id, + }, + }) + ).data.country; + + const expectedResult = { + objectId: obj4.id, + name: 'imACountry', + __typename: 'Country', + companies: [ + { + objectId: obj3.id, + name: 'imACompany', + __typename: 'Company', + employees: [ + { + objectId: obj1.id, + name: 'imAnEmployee', + __typename: 'Employee', + }, + ], + teams: [ + { + objectId: obj2.id, + name: 'imATeam', + __typename: 'Team', + employees: [ + { + objectId: obj1.id, + name: 'imAnEmployee', + __typename: 'Employee', + country: { + objectId: obj4.id, + name: 'imACountry', + __typename: 'Country', + }, + }, + ], + }, + ], + }, + ], + }; + expect(result).toEqual(expectedResult); + }); + + it('should respect level permissions', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + async function getObject(className, id, headers) { + const alias = className.charAt(0).toLowerCase() + className.slice(1); + const specificQueryResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + get: ${alias}(id: $id) { + id + createdAt + someField + } + } + `, + variables: { + id, + }, + context: { + headers, + }, + }); + + return specificQueryResult; + } + + await Promise.all( + objects + .slice(0, 3) + .map(obj => + expectAsync(getObject(obj.className, obj.id)).toBeRejectedWith( + jasmine.stringMatching('Object not found') + ) + ) + ); + expect((await getObject(object4.className, object4.id)).data.get.someField).toEqual( + 'someValue4' + ); + await Promise.all( + objects.map(async obj => + expect( + ( + await getObject(obj.className, obj.id, { + 'X-Parse-Master-Key': 'test', + }) + ).data.get.someField + ).toEqual(obj.get('someField')) + ) + ); + await Promise.all( + objects.map(async obj => + expect( + ( + await getObject(obj.className, obj.id, { + 'X-Parse-Session-Token': user1.getSessionToken(), + }) + ).data.get.someField + ).toEqual(obj.get('someField')) + ) + ); + await Promise.all( + objects.map(async obj => + expect( + ( + await getObject(obj.className, obj.id, { + 'X-Parse-Session-Token': user2.getSessionToken(), + }) + ).data.get.someField + ).toEqual(obj.get('someField')) + ) + ); + await expectAsync( + getObject(object2.className, object2.id, { + 'X-Parse-Session-Token': user3.getSessionToken(), + }) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await Promise.all( + [object1, object3, object4].map(async obj => + expect( + ( + await getObject(obj.className, obj.id, { + 'X-Parse-Session-Token': user3.getSessionToken(), + }) + ).data.get.someField + ).toEqual(obj.get('someField')) + ) + ); + await Promise.all( + objects.slice(0, 3).map(obj => + expectAsync( + getObject(obj.className, obj.id, { + 'X-Parse-Session-Token': user4.getSessionToken(), + }) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')) + ) + ); + expect( + ( + await getObject(object4.className, object4.id, { + 'X-Parse-Session-Token': user4.getSessionToken(), + }) + ).data.get.someField + ).toEqual('someValue4'); + await Promise.all( + objects.slice(0, 2).map(obj => + expectAsync( + getObject(obj.className, obj.id, { + 'X-Parse-Session-Token': user5.getSessionToken(), + }) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')) + ) + ); + expect( + ( + await getObject(object3.className, object3.id, { + 'X-Parse-Session-Token': user5.getSessionToken(), + }) + ).data.get.someField + ).toEqual('someValue3'); + expect( + ( + await getObject(object4.className, object4.id, { + 'X-Parse-Session-Token': user5.getSessionToken(), + }) + ).data.get.someField + ).toEqual('someValue4'); + }); + + it('should support keys argument', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const result1 = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + get: graphQLClass(id: $id) { + someField + } + } + `, + variables: { + id: object3.id, + }, + context: { + headers: { + 'X-Parse-Session-Token': user1.getSessionToken(), + }, + }, + }); + + const result2 = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + get: graphQLClass(id: $id) { + someField + pointerToUser { + id + } + } + } + `, + variables: { + id: object3.id, + }, + context: { + headers: { + 'X-Parse-Session-Token': user1.getSessionToken(), + }, + }, + }); + + expect(result1.data.get.someField).toBeDefined(); + expect(result1.data.get.pointerToUser).toBeUndefined(); + expect(result2.data.get.someField).toBeDefined(); + expect(result2.data.get.pointerToUser).toBeDefined(); + }); + + it('should support include argument', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const result1 = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + get: graphQLClass(id: $id) { + pointerToUser { + id + } + } + } + `, + variables: { + id: object3.id, + }, + context: { + headers: { + 'X-Parse-Session-Token': user1.getSessionToken(), + }, + }, + }); + + const result2 = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + graphQLClass(id: $id) { + pointerToUser { + username + } + } + } + `, + variables: { + id: object3.id, + }, + context: { + headers: { + 'X-Parse-Session-Token': user1.getSessionToken(), + }, + }, + }); + + expect(result1.data.get.pointerToUser.username).toBeUndefined(); + expect(result2.data.graphQLClass.pointerToUser.username).toBeDefined(); + }); + + it('should respect protectedFields', async done => { + await prepareData(); + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const className = 'GraphQLClass'; + + await updateCLP( + { + get: { '*': true }, + find: { '*': true }, + + protectedFields: { + '*': ['someField', 'someOtherField'], + authenticated: ['someField'], + 'userField:pointerToUser': [], + [user2.id]: [], + }, + }, + className + ); + + const getObject = async (className, id, user) => { + const headers = user + ? { ['X-Parse-Session-Token']: user.getSessionToken() } + : undefined; + + const specificQueryResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + get: graphQLClass(id: $id) { + pointerToUser { + username + id + } + someField + someOtherField + } + } + `, + variables: { + id: id, + }, + context: { + headers: headers, + }, + }); + + return specificQueryResult.data.get; + }; + + const id = object3.id; + + /* not authenticated */ + const objectPublic = await getObject(className, id, undefined); + + expect(objectPublic.someField).toBeNull(); + expect(objectPublic.someOtherField).toBeNull(); + + /* authenticated */ + const objectAuth = await getObject(className, id, user1); + + expect(objectAuth.someField).toBeNull(); + expect(objectAuth.someOtherField).toBe('B'); + + /* pointer field */ + const objectPointed = await getObject(className, id, user5); + + expect(objectPointed.someField).toBe('someValue3'); + expect(objectPointed.someOtherField).toBe('B'); + + /* for user id */ + const objectForUser = await getObject(className, id, user2); + + expect(objectForUser.someField).toBe('someValue3'); + expect(objectForUser.someOtherField).toBe('B'); + + done(); + }); + describe_only_db('mongo')('read preferences', () => { + it('should read from primary by default', async () => { + try { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + spyOn(Collection.prototype, 'find').and.callThrough(); + + await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + graphQLClass(id: $id) { + pointerToUser { + username + } + } + } + `, + variables: { + id: object3.id, + }, + context: { + headers: { + 'X-Parse-Session-Token': user1.getSessionToken(), + }, + }, + }); + + let foundGraphQLClassReadPreference = false; + let foundUserClassReadPreference = false; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('GraphQLClass') >= 0) { + foundGraphQLClassReadPreference = true; + expect(call.object.s.readPreference.mode).toBe(ReadPreference.PRIMARY); + } else if (call.object.s.namespace.collection.indexOf('_User') >= 0) { + foundUserClassReadPreference = true; + expect(call.object.s.readPreference.mode).toBe(ReadPreference.PRIMARY); + } + }); + + expect(foundGraphQLClassReadPreference).toBe(true); + expect(foundUserClassReadPreference).toBe(true); + } catch (e) { + handleError(e); + } + }); + + it('should support readPreference argument', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + spyOn(Collection.prototype, 'find').and.callThrough(); + + await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + graphQLClass(id: $id, options: { readPreference: SECONDARY }) { + pointerToUser { + username + } + } + } + `, + variables: { + id: object3.id, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + let foundGraphQLClassReadPreference = false; + let foundUserClassReadPreference = false; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('GraphQLClass') >= 0) { + foundGraphQLClassReadPreference = true; + expect(call.args[1].readPreference).toBe(ReadPreference.SECONDARY); + } else if (call.object.s.namespace.collection.indexOf('_User') >= 0) { + foundUserClassReadPreference = true; + expect(call.args[1].readPreference).toBe(ReadPreference.SECONDARY); + } + }); + + expect(foundGraphQLClassReadPreference).toBe(true); + expect(foundUserClassReadPreference).toBe(true); + }); + + it('should support includeReadPreference argument', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + spyOn(Collection.prototype, 'find').and.callThrough(); + + await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + graphQLClass( + id: $id + options: { readPreference: SECONDARY, includeReadPreference: NEAREST } + ) { + pointerToUser { + username + } + } + } + `, + variables: { + id: object3.id, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + let foundGraphQLClassReadPreference = false; + let foundUserClassReadPreference = false; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('GraphQLClass') >= 0) { + foundGraphQLClassReadPreference = true; + expect(call.args[1].readPreference).toBe(ReadPreference.SECONDARY); + } else if (call.object.s.namespace.collection.indexOf('_User') >= 0) { + foundUserClassReadPreference = true; + expect(call.args[1].readPreference).toBe(ReadPreference.NEAREST); + } + }); + + expect(foundGraphQLClassReadPreference).toBe(true); + expect(foundUserClassReadPreference).toBe(true); + }); + }); + }); + + describe('Find', () => { + it('should return class objects using class specific query', async () => { + const obj1 = new Parse.Object('Customer'); + obj1.set('someField', 'someValue1'); + await obj1.save(); + const obj2 = new Parse.Object('Customer'); + obj2.set('someField', 'someValue1'); + await obj2.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const result = await apolloClient.query({ + query: gql` + query FindCustomer { + customers { + edges { + node { + objectId + someField + createdAt + updatedAt + } + } + } + } + `, + }); + + expect(result.data.customers.edges.length).toEqual(2); + + result.data.customers.edges.forEach(resultObj => { + const obj = resultObj.node.objectId === obj1.id ? obj1 : obj2; + expect(resultObj.node.objectId).toEqual(obj.id); + expect(resultObj.node.someField).toEqual(obj.get('someField')); + expect(new Date(resultObj.node.createdAt)).toEqual(obj.createdAt); + expect(new Date(resultObj.node.updatedAt)).toEqual(obj.updatedAt); + }); + }); + + it('should respect level permissions', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + async function findObjects(className, headers) { + const graphqlClassName = pluralize( + className.charAt(0).toLowerCase() + className.slice(1) + ); + const result = await apolloClient.query({ + query: gql` + query FindSomeObjects { + find: ${graphqlClassName} { + edges { + node { + id + someField + } + } + } + } + `, + context: { + headers, + }, + }); + + return result; + } + + expect( + (await findObjects('GraphQLClass')).data.find.edges.map( + object => object.node.someField + ) + ).toEqual([]); + expect( + (await findObjects('PublicClass')).data.find.edges.map( + object => object.node.someField + ) + ).toEqual(['someValue4']); + expect( + ( + await findObjects('GraphQLClass', { + 'X-Parse-Master-Key': 'test', + }) + ).data.find.edges + .map(object => object.node.someField) + .sort() + ).toEqual(['someValue1', 'someValue2', 'someValue3']); + expect( + ( + await findObjects('PublicClass', { + 'X-Parse-Master-Key': 'test', + }) + ).data.find.edges.map(object => object.node.someField) + ).toEqual(['someValue4']); + expect( + ( + await findObjects('GraphQLClass', { + 'X-Parse-Session-Token': user1.getSessionToken(), + }) + ).data.find.edges + .map(object => object.node.someField) + .sort() + ).toEqual(['someValue1', 'someValue2', 'someValue3']); + expect( + ( + await findObjects('PublicClass', { + 'X-Parse-Session-Token': user1.getSessionToken(), + }) + ).data.find.edges.map(object => object.node.someField) + ).toEqual(['someValue4']); + expect( + ( + await findObjects('GraphQLClass', { + 'X-Parse-Session-Token': user2.getSessionToken(), + }) + ).data.find.edges + .map(object => object.node.someField) + .sort() + ).toEqual(['someValue1', 'someValue2', 'someValue3']); + expect( + ( + await findObjects('GraphQLClass', { + 'X-Parse-Session-Token': user3.getSessionToken(), + }) + ).data.find.edges + .map(object => object.node.someField) + .sort() + ).toEqual(['someValue1', 'someValue3']); + expect( + ( + await findObjects('GraphQLClass', { + 'X-Parse-Session-Token': user4.getSessionToken(), + }) + ).data.find.edges.map(object => object.node.someField) + ).toEqual([]); + expect( + ( + await findObjects('GraphQLClass', { + 'X-Parse-Session-Token': user5.getSessionToken(), + }) + ).data.find.edges.map(object => object.node.someField) + ).toEqual(['someValue3']); + }); + + it('should support where argument using class specific query', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const result = await apolloClient.query({ + query: gql` + query FindSomeObjects($where: GraphQLClassWhereInput) { + graphQLClasses(where: $where) { + edges { + node { + someField + } + } + } + } + `, + variables: { + where: { + someField: { + in: ['someValue1', 'someValue2', 'someValue3'], + }, + OR: [ + { + pointerToUser: { + have: { + objectId: { + equalTo: user5.id, + }, + }, + }, + }, + { + id: { + equalTo: object1.id, + }, + }, + ], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect( + result.data.graphQLClasses.edges.map(object => object.node.someField).sort() + ).toEqual(['someValue1', 'someValue3']); + }); + + it('should support in pointer operator using class specific query', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const result = await apolloClient.query({ + query: gql` + query FindSomeObjects($where: GraphQLClassWhereInput) { + graphQLClasses(where: $where) { + edges { + node { + someField + } + } + } + } + `, + variables: { + where: { + pointerToUser: { + have: { + objectId: { + in: [user5.id], + }, + }, + }, + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + const { edges } = result.data.graphQLClasses; + expect(edges.length).toBe(1); + expect(edges[0].node.someField).toEqual('someValue3'); + }); + + it('should support OR operation', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const result = await apolloClient.query({ + query: gql` + query { + graphQLClasses( + where: { + OR: [ + { someField: { equalTo: "someValue1" } } + { someField: { equalTo: "someValue2" } } + ] + } + ) { + edges { + node { + someField + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect( + result.data.graphQLClasses.edges.map(object => object.node.someField).sort() + ).toEqual(['someValue1', 'someValue2']); + }); + + it_id('accc59be-fd13-46c5-a103-ec63f2ad6670')(it)('should support full text search', async () => { + try { + const obj = new Parse.Object('FullTextSearchTest'); + obj.set('field1', 'Parse GraphQL Server'); + obj.set('field2', 'It rocks!'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const result = await apolloClient.query({ + query: gql` + query FullTextSearchTests($where: FullTextSearchTestWhereInput) { + fullTextSearchTests(where: $where) { + edges { + node { + objectId + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + variables: { + where: { + field1: { + text: { + search: { + term: 'graphql', + }, + }, + }, + }, + }, + }); + + expect(result.data.fullTextSearchTests.edges[0].node.objectId).toEqual(obj.id); + } catch (e) { + handleError(e); + } + }); + + it('should support in query key', async () => { + try { + const country = new Parse.Object('Country'); + country.set('code', 'FR'); + await country.save(); + + const country2 = new Parse.Object('Country'); + country2.set('code', 'US'); + await country2.save(); + + const city = new Parse.Object('City'); + city.set('country', 'FR'); + city.set('name', 'city1'); + await city.save(); + + const city2 = new Parse.Object('City'); + city2.set('country', 'US'); + city2.set('name', 'city2'); + await city2.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const { + data: { + cities: { edges: result }, + }, + } = await apolloClient.query({ + query: gql` + query inQueryKey($where: CityWhereInput) { + cities(where: $where) { + edges { + node { + country + name + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + variables: { + where: { + country: { + inQueryKey: { + query: { + className: 'Country', + where: { code: { equalTo: 'US' } }, + }, + key: 'code', + }, + }, + }, + }, + }); + + expect(result.length).toEqual(1); + expect(result[0].node.name).toEqual('city2'); + } catch (e) { + handleError(e); + } + }); + + it_id('0fd03d3c-a2c8-4fac-95cc-2391a3032ca2')(it)('should support order, skip and first arguments', async () => { + const promises = []; + for (let i = 0; i < 100; i++) { + const obj = new Parse.Object('SomeClass'); + obj.set('someField', `someValue${i < 10 ? '0' : ''}${i}`); + obj.set('numberField', i % 3); + promises.push(obj.save()); + } + await Promise.all(promises); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const result = await apolloClient.query({ + query: gql` + query FindSomeObjects( + $where: SomeClassWhereInput + $order: [SomeClassOrder!] + $skip: Int + $first: Int + ) { + find: someClasses(where: $where, order: $order, skip: $skip, first: $first) { + edges { + node { + someField + } + } + } + } + `, + variables: { + where: { + someField: { + matchesRegex: '^someValue', + }, + }, + order: ['numberField_DESC', 'someField_ASC'], + skip: 4, + first: 2, + }, + }); + + expect(result.data.find.edges.map(obj => obj.node.someField)).toEqual([ + 'someValue14', + 'someValue17', + ]); + }); + + it_id('588a70c6-2932-4d3b-a838-a74c59d8cffb')(it)('should support pagination', async () => { + const numberArray = (first, last) => { + const array = []; + for (let i = first; i <= last; i++) { + array.push(i); + } + return array; + }; + + const promises = []; + for (let i = 0; i < 100; i++) { + const obj = new Parse.Object('SomeClass'); + obj.set('numberField', i); + promises.push(obj.save()); + } + await Promise.all(promises); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const find = async ({ skip, after, first, before, last } = {}) => { + return await apolloClient.query({ + query: gql` + query FindSomeObjects( + $order: [SomeClassOrder!] + $skip: Int + $after: String + $first: Int + $before: String + $last: Int + ) { + someClasses( + order: $order + skip: $skip + after: $after + first: $first + before: $before + last: $last + ) { + edges { + cursor + node { + numberField + } + } + count + pageInfo { + hasPreviousPage + startCursor + endCursor + hasNextPage + } + } + } + `, + variables: { + order: ['numberField_ASC'], + skip, + after, + first, + before, + last, + }, + }); + }; + + let result = await find(); + expect(result.data.someClasses.edges.map(edge => edge.node.numberField)).toEqual( + numberArray(0, 99) + ); + expect(result.data.someClasses.count).toEqual(100); + expect(result.data.someClasses.pageInfo.hasPreviousPage).toEqual(false); + expect(result.data.someClasses.pageInfo.startCursor).toEqual( + result.data.someClasses.edges[0].cursor + ); + expect(result.data.someClasses.pageInfo.endCursor).toEqual( + result.data.someClasses.edges[99].cursor + ); + expect(result.data.someClasses.pageInfo.hasNextPage).toEqual(false); + + result = await find({ first: 10 }); + expect(result.data.someClasses.edges.map(edge => edge.node.numberField)).toEqual( + numberArray(0, 9) + ); + expect(result.data.someClasses.count).toEqual(100); + expect(result.data.someClasses.pageInfo.hasPreviousPage).toEqual(false); + expect(result.data.someClasses.pageInfo.startCursor).toEqual( + result.data.someClasses.edges[0].cursor + ); + expect(result.data.someClasses.pageInfo.endCursor).toEqual( + result.data.someClasses.edges[9].cursor + ); + expect(result.data.someClasses.pageInfo.hasNextPage).toEqual(true); + + result = await find({ + first: 10, + after: result.data.someClasses.pageInfo.endCursor, + }); + expect(result.data.someClasses.edges.map(edge => edge.node.numberField)).toEqual( + numberArray(10, 19) + ); + expect(result.data.someClasses.count).toEqual(100); + expect(result.data.someClasses.pageInfo.hasPreviousPage).toEqual(true); + expect(result.data.someClasses.pageInfo.startCursor).toEqual( + result.data.someClasses.edges[0].cursor + ); + expect(result.data.someClasses.pageInfo.endCursor).toEqual( + result.data.someClasses.edges[9].cursor + ); + expect(result.data.someClasses.pageInfo.hasNextPage).toEqual(true); + + result = await find({ last: 10 }); + expect(result.data.someClasses.edges.map(edge => edge.node.numberField)).toEqual( + numberArray(90, 99) + ); + expect(result.data.someClasses.count).toEqual(100); + expect(result.data.someClasses.pageInfo.hasPreviousPage).toEqual(true); + expect(result.data.someClasses.pageInfo.startCursor).toEqual( + result.data.someClasses.edges[0].cursor + ); + expect(result.data.someClasses.pageInfo.endCursor).toEqual( + result.data.someClasses.edges[9].cursor + ); + expect(result.data.someClasses.pageInfo.hasNextPage).toEqual(false); + + result = await find({ + last: 10, + before: result.data.someClasses.pageInfo.startCursor, + }); + expect(result.data.someClasses.edges.map(edge => edge.node.numberField)).toEqual( + numberArray(80, 89) + ); + expect(result.data.someClasses.count).toEqual(100); + expect(result.data.someClasses.pageInfo.hasPreviousPage).toEqual(true); + expect(result.data.someClasses.pageInfo.startCursor).toEqual( + result.data.someClasses.edges[0].cursor + ); + expect(result.data.someClasses.pageInfo.endCursor).toEqual( + result.data.someClasses.edges[9].cursor + ); + expect(result.data.someClasses.pageInfo.hasNextPage).toEqual(true); + }); + + it_id('4f6a5f20-9642-4cf0-b31d-e739672a9096')(it)('should support count', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const where = { + someField: { + in: ['someValue1', 'someValue2', 'someValue3'], + }, + OR: [ + { + pointerToUser: { + have: { + objectId: { + equalTo: user5.id, + }, + }, + }, + }, + { + id: { + equalTo: object1.id, + }, + }, + ], + }; + + const result = await apolloClient.query({ + query: gql` + query FindSomeObjects($where: GraphQLClassWhereInput, $first: Int) { + find: graphQLClasses(where: $where, first: $first) { + edges { + node { + id + } + } + count + } + } + `, + variables: { + where, + first: 0, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(result.data.find.edges).toEqual([]); + expect(result.data.find.count).toEqual(2); + }); + + it('should only count', async () => { + await prepareData(); + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const where = { + someField: { + in: ['someValue1', 'someValue2', 'someValue3'], + }, + OR: [ + { + pointerToUser: { + have: { + objectId: { + equalTo: user5.id, + }, + }, + }, + }, + { + id: { + equalTo: object1.id, + }, + }, + ], + }; + + const result = await apolloClient.query({ + query: gql` + query FindSomeObjects($where: GraphQLClassWhereInput) { + find: graphQLClasses(where: $where) { + count + } + } + `, + variables: { + where, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(result.data.find.edges).toBeUndefined(); + expect(result.data.find.count).toEqual(2); + }); + + it_id('942b57be-ca8a-4a5b-8104-2adef8743b1a')(it)('should respect max limit', async () => { + parseServer = await global.reconfigureServer({ + maxLimit: 10, + }); + await createGQLFromParseServer(parseServer); + const promises = []; + for (let i = 0; i < 100; i++) { + const obj = new Parse.Object('SomeClass'); + promises.push(obj.save()); + } + await Promise.all(promises); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const result = await apolloClient.query({ + query: gql` + query FindSomeObjects($limit: Int) { + find: someClasses(where: { id: { exists: true } }, first: $limit) { + edges { + node { + id + } + } + count + } + } + `, + variables: { + limit: 50, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(result.data.find.edges.length).toEqual(10); + expect(result.data.find.count).toEqual(100); + }); + + it_id('952634f0-0ad5-4a08-8da2-187c1bd9ee94')(it)('should support keys argument', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const result1 = await apolloClient.query({ + query: gql` + query FindSomeObject($where: GraphQLClassWhereInput) { + find: graphQLClasses(where: $where) { + edges { + node { + someField + } + } + } + } + `, + variables: { + where: { + id: { equalTo: object3.id }, + }, + }, + context: { + headers: { + 'X-Parse-Session-Token': user1.getSessionToken(), + }, + }, + }); + + const result2 = await apolloClient.query({ + query: gql` + query FindSomeObject($where: GraphQLClassWhereInput) { + find: graphQLClasses(where: $where) { + edges { + node { + someField + pointerToUser { + username + } + } + } + } + } + `, + variables: { + where: { + id: { equalTo: object3.id }, + }, + }, + context: { + headers: { + 'X-Parse-Session-Token': user1.getSessionToken(), + }, + }, + }); + + expect(result1.data.find.edges[0].node.someField).toBeDefined(); + expect(result1.data.find.edges[0].node.pointerToUser).toBeUndefined(); + expect(result2.data.find.edges[0].node.someField).toBeDefined(); + expect(result2.data.find.edges[0].node.pointerToUser).toBeDefined(); + }); + + it('should support include argument', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const where = { + id: { + equalTo: object3.id, + }, + }; + + const result1 = await apolloClient.query({ + query: gql` + query FindSomeObject($where: GraphQLClassWhereInput) { + find: graphQLClasses(where: $where) { + edges { + node { + pointerToUser { + id + } + } + } + } + } + `, + variables: { + where, + }, + context: { + headers: { + 'X-Parse-Session-Token': user1.getSessionToken(), + }, + }, + }); + + const result2 = await apolloClient.query({ + query: gql` + query FindSomeObject($where: GraphQLClassWhereInput) { + find: graphQLClasses(where: $where) { + edges { + node { + pointerToUser { + username + } + } + } + } + } + `, + variables: { + where, + }, + context: { + headers: { + 'X-Parse-Session-Token': user1.getSessionToken(), + }, + }, + }); + expect(result1.data.find.edges[0].node.pointerToUser.username).toBeUndefined(); + expect(result2.data.find.edges[0].node.pointerToUser.username).toBeDefined(); + }); + + describe_only_db('mongo')('read preferences', () => { + it('should read from primary by default', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + spyOn(Collection.prototype, 'find').and.callThrough(); + + await apolloClient.query({ + query: gql` + query FindSomeObjects { + find: graphQLClasses { + edges { + node { + pointerToUser { + username + } + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Session-Token': user1.getSessionToken(), + }, + }, + }); + + let foundGraphQLClassReadPreference = false; + let foundUserClassReadPreference = false; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('GraphQLClass') >= 0) { + foundGraphQLClassReadPreference = true; + expect(call.object.s.readPreference.mode).toBe(ReadPreference.PRIMARY); + } else if (call.object.s.namespace.collection.indexOf('_User') >= 0) { + foundUserClassReadPreference = true; + expect(call.object.s.readPreference.mode).toBe(ReadPreference.PRIMARY); + } + }); + + expect(foundGraphQLClassReadPreference).toBe(true); + expect(foundUserClassReadPreference).toBe(true); + }); + + it('should support readPreference argument', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + spyOn(Collection.prototype, 'find').and.callThrough(); + + await apolloClient.query({ + query: gql` + query FindSomeObjects { + find: graphQLClasses(options: { readPreference: SECONDARY }) { + edges { + node { + pointerToUser { + username + } + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + let foundGraphQLClassReadPreference = false; + let foundUserClassReadPreference = false; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('GraphQLClass') >= 0) { + foundGraphQLClassReadPreference = true; + expect(call.args[1].readPreference).toBe(ReadPreference.SECONDARY); + } else if (call.object.s.namespace.collection.indexOf('_User') >= 0) { + foundUserClassReadPreference = true; + expect(call.args[1].readPreference).toBe(ReadPreference.SECONDARY); + } + }); + + expect(foundGraphQLClassReadPreference).toBe(true); + expect(foundUserClassReadPreference).toBe(true); + }); + + it('should support includeReadPreference argument', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + spyOn(Collection.prototype, 'find').and.callThrough(); + + await apolloClient.query({ + query: gql` + query FindSomeObjects { + graphQLClasses( + options: { readPreference: SECONDARY, includeReadPreference: NEAREST } + ) { + edges { + node { + pointerToUser { + username + } + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + let foundGraphQLClassReadPreference = false; + let foundUserClassReadPreference = false; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('GraphQLClass') >= 0) { + foundGraphQLClassReadPreference = true; + expect(call.args[1].readPreference).toBe(ReadPreference.SECONDARY); + } else if (call.object.s.namespace.collection.indexOf('_User') >= 0) { + foundUserClassReadPreference = true; + expect(call.args[1].readPreference).toBe(ReadPreference.NEAREST); + } + }); + + expect(foundGraphQLClassReadPreference).toBe(true); + expect(foundUserClassReadPreference).toBe(true); + }); + + it('should support subqueryReadPreference argument', async () => { + try { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + spyOn(Collection.prototype, 'find').and.callThrough(); + + await apolloClient.query({ + query: gql` + query FindSomeObjects($where: GraphQLClassWhereInput) { + find: graphQLClasses( + where: $where + options: { readPreference: SECONDARY, subqueryReadPreference: NEAREST } + ) { + edges { + node { + id + } + } + } + } + `, + variables: { + where: { + pointerToUser: { + have: { + objectId: { + equalTo: 'xxxx', + }, + }, + }, + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + let foundGraphQLClassReadPreference = false; + let foundUserClassReadPreference = false; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('GraphQLClass') >= 0) { + foundGraphQLClassReadPreference = true; + expect(call.args[1].readPreference).toBe(ReadPreference.SECONDARY); + } else if (call.object.s.namespace.collection.indexOf('_User') >= 0) { + foundUserClassReadPreference = true; + expect(call.args[1].readPreference).toBe(ReadPreference.NEAREST); + } + }); + + expect(foundGraphQLClassReadPreference).toBe(true); + expect(foundUserClassReadPreference).toBe(true); + } catch (e) { + handleError(e); + } + }); + }); + + it('should order by multiple fields', async () => { + await prepareData(); + + await resetGraphQLCache(); + + let result; + try { + result = await apolloClient.query({ + query: gql` + query OrderByMultipleFields($order: [GraphQLClassOrder!]) { + graphQLClasses(order: $order) { + edges { + node { + objectId + } + } + } + } + `, + variables: { + order: ['someOtherField_DESC', 'someField_ASC'], + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + } catch (e) { + handleError(e); + } + + expect(result.data.graphQLClasses.edges.map(edge => edge.node.objectId)).toEqual([ + object3.id, + object1.id, + object2.id, + ]); + }); + + it_only_db('mongo')('should order by multiple fields on a relation field', async () => { + await prepareData(); + + const parentObject = new Parse.Object('ParentClass'); + const relation = parentObject.relation('graphQLClasses'); + relation.add(object1); + relation.add(object2); + relation.add(object3); + await parentObject.save(); + + await resetGraphQLCache(); + + let result; + try { + result = await apolloClient.query({ + query: gql` + query OrderByMultipleFieldsOnRelation($id: ID!, $order: [GraphQLClassOrder!]) { + parentClass(id: $id) { + graphQLClasses(order: $order) { + edges { + node { + objectId + } + } + } + } + } + `, + variables: { + id: parentObject.id, + order: ['someOtherField_DESC', 'someField_ASC'], + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + } catch (e) { + handleError(e); + } + + expect( + result.data.parentClass.graphQLClasses.edges.map(edge => edge.node.objectId) + ).toEqual([object3.id, object1.id, object2.id]); + }); + + it_id('47a6adf3-1cb4-4d92-b74c-e480363f9cb5')(it)('should support including relation', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const result1 = await apolloClient.query({ + query: gql` + query FindRoles { + roles { + edges { + node { + name + } + } + } + } + `, + variables: {}, + context: { + headers: { + 'X-Parse-Session-Token': user1.getSessionToken(), + }, + }, + }); + + const result2 = await apolloClient.query({ + query: gql` + query FindRoles { + roles { + edges { + node { + name + users { + edges { + node { + username + } + } + } + } + } + } + } + `, + variables: {}, + context: { + headers: { + 'X-Parse-Session-Token': user1.getSessionToken(), + }, + }, + }); + + expect(result1.data.roles.edges[0].node.name).toBeDefined(); + expect(result1.data.roles.edges[0].node.users).toBeUndefined(); + expect(result1.data.roles.edges[0].node.roles).toBeUndefined(); + expect(result2.data.roles.edges[0].node.name).toBeDefined(); + expect(result2.data.roles.edges[0].node.users).toBeDefined(); + expect(result2.data.roles.edges[0].node.users.edges[0].node.username).toBeDefined(); + expect(result2.data.roles.edges[0].node.roles).toBeUndefined(); + }); + }); + }); + + describe('Objects Mutations', () => { + describe('Create', () => { + it('should return specific type object using class specific mutation', async () => { + const clientMutationId = uuidv4(); + const customerSchema = new Parse.Schema('Customer'); + customerSchema.addString('someField'); + await customerSchema.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const result = await apolloClient.mutate({ + mutation: gql` + mutation CreateCustomer($input: CreateCustomerInput!) { + createCustomer(input: $input) { + clientMutationId + customer { + id + objectId + createdAt + someField + } + } + } + `, + variables: { + input: { + clientMutationId, + fields: { + someField: 'someValue', + }, + }, + }, + }); + + expect(result.data.createCustomer.clientMutationId).toEqual(clientMutationId); + expect(result.data.createCustomer.customer.id).toBeDefined(); + expect(result.data.createCustomer.customer.someField).toEqual('someValue'); + + const customer = await new Parse.Query('Customer').get( + result.data.createCustomer.customer.objectId + ); + + expect(customer.createdAt).toEqual( + new Date(result.data.createCustomer.customer.createdAt) + ); + expect(customer.get('someField')).toEqual('someValue'); + }); + + it('should respect level permissions', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + async function createObject(className, headers) { + const getClassName = className.charAt(0).toLowerCase() + className.slice(1); + const result = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject { + create${className}(input: {}) { + ${getClassName} { + id + createdAt + } + } + } + `, + context: { + headers, + }, + }); + + const specificCreate = result.data[`create${className}`][getClassName]; + expect(specificCreate.id).toBeDefined(); + expect(specificCreate.createdAt).toBeDefined(); + + return result; + } + + await expectAsync(createObject('GraphQLClass')).toBeRejectedWith( + jasmine.stringMatching('Permission denied') + ); + await expectAsync(createObject('PublicClass')).toBeResolved(); + await expectAsync( + createObject('GraphQLClass', { 'X-Parse-Master-Key': 'test' }) + ).toBeResolved(); + await expectAsync( + createObject('PublicClass', { 'X-Parse-Master-Key': 'test' }) + ).toBeResolved(); + await expectAsync( + createObject('GraphQLClass', { + 'X-Parse-Session-Token': user1.getSessionToken(), + }) + ).toBeResolved(); + await expectAsync( + createObject('PublicClass', { + 'X-Parse-Session-Token': user1.getSessionToken(), + }) + ).toBeResolved(); + await expectAsync( + createObject('GraphQLClass', { + 'X-Parse-Session-Token': user2.getSessionToken(), + }) + ).toBeResolved(); + await expectAsync( + createObject('PublicClass', { + 'X-Parse-Session-Token': user2.getSessionToken(), + }) + ).toBeResolved(); + await expectAsync( + createObject('GraphQLClass', { + 'X-Parse-Session-Token': user4.getSessionToken(), + }) + ).toBeRejectedWith( + jasmine.stringMatching('Permission denied') + ); + await expectAsync( + createObject('PublicClass', { + 'X-Parse-Session-Token': user4.getSessionToken(), + }) + ).toBeResolved(); + }); + }); + + describe('Update', () => { + it('should return specific type object using class specific mutation', async () => { + const clientMutationId = uuidv4(); + const obj = new Parse.Object('Customer'); + obj.set('someField1', 'someField1Value1'); + obj.set('someField2', 'someField2Value1'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const result = await apolloClient.mutate({ + mutation: gql` + mutation UpdateCustomer($input: UpdateCustomerInput!) { + updateCustomer(input: $input) { + clientMutationId + customer { + updatedAt + someField1 + someField2 + } + } + } + `, + variables: { + input: { + clientMutationId, + id: obj.id, + fields: { + someField1: 'someField1Value2', + }, + }, + }, + }); + + expect(result.data.updateCustomer.clientMutationId).toEqual(clientMutationId); + expect(result.data.updateCustomer.customer.updatedAt).toBeDefined(); + expect(result.data.updateCustomer.customer.someField1).toEqual('someField1Value2'); + expect(result.data.updateCustomer.customer.someField2).toEqual('someField2Value1'); + + await obj.fetch(); + + expect(obj.get('someField1')).toEqual('someField1Value2'); + expect(obj.get('someField2')).toEqual('someField2Value1'); + }); + + it('should return only id using class specific mutation', async () => { + const obj = new Parse.Object('Customer'); + obj.set('someField1', 'someField1Value1'); + obj.set('someField2', 'someField2Value1'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const result = await apolloClient.mutate({ + mutation: gql` + mutation UpdateCustomer($id: ID!, $fields: UpdateCustomerFieldsInput) { + updateCustomer(input: { id: $id, fields: $fields }) { + customer { + id + objectId + } + } + } + `, + variables: { + id: obj.id, + fields: { + someField1: 'someField1Value2', + }, + }, + }); + + expect(result.data.updateCustomer.customer.objectId).toEqual(obj.id); + + await obj.fetch(); + + expect(obj.get('someField1')).toEqual('someField1Value2'); + expect(obj.get('someField2')).toEqual('someField2Value1'); + }); + + it('should respect level permissions', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + async function updateObject(className, id, fields, headers) { + return await apolloClient.mutate({ + mutation: gql` + mutation UpdateSomeObject( + $id: ID! + $fields: Update${className}FieldsInput + ) { + update: update${className}(input: { + id: $id + fields: $fields + clientMutationId: "someid" + }) { + clientMutationId + } + } + `, + variables: { + id, + fields, + }, + context: { + headers, + }, + }); + } + + await Promise.all( + objects.slice(0, 3).map(async obj => { + const originalFieldValue = obj.get('someField'); + await expectAsync( + updateObject(obj.className, obj.id, { + someField: 'changedValue1', + }) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual(originalFieldValue); + }) + ); + expect( + ( + await updateObject(object4.className, object4.id, { + someField: 'changedValue1', + }) + ).data.update.clientMutationId + ).toBeDefined(); + await object4.fetch({ useMasterKey: true }); + expect(object4.get('someField')).toEqual('changedValue1'); + await Promise.all( + objects.map(async obj => { + expect( + ( + await updateObject( + obj.className, + obj.id, + { someField: 'changedValue2' }, + { 'X-Parse-Master-Key': 'test' } + ) + ).data.update.clientMutationId + ).toBeDefined(); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual('changedValue2'); + }) + ); + await Promise.all( + objects.map(async obj => { + expect( + ( + await updateObject( + obj.className, + obj.id, + { someField: 'changedValue3' }, + { 'X-Parse-Session-Token': user1.getSessionToken() } + ) + ).data.update.clientMutationId + ).toBeDefined(); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual('changedValue3'); + }) + ); + await Promise.all( + objects.map(async obj => { + expect( + ( + await updateObject( + obj.className, + obj.id, + { someField: 'changedValue4' }, + { 'X-Parse-Session-Token': user2.getSessionToken() } + ) + ).data.update.clientMutationId + ).toBeDefined(); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual('changedValue4'); + }) + ); + await Promise.all( + [object1, object3, object4].map(async obj => { + expect( + ( + await updateObject( + obj.className, + obj.id, + { someField: 'changedValue5' }, + { 'X-Parse-Session-Token': user3.getSessionToken() } + ) + ).data.update.clientMutationId + ).toBeDefined(); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual('changedValue5'); + }) + ); + const originalFieldValue = object2.get('someField'); + await expectAsync( + updateObject( + object2.className, + object2.id, + { someField: 'changedValue5' }, + { 'X-Parse-Session-Token': user3.getSessionToken() } + ) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await object2.fetch({ useMasterKey: true }); + expect(object2.get('someField')).toEqual(originalFieldValue); + await Promise.all( + objects.slice(0, 3).map(async obj => { + const originalFieldValue = obj.get('someField'); + await expectAsync( + updateObject( + obj.className, + obj.id, + { someField: 'changedValue6' }, + { 'X-Parse-Session-Token': user4.getSessionToken() } + ) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual(originalFieldValue); + }) + ); + expect( + ( + await updateObject( + object4.className, + object4.id, + { someField: 'changedValue6' }, + { 'X-Parse-Session-Token': user4.getSessionToken() } + ) + ).data.update.clientMutationId + ).toBeDefined(); + await object4.fetch({ useMasterKey: true }); + expect(object4.get('someField')).toEqual('changedValue6'); + await Promise.all( + objects.slice(0, 2).map(async obj => { + const originalFieldValue = obj.get('someField'); + await expectAsync( + updateObject( + obj.className, + obj.id, + { someField: 'changedValue7' }, + { 'X-Parse-Session-Token': user5.getSessionToken() } + ) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual(originalFieldValue); + }) + ); + expect( + ( + await updateObject( + object3.className, + object3.id, + { someField: 'changedValue7' }, + { 'X-Parse-Session-Token': user5.getSessionToken() } + ) + ).data.update.clientMutationId + ).toBeDefined(); + await object3.fetch({ useMasterKey: true }); + expect(object3.get('someField')).toEqual('changedValue7'); + expect( + ( + await updateObject( + object4.className, + object4.id, + { someField: 'changedValue7' }, + { 'X-Parse-Session-Token': user5.getSessionToken() } + ) + ).data.update.clientMutationId + ).toBeDefined(); + await object4.fetch({ useMasterKey: true }); + expect(object4.get('someField')).toEqual('changedValue7'); + }); + + it('should respect level permissions with specific class mutation', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + function updateObject(className, id, fields, headers) { + const mutationName = className.charAt(0).toLowerCase() + className.slice(1); + + return apolloClient.mutate({ + mutation: gql` + mutation UpdateSomeObject( + $id: ID! + $fields: Update${className}FieldsInput + ) { + update${className}(input: { + id: $id + fields: $fields + }) { + ${mutationName} { + updatedAt + } + } + } + `, + variables: { + id, + fields, + }, + context: { + headers, + }, + }); + } + + await Promise.all( + objects.slice(0, 3).map(async obj => { + const originalFieldValue = obj.get('someField'); + await expectAsync( + updateObject(obj.className, obj.id, { + someField: 'changedValue1', + }) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual(originalFieldValue); + }) + ); + expect( + ( + await updateObject(object4.className, object4.id, { + someField: 'changedValue1', + }) + ).data[`update${object4.className}`][ + object4.className.charAt(0).toLowerCase() + object4.className.slice(1) + ].updatedAt + ).toBeDefined(); + await object4.fetch({ useMasterKey: true }); + expect(object4.get('someField')).toEqual('changedValue1'); + await Promise.all( + objects.map(async obj => { + expect( + ( + await updateObject( + obj.className, + obj.id, + { someField: 'changedValue2' }, + { 'X-Parse-Master-Key': 'test' } + ) + ).data[`update${obj.className}`][ + obj.className.charAt(0).toLowerCase() + obj.className.slice(1) + ].updatedAt + ).toBeDefined(); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual('changedValue2'); + }) + ); + await Promise.all( + objects.map(async obj => { + expect( + ( + await updateObject( + obj.className, + obj.id, + { someField: 'changedValue3' }, + { 'X-Parse-Session-Token': user1.getSessionToken() } + ) + ).data[`update${obj.className}`][ + obj.className.charAt(0).toLowerCase() + obj.className.slice(1) + ].updatedAt + ).toBeDefined(); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual('changedValue3'); + }) + ); + await Promise.all( + objects.map(async obj => { + expect( + ( + await updateObject( + obj.className, + obj.id, + { someField: 'changedValue4' }, + { 'X-Parse-Session-Token': user2.getSessionToken() } + ) + ).data[`update${obj.className}`][ + obj.className.charAt(0).toLowerCase() + obj.className.slice(1) + ].updatedAt + ).toBeDefined(); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual('changedValue4'); + }) + ); + await Promise.all( + [object1, object3, object4].map(async obj => { + expect( + ( + await updateObject( + obj.className, + obj.id, + { someField: 'changedValue5' }, + { 'X-Parse-Session-Token': user3.getSessionToken() } + ) + ).data[`update${obj.className}`][ + obj.className.charAt(0).toLowerCase() + obj.className.slice(1) + ].updatedAt + ).toBeDefined(); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual('changedValue5'); + }) + ); + const originalFieldValue = object2.get('someField'); + await expectAsync( + updateObject( + object2.className, + object2.id, + { someField: 'changedValue5' }, + { 'X-Parse-Session-Token': user3.getSessionToken() } + ) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await object2.fetch({ useMasterKey: true }); + expect(object2.get('someField')).toEqual(originalFieldValue); + await Promise.all( + objects.slice(0, 3).map(async obj => { + const originalFieldValue = obj.get('someField'); + await expectAsync( + updateObject( + obj.className, + obj.id, + { someField: 'changedValue6' }, + { 'X-Parse-Session-Token': user4.getSessionToken() } + ) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual(originalFieldValue); + }) + ); + expect( + ( + await updateObject( + object4.className, + object4.id, + { someField: 'changedValue6' }, + { 'X-Parse-Session-Token': user4.getSessionToken() } + ) + ).data[`update${object4.className}`][ + object4.className.charAt(0).toLowerCase() + object4.className.slice(1) + ].updatedAt + ).toBeDefined(); + await object4.fetch({ useMasterKey: true }); + expect(object4.get('someField')).toEqual('changedValue6'); + await Promise.all( + objects.slice(0, 2).map(async obj => { + const originalFieldValue = obj.get('someField'); + await expectAsync( + updateObject( + obj.className, + obj.id, + { someField: 'changedValue7' }, + { 'X-Parse-Session-Token': user5.getSessionToken() } + ) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual(originalFieldValue); + }) + ); + expect( + ( + await updateObject( + object3.className, + object3.id, + { someField: 'changedValue7' }, + { 'X-Parse-Session-Token': user5.getSessionToken() } + ) + ).data[`update${object3.className}`][ + object3.className.charAt(0).toLowerCase() + object3.className.slice(1) + ].updatedAt + ).toBeDefined(); + await object3.fetch({ useMasterKey: true }); + expect(object3.get('someField')).toEqual('changedValue7'); + expect( + ( + await updateObject( + object4.className, + object4.id, + { someField: 'changedValue7' }, + { 'X-Parse-Session-Token': user5.getSessionToken() } + ) + ).data[`update${object4.className}`][ + object4.className.charAt(0).toLowerCase() + object4.className.slice(1) + ].updatedAt + ).toBeDefined(); + await object4.fetch({ useMasterKey: true }); + expect(object4.get('someField')).toEqual('changedValue7'); + }); + }); + + describe('Delete', () => { + it('should return a specific type using class specific mutation', async () => { + const clientMutationId = uuidv4(); + const obj = new Parse.Object('Customer'); + obj.set('someField1', 'someField1Value1'); + obj.set('someField2', 'someField2Value1'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const result = await apolloClient.mutate({ + mutation: gql` + mutation DeleteCustomer($input: DeleteCustomerInput!) { + deleteCustomer(input: $input) { + clientMutationId + customer { + id + objectId + someField1 + someField2 + } + } + } + `, + variables: { + input: { + clientMutationId, + id: obj.id, + }, + }, + }); + + expect(result.data.deleteCustomer.clientMutationId).toEqual(clientMutationId); + expect(result.data.deleteCustomer.customer.objectId).toEqual(obj.id); + expect(result.data.deleteCustomer.customer.someField1).toEqual('someField1Value1'); + expect(result.data.deleteCustomer.customer.someField2).toEqual('someField2Value1'); + + await expectAsync(obj.fetch({ useMasterKey: true })).toBeRejectedWith( + jasmine.stringMatching('Object not found') + ); + }); + + it('should respect level permissions', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + function deleteObject(className, id, headers) { + const mutationName = className.charAt(0).toLowerCase() + className.slice(1); + return apolloClient.mutate({ + mutation: gql` + mutation DeleteSomeObject( + $id: ID! + ) { + delete: delete${className}(input: { id: $id }) { + ${mutationName} { + objectId + } + } + } + `, + variables: { + id, + }, + context: { + headers, + }, + }); + } + + await Promise.all( + objects.slice(0, 3).map(async obj => { + const originalFieldValue = obj.get('someField'); + await expectAsync(deleteObject(obj.className, obj.id)).toBeRejectedWith( + jasmine.stringMatching('Object not found') + ); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual(originalFieldValue); + }) + ); + await Promise.all( + objects.slice(0, 3).map(async obj => { + const originalFieldValue = obj.get('someField'); + await expectAsync( + deleteObject(obj.className, obj.id, { + 'X-Parse-Session-Token': user4.getSessionToken(), + }) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual(originalFieldValue); + }) + ); + expect( + (await deleteObject(object4.className, object4.id)).data.delete[ + object4.className.charAt(0).toLowerCase() + object4.className.slice(1) + ] + ).toEqual({ objectId: object4.id, __typename: 'PublicClass' }); + await expectAsync(object4.fetch({ useMasterKey: true })).toBeRejectedWith( + jasmine.stringMatching('Object not found') + ); + expect( + ( + await deleteObject(object1.className, object1.id, { + 'X-Parse-Master-Key': 'test', + }) + ).data.delete[object1.className.charAt(0).toLowerCase() + object1.className.slice(1)] + ).toEqual({ objectId: object1.id, __typename: 'GraphQLClass' }); + await expectAsync(object1.fetch({ useMasterKey: true })).toBeRejectedWith( + jasmine.stringMatching('Object not found') + ); + expect( + ( + await deleteObject(object2.className, object2.id, { + 'X-Parse-Session-Token': user2.getSessionToken(), + }) + ).data.delete[object2.className.charAt(0).toLowerCase() + object2.className.slice(1)] + ).toEqual({ objectId: object2.id, __typename: 'GraphQLClass' }); + await expectAsync(object2.fetch({ useMasterKey: true })).toBeRejectedWith( + jasmine.stringMatching('Object not found') + ); + expect( + ( + await deleteObject(object3.className, object3.id, { + 'X-Parse-Session-Token': user5.getSessionToken(), + }) + ).data.delete[object3.className.charAt(0).toLowerCase() + object3.className.slice(1)] + ).toEqual({ objectId: object3.id, __typename: 'GraphQLClass' }); + await expectAsync(object3.fetch({ useMasterKey: true })).toBeRejectedWith( + jasmine.stringMatching('Object not found') + ); + }); + + it('should respect level permissions with specific class mutation', async () => { + await prepareData(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + function deleteObject(className, id, headers) { + const mutationName = className.charAt(0).toLowerCase() + className.slice(1); + return apolloClient.mutate({ + mutation: gql` + mutation DeleteSomeObject( + $id: ID! + ) { + delete${className}(input: { id: $id }) { + ${mutationName} { + objectId + } + } + } + `, + variables: { + id, + }, + context: { + headers, + }, + }); + } + + await Promise.all( + objects.slice(0, 3).map(async obj => { + const originalFieldValue = obj.get('someField'); + await expectAsync(deleteObject(obj.className, obj.id)).toBeRejectedWith( + jasmine.stringMatching('Object not found') + ); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual(originalFieldValue); + }) + ); + await Promise.all( + objects.slice(0, 3).map(async obj => { + const originalFieldValue = obj.get('someField'); + await expectAsync( + deleteObject(obj.className, obj.id, { + 'X-Parse-Session-Token': user4.getSessionToken(), + }) + ).toBeRejectedWith(jasmine.stringMatching('Object not found')); + await obj.fetch({ useMasterKey: true }); + expect(obj.get('someField')).toEqual(originalFieldValue); + }) + ); + expect( + (await deleteObject(object4.className, object4.id)).data[ + `delete${object4.className}` + ][object4.className.charAt(0).toLowerCase() + object4.className.slice(1)].objectId + ).toEqual(object4.id); + await expectAsync(object4.fetch({ useMasterKey: true })).toBeRejectedWith( + jasmine.stringMatching('Object not found') + ); + expect( + ( + await deleteObject(object1.className, object1.id, { + 'X-Parse-Master-Key': 'test', + }) + ).data[`delete${object1.className}`][ + object1.className.charAt(0).toLowerCase() + object1.className.slice(1) + ].objectId + ).toEqual(object1.id); + await expectAsync(object1.fetch({ useMasterKey: true })).toBeRejectedWith( + jasmine.stringMatching('Object not found') + ); + expect( + ( + await deleteObject(object2.className, object2.id, { + 'X-Parse-Session-Token': user2.getSessionToken(), + }) + ).data[`delete${object2.className}`][ + object2.className.charAt(0).toLowerCase() + object2.className.slice(1) + ].objectId + ).toEqual(object2.id); + await expectAsync(object2.fetch({ useMasterKey: true })).toBeRejectedWith( + jasmine.stringMatching('Object not found') + ); + expect( + ( + await deleteObject(object3.className, object3.id, { + 'X-Parse-Session-Token': user5.getSessionToken(), + }) + ).data[`delete${object3.className}`][ + object3.className.charAt(0).toLowerCase() + object3.className.slice(1) + ].objectId + ).toEqual(object3.id); + await expectAsync(object3.fetch({ useMasterKey: true })).toBeRejectedWith( + jasmine.stringMatching('Object not found') + ); + }); + }); + + it_id('f722e98e-1fd7-45c5-ade3-5177e3d542e8')(it)('should unset fields when null used on update/create', async () => { + const customerSchema = new Parse.Schema('Customer'); + customerSchema.addString('aString'); + customerSchema.addBoolean('aBoolean'); + customerSchema.addDate('aDate'); + customerSchema.addArray('aArray'); + customerSchema.addGeoPoint('aGeoPoint'); + customerSchema.addPointer('aPointer', 'Customer'); + customerSchema.addObject('aObject'); + customerSchema.addPolygon('aPolygon'); + await customerSchema.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const cus = new Parse.Object('Customer'); + await cus.save({ aString: 'hello' }); + + const fields = { + aString: "i'm string", + aBoolean: true, + aDate: new Date().toISOString(), + aArray: ['hello', 1], + aGeoPoint: { latitude: 30, longitude: 30 }, + aPointer: { link: cus.id }, + aObject: { prop: { subprop: 1 }, prop2: 'test' }, + aPolygon: [ + { latitude: 30, longitude: 30 }, + { latitude: 31, longitude: 31 }, + { latitude: 32, longitude: 32 }, + { latitude: 30, longitude: 30 }, + ], + }; + const nullFields = Object.keys(fields).reduce((acc, k) => ({ ...acc, [k]: null }), {}); + const result = await apolloClient.mutate({ + mutation: gql` + mutation CreateCustomer($input: CreateCustomerInput!) { + createCustomer(input: $input) { + customer { + id + aString + aBoolean + aDate + aArray { + ... on Element { + value + } + } + aGeoPoint { + longitude + latitude + } + aPointer { + objectId + } + aObject + aPolygon { + longitude + latitude + } + } + } + } + `, + variables: { + input: { fields }, + }, + }); + const { + data: { + createCustomer: { + customer: { aPointer, aArray, id, ...otherFields }, + }, + }, + } = result; + expect(id).toBeDefined(); + delete otherFields.__typename; + delete otherFields.aGeoPoint.__typename; + otherFields.aPolygon.forEach(v => { + delete v.__typename; + }); + expect({ + ...otherFields, + aPointer: { link: aPointer.objectId }, + aArray: aArray.map(({ value }) => value), + }).toEqual(fields); + + const updated = await apolloClient.mutate({ + mutation: gql` + mutation UpdateCustomer($input: UpdateCustomerInput!) { + updateCustomer(input: $input) { + customer { + aString + aBoolean + aDate + aArray { + ... on Element { + value + } + } + aGeoPoint { + longitude + latitude + } + aPointer { + objectId + } + aObject + aPolygon { + longitude + latitude + } + } + } + } + `, + variables: { + input: { fields: nullFields, id }, + }, + }); + const { + data: { + updateCustomer: { customer }, + }, + } = updated; + delete customer.__typename; + expect(Object.keys(customer).length).toEqual(8); + Object.keys(customer).forEach(k => { + expect(customer[k]).toBeNull(); + }); + try { + const queryResult = await apolloClient.query({ + query: gql` + query getEmptyCustomer($where: CustomerWhereInput!) { + customers(where: $where) { + edges { + node { + id + } + } + } + } + `, + variables: { + where: Object.keys(fields).reduce( + (acc, k) => ({ ...acc, [k]: { exists: false } }), + {} + ), + }, + }); + + expect(queryResult.data.customers.edges.length).toEqual(1); + } catch (e) { + console.error(JSON.stringify(e)); + } + }); + }); + + describe('Files Mutations', () => { + describe('Create', () => { + it('should return File object', async () => { + const clientMutationId = uuidv4(); + + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + }); + await createGQLFromParseServer(parseServer); + const body = new FormData(); + body.append( + 'operations', + JSON.stringify({ + query: ` + mutation CreateFile($input: CreateFileInput!) { + createFile(input: $input) { + clientMutationId + fileInfo { + name + url + } + } + } + `, + variables: { + input: { + clientMutationId, + upload: null, + }, + }, + }) + ); + body.append('map', JSON.stringify({ 1: ['variables.input.upload'] })); + body.append('1', 'My File Content', { + filename: 'myFileName.txt', + contentType: 'text/plain', + }); + + let res = await fetch('http://localhost:13377/graphql', { + method: 'POST', + headers, + body, + }); + + expect(res.status).toEqual(200); + + const result = JSON.parse(await res.text()); + + expect(result.data.createFile.clientMutationId).toEqual(clientMutationId); + expect(result.data.createFile.fileInfo.name).toEqual( + jasmine.stringMatching(/_myFileName.txt$/) + ); + expect(result.data.createFile.fileInfo.url).toEqual( + jasmine.stringMatching(/_myFileName.txt$/) + ); + + res = await fetch(result.data.createFile.fileInfo.url); + + expect(res.status).toEqual(200); + expect(await res.text()).toEqual('My File Content'); + }); + }); + }); + + describe("Config Queries", () => { + beforeEach(async () => { + // Setup initial config data + await Parse.Config.save( + { publicParam: 'publicValue', privateParam: 'privateValue' }, + { privateParam: true }, + { useMasterKey: true } + ); + }); + + it("should return the config value for a specific parameter", async () => { + const query = gql` + query cloudConfig($paramName: String!) { + cloudConfig(paramName: $paramName) { + value + isMasterKeyOnly + } + } + `; + + const result = await apolloClient.query({ + query, + variables: { paramName: 'publicParam' }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(result.errors).toBeUndefined(); + expect(result.data.cloudConfig.value).toEqual('publicValue'); + expect(result.data.cloudConfig.isMasterKeyOnly).toEqual(false); + }); + + it("should return null for non-existent parameter", async () => { + const query = gql` + query cloudConfig($paramName: String!) { + cloudConfig(paramName: $paramName) { + value + isMasterKeyOnly + } + } + `; + + const result = await apolloClient.query({ + query, + variables: { paramName: 'nonExistentParam' }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(result.errors).toBeUndefined(); + expect(result.data.cloudConfig.value).toBeNull(); + expect(result.data.cloudConfig.isMasterKeyOnly).toBeNull(); + }); + }); + + describe("Config Mutations", () => { + it("should update a config value using mutation and retrieve it with query", async () => { + const mutation = gql` + mutation updateCloudConfig($input: UpdateCloudConfigInput!) { + updateCloudConfig(input: $input) { + clientMutationId + cloudConfig { + value + isMasterKeyOnly + } + } + } + `; + + const query = gql` + query cloudConfig($paramName: String!) { + cloudConfig(paramName: $paramName) { + value + isMasterKeyOnly + } + } + `; + + const mutationResult = await apolloClient.mutate({ + mutation, + variables: { + input: { + clientMutationId: 'test-mutation-id', + paramName: 'testParam', + value: 'testValue', + isMasterKeyOnly: false, + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(mutationResult.errors).toBeUndefined(); + expect(mutationResult.data.updateCloudConfig.cloudConfig.value).toEqual('testValue'); + expect(mutationResult.data.updateCloudConfig.cloudConfig.isMasterKeyOnly).toEqual(false); + + const queryResult = await apolloClient.query({ + query, + variables: { paramName: 'testParam' }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(queryResult.errors).toBeUndefined(); + expect(queryResult.data.cloudConfig.value).toEqual('testValue'); + expect(queryResult.data.cloudConfig.isMasterKeyOnly).toEqual(false); + }); + + it("should update a config value with isMasterKeyOnly set to true", async () => { + const mutation = gql` + mutation updateCloudConfig($input: UpdateCloudConfigInput!) { + updateCloudConfig(input: $input) { + clientMutationId + cloudConfig { + value + isMasterKeyOnly + } + } + } + `; + + const query = gql` + query cloudConfig($paramName: String!) { + cloudConfig(paramName: $paramName) { + value + isMasterKeyOnly + } + } + `; + + const mutationResult = await apolloClient.mutate({ + mutation, + variables: { + input: { + clientMutationId: 'test-mutation-id-2', + paramName: 'privateTestParam', + value: 'privateValue', + isMasterKeyOnly: true, + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(mutationResult.errors).toBeUndefined(); + expect(mutationResult.data.updateCloudConfig.cloudConfig.value).toEqual('privateValue'); + expect(mutationResult.data.updateCloudConfig.cloudConfig.isMasterKeyOnly).toEqual(true); + + const queryResult = await apolloClient.query({ + query, + variables: { paramName: 'privateTestParam' }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(queryResult.errors).toBeUndefined(); + expect(queryResult.data.cloudConfig.value).toEqual('privateValue'); + expect(queryResult.data.cloudConfig.isMasterKeyOnly).toEqual(true); + }); + + it("should update an existing config value", async () => { + await Parse.Config.save( + { existingParam: 'initialValue' }, + {}, + { useMasterKey: true } + ); + + const mutation = gql` + mutation updateCloudConfig($input: UpdateCloudConfigInput!) { + updateCloudConfig(input: $input) { + clientMutationId + cloudConfig { + value + isMasterKeyOnly + } + } + } + `; + + const query = gql` + query cloudConfig($paramName: String!) { + cloudConfig(paramName: $paramName) { + value + isMasterKeyOnly + } + } + `; + + const mutationResult = await apolloClient.mutate({ + mutation, + variables: { + input: { + clientMutationId: 'test-mutation-id-3', + paramName: 'existingParam', + value: 'updatedValue', + isMasterKeyOnly: false, + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(mutationResult.errors).toBeUndefined(); + expect(mutationResult.data.updateCloudConfig.cloudConfig.value).toEqual('updatedValue'); + + const queryResult = await apolloClient.query({ + query, + variables: { paramName: 'existingParam' }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(queryResult.errors).toBeUndefined(); + expect(queryResult.data.cloudConfig.value).toEqual('updatedValue'); + }); + + it("should require master key to update config", async () => { + const mutation = gql` + mutation updateCloudConfig($input: UpdateCloudConfigInput!) { + updateCloudConfig(input: $input) { + clientMutationId + cloudConfig { + value + isMasterKeyOnly + } + } + } + `; + + try { + await apolloClient.mutate({ + mutation, + variables: { + input: { + clientMutationId: 'test-mutation-id-4', + paramName: 'testParam', + value: 'testValue', + isMasterKeyOnly: false, + }, + }, + context: { + headers: { + 'X-Parse-Application-Id': 'test', + }, + }, + }); + fail('Should have thrown an error'); + } catch (error) { + expect(error.graphQLErrors).toBeDefined(); + expect(error.graphQLErrors[0].message).toContain('Permission denied'); + } + }); + }) + + describe('Users Queries', () => { + it('should return current logged user', async () => { + const userName = 'user1', + password = 'user1', + email = 'emailUser1@parse.com'; + + const user = new Parse.User(); + user.setUsername(userName); + user.setPassword(password); + user.setEmail(email); + await user.signUp(); + + const session = await Parse.Session.current(); + const result = await apolloClient.query({ + query: gql` + query GetCurrentUser { + viewer { + user { + id + username + email + } + } + } + `, + context: { + headers: { + 'X-Parse-Session-Token': session.getSessionToken(), + }, + }, + }); + + const { id, username: resultUserName, email: resultEmail } = result.data.viewer.user; + expect(id).toBeDefined(); + expect(resultUserName).toEqual(userName); + expect(resultEmail).toEqual(email); + }); + + it('should return logged user including pointer', async () => { + const foo = new Parse.Object('Foo'); + foo.set('bar', 'hello'); + + const userName = 'user1', + password = 'user1', + email = 'emailUser1@parse.com'; + + const user = new Parse.User(); + user.setUsername(userName); + user.setPassword(password); + user.setEmail(email); + user.set('userFoo', foo); + await user.signUp(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const session = await Parse.Session.current(); + const result = await apolloClient.query({ + query: gql` + query GetCurrentUser { + viewer { + sessionToken + user { + id + objectId + userFoo { + bar + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Session-Token': session.getSessionToken(), + }, + }, + }); + + const sessionToken = result.data.viewer.sessionToken; + const { objectId, userFoo: resultFoo } = result.data.viewer.user; + expect(objectId).toEqual(user.id); + expect(sessionToken).toBeDefined(); + expect(resultFoo).toBeDefined(); + expect(resultFoo.bar).toEqual('hello'); + }); + it('should return logged user and do not by pass pointer security', async () => { + const masterKeyOnlyACL = new Parse.ACL(); + masterKeyOnlyACL.setPublicReadAccess(false); + masterKeyOnlyACL.setPublicWriteAccess(false); + const foo = new Parse.Object('Foo'); + foo.setACL(masterKeyOnlyACL); + foo.set('bar', 'hello'); + await foo.save(null, { useMasterKey: true }); + const userName = 'userx1', + password = 'user1', + email = 'emailUserx1@parse.com'; + + const user = new Parse.User(); + user.setUsername(userName); + user.setPassword(password); + user.setEmail(email); + user.set('userFoo', foo); + await user.signUp(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const session = await Parse.Session.current(); + const result = await apolloClient.query({ + query: gql` + query GetCurrentUser { + viewer { + sessionToken + user { + id + objectId + userFoo { + bar + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Session-Token': session.getSessionToken(), + }, + }, + }); + + const sessionToken = result.data.viewer.sessionToken; + const { objectId, userFoo: resultFoo } = result.data.viewer.user; + expect(objectId).toEqual(user.id); + expect(sessionToken).toBeDefined(); + expect(resultFoo).toEqual(null); + }); + }); + + describe('Users Mutations', () => { + const challengeAdapter = { + validateAuthData: () => Promise.resolve({ response: { someData: true } }), + validateAppId: () => Promise.resolve(), + challenge: () => Promise.resolve({ someData: true }), + options: { anOption: true }, + }; + + it('should create user and return authData response', async () => { + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + auth: { + challengeAdapter, + }, + }); + await createGQLFromParseServer(parseServer); + const clientMutationId = uuidv4(); + + const result = await apolloClient.mutate({ + mutation: gql` + mutation createUser($input: CreateUserInput!) { + createUser(input: $input) { + clientMutationId + user { + id + authDataResponse + } + } + } + `, + variables: { + input: { + clientMutationId, + fields: { + authData: { + challengeAdapter: { + id: 'challengeAdapter', + }, + }, + }, + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(result.data.createUser.clientMutationId).toEqual(clientMutationId); + expect(result.data.createUser.user.authDataResponse).toEqual({ + challengeAdapter: { someData: true }, + }); + }); + + it('should sign user up', async () => { + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + auth: { + challengeAdapter, + }, + }); + await createGQLFromParseServer(parseServer); + const clientMutationId = uuidv4(); + const userSchema = new Parse.Schema('_User'); + userSchema.addString('someField'); + userSchema.addPointer('aPointer', '_User'); + await userSchema.update(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + const result = await apolloClient.mutate({ + mutation: gql` + mutation SignUp($input: SignUpInput!) { + signUp(input: $input) { + clientMutationId + viewer { + sessionToken + user { + someField + authDataResponse + aPointer { + id + username + } + } + } + } + } + `, + variables: { + input: { + clientMutationId, + fields: { + username: 'user1', + password: 'user1', + authData: { + challengeAdapter: { + id: 'challengeAdapter', + }, + }, + aPointer: { + createAndLink: { + username: 'user2', + password: 'user2', + someField: 'someValue2', + ACL: { public: { read: true, write: true } }, + }, + }, + someField: 'someValue', + }, + }, + }, + }); + + expect(result.data.signUp.clientMutationId).toEqual(clientMutationId); + expect(result.data.signUp.viewer.sessionToken).toBeDefined(); + expect(result.data.signUp.viewer.user.someField).toEqual('someValue'); + expect(result.data.signUp.viewer.user.aPointer.id).toBeDefined(); + expect(result.data.signUp.viewer.user.aPointer.username).toEqual('user2'); + expect(typeof result.data.signUp.viewer.sessionToken).toBe('string'); + expect(result.data.signUp.viewer.user.authDataResponse).toEqual({ + challengeAdapter: { someData: true }, + }); + }); + + it('should login with user', async () => { + const clientMutationId = uuidv4(); + const userSchema = new Parse.Schema('_User'); + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + auth: { + challengeAdapter, + myAuth: { + module: global.mockCustomAuthenticator('parse', 'graphql'), + }, + }, + }); + await createGQLFromParseServer(parseServer); + userSchema.addString('someField'); + userSchema.addPointer('aPointer', '_User'); + await userSchema.update(); + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + const result = await apolloClient.mutate({ + mutation: gql` + mutation LogInWith($input: LogInWithInput!) { + logInWith(input: $input) { + clientMutationId + viewer { + sessionToken + user { + someField + authDataResponse + aPointer { + id + username + } + } + } + } + } + `, + variables: { + input: { + clientMutationId, + authData: { + challengeAdapter: { id: 'challengeAdapter' }, + myAuth: { + id: 'parse', + password: 'graphql', + }, + }, + fields: { + someField: 'someValue', + aPointer: { + createAndLink: { + username: 'user2', + password: 'user2', + someField: 'someValue2', + ACL: { public: { read: true, write: true } }, + }, + }, + }, + }, + }, + }); + + expect(result.data.logInWith.clientMutationId).toEqual(clientMutationId); + expect(result.data.logInWith.viewer.sessionToken).toBeDefined(); + expect(result.data.logInWith.viewer.user.someField).toEqual('someValue'); + expect(typeof result.data.logInWith.viewer.sessionToken).toBe('string'); + expect(result.data.logInWith.viewer.user.aPointer.id).toBeDefined(); + expect(result.data.logInWith.viewer.user.aPointer.username).toEqual('user2'); + expect(result.data.logInWith.viewer.user.authDataResponse).toEqual({ + challengeAdapter: { someData: true }, + }); + }); + + it('should handle challenge', async () => { + const clientMutationId = uuidv4(); + + spyOn(challengeAdapter, 'challenge').and.callThrough(); + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + auth: { + challengeAdapter, + }, + }); + await createGQLFromParseServer(parseServer); + const user = new Parse.User(); + await user.save({ username: 'username', password: 'password' }); + + const result = await apolloClient.mutate({ + mutation: gql` + mutation Challenge($input: ChallengeInput!) { + challenge(input: $input) { + clientMutationId + challengeData + } + } + `, + variables: { + input: { + clientMutationId, + username: 'username', + password: 'password', + challengeData: { + challengeAdapter: { someChallengeData: true }, + }, + }, + }, + }); + + const challengeCall = challengeAdapter.challenge.calls.argsFor(0); + expect(challengeAdapter.challenge).toHaveBeenCalledTimes(1); + expect(challengeCall[0]).toEqual({ someChallengeData: true }); + expect(challengeCall[1]).toEqual(undefined); + expect(challengeCall[2]).toEqual(challengeAdapter); + expect(challengeCall[3].object instanceof Parse.User).toBeTruthy(); + expect(challengeCall[3].original instanceof Parse.User).toBeTruthy(); + expect(challengeCall[3].isChallenge).toBeTruthy(); + expect(challengeCall[3].object.id).toEqual(user.id); + expect(challengeCall[3].original.id).toEqual(user.id); + expect(result.data.challenge.clientMutationId).toEqual(clientMutationId); + expect(result.data.challenge.challengeData).toEqual({ + challengeAdapter: { someData: true }, + }); + + await expectAsync( + apolloClient.mutate({ + mutation: gql` + mutation Challenge($input: ChallengeInput!) { + challenge(input: $input) { + clientMutationId + challengeData + } + } + `, + variables: { + input: { + clientMutationId, + username: 'username', + password: 'wrongPassword', + challengeData: { + challengeAdapter: { someChallengeData: true }, + }, + }, + }, + }) + ).toBeRejected(); + }); + + it('should log the user in', async () => { + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + auth: { + challengeAdapter, + }, + }); + await createGQLFromParseServer(parseServer); + const clientMutationId = uuidv4(); + const user = new Parse.User(); + user.setUsername('user1'); + user.setPassword('user1'); + user.set('someField', 'someValue'); + await user.signUp(); + await Parse.User.logOut(); + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + const result = await apolloClient.mutate({ + mutation: gql` + mutation LogInUser($input: LogInInput!) { + logIn(input: $input) { + clientMutationId + viewer { + sessionToken + user { + authDataResponse + someField + } + } + } + } + `, + variables: { + input: { + clientMutationId, + username: 'user1', + password: 'user1', + authData: { challengeAdapter: { token: true } }, + }, + }, + }); + + expect(result.data.logIn.clientMutationId).toEqual(clientMutationId); + expect(result.data.logIn.viewer.sessionToken).toBeDefined(); + expect(result.data.logIn.viewer.user.someField).toEqual('someValue'); + expect(typeof result.data.logIn.viewer.sessionToken).toBe('string'); + expect(result.data.logIn.viewer.user.authDataResponse).toEqual({ + challengeAdapter: { someData: true }, + }); + }); + + it('should log the user out', async () => { + const clientMutationId = uuidv4(); + const user = new Parse.User(); + user.setUsername('user1'); + user.setPassword('user1'); + await user.signUp(); + await Parse.User.logOut(); + + const logIn = await apolloClient.mutate({ + mutation: gql` + mutation LogInUser($input: LogInInput!) { + logIn(input: $input) { + viewer { + sessionToken + } + } + } + `, + variables: { + input: { + username: 'user1', + password: 'user1', + }, + }, + }); + + const sessionToken = logIn.data.logIn.viewer.sessionToken; + + const logOut = await apolloClient.mutate({ + mutation: gql` + mutation LogOutUser($input: LogOutInput!) { + logOut(input: $input) { + clientMutationId + ok + } + } + `, + context: { + headers: { + 'X-Parse-Session-Token': sessionToken, + }, + }, + variables: { + input: { + clientMutationId, + }, + }, + }); + expect(logOut.data.logOut.clientMutationId).toEqual(clientMutationId); + expect(logOut.data.logOut.ok).toEqual(true); + + try { + await apolloClient.query({ + query: gql` + query GetCurrentUser { + viewer { + username + } + } + `, + context: { + headers: { + 'X-Parse-Session-Token': sessionToken, + }, + }, + }); + fail('should not retrieve current user due to session token'); + } catch (err) { + const { statusCode, result } = err.networkError; + expect(statusCode).toBe(400); + expect(result).toEqual({ + code: 209, + error: 'Invalid session token', + }); + } + }); + + it('should send reset password', async () => { + const clientMutationId = uuidv4(); + const emailAdapter = { + sendVerificationEmail: () => { }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => { }, + }; + parseServer = await global.reconfigureServer({ + appName: 'test', + emailAdapter: emailAdapter, + publicServerURL: 'http://test.test', + }); + await createGQLFromParseServer(parseServer); + const user = new Parse.User(); + user.setUsername('user1'); + user.setPassword('user1'); + user.setEmail('user1@user1.user1'); + await user.signUp(); + await Parse.User.logOut(); + const result = await apolloClient.mutate({ + mutation: gql` + mutation ResetPassword($input: ResetPasswordInput!) { + resetPassword(input: $input) { + clientMutationId + ok + } + } + `, + variables: { + input: { + clientMutationId, + email: 'user1@user1.user1', + }, + }, + }); + + expect(result.data.resetPassword.clientMutationId).toEqual(clientMutationId); + expect(result.data.resetPassword.ok).toBeTruthy(); + }); + + it('should reset password', async () => { + const clientMutationId = uuidv4(); + let resetPasswordToken; + const emailAdapter = { + sendVerificationEmail: () => { }, + sendPasswordResetEmail: ({ link }) => { + resetPasswordToken = link.split('token=')[1].split('&')[0]; + }, + sendMail: () => { }, + }; + parseServer = await global.reconfigureServer({ + appName: 'test', + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:13377/parse', + auth: { + myAuth: { + module: global.mockCustomAuthenticator('parse', 'graphql'), + }, + }, + }); + await createGQLFromParseServer(parseServer); + const user = new Parse.User(); + user.setUsername('user1'); + user.setPassword('user1'); + user.setEmail('user1@user1.user1'); + await user.signUp(); + await Parse.User.logOut(); + await Parse.User.requestPasswordReset('user1@user1.user1'); + await apolloClient.mutate({ + mutation: gql` + mutation ConfirmResetPassword($input: ConfirmResetPasswordInput!) { + confirmResetPassword(input: $input) { + clientMutationId + ok + } + } + `, + variables: { + input: { + clientMutationId, + username: 'user1', + password: 'newPassword', + token: resetPasswordToken, + }, + }, + }); + const result = await apolloClient.mutate({ + mutation: gql` + mutation LogInUser($input: LogInInput!) { + logIn(input: $input) { + clientMutationId + viewer { + sessionToken + } + } + } + `, + variables: { + input: { + clientMutationId, + username: 'user1', + password: 'newPassword', + }, + }, + }); + + expect(result.data.logIn.clientMutationId).toEqual(clientMutationId); + expect(result.data.logIn.viewer.sessionToken).toBeDefined(); + expect(typeof result.data.logIn.viewer.sessionToken).toBe('string'); + }); + + it('should send verification email again', async () => { + const clientMutationId = uuidv4(); + const emailAdapter = { + sendVerificationEmail: () => { }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => { }, + }; + parseServer = await global.reconfigureServer({ + appName: 'test', + emailAdapter: emailAdapter, + publicServerURL: 'http://test.test', + }); + await createGQLFromParseServer(parseServer); + const user = new Parse.User(); + user.setUsername('user1'); + user.setPassword('user1'); + user.setEmail('user1@user1.user1'); + await user.signUp(); + await Parse.User.logOut(); + const result = await apolloClient.mutate({ + mutation: gql` + mutation SendVerificationEmail($input: SendVerificationEmailInput!) { + sendVerificationEmail(input: $input) { + clientMutationId + ok + } + } + `, + variables: { + input: { + clientMutationId, + email: 'user1@user1.user1', + }, + }, + }); + + expect(result.data.sendVerificationEmail.clientMutationId).toEqual(clientMutationId); + expect(result.data.sendVerificationEmail.ok).toBeTruthy(); + }); + }); + + describe('Session Token', () => { + it('should fail due to invalid session token', async () => { + try { + await apolloClient.query({ + query: gql` + query GetCurrentUser { + me { + username + } + } + `, + context: { + headers: { + 'X-Parse-Session-Token': 'foo', + }, + }, + }); + fail('should not retrieve current user due to session token'); + } catch (err) { + const { statusCode, result } = err.networkError; + expect(statusCode).toBe(400); + expect(result).toEqual({ + code: 209, + error: 'Invalid session token', + }); + } + }); + + it('should fail due to empty session token', async () => { + try { + await apolloClient.query({ + query: gql` + query GetCurrentUser { + viewer { + user { + username + } + } + } + `, + context: { + headers: { + 'X-Parse-Session-Token': '', + }, + }, + }); + fail('should not retrieve current user due to session token'); + } catch (err) { + const { graphQLErrors } = err; + expect(graphQLErrors.length).toBe(1); + expect(graphQLErrors[0].message).toBe('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('Invalid session token')); + } + }); + + it('should find a user and fail due to empty session token', async () => { + const car = new Parse.Object('Car'); + await car.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + try { + await apolloClient.query({ + query: gql` + query GetCurrentUser { + viewer { + user { + username + } + } + cars { + edges { + node { + id + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Session-Token': '', + }, + }, + }); + fail('should not retrieve current user due to session token'); + } catch (err) { + const { graphQLErrors } = err; + expect(graphQLErrors.length).toBe(1); + expect(graphQLErrors[0].message).toBe('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('Invalid session token')); + } + }); + }); + + describe('Functions Mutations', () => { + beforeEach(async () => { + await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true }); + }); + it('can be called', async () => { + try { + const clientMutationId = uuidv4(); + + Parse.Cloud.define('hello', async () => { + return 'Hello world!'; + }); + + const result = await apolloClient.mutate({ + mutation: gql` + mutation CallFunction($input: CallCloudCodeInput!) { + callCloudCode(input: $input) { + clientMutationId + result + } + } + `, + variables: { + input: { + clientMutationId, + functionName: 'hello', + }, + }, + }); + + expect(result.data.callCloudCode.clientMutationId).toEqual(clientMutationId); + expect(result.data.callCloudCode.result).toEqual('Hello world!'); + } catch (e) { + handleError(e); + } + }); + + it('can throw errors', async () => { + Parse.Cloud.define('hello', async () => { + throw new Error('Some error message.'); + }); + + try { + await apolloClient.mutate({ + mutation: gql` + mutation CallFunction { + callCloudCode(input: { functionName: hello }) { + result + } + } + `, + }); + fail('Should throw an error'); + } catch (e) { + const { graphQLErrors } = e; + expect(graphQLErrors.length).toBe(1); + expect(graphQLErrors[0].message).toBe('Some error message.'); + } + }); + + it('should accept different params', done => { + Parse.Cloud.define('hello', async req => { + expect(Utils.isDate(req.params.date)).toBe(true); + expect(req.params.date.getTime()).toBe(1463907600000); + expect(Utils.isDate(req.params.dateList[0])).toBe(true); + expect(req.params.dateList[0].getTime()).toBe(1463907600000); + expect(Utils.isDate(req.params.complexStructure.date[0])).toBe(true); + expect(req.params.complexStructure.date[0].getTime()).toBe(1463907600000); + expect(Utils.isDate(req.params.complexStructure.deepDate.date[0])).toBe(true); + expect(req.params.complexStructure.deepDate.date[0].getTime()).toBe(1463907600000); + expect(Utils.isDate(req.params.complexStructure.deepDate2[0].date)).toBe(true); + expect(req.params.complexStructure.deepDate2[0].date.getTime()).toBe(1463907600000); + // Regression for #2294 + expect(req.params.file instanceof Parse.File).toBe(true); + expect(req.params.file.url()).toEqual('https://some.url'); + // Regression for #2204 + expect(req.params.array).toEqual(['a', 'b', 'c']); + expect(Array.isArray(req.params.array)).toBe(true); + expect(req.params.arrayOfArray).toEqual([ + ['a', 'b', 'c'], + ['d', 'e', 'f'], + ]); + expect(Array.isArray(req.params.arrayOfArray)).toBe(true); + expect(Array.isArray(req.params.arrayOfArray[0])).toBe(true); + expect(Array.isArray(req.params.arrayOfArray[1])).toBe(true); + + done(); + }); + + const params = { + date: { + __type: 'Date', + iso: '2016-05-22T09:00:00.000Z', + }, + dateList: [ + { + __type: 'Date', + iso: '2016-05-22T09:00:00.000Z', + }, + ], + lol: 'hello', + complexStructure: { + date: [ + { + __type: 'Date', + iso: '2016-05-22T09:00:00.000Z', + }, + ], + deepDate: { + date: [ + { + __type: 'Date', + iso: '2016-05-22T09:00:00.000Z', + }, + ], + }, + deepDate2: [ + { + date: { + __type: 'Date', + iso: '2016-05-22T09:00:00.000Z', + }, + }, + ], + }, + file: Parse.File.fromJSON({ + __type: 'File', + name: 'name', + url: 'https://some.url', + }), + array: ['a', 'b', 'c'], + arrayOfArray: [ + ['a', 'b', 'c'], + ['d', 'e', 'f'], + ], + }; + + apolloClient.mutate({ + mutation: gql` + mutation CallFunction($params: Object) { + callCloudCode(input: { functionName: hello, params: $params }) { + result + } + } + `, + variables: { + params, + }, + }); + }); + + it('should list all functions in the enum type', async () => { + try { + Parse.Cloud.define('a', async () => { + return 'hello a'; + }); + + Parse.Cloud.define('b', async () => { + return 'hello b'; + }); + + Parse.Cloud.define('_underscored', async () => { + return 'hello _underscored'; + }); + + Parse.Cloud.define('contains1Number', async () => { + return 'hello contains1Number'; + }); + + const functionEnum = ( + await apolloClient.query({ + query: gql` + query ObjectType { + __type(name: "CloudCodeFunction") { + kind + enumValues { + name + } + } + } + `, + }) + ).data['__type']; + expect(functionEnum.kind).toEqual('ENUM'); + expect(functionEnum.enumValues.map(value => value.name).sort()).toEqual([ + '_underscored', + 'a', + 'b', + 'contains1Number', + ]); + } catch (e) { + handleError(e); + } + }); + + it('should warn functions not matching GraphQL allowed names', async () => { + try { + spyOn(parseGraphQLServer.parseGraphQLSchema.log, 'warn').and.callThrough(); + + Parse.Cloud.define('a', async () => { + return 'hello a'; + }); + + Parse.Cloud.define('double-barrelled', async () => { + return 'hello b'; + }); + + Parse.Cloud.define('1NumberInTheBeggning', async () => { + return 'hello contains1Number'; + }); + + const functionEnum = ( + await apolloClient.query({ + query: gql` + query ObjectType { + __type(name: "CloudCodeFunction") { + kind + enumValues { + name + } + } + } + `, + }) + ).data['__type']; + expect(functionEnum.kind).toEqual('ENUM'); + expect(functionEnum.enumValues.map(value => value.name).sort()).toEqual(['a']); + expect( + parseGraphQLServer.parseGraphQLSchema.log.warn.calls + .all() + .map(call => call.args[0]) + .sort() + ).toEqual([ + 'Function 1NumberInTheBeggning could not be added to the auto schema because GraphQL names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/.', + 'Function double-barrelled could not be added to the auto schema because GraphQL names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/.', + ]); + } catch (e) { + handleError(e); + } + }); + }); + + describe('Data Types', () => { + beforeEach(async () => { + const schema = new Parse.Schema('SomeClass'); + await schema.purge().catch(() => {}); + await schema.delete().catch(() => {}); + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + }); + + it('should support String', async () => { + try { + const someFieldValue = 'some string'; + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass($schemaFields: SchemaFieldsInput) { + createClass(input: { name: "SomeClass", schemaFields: $schemaFields }) { + clientMutationId + } + } + `, + variables: { + schemaFields: { + addStrings: [{ name: 'someField' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.someField.type).toEqual('String'); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + } + } + } + `, + variables: { + fields: { + someField: someFieldValue, + }, + }, + }); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!, $someFieldValue: String) { + someClass(id: $id) { + someField + } + someClasses(where: { someField: { equalTo: $someFieldValue } }) { + edges { + node { + someField + } + } + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + someFieldValue, + }, + }); + + expect(typeof getResult.data.someClass.someField).toEqual('string'); + expect(getResult.data.someClass.someField).toEqual(someFieldValue); + expect(getResult.data.someClasses.edges.length).toEqual(1); + } catch (e) { + handleError(e); + } + }); + + it('should support Int numbers', async () => { + try { + const someFieldValue = 123; + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass($schemaFields: SchemaFieldsInput) { + createClass(input: { name: "SomeClass", schemaFields: $schemaFields }) { + clientMutationId + } + } + `, + variables: { + schemaFields: { + addNumbers: [{ name: 'someField' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + } + } + } + `, + variables: { + fields: { + someField: someFieldValue, + }, + }, + }); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.someField.type).toEqual('Number'); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!, $someFieldValue: Float) { + someClass(id: $id) { + someField + } + someClasses(where: { someField: { equalTo: $someFieldValue } }) { + edges { + node { + someField + } + } + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + someFieldValue, + }, + }); + + expect(typeof getResult.data.someClass.someField).toEqual('number'); + expect(getResult.data.someClass.someField).toEqual(someFieldValue); + expect(getResult.data.someClasses.edges.length).toEqual(1); + } catch (e) { + handleError(e); + } + }); + + it('should support Float numbers', async () => { + try { + const someFieldValue = 123.4; + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass($schemaFields: SchemaFieldsInput) { + createClass(input: { name: "SomeClass", schemaFields: $schemaFields }) { + clientMutationId + } + } + `, + variables: { + schemaFields: { + addNumbers: [{ name: 'someField' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.someField.type).toEqual('Number'); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + } + } + } + `, + variables: { + fields: { + someField: someFieldValue, + }, + }, + }); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!, $someFieldValue: Float) { + someClass(id: $id) { + someField + } + someClasses(where: { someField: { equalTo: $someFieldValue } }) { + edges { + node { + someField + } + } + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + someFieldValue, + }, + }); + + expect(typeof getResult.data.someClass.someField).toEqual('number'); + expect(getResult.data.someClass.someField).toEqual(someFieldValue); + expect(getResult.data.someClasses.edges.length).toEqual(1); + } catch (e) { + handleError(e); + } + }); + + it('should support Boolean', async () => { + try { + const someFieldValueTrue = true; + const someFieldValueFalse = false; + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass($schemaFields: SchemaFieldsInput) { + createClass(input: { name: "SomeClass", schemaFields: $schemaFields }) { + clientMutationId + } + } + `, + variables: { + schemaFields: { + addBooleans: [{ name: 'someFieldTrue' }, { name: 'someFieldFalse' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.someFieldTrue.type).toEqual('Boolean'); + expect(schema.fields.someFieldFalse.type).toEqual('Boolean'); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + } + } + } + `, + variables: { + fields: { + someFieldTrue: someFieldValueTrue, + someFieldFalse: someFieldValueFalse, + }, + }, + }); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject( + $id: ID! + $someFieldValueTrue: Boolean + $someFieldValueFalse: Boolean + ) { + someClass(id: $id) { + someFieldTrue + someFieldFalse + } + someClasses( + where: { + someFieldTrue: { equalTo: $someFieldValueTrue } + someFieldFalse: { equalTo: $someFieldValueFalse } + } + ) { + edges { + node { + id + } + } + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + someFieldValueTrue, + someFieldValueFalse, + }, + }); + + expect(typeof getResult.data.someClass.someFieldTrue).toEqual('boolean'); + expect(typeof getResult.data.someClass.someFieldFalse).toEqual('boolean'); + expect(getResult.data.someClass.someFieldTrue).toEqual(true); + expect(getResult.data.someClass.someFieldFalse).toEqual(false); + expect(getResult.data.someClasses.edges.length).toEqual(1); + } catch (e) { + handleError(e); + } + }); + + it('should support Date', async () => { + try { + const someFieldValue = new Date(); + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass($schemaFields: SchemaFieldsInput) { + createClass(input: { name: "SomeClass", schemaFields: $schemaFields }) { + clientMutationId + } + } + `, + variables: { + schemaFields: { + addDates: [{ name: 'someField' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.someField.type).toEqual('Date'); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + } + } + } + `, + variables: { + fields: { + someField: someFieldValue, + }, + }, + }); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + someClass(id: $id) { + someField + } + someClasses(where: { someField: { exists: true } }) { + edges { + node { + id + } + } + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + }, + }); + + expect(new Date(getResult.data.someClass.someField)).toEqual(someFieldValue); + expect(getResult.data.someClasses.edges.length).toEqual(1); + } catch (e) { + handleError(e); + } + }); + + it('should support createdAt and updatedAt', async () => { + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass { + createClass(input: { name: "SomeClass" }) { + clientMutationId + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.createdAt.type).toEqual('Date'); + expect(schema.fields.updatedAt.type).toEqual('Date'); + }); + + it_id('93e748f6-ad9b-4c31-8e1e-c5685e2382fb')(it)('should support ACL', async () => { + const someClass = new Parse.Object('SomeClass'); + await someClass.save(); + + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + + const user = new Parse.User(); + user.set('username', 'username'); + user.set('password', 'password'); + user.setACL(roleACL); + await user.signUp(); + + const user2 = new Parse.User(); + user2.set('username', 'username2'); + user2.set('password', 'password2'); + user2.setACL(roleACL); + await user2.signUp(); + + const role = new Parse.Role('aRole', roleACL); + await role.save(); + + const role2 = new Parse.Role('aRole2', roleACL); + await role2.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const gqlUser = ( + await apolloClient.query({ + query: gql` + query getUser($id: ID!) { + user(id: $id) { + id + } + } + `, + variables: { id: user.id }, + }) + ).data.user; + const { + data: { createSomeClass }, + } = await apolloClient.mutate({ + mutation: gql` + mutation Create($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + objectId + ACL { + users { + userId + read + write + } + roles { + roleName + read + write + } + public { + read + write + } + } + } + } + } + `, + variables: { + fields: { + ACL: { + users: [ + { userId: gqlUser.id, read: true, write: true }, + { userId: user2.id, read: true, write: false }, + ], + roles: [ + { roleName: 'aRole', read: true, write: false }, + { roleName: 'aRole2', read: false, write: true }, + ], + public: { read: true, write: true }, + }, + }, + }, + }); + + const expectedCreateACL = { + __typename: 'ACL', + users: [ + { + userId: toGlobalId('_User', user.id), + read: true, + write: true, + __typename: 'UserACL', + }, + { + userId: toGlobalId('_User', user2.id), + read: true, + write: false, + __typename: 'UserACL', + }, + ], + roles: [ + { + roleName: 'aRole', + read: true, + write: false, + __typename: 'RoleACL', + }, + { + roleName: 'aRole2', + read: false, + write: true, + __typename: 'RoleACL', + }, + ], + public: { read: true, write: true, __typename: 'PublicACL' }, + }; + const query1 = new Parse.Query('SomeClass'); + const obj1 = ( + await query1.get(createSomeClass.someClass.objectId, { + useMasterKey: true, + }) + ).toJSON(); + expect(obj1.ACL[user.id]).toEqual({ read: true, write: true }); + expect(obj1.ACL[user2.id]).toEqual({ read: true }); + expect(obj1.ACL['role:aRole']).toEqual({ read: true }); + expect(obj1.ACL['role:aRole2']).toEqual({ write: true }); + expect(obj1.ACL['*']).toEqual({ read: true, write: true }); + expect(createSomeClass.someClass.ACL).toEqual(expectedCreateACL); + + const { + data: { updateSomeClass }, + } = await apolloClient.mutate({ + mutation: gql` + mutation Update($id: ID!, $fields: UpdateSomeClassFieldsInput) { + updateSomeClass(input: { id: $id, fields: $fields }) { + someClass { + id + objectId + ACL { + users { + userId + read + write + } + roles { + roleName + read + write + } + public { + read + write + } + } + } + } + } + `, + variables: { + id: createSomeClass.someClass.id, + fields: { + ACL: { + roles: [{ roleName: 'aRole', write: true, read: true }], + public: { read: true, write: false }, + }, + }, + }, + }); + + const expectedUpdateACL = { + __typename: 'ACL', + users: null, + roles: [ + { + roleName: 'aRole', + read: true, + write: true, + __typename: 'RoleACL', + }, + ], + public: { read: true, write: false, __typename: 'PublicACL' }, + }; + + const query2 = new Parse.Query('SomeClass'); + const obj2 = ( + await query2.get(createSomeClass.someClass.objectId, { + useMasterKey: true, + }) + ).toJSON(); + + expect(obj2.ACL['role:aRole']).toEqual({ write: true, read: true }); + expect(obj2.ACL[user.id]).toBeUndefined(); + expect(obj2.ACL['*']).toEqual({ read: true }); + expect(updateSomeClass.someClass.ACL).toEqual(expectedUpdateACL); + }); + + it('should support pointer on create', async () => { + const company = new Parse.Object('Company'); + company.set('name', 'imACompany1'); + await company.save(); + + const country = new Parse.Object('Country'); + country.set('name', 'imACountry'); + country.set('company', company); + await country.save(); + + const company2 = new Parse.Object('Company'); + company2.set('name', 'imACompany2'); + await company2.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const { + data: { + createCountry: { country: result }, + }, + } = await apolloClient.mutate({ + mutation: gql` + mutation Create($fields: CreateCountryFieldsInput) { + createCountry(input: { fields: $fields }) { + country { + id + objectId + company { + id + objectId + name + } + } + } + } + `, + variables: { + fields: { + name: 'imCountry2', + company: { link: company2.id }, + }, + }, + }); + + expect(result.id).toBeDefined(); + expect(result.company.objectId).toEqual(company2.id); + expect(result.company.name).toEqual('imACompany2'); + }); + + it('should support nested pointer on create', async () => { + const company = new Parse.Object('Company'); + company.set('name', 'imACompany1'); + await company.save(); + + const country = new Parse.Object('Country'); + country.set('name', 'imACountry'); + country.set('company', company); + await country.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const { + data: { + createCountry: { country: result }, + }, + } = await apolloClient.mutate({ + mutation: gql` + mutation Create($fields: CreateCountryFieldsInput) { + createCountry(input: { fields: $fields }) { + country { + id + company { + id + name + } + } + } + } + `, + variables: { + fields: { + name: 'imCountry2', + company: { + createAndLink: { + name: 'imACompany2', + }, + }, + }, + }, + }); + + expect(result.id).toBeDefined(); + expect(result.company.id).toBeDefined(); + expect(result.company.name).toEqual('imACompany2'); + }); + + it('should support pointer on update', async () => { + const company = new Parse.Object('Company'); + company.set('name', 'imACompany1'); + await company.save(); + + const country = new Parse.Object('Country'); + country.set('name', 'imACountry'); + country.set('company', company); + await country.save(); + + const company2 = new Parse.Object('Company'); + company2.set('name', 'imACompany2'); + await company2.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const { + data: { + updateCountry: { country: result }, + }, + } = await apolloClient.mutate({ + mutation: gql` + mutation Update($id: ID!, $fields: UpdateCountryFieldsInput) { + updateCountry(input: { id: $id, fields: $fields }) { + country { + id + objectId + company { + id + objectId + name + } + } + } + } + `, + variables: { + id: country.id, + fields: { + company: { link: company2.id }, + }, + }, + }); + + expect(result.id).toBeDefined(); + expect(result.company.objectId).toEqual(company2.id); + expect(result.company.name).toEqual('imACompany2'); + }); + + it('should support nested pointer on update', async () => { + const company = new Parse.Object('Company'); + company.set('name', 'imACompany1'); + await company.save(); + + const country = new Parse.Object('Country'); + country.set('name', 'imACountry'); + country.set('company', company); + await country.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const { + data: { + updateCountry: { country: result }, + }, + } = await apolloClient.mutate({ + mutation: gql` + mutation Update($id: ID!, $fields: UpdateCountryFieldsInput) { + updateCountry(input: { id: $id, fields: $fields }) { + country { + id + company { + id + name + } + } + } + } + `, + variables: { + id: country.id, + fields: { + company: { + createAndLink: { + name: 'imACompany2', + }, + }, + }, + }, + }); + + expect(result.id).toBeDefined(); + expect(result.company.id).toBeDefined(); + expect(result.company.name).toEqual('imACompany2'); + }); + + it_only_db('mongo')('should support relation and nested relation on create', async () => { + const company = new Parse.Object('Company'); + company.set('name', 'imACompany1'); + await company.save(); + + const country = new Parse.Object('Country'); + country.set('name', 'imACountry'); + country.relation('companies').add(company); + await country.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const { + data: { + createCountry: { country: result }, + }, + } = await apolloClient.mutate({ + mutation: gql` + mutation CreateCountry($fields: CreateCountryFieldsInput) { + createCountry(input: { fields: $fields }) { + country { + id + objectId + name + companies { + edges { + node { + id + objectId + name + } + } + } + } + } + } + `, + variables: { + fields: { + name: 'imACountry2', + companies: { + add: [company.id], + createAndAdd: [ + { + name: 'imACompany2', + }, + { + name: 'imACompany3', + }, + ], + }, + }, + }, + }); + + expect(result.id).toBeDefined(); + expect(result.name).toEqual('imACountry2'); + expect(result.companies.edges.length).toEqual(3); + expect(result.companies.edges.some(o => o.node.objectId === company.id)).toBeTruthy(); + expect(result.companies.edges.some(o => o.node.name === 'imACompany2')).toBeTruthy(); + expect(result.companies.edges.some(o => o.node.name === 'imACompany3')).toBeTruthy(); + }); + + it_only_db('mongo')('should support deep nested creation', async () => { + parseServer = await global.reconfigureServer({ + maintenanceKey: 'test2', + maxUploadSize: '1kb', + requestComplexity: { includeDepth: 10 }, + }); + await createGQLFromParseServer(parseServer); + const team = new Parse.Object('Team'); + team.set('name', 'imATeam1'); + await team.save(); + + const company = new Parse.Object('Company'); + company.set('name', 'imACompany1'); + company.relation('teams').add(team); + await company.save(); + + const country = new Parse.Object('Country'); + country.set('name', 'imACountry'); + country.relation('companies').add(company); + await country.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const { + data: { + createCountry: { country: result }, + }, + } = await apolloClient.mutate({ + mutation: gql` + mutation CreateCountry($fields: CreateCountryFieldsInput) { + createCountry(input: { fields: $fields }) { + country { + id + name + companies { + edges { + node { + id + name + teams { + edges { + node { + id + name + } + } + } + } + } + } + } + } + } + `, + variables: { + fields: { + name: 'imACountry2', + companies: { + createAndAdd: [ + { + name: 'imACompany2', + teams: { + createAndAdd: { + name: 'imATeam2', + }, + }, + }, + { + name: 'imACompany3', + teams: { + createAndAdd: { + name: 'imATeam3', + }, + }, + }, + ], + }, + }, + }, + }); + + expect(result.id).toBeDefined(); + expect(result.name).toEqual('imACountry2'); + expect(result.companies.edges.length).toEqual(2); + expect( + result.companies.edges.some( + c => + c.node.name === 'imACompany2' && + c.node.teams.edges.some(t => t.node.name === 'imATeam2') + ) + ).toBeTruthy(); + expect( + result.companies.edges.some( + c => + c.node.name === 'imACompany3' && + c.node.teams.edges.some(t => t.node.name === 'imATeam3') + ) + ).toBeTruthy(); + }); + + it_only_db('mongo')('should support relation and nested relation on update', async () => { + const company1 = new Parse.Object('Company'); + company1.set('name', 'imACompany1'); + await company1.save(); + + const company2 = new Parse.Object('Company'); + company2.set('name', 'imACompany2'); + await company2.save(); + + const country = new Parse.Object('Country'); + country.set('name', 'imACountry'); + country.relation('companies').add(company1); + await country.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const { + data: { + updateCountry: { country: result }, + }, + } = await apolloClient.mutate({ + mutation: gql` + mutation UpdateCountry($id: ID!, $fields: UpdateCountryFieldsInput) { + updateCountry(input: { id: $id, fields: $fields }) { + country { + id + objectId + companies { + edges { + node { + id + objectId + name + } + } + } + } + } + } + `, + variables: { + id: country.id, + fields: { + companies: { + add: [company2.id], + remove: [company1.id], + createAndAdd: [ + { + name: 'imACompany3', + }, + ], + }, + }, + }, + }); + + expect(result.objectId).toEqual(country.id); + expect(result.companies.edges.length).toEqual(2); + expect(result.companies.edges.some(o => o.node.objectId === company2.id)).toBeTruthy(); + expect(result.companies.edges.some(o => o.node.name === 'imACompany3')).toBeTruthy(); + expect(result.companies.edges.some(o => o.node.objectId === company1.id)).toBeFalsy(); + }); + + it_only_db('mongo')('should support nested relation on create with filter', async () => { + const company = new Parse.Object('Company'); + company.set('name', 'imACompany1'); + await company.save(); + + const country = new Parse.Object('Country'); + country.set('name', 'imACountry'); + country.relation('companies').add(company); + await country.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const { + data: { + createCountry: { country: result }, + }, + } = await apolloClient.mutate({ + mutation: gql` + mutation CreateCountry($fields: CreateCountryFieldsInput, $where: CompanyWhereInput) { + createCountry(input: { fields: $fields }) { + country { + id + name + companies(where: $where) { + edges { + node { + id + name + } + } + } + } + } + } + `, + variables: { + where: { + name: { + equalTo: 'imACompany2', + }, + }, + fields: { + name: 'imACountry2', + companies: { + add: [company.id], + createAndAdd: [ + { + name: 'imACompany2', + }, + { + name: 'imACompany3', + }, + ], + }, + }, + }, + }); + + expect(result.id).toBeDefined(); + expect(result.name).toEqual('imACountry2'); + expect(result.companies.edges.length).toEqual(1); + expect(result.companies.edges.some(o => o.node.name === 'imACompany2')).toBeTruthy(); + }); + + it_only_db('mongo')('should support relation on query', async () => { + const company1 = new Parse.Object('Company'); + company1.set('name', 'imACompany1'); + await company1.save(); + + const company2 = new Parse.Object('Company'); + company2.set('name', 'imACompany2'); + await company2.save(); + + const country = new Parse.Object('Country'); + country.set('name', 'imACountry'); + country.relation('companies').add([company1, company2]); + await country.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + // Without where + const { + data: { country: result1 }, + } = await apolloClient.query({ + query: gql` + query getCountry($id: ID!) { + country(id: $id) { + id + objectId + companies { + edges { + node { + id + objectId + name + } + } + count + } + } + } + `, + variables: { + id: country.id, + }, + }); + + expect(result1.objectId).toEqual(country.id); + expect(result1.companies.edges.length).toEqual(2); + expect(result1.companies.edges.some(o => o.node.objectId === company1.id)).toBeTruthy(); + expect(result1.companies.edges.some(o => o.node.objectId === company2.id)).toBeTruthy(); + + // With where + const { + data: { country: result2 }, + } = await apolloClient.query({ + query: gql` + query getCountry($id: ID!, $where: CompanyWhereInput) { + country(id: $id) { + id + objectId + companies(where: $where) { + edges { + node { + id + objectId + name + } + } + } + } + } + `, + variables: { + id: country.id, + where: { + name: { equalTo: 'imACompany1' }, + }, + }, + }); + expect(result2.objectId).toEqual(country.id); + expect(result2.companies.edges.length).toEqual(1); + expect(result2.companies.edges[0].node.objectId).toEqual(company1.id); + }); + + it_id('f4312f2c-90bb-4583-b033-02078ae0ce84')(it)('should support relational where query', async () => { + const president = new Parse.Object('President'); + president.set('name', 'James'); + await president.save(); + + const employee = new Parse.Object('Employee'); + employee.set('name', 'John'); + await employee.save(); + + const company1 = new Parse.Object('Company'); + company1.set('name', 'imACompany1'); + await company1.save(); + + const company2 = new Parse.Object('Company'); + company2.set('name', 'imACompany2'); + company2.relation('employees').add([employee]); + await company2.save(); + + const country = new Parse.Object('Country'); + country.set('name', 'imACountry'); + country.relation('companies').add([company1, company2]); + await country.save(); + + const country2 = new Parse.Object('Country'); + country2.set('name', 'imACountry2'); + country2.relation('companies').add([company1]); + await country2.save(); + + const country3 = new Parse.Object('Country'); + country3.set('name', 'imACountry3'); + country3.set('president', president); + await country3.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + let { + data: { + countries: { edges: result }, + }, + } = await apolloClient.query({ + query: gql` + query findCountry($where: CountryWhereInput) { + countries(where: $where) { + edges { + node { + id + objectId + companies { + edges { + node { + id + objectId + name + } + } + } + } + } + } + } + `, + variables: { + where: { + companies: { + have: { + employees: { have: { name: { equalTo: 'John' } } }, + }, + }, + }, + }, + }); + expect(result.length).toEqual(1); + result = result[0].node; + expect(result.objectId).toEqual(country.id); + expect(result.companies.edges.length).toEqual(2); + + const { + data: { + countries: { edges: result2 }, + }, + } = await apolloClient.query({ + query: gql` + query findCountry($where: CountryWhereInput) { + countries(where: $where) { + edges { + node { + id + objectId + companies { + edges { + node { + id + objectId + name + } + } + } + } + } + } + } + `, + variables: { + where: { + companies: { + have: { + OR: [ + { name: { equalTo: 'imACompany1' } }, + { name: { equalTo: 'imACompany2' } }, + ], + }, + }, + }, + }, + }); + expect(result2.length).toEqual(2); + + const { + data: { + countries: { edges: result3 }, + }, + } = await apolloClient.query({ + query: gql` + query findCountry($where: CountryWhereInput) { + countries(where: $where) { + edges { + node { + id + name + } + } + } + } + `, + variables: { + where: { + companies: { exists: false }, + }, + }, + }); + expect(result3.length).toEqual(1); + expect(result3[0].node.name).toEqual('imACountry3'); + + const { + data: { + countries: { edges: result4 }, + }, + } = await apolloClient.query({ + query: gql` + query findCountry($where: CountryWhereInput) { + countries(where: $where) { + edges { + node { + id + name + } + } + } + } + `, + variables: { + where: { + president: { exists: false }, + }, + }, + }); + expect(result4.length).toEqual(2); + const { + data: { + countries: { edges: result5 }, + }, + } = await apolloClient.query({ + query: gql` + query findCountry($where: CountryWhereInput) { + countries(where: $where) { + edges { + node { + id + name + } + } + } + } + `, + variables: { + where: { + president: { exists: true }, + }, + }, + }); + expect(result5.length).toEqual(1); + const { + data: { + countries: { edges: result6 }, + }, + } = await apolloClient.query({ + query: gql` + query findCountry($where: CountryWhereInput) { + countries(where: $where) { + edges { + node { + id + objectId + name + } + } + } + } + `, + variables: { + where: { + companies: { + haveNot: { + OR: [ + { name: { equalTo: 'imACompany1' } }, + { name: { equalTo: 'imACompany2' } }, + ], + }, + }, + }, + }, + }); + expect(result6.length).toEqual(1); + expect(result6.length).toEqual(1); + expect(result6[0].node.name).toEqual('imACountry3'); + }); + + it('should support files', async () => { + try { + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + }); + await createGQLFromParseServer(parseServer); + const body = new FormData(); + body.append( + 'operations', + JSON.stringify({ + query: ` + mutation CreateFile($input: CreateFileInput!) { + createFile(input: $input) { + fileInfo { + name + url + } + } + } + `, + variables: { + input: { + upload: null, + }, + }, + }) + ); + body.append('map', JSON.stringify({ 1: ['variables.input.upload'] })); + body.append('1', 'My File Content', { + filename: 'myFileName.txt', + contentType: 'text/plain', + }); + + let res = await fetch('http://localhost:13377/graphql', { + method: 'POST', + headers, + body, + }); + expect(res.status).toEqual(200); + + const result = JSON.parse(await res.text()); + expect(result.data.createFile.fileInfo.name).toEqual( + jasmine.stringMatching(/_myFileName.txt$/) + ); + expect(result.data.createFile.fileInfo.url).toEqual( + jasmine.stringMatching(/_myFileName.txt$/) + ); + + const someFieldValue = result.data.createFile.fileInfo.name; + const someFieldObjectValue = result.data.createFile.fileInfo; + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass($schemaFields: SchemaFieldsInput) { + createClass(input: { name: "SomeClass", schemaFields: $schemaFields }) { + clientMutationId + } + } + `, + variables: { + schemaFields: { + addFiles: [{ name: 'someField' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const body2 = new FormData(); + body2.append( + 'operations', + JSON.stringify({ + query: ` + mutation CreateSomeObject( + $fields1: CreateSomeClassFieldsInput + $fields2: CreateSomeClassFieldsInput + $fields3: CreateSomeClassFieldsInput + ) { + createSomeClass1: createSomeClass( + input: { fields: $fields1 } + ) { + someClass { + id + someField { + name + url + } + } + } + createSomeClass2: createSomeClass( + input: { fields: $fields2 } + ) { + someClass { + id + someField { + name + url + } + } + } + createSomeClass3: createSomeClass( + input: { fields: $fields3 } + ) { + someClass { + id + someField { + name + url + } + } + } + } + `, + variables: { + fields1: { + someField: { file: someFieldValue }, + }, + fields2: { + someField: { + file: { + name: someFieldObjectValue.name, + url: someFieldObjectValue.url, + __type: 'File', + }, + }, + }, + fields3: { + someField: { upload: null }, + }, + }, + }) + ); + body2.append('map', JSON.stringify({ 1: ['variables.fields3.someField.upload'] })); + body2.append('1', 'My File Content', { + filename: 'myFileName.txt', + contentType: 'text/plain', + }); + + res = await fetch('http://localhost:13377/graphql', { + method: 'POST', + headers, + body: body2, + }); + expect(res.status).toEqual(200); + const result2 = JSON.parse(await res.text()); + expect(result2.data.createSomeClass1.someClass.someField.name).toEqual( + jasmine.stringMatching(/_myFileName.txt$/) + ); + expect(result2.data.createSomeClass1.someClass.someField.url).toEqual( + jasmine.stringMatching(/_myFileName.txt$/) + ); + expect(result2.data.createSomeClass2.someClass.someField.name).toEqual( + jasmine.stringMatching(/_myFileName.txt$/) + ); + expect(result2.data.createSomeClass2.someClass.someField.url).toEqual( + jasmine.stringMatching(/_myFileName.txt$/) + ); + expect(result2.data.createSomeClass3.someClass.someField.name).toEqual( + jasmine.stringMatching(/_myFileName.txt$/) + ); + expect(result2.data.createSomeClass3.someClass.someField.url).toEqual( + jasmine.stringMatching(/_myFileName.txt$/) + ); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.someField.type).toEqual('File'); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + someClass(id: $id) { + someField { + name + url + } + } + findSomeClass1: someClasses(where: { someField: { exists: true } }) { + edges { + node { + someField { + name + url + } + } + } + } + findSomeClass2: someClasses(where: { someField: { exists: true } }) { + edges { + node { + someField { + name + url + } + } + } + } + } + `, + variables: { + id: result2.data.createSomeClass1.someClass.id, + }, + }); + + expect(typeof getResult.data.someClass.someField).toEqual('object'); + expect(getResult.data.someClass.someField.name).toEqual( + result.data.createFile.fileInfo.name + ); + expect(getResult.data.someClass.someField.url).toEqual( + result.data.createFile.fileInfo.url + ); + expect(getResult.data.findSomeClass1.edges.length).toEqual(3); + expect(getResult.data.findSomeClass2.edges.length).toEqual(3); + + res = await fetch(getResult.data.someClass.someField.url); + + expect(res.status).toEqual(200); + expect(await res.text()).toEqual('My File Content'); + + const mutationResult = await apolloClient.mutate({ + mutation: gql` + mutation UnlinkFile($id: ID!) { + updateSomeClass(input: { id: $id, fields: { someField: null } }) { + someClass { + someField { + name + url + } + } + } + } + `, + variables: { + id: result2.data.createSomeClass3.someClass.id, + }, + }); + expect(mutationResult.data.updateSomeClass.someClass.someField).toEqual(null); + } catch (e) { + handleError(e); + } + }); + + it('should reject file with disallowed URL domain', async () => { + try { + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + fileUpload: { + allowedFileUrlDomains: [], + }, + }); + await createGQLFromParseServer(parseServer); + + const schemaController = await parseServer.config.databaseController.loadSchema(); + await schemaController.addClassIfNotExists('SomeClass', { + someField: { type: 'File' }, + }); + await resetGraphQLCache(); + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + } + } + } + `, + variables: { + fields: { + someField: { + file: { + name: 'test.txt', + url: 'http://malicious.example.com/leak', + __type: 'File', + }, + }, + }, + }, + }); + fail('should have thrown'); + expect(createResult).toBeUndefined(); + } catch (e) { + expect(e.message).toMatch(/not allowed/); + } + }); + + it('should support files on required file', async () => { + try { + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + }); + await createGQLFromParseServer(parseServer); + const schemaController = await parseServer.config.databaseController.loadSchema(); + await schemaController.addClassIfNotExists('SomeClassWithRequiredFile', { + someField: { type: 'File', required: true }, + }); + await resetGraphQLCache(); + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const body = new FormData(); + body.append( + 'operations', + JSON.stringify({ + query: ` + mutation CreateSomeObject( + $fields: CreateSomeClassWithRequiredFileFieldsInput + ) { + createSomeClassWithRequiredFile( + input: { fields: $fields } + ) { + someClassWithRequiredFile { + id + someField { + name + url + } + } + } + } + `, + variables: { + fields: { + someField: { upload: null }, + }, + }, + }) + ); + body.append('map', JSON.stringify({ 1: ['variables.fields.someField.upload'] })); + body.append('1', 'My File Content', { + filename: 'myFileName.txt', + contentType: 'text/plain', + }); + + const res = await fetch('http://localhost:13377/graphql', { + method: 'POST', + headers, + body, + }); + expect(res.status).toEqual(200); + const resText = await res.text(); + const result = JSON.parse(resText); + expect( + result.data.createSomeClassWithRequiredFile.someClassWithRequiredFile.someField.name + ).toEqual(jasmine.stringMatching(/_myFileName.txt$/)); + expect( + result.data.createSomeClassWithRequiredFile.someClassWithRequiredFile.someField.url + ).toEqual(jasmine.stringMatching(/_myFileName.txt$/)); + } catch (e) { + handleError(e); + } + }); + + it('should support file upload for on fly creation through pointer and relation', async () => { + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + }); + await createGQLFromParseServer(parseServer); + const schema = new Parse.Schema('SomeClass'); + schema.addFile('someFileField'); + schema.addPointer('somePointerField', 'SomeClass'); + schema.addRelation('someRelationField', 'SomeClass'); + await schema.save(); + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const body = new FormData(); + body.append( + 'operations', + JSON.stringify({ + query: ` + mutation UploadFiles( + $fields: CreateSomeClassFieldsInput + ) { + createSomeClass( + input: { fields: $fields } + ) { + someClass { + id + someFileField { + name + url + } + somePointerField { + id + someFileField { + name + url + } + } + someRelationField { + edges { + node { + id + someFileField { + name + url + } + } + } + } + } + } + } + `, + variables: { + fields: { + someFileField: { upload: null }, + somePointerField: { + createAndLink: { + someFileField: { upload: null }, + }, + }, + someRelationField: { + createAndAdd: [ + { + someFileField: { upload: null }, + }, + ], + }, + }, + }, + }) + ); + body.append( + 'map', + JSON.stringify({ + 1: ['variables.fields.someFileField.upload'], + 2: ['variables.fields.somePointerField.createAndLink.someFileField.upload'], + 3: ['variables.fields.someRelationField.createAndAdd.0.someFileField.upload'], + }) + ); + body.append('1', 'My File Content someFileField', { + filename: 'someFileField.txt', + contentType: 'text/plain', + }); + body.append('2', 'My File Content somePointerField', { + filename: 'somePointerField.txt', + contentType: 'text/plain', + }); + body.append('3', 'My File Content someRelationField', { + filename: 'someRelationField.txt', + contentType: 'text/plain', + }); + + const res = await fetch('http://localhost:13377/graphql', { + method: 'POST', + headers, + body, + }); + expect(res.status).toEqual(200); + const result = await res.json(); + expect(result.data.createSomeClass.someClass.someFileField.name).toEqual( + jasmine.stringMatching(/_someFileField.txt$/) + ); + expect(result.data.createSomeClass.someClass.somePointerField.someFileField.name).toEqual( + jasmine.stringMatching(/_somePointerField.txt$/) + ); + expect( + result.data.createSomeClass.someClass.someRelationField.edges[0].node.someFileField.name + ).toEqual(jasmine.stringMatching(/_someRelationField.txt$/)); + }); + + it('should support files and add extension from mimetype', async () => { + try { + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + }); + await createGQLFromParseServer(parseServer); + const body = new FormData(); + body.append( + 'operations', + JSON.stringify({ + query: ` + mutation CreateFile($input: CreateFileInput!) { + createFile(input: $input) { + fileInfo { + name + url + } + } + } + `, + variables: { + input: { + upload: null, + }, + }, + }) + ); + body.append('map', JSON.stringify({ 1: ['variables.input.upload'] })); + body.append('1', 'My File Content', { + // No extension, the system should add it from mimetype + filename: 'myFileName', + contentType: 'text/plain', + }); + + const res = await fetch('http://localhost:13377/graphql', { + method: 'POST', + headers, + body, + }); + + expect(res.status).toEqual(200); + + const result = JSON.parse(await res.text()); + expect(result.data.createFile.fileInfo.name).toEqual( + jasmine.stringMatching(/_myFileName.txt$/) + ); + expect(result.data.createFile.fileInfo.url).toEqual( + jasmine.stringMatching(/_myFileName.txt$/) + ); + } catch (e) { + handleError(e); + } + }); + + it('should not upload if file is too large', async () => { + const body = new FormData(); + body.append( + 'operations', + JSON.stringify({ + query: ` + mutation CreateFile($input: CreateFileInput!) { + createFile(input: $input) { + fileInfo { + name + url + } + } + } + `, + variables: { + input: { + upload: null, + }, + }, + }) + ); + body.append('map', JSON.stringify({ 1: ['variables.input.upload'] })); + body.append( + '1', + // In this test file parse server is setup with 1kb limit + Buffer.alloc(parseGraphQLServer._transformMaxUploadSizeToBytes('2kb'), 1), + { + filename: 'myFileName.txt', + contentType: 'text/plain', + } + ); + + const res = await fetch('http://localhost:13377/graphql', { + method: 'POST', + headers, + body, + }); + + const result = JSON.parse(await res.text()); + expect(res.status).toEqual(200); + expect(result.errors[0].message).toEqual( + 'File truncated as it exceeds the 1024 byte size limit.' + ); + }); + + it('should support object values', async () => { + try { + const someObjectFieldValue = { + foo: { bar: 'baz' }, + number: 10, + }; + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass($schemaFields: SchemaFieldsInput) { + createClass(input: { name: "SomeClass", schemaFields: $schemaFields }) { + clientMutationId + } + } + `, + variables: { + schemaFields: { + addObjects: [{ name: 'someObjectField' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.someObjectField.type).toEqual('Object'); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + } + } + } + `, + variables: { + fields: { + someObjectField: someObjectFieldValue, + }, + }, + }); + + const where = { + someObjectField: { + equalTo: { key: 'foo.bar', value: 'baz' }, + notEqualTo: { key: 'foo.bar', value: 'bat' }, + greaterThan: { key: 'number', value: 9 }, + lessThan: { key: 'number', value: 11 }, + }, + }; + const queryResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!, $where: SomeClassWhereInput) { + someClass(id: $id) { + id + someObjectField + } + someClasses(where: $where) { + edges { + node { + id + someObjectField + } + } + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + where, + }, + }); + + const { someClass: getResult, someClasses } = queryResult.data; + + const { someObjectField } = getResult; + expect(typeof someObjectField).toEqual('object'); + expect(someObjectField).toEqual(someObjectFieldValue); + + // Checks class query results + expect(someClasses.edges.length).toEqual(1); + expect(someClasses.edges[0].node.someObjectField).toEqual(someObjectFieldValue); + } catch (e) { + handleError(e); + } + }); + + it('should support where argument on object field that contains false boolean value or 0 number value', async () => { + try { + const someObjectFieldValue1 = { + foo: { bar: true, baz: 100 }, + }; + + const someObjectFieldValue2 = { + foo: { bar: false, baz: 0 }, + }; + + const object1 = new Parse.Object('SomeClass'); + await object1.save({ + someObjectField: someObjectFieldValue1, + }); + const object2 = new Parse.Object('SomeClass'); + await object2.save({ + someObjectField: someObjectFieldValue2, + }); + + const whereToObject1 = { + someObjectField: { + equalTo: { key: 'foo.bar', value: true }, + notEqualTo: { key: 'foo.baz', value: 0 }, + }, + }; + const whereToObject2 = { + someObjectField: { + notEqualTo: { key: 'foo.bar', value: true }, + equalTo: { key: 'foo.baz', value: 0 }, + }, + }; + + const whereToAll = { + someObjectField: { + lessThan: { key: 'foo.baz', value: 101 }, + }, + }; + + const whereToNone = { + someObjectField: { + notEqualTo: { key: 'foo.bar', value: true }, + equalTo: { key: 'foo.baz', value: 1 }, + }, + }; + + const queryResult = await apolloClient.query({ + query: gql` + query GetSomeObject( + $id1: ID! + $id2: ID! + $whereToObject1: SomeClassWhereInput + $whereToObject2: SomeClassWhereInput + $whereToAll: SomeClassWhereInput + $whereToNone: SomeClassWhereInput + ) { + obj1: someClass(id: $id1) { + id + someObjectField + } + obj2: someClass(id: $id2) { + id + someObjectField + } + onlyObj1: someClasses(where: $whereToObject1) { + edges { + node { + id + someObjectField + } + } + } + onlyObj2: someClasses(where: $whereToObject2) { + edges { + node { + id + someObjectField + } + } + } + all: someClasses(where: $whereToAll) { + edges { + node { + id + someObjectField + } + } + } + none: someClasses(where: $whereToNone) { + edges { + node { + id + someObjectField + } + } + } + } + `, + variables: { + id1: object1.id, + id2: object2.id, + whereToObject1, + whereToObject2, + whereToAll, + whereToNone, + }, + }); + + const { obj1, obj2, onlyObj1, onlyObj2, all, none } = queryResult.data; + + expect(obj1.someObjectField).toEqual(someObjectFieldValue1); + expect(obj2.someObjectField).toEqual(someObjectFieldValue2); + + // Checks class query results + expect(onlyObj1.edges.length).toEqual(1); + expect(onlyObj1.edges[0].node.someObjectField).toEqual(someObjectFieldValue1); + expect(onlyObj2.edges.length).toEqual(1); + expect(onlyObj2.edges[0].node.someObjectField).toEqual(someObjectFieldValue2); + expect(all.edges.length).toEqual(2); + expect(none.edges.length).toEqual(0); + } catch (e) { + handleError(e); + } + }); + + it('should support object composed queries', async () => { + try { + const someObjectFieldValue1 = { + lorem: 'ipsum', + number: 10, + }; + const someObjectFieldValue2 = { + foo: { + test: 'bar', + }, + number: 10, + }; + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass { + createClass( + input: { + name: "SomeClass" + schemaFields: { addObjects: [{ name: "someObjectField" }] } + } + ) { + clientMutationId + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject( + $fields1: CreateSomeClassFieldsInput + $fields2: CreateSomeClassFieldsInput + ) { + create1: createSomeClass(input: { fields: $fields1 }) { + someClass { + id + } + } + create2: createSomeClass(input: { fields: $fields2 }) { + someClass { + id + } + } + } + `, + variables: { + fields1: { + someObjectField: someObjectFieldValue1, + }, + fields2: { + someObjectField: someObjectFieldValue2, + }, + }, + }); + + const where = { + AND: [ + { + someObjectField: { + greaterThan: { key: 'number', value: 9 }, + }, + }, + { + someObjectField: { + lessThan: { key: 'number', value: 11 }, + }, + }, + { + OR: [ + { + someObjectField: { + equalTo: { key: 'lorem', value: 'ipsum' }, + }, + }, + { + someObjectField: { + equalTo: { key: 'foo.test', value: 'bar' }, + }, + }, + ], + }, + ], + }; + const findResult = await apolloClient.query({ + query: gql` + query FindSomeObject($where: SomeClassWhereInput) { + someClasses(where: $where) { + edges { + node { + id + someObjectField + } + } + } + } + `, + variables: { + where, + }, + }); + + const { create1, create2 } = createResult.data; + const { someClasses } = findResult.data; + + // Checks class query results + const { edges } = someClasses; + expect(edges.length).toEqual(2); + expect( + edges.find(result => result.node.id === create1.someClass.id).node.someObjectField + ).toEqual(someObjectFieldValue1); + expect( + edges.find(result => result.node.id === create2.someClass.id).node.someObjectField + ).toEqual(someObjectFieldValue2); + } catch (e) { + handleError(e); + } + }); + + it('should support array values', async () => { + try { + const someArrayFieldValue = [1, 'foo', ['bar'], { lorem: 'ipsum' }, true]; + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass($schemaFields: SchemaFieldsInput) { + createClass(input: { name: "SomeClass", schemaFields: $schemaFields }) { + clientMutationId + } + } + `, + variables: { + schemaFields: { + addArrays: [{ name: 'someArrayField' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.someArrayField.type).toEqual('Array'); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + } + } + } + `, + variables: { + fields: { + someArrayField: someArrayFieldValue, + }, + }, + }); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + someClass(id: $id) { + someArrayField { + ... on Element { + value + } + } + } + someClasses(where: { someArrayField: { exists: true } }) { + edges { + node { + id + someArrayField { + ... on Element { + value + } + } + } + } + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + }, + }); + + const { someArrayField } = getResult.data.someClass; + expect(Array.isArray(someArrayField)).toBeTruthy(); + expect(someArrayField.map(element => element.value)).toEqual(someArrayFieldValue); + expect(getResult.data.someClasses.edges.length).toEqual(1); + } catch (e) { + handleError(e); + } + }); + + it('should support undefined array', async () => { + const schema = await new Parse.Schema('SomeClass'); + schema.addArray('someArray'); + await schema.save(); + + const obj = new Parse.Object('SomeClass'); + await obj.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + someClass(id: $id) { + id + someArray { + ... on Element { + value + } + } + } + } + `, + variables: { + id: obj.id, + }, + }); + expect(getResult.data.someClass.someArray).toEqual(null); + }); + + it('should support null values', async () => { + try { + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass { + createClass( + input: { + name: "SomeClass" + schemaFields: { + addStrings: [{ name: "someStringField" }, { name: "someNullField" }] + addNumbers: [{ name: "someNumberField" }] + addBooleans: [{ name: "someBooleanField" }] + addObjects: [{ name: "someObjectField" }] + } + } + ) { + clientMutationId + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + } + } + } + `, + variables: { + fields: { + someStringField: 'some string', + someNumberField: 123, + someBooleanField: true, + someObjectField: { someField: 'some value' }, + someNullField: null, + }, + }, + }); + + await apolloClient.mutate({ + mutation: gql` + mutation UpdateSomeObject($id: ID!, $fields: UpdateSomeClassFieldsInput) { + updateSomeClass(input: { id: $id, fields: $fields }) { + clientMutationId + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + fields: { + someStringField: null, + someNumberField: null, + someBooleanField: null, + someObjectField: null, + someNullField: 'now it has a string', + }, + }, + }); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + someClass(id: $id) { + someStringField + someNumberField + someBooleanField + someObjectField + someNullField + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + }, + }); + + expect(getResult.data.someClass.someStringField).toBeFalsy(); + expect(getResult.data.someClass.someNumberField).toBeFalsy(); + expect(getResult.data.someClass.someBooleanField).toBeFalsy(); + expect(getResult.data.someClass.someObjectField).toBeFalsy(); + expect(getResult.data.someClass.someNullField).toEqual('now it has a string'); + } catch (e) { + handleError(e); + } + }); + + it_id('43303db7-c5a7-4bc0-91c3-57e03fffa225')(it)('should support Bytes', async () => { + try { + const someFieldValue = 'aGVsbG8gd29ybGQ='; + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass($schemaFields: SchemaFieldsInput) { + createClass(input: { name: "SomeClass", schemaFields: $schemaFields }) { + clientMutationId + } + } + `, + variables: { + schemaFields: { + addBytes: [{ name: 'someField' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.someField.type).toEqual('Bytes'); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject( + $fields1: CreateSomeClassFieldsInput + $fields2: CreateSomeClassFieldsInput + ) { + createSomeClass1: createSomeClass(input: { fields: $fields1 }) { + someClass { + id + } + } + createSomeClass2: createSomeClass(input: { fields: $fields2 }) { + someClass { + id + } + } + } + `, + variables: { + fields1: { + someField: someFieldValue, + }, + fields2: { + someField: someFieldValue, + }, + }, + }); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!, $someFieldValue: Bytes) { + someClass(id: $id) { + someField + } + someClasses(where: { someField: { equalTo: $someFieldValue } }) { + edges { + node { + id + someField + } + } + } + } + `, + variables: { + id: createResult.data.createSomeClass1.someClass.id, + someFieldValue, + }, + }); + + expect(typeof getResult.data.someClass.someField).toEqual('string'); + expect(getResult.data.someClass.someField).toEqual(someFieldValue); + expect(getResult.data.someClasses.edges.length).toEqual(2); + } catch (e) { + handleError(e); + } + }); + + it_id('6a253e47-6959-4427-b841-c0c1fa77cf01')(it)('should support Geo Points', async () => { + try { + const someFieldValue = { + __typename: 'GeoPoint', + latitude: 45, + longitude: 45, + }; + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass($schemaFields: SchemaFieldsInput) { + createClass(input: { name: "SomeClass", schemaFields: $schemaFields }) { + clientMutationId + } + } + `, + variables: { + schemaFields: { + addGeoPoint: { name: 'someField' }, + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.someField.type).toEqual('GeoPoint'); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + } + } + } + `, + variables: { + fields: { + someField: { + latitude: someFieldValue.latitude, + longitude: someFieldValue.longitude, + }, + }, + }, + }); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + someClass(id: $id) { + someField { + latitude + longitude + } + } + someClasses(where: { someField: { exists: true } }) { + edges { + node { + id + someField { + latitude + longitude + } + } + } + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + }, + }); + + expect(typeof getResult.data.someClass.someField).toEqual('object'); + expect(getResult.data.someClass.someField).toEqual(someFieldValue); + expect(getResult.data.someClasses.edges.length).toEqual(1); + + const getGeoWhere = await apolloClient.query({ + query: gql` + query GeoQuery($latitude: Float!, $longitude: Float!) { + nearSphere: someClasses( + where: { + someField: { nearSphere: { latitude: $latitude, longitude: $longitude } } + } + ) { + edges { + node { + id + } + } + } + geoWithin: someClasses( + where: { + someField: { + geoWithin: { + centerSphere: { + distance: 10 + center: { latitude: $latitude, longitude: $longitude } + } + } + } + } + ) { + edges { + node { + id + } + } + } + within: someClasses( + where: { + someField: { + within: { + box: { + bottomLeft: { latitude: $latitude, longitude: $longitude } + upperRight: { latitude: $latitude, longitude: $longitude } + } + } + } + } + ) { + edges { + node { + id + } + } + } + } + `, + variables: { + latitude: 45, + longitude: 45, + }, + }); + expect(getGeoWhere.data.nearSphere.edges[0].node.id).toEqual( + createResult.data.createSomeClass.someClass.id + ); + expect(getGeoWhere.data.geoWithin.edges[0].node.id).toEqual( + createResult.data.createSomeClass.someClass.id + ); + expect(getGeoWhere.data.within.edges[0].node.id).toEqual( + createResult.data.createSomeClass.someClass.id + ); + } catch (e) { + handleError(e); + } + }); + + it('should support Polygons', async () => { + try { + const somePolygonFieldValue = [ + [44, 45], + [46, 47], + [48, 49], + [44, 45], + ].map(point => ({ + latitude: point[0], + longitude: point[1], + })); + + await apolloClient.mutate({ + mutation: gql` + mutation CreateClass($schemaFields: SchemaFieldsInput) { + createClass(input: { name: "SomeClass", schemaFields: $schemaFields }) { + clientMutationId + } + } + `, + variables: { + schemaFields: { + addPolygons: [{ name: 'somePolygonField' }], + }, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.somePolygonField.type).toEqual('Polygon'); + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + } + } + } + `, + variables: { + fields: { + somePolygonField: somePolygonFieldValue, + }, + }, + }); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + someClass(id: $id) { + somePolygonField { + latitude + longitude + } + } + someClasses(where: { somePolygonField: { exists: true } }) { + edges { + node { + id + somePolygonField { + latitude + longitude + } + } + } + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + }, + }); + + expect(typeof getResult.data.someClass.somePolygonField).toEqual('object'); + expect(getResult.data.someClass.somePolygonField).toEqual( + somePolygonFieldValue.map(geoPoint => ({ + ...geoPoint, + __typename: 'GeoPoint', + })) + ); + expect(getResult.data.someClasses.edges.length).toEqual(1); + const getIntersect = await apolloClient.query({ + query: gql` + query IntersectQuery($point: GeoPointInput!) { + someClasses(where: { somePolygonField: { geoIntersects: { point: $point } } }) { + edges { + node { + id + somePolygonField { + latitude + longitude + } + } + } + } + } + `, + variables: { + point: { latitude: 44, longitude: 45 }, + }, + }); + expect(getIntersect.data.someClasses.edges.length).toEqual(1); + expect(getIntersect.data.someClasses.edges[0].node.id).toEqual( + createResult.data.createSomeClass.someClass.id + ); + } catch (e) { + handleError(e); + } + }); + + it_only_db('mongo')('should support bytes values', async () => { + const SomeClass = Parse.Object.extend('SomeClass'); + const someClass = new SomeClass(); + someClass.set('someField', { + __type: 'Bytes', + base64: 'foo', + }); + await someClass.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + const schema = await new Parse.Schema('SomeClass').get(); + expect(schema.fields.someField.type).toEqual('Bytes'); + + const someFieldValue = { + __type: 'Bytes', + base64: 'bytesContent', + }; + + const createResult = await apolloClient.mutate({ + mutation: gql` + mutation CreateSomeObject($fields: CreateSomeClassFieldsInput) { + createSomeClass(input: { fields: $fields }) { + someClass { + id + } + } + } + `, + variables: { + fields: { + someField: someFieldValue, + }, + }, + }); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + someClass(id: $id) { + someField + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + }, + }); + + expect(getResult.data.someClass.someField).toEqual(someFieldValue.base64); + + const updatedSomeFieldValue = { + __type: 'Bytes', + base64: 'newBytesContent', + }; + + const updatedResult = await apolloClient.mutate({ + mutation: gql` + mutation UpdateSomeObject($id: ID!, $fields: UpdateSomeClassFieldsInput) { + updateSomeClass(input: { id: $id, fields: $fields }) { + someClass { + updatedAt + } + } + } + `, + variables: { + id: createResult.data.createSomeClass.someClass.id, + fields: { + someField: updatedSomeFieldValue, + }, + }, + }); + + const { updatedAt } = updatedResult.data.updateSomeClass.someClass; + expect(updatedAt).toBeDefined(); + + const findResult = await apolloClient.query({ + query: gql` + query FindSomeObject($where: SomeClassWhereInput!) { + someClasses(where: $where) { + edges { + node { + id + } + } + } + } + `, + variables: { + where: { + someField: { + equalTo: updatedSomeFieldValue.base64, + }, + }, + }, + }); + const findResults = findResult.data.someClasses.edges; + expect(findResults.length).toBe(1); + expect(findResults[0].node.id).toBe(createResult.data.createSomeClass.someClass.id); + }); + }); + + describe('Special Classes', () => { + it('should support User class', async () => { + const user = new Parse.User(); + user.setUsername('user1'); + user.setPassword('user1'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + user.setACL(acl); + await user.signUp(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + get: user(id: $id) { + objectId + } + } + `, + variables: { + id: user.id, + }, + }); + + expect(getResult.data.get.objectId).toEqual(user.id); + }); + + it('should support Installation class', async () => { + const installation = new Parse.Installation(); + await installation.save({ + deviceType: 'foo', + }); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + get: installation(id: $id) { + objectId + } + } + `, + variables: { + id: installation.id, + }, + }); + + expect(getResult.data.get.objectId).toEqual(installation.id); + }); + + it('should support Role class', async () => { + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + const role = new Parse.Role('MyRole', roleACL); + await role.save(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + get: role(id: $id) { + objectId + } + } + `, + variables: { + id: role.id, + }, + }); + + expect(getResult.data.get.objectId).toEqual(role.id); + }); + + it('should support Session class', async () => { + const user = new Parse.User(); + user.setUsername('user1'); + user.setPassword('user1'); + await user.signUp(); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const session = await Parse.Session.current(); + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + get: session(id: $id) { + id + objectId + } + } + `, + variables: { + id: session.id, + }, + context: { + headers: { + 'X-Parse-Session-Token': session.getSessionToken(), + }, + }, + }); + + expect(getResult.data.get.objectId).toEqual(session.id); + }); + + it('should support Product class', async () => { + const Product = Parse.Object.extend('_Product'); + const product = new Product(); + await product.save( + { + productIdentifier: 'foo', + icon: new Parse.File('icon', ['foo']), + order: 1, + title: 'Foo', + subtitle: 'My product', + }, + { useMasterKey: true } + ); + + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + + const getResult = await apolloClient.query({ + query: gql` + query GetSomeObject($id: ID!) { + get: product(id: $id) { + objectId + } + } + `, + variables: { + id: product.id, + }, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + + expect(getResult.data.get.objectId).toEqual(product.id); + }); + }); + }); + }); + + describe('Custom API', () => { + describe('SDL based', () => { + let httpServer; + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }; + let apolloClient; + beforeEach(async () => { + const expressApp = express(); + httpServer = http.createServer(expressApp); + parseGraphQLServer = new ParseGraphQLServer(parseServer, { + graphQLPath: '/graphql', + graphQLCustomTypeDefs: gql` + extend type Query { + hello: String @resolve + hello2: String @resolve(to: "hello") + userEcho(user: CreateUserFieldsInput!): User! @resolve + hello3: String! @mock(with: "Hello world!") + hello4: User! @mock(with: { username: "somefolk" }) + } + `, + }); + parseGraphQLServer.applyGraphQL(expressApp); + await new Promise(resolve => httpServer.listen({ port: 13377 }, resolve)); + const httpLink = await createUploadLink({ + uri: 'http://localhost:13377/graphql', + fetch, + headers, + }); + apolloClient = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + defaultOptions: { + query: { + fetchPolicy: 'no-cache', + }, + }, + }); + }); + + afterEach(async () => { + await httpServer.close(); + }); + + it('can resolve a custom query using default function name', async () => { + Parse.Cloud.define('hello', async () => { + return 'Hello world!'; + }); + + const result = await apolloClient.query({ + query: gql` + query Hello { + hello + } + `, + }); + + expect(result.data.hello).toEqual('Hello world!'); + }); + + it('can resolve a custom query using function name set by "to" argument', async () => { + Parse.Cloud.define('hello', async () => { + return 'Hello world!'; + }); + + const result = await apolloClient.query({ + query: gql` + query Hello { + hello2 + } + `, + }); + + expect(result.data.hello2).toEqual('Hello world!'); + }); + + it('order option should continue working', async () => { + const schemaController = await parseServer.config.databaseController.loadSchema(); + + await schemaController.addClassIfNotExists('SuperCar', { + engine: { type: 'String' }, + doors: { type: 'Number' }, + price: { type: 'String' }, + mileage: { type: 'Number' }, + }); + + await new Parse.Object('SuperCar').save({ + engine: 'petrol', + doors: 3, + price: 'ÂŖ7500', + mileage: 0, + }); + + await new Parse.Object('SuperCar').save({ + engine: 'petrol', + doors: 3, + price: 'ÂŖ7500', + mileage: 10000, + }); + + await Promise.all([ + parseGraphQLServer.parseGraphQLController.cacheController.graphQL.clear(), + parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(), + ]); + + await expectAsync( + apolloClient.query({ + query: gql` + query FindSuperCar { + superCars(order: [mileage_ASC]) { + edges { + node { + id + } + } + } + } + `, + }) + ).toBeResolved(); + }); + }); + + describe('GraphQL Schema Based', () => { + let httpServer; + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }; + let apolloClient; + + beforeEach(async () => { + const expressApp = express(); + httpServer = http.createServer(expressApp); + const TypeEnum = new GraphQLEnumType({ + name: 'TypeEnum', + values: { + human: { value: 'human' }, + robot: { value: 'robot' }, + }, + }); + const TypeEnumWhereInput = new GraphQLInputObjectType({ + name: 'TypeEnumWhereInput', + fields: { + equalTo: { type: TypeEnum }, + }, + }); + const SomeClass2WhereInput = new GraphQLInputObjectType({ + name: 'SomeClass2WhereInput', + fields: { + type: { type: TypeEnumWhereInput }, + }, + }); + const SomeClassType = new GraphQLObjectType({ + name: 'SomeClass', + fields: { + nameUpperCase: { + type: new GraphQLNonNull(GraphQLString), + resolve: p => p.name.toUpperCase(), + }, + type: { type: TypeEnum }, + language: { + type: new GraphQLEnumType({ + name: 'LanguageEnum', + values: { + fr: { value: 'fr' }, + en: { value: 'en' }, + }, + }), + resolve: () => 'fr', + }, + }, + }), + parseGraphQLServer = new ParseGraphQLServer(parseServer, { + graphQLPath: '/graphql', + graphQLCustomTypeDefs: new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + customQuery: { + type: new GraphQLNonNull(GraphQLString), + args: { + message: { type: new GraphQLNonNull(GraphQLString) }, + }, + resolve: (p, { message }) => message, + }, + errorQuery: { + type: new GraphQLNonNull(GraphQLString), + resolve: () => { + throw new Error('A test error'); + }, + }, + customQueryWithAutoTypeReturn: { + type: SomeClassType, + args: { + id: { type: new GraphQLNonNull(GraphQLString) }, + }, + resolve: async (p, { id }) => { + const obj = new Parse.Object('SomeClass'); + obj.id = id; + await obj.fetch(); + return obj.toJSON(); + }, + }, + customQueryWithAutoTypeReturnList: { + type: new GraphQLList(SomeClassType), + args: { + id: { type: new GraphQLNonNull(GraphQLString) }, + }, + resolve: async (p, { id }) => { + const obj = new Parse.Object('SomeClass'); + obj.id = id; + await obj.fetch(); + return [obj.toJSON(), obj.toJSON(), obj.toJSON()]; + }, + }, + }, + }), + types: [ + new GraphQLInputObjectType({ + name: 'CreateSomeClassFieldsInput', + fields: { + type: { type: TypeEnum }, + }, + }), + new GraphQLInputObjectType({ + name: 'UpdateSomeClassFieldsInput', + fields: { + type: { type: TypeEnum }, + }, + }), + // Enhanced where input with a extended enum + new GraphQLInputObjectType({ + name: 'SomeClassWhereInput', + fields: { + type: { + type: TypeEnumWhereInput, + }, + }, + }), + SomeClassType, + SomeClass2WhereInput, + ], + }), + }); + + parseGraphQLServer.applyGraphQL(expressApp); + await new Promise(resolve => httpServer.listen({ port: 13377 }, resolve)); + const httpLink = await createUploadLink({ + uri: 'http://localhost:13377/graphql', + fetch, + headers, + }); + apolloClient = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + defaultOptions: { + query: { + fetchPolicy: 'no-cache', + }, + }, + }); + }); + + afterEach(async () => { + await httpServer.close(); + }); + + it('can resolve a custom query', async () => { + const result = await apolloClient.query({ + variables: { message: 'hello' }, + query: gql` + query CustomQuery($message: String!) { + customQuery(message: $message) + } + `, + }); + expect(result.data.customQuery).toEqual('hello'); + }); + + it('can forward original error of a custom query', async () => { + await expectAsync( + apolloClient.query({ + query: gql` + query ErrorQuery { + errorQuery + } + `, + }) + ).toBeRejectedWithError('A test error'); + }); + + it('can resolve a custom query with auto type return', async () => { + const obj = new Parse.Object('SomeClass'); + await obj.save({ name: 'aname', type: 'robot' }); + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + const result = await apolloClient.query({ + variables: { id: obj.id }, + query: gql` + query CustomQuery($id: String!) { + customQueryWithAutoTypeReturn(id: $id) { + objectId + nameUpperCase + name + type + } + } + `, + }); + expect(result.data.customQueryWithAutoTypeReturn.objectId).toEqual(obj.id); + expect(result.data.customQueryWithAutoTypeReturn.name).toEqual('aname'); + expect(result.data.customQueryWithAutoTypeReturn.nameUpperCase).toEqual('ANAME'); + expect(result.data.customQueryWithAutoTypeReturn.type).toEqual('robot'); + }); + + it('can resolve a custom query with auto type list return', async () => { + const obj = new Parse.Object('SomeClass'); + await obj.save({ name: 'aname', type: 'robot' }); + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + const result = await apolloClient.query({ + variables: { id: obj.id }, + query: gql` + query CustomQuery($id: String!) { + customQueryWithAutoTypeReturnList(id: $id) { + id + objectId + nameUpperCase + name + type + } + } + `, + }); + result.data.customQueryWithAutoTypeReturnList.forEach(rObj => { + expect(rObj.objectId).toBeDefined(); + expect(rObj.objectId).toEqual(obj.id); + expect(rObj.name).toEqual('aname'); + expect(rObj.nameUpperCase).toEqual('ANAME'); + expect(rObj.type).toEqual('robot'); + }); + }); + + it('can resolve a stacked query with same where variables on overloaded where input', async () => { + const objPointer = new Parse.Object('SomeClass2'); + await objPointer.save({ name: 'aname', type: 'robot' }); + const obj = new Parse.Object('SomeClass'); + await obj.save({ name: 'aname', type: 'robot', pointer: objPointer }); + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + const result = await apolloClient.query({ + variables: { where: { OR: [{ pointer: { have: { objectId: { exists: true } } } }] } }, + query: gql` + query someQuery($where: SomeClassWhereInput!) { + q1: someClasses(where: $where) { + edges { + node { + id + } + } + } + q2: someClasses(where: $where) { + edges { + node { + id + } + } + } + } + `, + }); + expect(result.data.q1.edges.length).toEqual(1); + expect(result.data.q2.edges.length).toEqual(1); + expect(result.data.q1.edges[0].node.id).toEqual(result.data.q2.edges[0].node.id); + }); + + it('can resolve a custom extend type', async () => { + const obj = new Parse.Object('SomeClass'); + await obj.save({ name: 'aname', type: 'robot' }); + await parseGraphQLServer.parseGraphQLSchema.schemaCache.clear(); + const result = await apolloClient.query({ + variables: { id: obj.id }, + query: gql` + query someClass($id: ID!) { + someClass(id: $id) { + nameUpperCase + language + type + } + } + `, + }); + expect(result.data.someClass.nameUpperCase).toEqual('ANAME'); + expect(result.data.someClass.language).toEqual('fr'); + expect(result.data.someClass.type).toEqual('robot'); + + const result2 = await apolloClient.query({ + variables: { id: obj.id }, + query: gql` + query someClass($id: ID!) { + someClass(id: $id) { + name + language + } + } + `, + }); + expect(result2.data.someClass.name).toEqual('aname'); + expect(result.data.someClass.language).toEqual('fr'); + const result3 = await apolloClient.mutate({ + variables: { id: obj.id, name: 'anewname', type: 'human' }, + mutation: gql` + mutation someClass($id: ID!, $name: String!, $type: TypeEnum!) { + updateSomeClass(input: { id: $id, fields: { name: $name, type: $type } }) { + someClass { + nameUpperCase + type + } + } + } + `, + }); + expect(result3.data.updateSomeClass.someClass.nameUpperCase).toEqual('ANEWNAME'); + expect(result3.data.updateSomeClass.someClass.type).toEqual('human'); + }); + }); + describe('Async Function Based Merge', () => { + let httpServer; + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }; + let apolloClient; + + beforeEach(async () => { + if (!httpServer) { + const expressApp = express(); + httpServer = http.createServer(expressApp); + parseGraphQLServer = new ParseGraphQLServer(parseServer, { + graphQLPath: '/graphql', + graphQLCustomTypeDefs: ({ autoSchema }) => mergeSchemas({ schemas: [autoSchema] }), + }); + + parseGraphQLServer.applyGraphQL(expressApp); + await new Promise(resolve => httpServer.listen({ port: 13377 }, resolve)); + const httpLink = await createUploadLink({ + uri: 'http://localhost:13377/graphql', + fetch, + headers, + }); + apolloClient = new ApolloClient({ + link: httpLink, + cache: new InMemoryCache(), + defaultOptions: { + query: { + fetchPolicy: 'no-cache', + }, + }, + }); + } + }); + + afterAll(async () => { + await httpServer.close(); + }); + + it('can resolve a query', async () => { + const result = await apolloClient.query({ + query: gql` + query Health { + health + } + `, + }); + expect(result.data.health).toEqual(true); + }); + }); + }); +}); diff --git a/spec/ParseHooks.spec.js b/spec/ParseHooks.spec.js index f335d932b1..a071fccfa6 100644 --- a/spec/ParseHooks.spec.js +++ b/spec/ParseHooks.spec.js @@ -1,459 +1,741 @@ -"use strict"; -/* global describe, it, expect, fail, Parse */ -var request = require('request'); -var triggers = require('../src/triggers'); -var HooksController = require('../src/Controllers/HooksController').default; -var express = require("express"); -var bodyParser = require('body-parser'); - -var port = 12345; -var hookServerURL = "http://localhost:"+port; -let AppCache = require('../src/cache').AppCache; - -var app = express(); -app.use(bodyParser.json({ 'type': '*/*' })) -app.listen(12345); +'use strict'; + +const request = require('../lib/request'); +const triggers = require('../lib/triggers'); +const HooksController = require('../lib/Controllers/HooksController').default; +const express = require('express'); +const auth = require('../lib/Auth'); +const Config = require('../lib/Config'); + +const port = 34567; +const hookServerURL = 'http://localhost:' + port; describe('Hooks', () => { - it_exclude_dbs(['postgres'])("should have no hooks registered", (done) => { - Parse.Hooks.getFunctions().then((res) => { - expect(res.constructor).toBe(Array.prototype.constructor); - done(); - }, (err) => { - fail(err); - done(); - }); - }); - - it_exclude_dbs(['postgres'])("should have no triggers registered", (done) => { - Parse.Hooks.getTriggers().then( (res) => { - expect(res.constructor).toBe(Array.prototype.constructor); - done(); - }, (err) => { - fail(err); - done(); - }); - }); - - it_exclude_dbs(['postgres'])("should CRUD a function registration", (done) => { - // Create - Parse.Hooks.createFunction("My-Test-Function", "http://someurl") - .then(response => { - expect(response.functionName).toBe("My-Test-Function"); - expect(response.url).toBe("http://someurl") - // Find - return Parse.Hooks.getFunction("My-Test-Function") - }).then(response => { - expect(response.objectId).toBeUndefined(); - expect(response.url).toBe("http://someurl"); - return Parse.Hooks.updateFunction("My-Test-Function", "http://anotherurl"); - }) - .then((res) => { - expect(res.objectId).toBeUndefined(); - expect(res.functionName).toBe("My-Test-Function"); - expect(res.url).toBe("http://anotherurl") - // delete - return Parse.Hooks.removeFunction("My-Test-Function") - }) - .then((res) => { - // Find again! but should be deleted - return Parse.Hooks.getFunction("My-Test-Function") - .then(res => { - fail("Failed to delete hook") - fail(res) + let server; + let app; + beforeEach(done => { + if (!app) { + app = express(); + app.use(express.json({ type: '*/*' })); + server = app.listen(port, undefined, done); + } else { + done(); + } + }); + + afterAll(done => { + server.close(done); + }); + + it('should have no hooks registered', done => { + Parse.Hooks.getFunctions().then( + res => { + expect(res.constructor).toBe(Array.prototype.constructor); + done(); + }, + err => { + jfail(err); + done(); + } + ); + }); + + it('should have no triggers registered', done => { + Parse.Hooks.getTriggers().then( + res => { + expect(res.constructor).toBe(Array.prototype.constructor); done(); - return Promise.resolve(); - }, (err) => { - expect(err.code).toBe(143); - expect(err.message).toBe("no function named: My-Test-Function is defined") + }, + err => { + jfail(err); done(); - return Promise.resolve(); + } + ); + }); + + it_id('26c9a13d-3d71-452e-a91c-9a4589be021c')(it)('should CRUD a function registration', done => { + // Create + Parse.Hooks.createFunction('My-Test-Function', 'http://someurl') + .then(response => { + expect(response.functionName).toBe('My-Test-Function'); + expect(response.url).toBe('http://someurl'); + // Find + return Parse.Hooks.getFunction('My-Test-Function'); + }) + .then(response => { + expect(response.objectId).toBeUndefined(); + expect(response.url).toBe('http://someurl'); + return Parse.Hooks.updateFunction('My-Test-Function', 'http://anotherurl'); }) - }) - .catch(error => { - fail(error); + .then(res => { + expect(res.objectId).toBeUndefined(); + expect(res.functionName).toBe('My-Test-Function'); + expect(res.url).toBe('http://anotherurl'); + // delete + return Parse.Hooks.removeFunction('My-Test-Function'); + }) + .then(() => { + // Find again! but should be deleted + return Parse.Hooks.getFunction('My-Test-Function').then( + res => { + fail('Failed to delete hook'); + fail(res); + done(); + return Promise.resolve(); + }, + err => { + expect(err.code).toBe(143); + expect(err.message).toBe('no function named: My-Test-Function is defined'); + done(); + return Promise.resolve(); + } + ); + }) + .catch(error => { + jfail(error); + done(); + }); + }); + + it_id('7a81069e-2ee9-47fb-8e27-1120eda09e99')(it)('should CRUD a trigger registration', done => { + // Create + Parse.Hooks.createTrigger('MyClass', 'beforeDelete', 'http://someurl') + .then( + res => { + expect(res.className).toBe('MyClass'); + expect(res.triggerName).toBe('beforeDelete'); + expect(res.url).toBe('http://someurl'); + // Find + return Parse.Hooks.getTrigger('MyClass', 'beforeDelete'); + }, + err => { + fail(err); + done(); + } + ) + .then( + res => { + expect(res).not.toBe(null); + expect(res).not.toBe(undefined); + expect(res.objectId).toBeUndefined(); + expect(res.url).toBe('http://someurl'); + // delete + return Parse.Hooks.updateTrigger('MyClass', 'beforeDelete', 'http://anotherurl'); + }, + err => { + jfail(err); + done(); + } + ) + .then( + res => { + expect(res.className).toBe('MyClass'); + expect(res.url).toBe('http://anotherurl'); + expect(res.objectId).toBeUndefined(); + + return Parse.Hooks.removeTrigger('MyClass', 'beforeDelete'); + }, + err => { + jfail(err); + done(); + } + ) + .then( + () => { + // Find again! but should be deleted + return Parse.Hooks.getTrigger('MyClass', 'beforeDelete'); + }, + err => { + jfail(err); + done(); + } + ) + .then( + function () { + fail('should not succeed'); + done(); + }, + err => { + if (err) { + expect(err).not.toBe(null); + expect(err).not.toBe(undefined); + expect(err.code).toBe(143); + expect(err.message).toBe('class MyClass does not exist'); + } else { + fail('should have errored'); + } + done(); + } + ); + }); + + it('should fail to register hooks without Master Key', done => { + request({ + method: 'POST', + url: Parse.serverURL + '/hooks/functions', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + }, + body: JSON.stringify({ + url: 'http://hello.word', + functionName: 'SomeFunction', + }), + }).then(fail, response => { + const body = response.data; + expect(body.error).toBe('unauthorized'); done(); - }) + }); + }); + + it_id('f7ad092f-81dc-4729-afd1-3b02db2f0948')(it)('should fail trying to create two times the same function', done => { + Parse.Hooks.createFunction('my_new_function', 'http://url.com') + .then(() => jasmine.timeout()) + .then( + () => { + return Parse.Hooks.createFunction('my_new_function', 'http://url.com'); + }, + () => { + fail('should create a new function'); + } + ) + .then( + () => { + fail('should not be able to create the same function'); + }, + err => { + expect(err).not.toBe(undefined); + expect(err).not.toBe(null); + if (err) { + expect(err.code).toBe(143); + expect(err.message).toBe('function name: my_new_function already exists'); + } + return Parse.Hooks.removeFunction('my_new_function'); + } + ) + .then( + () => { + done(); + }, + err => { + jfail(err); + done(); + } + ); + }); + + it_id('4db8c249-9174-4e8e-b959-55c8ea959a02')(it)('should fail trying to create two times the same trigger', done => { + Parse.Hooks.createTrigger('MyClass', 'beforeSave', 'http://url.com') + .then( + () => { + return Parse.Hooks.createTrigger('MyClass', 'beforeSave', 'http://url.com'); + }, + () => { + fail('should create a new trigger'); + } + ) + .then( + () => { + fail('should not be able to create the same trigger'); + }, + err => { + expect(err).not.toBe(undefined); + expect(err).not.toBe(null); + if (err) { + expect(err.code).toBe(143); + expect(err.message).toBe('class MyClass already has trigger beforeSave'); + } + return Parse.Hooks.removeTrigger('MyClass', 'beforeSave'); + } + ) + .then( + () => { + done(); + }, + err => { + jfail(err); + done(); + } + ); + }); + + it("should fail trying to update a function that don't exist", done => { + Parse.Hooks.updateFunction('A_COOL_FUNCTION', 'http://url.com') + .then( + () => { + fail('Should not succeed'); + }, + err => { + expect(err).not.toBe(undefined); + expect(err).not.toBe(null); + if (err) { + expect(err.code).toBe(143); + expect(err.message).toBe('no function named: A_COOL_FUNCTION is defined'); + } + return Parse.Hooks.getFunction('A_COOL_FUNCTION'); + } + ) + .then( + () => { + fail('the function should not exist'); + done(); + }, + err => { + expect(err).not.toBe(undefined); + expect(err).not.toBe(null); + if (err) { + expect(err.code).toBe(143); + expect(err.message).toBe('no function named: A_COOL_FUNCTION is defined'); + } + done(); + } + ); }); - it_exclude_dbs(['postgres'])("should CRUD a trigger registration", (done) => { - // Create - Parse.Hooks.createTrigger("MyClass","beforeDelete", "http://someurl").then((res) => { - expect(res.className).toBe("MyClass"); - expect(res.triggerName).toBe("beforeDelete"); - expect(res.url).toBe("http://someurl") - // Find - return Parse.Hooks.getTrigger("MyClass","beforeDelete"); - }, (err) => { - fail(err); - done(); - }).then((res) => { - expect(res).not.toBe(null); - expect(res).not.toBe(undefined); - expect(res.objectId).toBeUndefined(); - expect(res.url).toBe("http://someurl"); - // delete - return Parse.Hooks.updateTrigger("MyClass","beforeDelete", "http://anotherurl"); - }, (err) => { - fail(err); - done(); - }).then((res) => { - expect(res.className).toBe("MyClass"); - expect(res.url).toBe("http://anotherurl") - expect(res.objectId).toBeUndefined(); - - return Parse.Hooks.removeTrigger("MyClass","beforeDelete"); - }, (err) => { - fail(err); - done(); - }).then((res) => { - // Find again! but should be deleted - return Parse.Hooks.getTrigger("MyClass","beforeDelete"); - }, (err) => { - fail(err); - done(); - }).then(function(){ - fail("should not succeed"); - done(); - }, (err) => { - expect(err).not.toBe(null); - expect(err).not.toBe(undefined); - expect(err.code).toBe(143); - expect(err.message).toBe("class MyClass does not exist") - done(); - }); - }); - - it("should fail to register hooks without Master Key", (done) => { - request.post(Parse.serverURL+"/hooks/functions", { - headers: { - "X-Parse-Application-Id": Parse.applicationId, - "X-Parse-REST-API-Key": Parse.restKey, - }, - body: JSON.stringify({ url: "http://hello.word", functionName: "SomeFunction"}) - }, (err, res, body) => { - body = JSON.parse(body); - expect(body.error).toBe("unauthorized"); - done(); - }) - }); - - it_exclude_dbs(['postgres'])("should fail trying to create two times the same function", (done) => { - Parse.Hooks.createFunction("my_new_function", "http://url.com").then( () => { - return Parse.Hooks.createFunction("my_new_function", "http://url.com") - }, () => { - fail("should create a new function"); - }).then( () => { - fail("should not be able to create the same function"); - }, (err) => { + it("should fail trying to update a trigger that don't exist", done => { + Parse.Hooks.updateTrigger('AClassName', 'beforeSave', 'http://url.com') + .then( + () => { + fail('Should not succeed'); + }, + err => { + expect(err).not.toBe(undefined); + expect(err).not.toBe(null); + if (err) { + expect(err.code).toBe(143); + expect(err.message).toBe('class AClassName does not exist'); + } + return Parse.Hooks.getTrigger('AClassName', 'beforeSave'); + } + ) + .then( + () => { + fail('the function should not exist'); + done(); + }, + err => { + expect(err).not.toBe(undefined); + expect(err).not.toBe(null); + if (err) { + expect(err.code).toBe(143); + expect(err.message).toBe('class AClassName does not exist'); + } + done(); + } + ); + }); + + it('should fail trying to create a malformed function', done => { + Parse.Hooks.createFunction('MyFunction').then( + res => { + fail(res); + }, + err => { expect(err).not.toBe(undefined); expect(err).not.toBe(null); - expect(err.code).toBe(143); - expect(err.message).toBe('function name: my_new_function already exits') - return Parse.Hooks.removeFunction("my_new_function"); - }).then(() => { - done(); - }, (err) => { - fail(err); + if (err) { + expect(err.code).toBe(143); + expect(err.error).toBe('invalid hook declaration'); + } done(); + } + ); + }); + + it('should fail trying to create a malformed function (REST)', done => { + request({ + method: 'POST', + url: Parse.serverURL + '/hooks/functions', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + body: JSON.stringify({ functionName: 'SomeFunction' }), + }).then(fail, response => { + const body = response.data; + expect(body.error).toBe('invalid hook declaration'); + expect(body.code).toBe(143); + done(); + }); + }); + + it_id('96d99414-b739-4e36-b3f4-8135e0be83ea')(it)('should create hooks and properly preload them', done => { + const promises = []; + for (let i = 0; i < 5; i++) { + promises.push( + Parse.Hooks.createTrigger('MyClass' + i, 'beforeSave', 'http://url.com/beforeSave/' + i) + ); + promises.push(Parse.Hooks.createFunction('AFunction' + i, 'http://url.com/function' + i)); + } + + Promise.all(promises) + .then( + function () { + for (let i = 0; i < 5; i++) { + // Delete everything from memory, as the server just started + triggers.removeTrigger('beforeSave', 'MyClass' + i, Parse.applicationId); + triggers.removeFunction('AFunction' + i, Parse.applicationId); + expect( + triggers.getTrigger('MyClass' + i, 'beforeSave', Parse.applicationId) + ).toBeUndefined(); + expect(triggers.getFunction('AFunction' + i, Parse.applicationId)).toBeUndefined(); + } + const hooksController = new HooksController( + Parse.applicationId, + Config.get('test').database + ); + return hooksController.load(); + }, + err => { + jfail(err); + fail('Should properly create all hooks'); + done(); + } + ) + .then( + function () { + for (let i = 0; i < 5; i++) { + expect( + triggers.getTrigger('MyClass' + i, 'beforeSave', Parse.applicationId) + ).not.toBeUndefined(); + expect(triggers.getFunction('AFunction' + i, Parse.applicationId)).not.toBeUndefined(); + } + done(); + }, + err => { + jfail(err); + fail('should properly load all hooks'); + done(); + } + ); + }); + + it_id('fe7d41eb-e570-4804-ac1f-8b6c407fdafe')(it)('should run the function on the test server', done => { + app.post('/SomeFunction', function (req, res) { + res.json({ success: 'OK!' }); + }); + + Parse.Hooks.createFunction('SOME_TEST_FUNCTION', hookServerURL + '/SomeFunction') + .then( + function () { + return Parse.Cloud.run('SOME_TEST_FUNCTION'); + }, + err => { + jfail(err); + fail('Should not fail creating a function'); + done(); + } + ) + .then( + function (res) { + expect(res).toBe('OK!'); + done(); + }, + err => { + jfail(err); + fail('Should not fail calling a function'); + done(); + } + ); + }); + + it_id('63985b4c-a212-4a86-aa0e-eb4600bb485b')(it)('should run the function on the test server (error handling)', done => { + app.post('/SomeFunctionError', function (req, res) { + res.json({ error: { code: 1337, error: 'hacking that one!' } }); + }); + // The function is deleted as the DB is dropped between calls + Parse.Hooks.createFunction('SOME_TEST_FUNCTION', hookServerURL + '/SomeFunctionError') + .then( + function () { + return Parse.Cloud.run('SOME_TEST_FUNCTION'); + }, + err => { + jfail(err); + fail('Should not fail creating a function'); + done(); + } + ) + .then( + function () { + fail('Should not succeed calling that function'); + done(); + }, + err => { + expect(err).not.toBe(undefined); + expect(err).not.toBe(null); + if (err) { + expect(err.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(err.message.code).toEqual(1337); + expect(err.message.error).toEqual('hacking that one!'); + } + done(); + } + ); + }); + + it_id('bacc1754-2a3a-4a7a-8d0e-f80af36da1ef')(it)('should provide X-Parse-Webhook-Key when defined', done => { + app.post('/ExpectingKey', function (req, res) { + if (req.get('X-Parse-Webhook-Key') === 'hook') { + res.json({ success: 'correct key provided' }); + } else { + res.json({ error: 'incorrect key provided' }); + } + }); + + Parse.Hooks.createFunction('SOME_TEST_FUNCTION', hookServerURL + '/ExpectingKey') + .then( + function () { + return Parse.Cloud.run('SOME_TEST_FUNCTION'); + }, + err => { + jfail(err); + fail('Should not fail creating a function'); + done(); + } + ) + .then( + function (res) { + expect(res).toBe('correct key provided'); + done(); + }, + err => { + jfail(err); + fail('Should not fail calling a function'); + done(); + } + ); + }); + + it_id('eeb67946-42c6-4581-89af-2abb4927913e')(it)('should not pass X-Parse-Webhook-Key if not provided', done => { + reconfigureServer({ webhookKey: undefined }).then(() => { + app.post('/ExpectingKeyAlso', function (req, res) { + if (req.get('X-Parse-Webhook-Key') === 'hook') { + res.json({ success: 'correct key provided' }); + } else { + res.json({ error: 'incorrect key provided' }); + } + }); + + Parse.Hooks.createFunction('SOME_TEST_FUNCTION', hookServerURL + '/ExpectingKeyAlso') + .then( + function () { + return Parse.Cloud.run('SOME_TEST_FUNCTION'); + }, + err => { + jfail(err); + fail('Should not fail creating a function'); + done(); + } + ) + .then( + function () { + fail('Should not succeed calling that function'); + done(); + }, + err => { + expect(err).not.toBe(undefined); + expect(err).not.toBe(null); + if (err) { + expect(err.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(err.message).toEqual('incorrect key provided'); + } + done(); + } + ); + }); + }); + + it_id('21decb65-4b93-4791-85a3-ab124a9ea3ac')(it)('should run the beforeSave hook on the test server', done => { + let triggerCount = 0; + app.post('/BeforeSaveSome', function (req, res) { + triggerCount++; + const object = req.body.object; + object.hello = 'world'; + // Would need parse cloud express to set much more + // But this should override the key upon return + res.json({ success: object }); + }); + // The function is deleted as the DB is dropped between calls + Parse.Hooks.createTrigger('SomeRandomObject', 'beforeSave', hookServerURL + '/BeforeSaveSome') + .then(function () { + const obj = new Parse.Object('SomeRandomObject'); + return obj.save(); + }) + .then(function (res) { + expect(triggerCount).toBe(1); + return res.fetch(); }) - }); - - it_exclude_dbs(['postgres'])("should fail trying to create two times the same trigger", (done) => { - Parse.Hooks.createTrigger("MyClass", "beforeSave", "http://url.com").then( () => { - return Parse.Hooks.createTrigger("MyClass", "beforeSave", "http://url.com") - }, () => { - fail("should create a new trigger"); - }).then( () => { - fail("should not be able to create the same trigger"); - }, (err) => { - expect(err.code).toBe(143); - expect(err.message).toBe('class MyClass already has trigger beforeSave') - return Parse.Hooks.removeTrigger("MyClass", "beforeSave"); - }).then(() => { + .then(function (res) { + expect(res.get('hello')).toEqual('world'); done(); - }, (err) => { - fail(err); + }) + .catch(err => { + jfail(err); + fail('Should not fail creating a function'); done(); + }); + }); + + it_id('52e3152b-5514-4418-9e76-1f394368b8fb')(it)('beforeSave hooks should correctly handle responses containing entire object', done => { + app.post('/BeforeSaveSome2', function (req, res) { + const object = Parse.Object.fromJSON(req.body.object); + object.set('hello', 'world'); + res.json({ success: object }); + }); + Parse.Hooks.createTrigger('SomeRandomObject2', 'beforeSave', hookServerURL + '/BeforeSaveSome2') + .then(function () { + const obj = new Parse.Object('SomeRandomObject2'); + return obj.save(); + }) + .then(function (res) { + return res.save(); }) - }); - - it_exclude_dbs(['postgres'])("should fail trying to update a function that don't exist", (done) => { - Parse.Hooks.updateFunction("A_COOL_FUNCTION", "http://url.com").then( () => { - fail("Should not succeed") - }, (err) => { - expect(err.code).toBe(143); - expect(err.message).toBe('no function named: A_COOL_FUNCTION is defined'); - return Parse.Hooks.getFunction("A_COOL_FUNCTION") - }).then( (res) => { - fail("the function should not exist"); + .then(function (res) { + expect(res.get('hello')).toEqual('world'); done(); - }, (err) => { - expect(err.code).toBe(143); - expect(err.message).toBe('no function named: A_COOL_FUNCTION is defined'); + }) + .catch(err => { + fail(`Should not fail: ${JSON.stringify(err)}`); done(); }); - }); - - it_exclude_dbs(['postgres'])("should fail trying to update a trigger that don't exist", (done) => { - Parse.Hooks.updateTrigger("AClassName","beforeSave", "http://url.com").then( () => { - fail("Should not succeed") - }, (err) => { - expect(err.code).toBe(143); - expect(err.message).toBe('class AClassName does not exist'); - return Parse.Hooks.getTrigger("AClassName","beforeSave") - }).then( (res) => { - fail("the function should not exist"); + }); + + it_id('d27a7587-abb5-40d5-9805-051ee91de474')(it)('should run the afterSave hook on the test server', done => { + let triggerCount = 0; + let newObjectId; + app.post('/AfterSaveSome', function (req, res) { + triggerCount++; + const obj = new Parse.Object('AnotherObject'); + obj.set('foo', 'bar'); + obj.save().then(function (obj) { + newObjectId = obj.id; + res.json({ success: {} }); + }); + }); + // The function is deleted as the DB is dropped between calls + Parse.Hooks.createTrigger('SomeRandomObject', 'afterSave', hookServerURL + '/AfterSaveSome') + .then(function () { + const obj = new Parse.Object('SomeRandomObject'); + return obj.save(); + }) + .then(function () { + return new Promise(resolve => { + setTimeout(() => { + expect(triggerCount).toBe(1); + new Parse.Query('AnotherObject').get(newObjectId).then(r => resolve(r)); + }, 500); + }); + }) + .then(function (res) { + expect(res.get('foo')).toEqual('bar'); done(); - }, (err) => { - expect(err.code).toBe(143); - expect(err.message).toBe('class AClassName does not exist'); + }) + .catch(err => { + jfail(err); + fail('Should not fail creating a function'); done(); }); - }); + }); + it('should log warning when webhook overwrites existing Cloud Code function', async () => { + const logger = require('../lib/logger').logger; + spyOn(logger, 'warn').and.callFake(() => {}); + Parse.Cloud.define('myCloudFunction', () => 'cloud result', { requireMaster: true }); + expect(triggers.getFunction('myCloudFunction', Parse.applicationId)).toBeDefined(); + expect(triggers.getValidator('myCloudFunction', Parse.applicationId)).toBeDefined(); + await Parse.Hooks.createFunction('myCloudFunction', hookServerURL + '/hook'); + expect(logger.warn).toHaveBeenCalledWith( + 'Warning: Duplicate cloud functions exist for myCloudFunction. Only the last one will be used and the others will be ignored.' + ); + await Parse.Hooks.removeFunction('myCloudFunction'); + }); +}); - it("should fail trying to create a malformed function", (done) => { - Parse.Hooks.createFunction("MyFunction").then( (res) => { - fail(res); - }, (err) => { - expect(err.code).toBe(143); - expect(err.error).toBe("invalid hook declaration"); - done(); - }); - }); - - it("should fail trying to create a malformed function (REST)", (done) => { - request.post(Parse.serverURL+"/hooks/functions", { - headers: { - "X-Parse-Application-Id": Parse.applicationId, - "X-Parse-Master-Key": Parse.masterKey, - }, - body: JSON.stringify({ functionName: "SomeFunction"}) - }, (err, res, body) => { - body = JSON.parse(body); - expect(body.error).toBe("invalid hook declaration"); - expect(body.code).toBe(143); - done(); - }) - }); - - - it_exclude_dbs(['postgres'])("should create hooks and properly preload them", (done) => { - - var promises = []; - for (var i = 0; i<5; i++) { - promises.push(Parse.Hooks.createTrigger("MyClass"+i, "beforeSave", "http://url.com/beforeSave/"+i)); - promises.push(Parse.Hooks.createFunction("AFunction"+i, "http://url.com/function"+i)); - } - - Parse.Promise.when(promises).then(function(results){ - for (var i=0; i<5; i++) { - // Delete everything from memory, as the server just started - triggers.removeTrigger("beforeSave", "MyClass"+i, Parse.applicationId); - triggers.removeFunction("AFunction"+i, Parse.applicationId); - expect(triggers.getTrigger("MyClass"+i, "beforeSave", Parse.applicationId)).toBeUndefined(); - expect(triggers.getFunction("AFunction"+i, Parse.applicationId)).toBeUndefined(); - } - const hooksController = new HooksController(Parse.applicationId, AppCache.get('test').databaseController); - return hooksController.load() - }, (err) => { - console.error(err); - fail('Should properly create all hooks'); - done(); - }).then(function() { - for (var i=0; i<5; i++) { - expect(triggers.getTrigger("MyClass"+i, "beforeSave", Parse.applicationId)).not.toBeUndefined(); - expect(triggers.getFunction("AFunction"+i, Parse.applicationId)).not.toBeUndefined(); - } - done(); - }, (err) => { - console.error(err); - fail('should properly load all hooks'); - done(); - }) - }); - - it_exclude_dbs(['postgres'])("should run the function on the test server", (done) => { - - app.post("/SomeFunction", function(req, res) { - res.json({success:"OK!"}); - }); - - Parse.Hooks.createFunction("SOME_TEST_FUNCTION", hookServerURL+"/SomeFunction").then(function(){ - return Parse.Cloud.run("SOME_TEST_FUNCTION") - }, (err) => { - console.error(err); - fail("Should not fail creating a function"); - done(); - }).then(function(res){ - expect(res).toBe("OK!"); - done(); - }, (err) => { - console.error(err); - fail("Should not fail calling a function"); - done(); - }); - }); - - it_exclude_dbs(['postgres'])("should run the function on the test server", (done) => { - - app.post("/SomeFunctionError", function(req, res) { - res.json({error: {code: 1337, error: "hacking that one!"}}); - }); - // The function is deleted as the DB is dropped between calls - Parse.Hooks.createFunction("SOME_TEST_FUNCTION", hookServerURL+"/SomeFunctionError").then(function(){ - return Parse.Cloud.run("SOME_TEST_FUNCTION") - }, (err) => { - console.error(err); - fail("Should not fail creating a function"); - done(); - }).then(function(res){ - fail("Should not succeed calling that function"); - done(); - }, (err) => { - expect(err.code).toBe(141); - expect(err.message.code).toEqual(1337) - expect(err.message.error).toEqual("hacking that one!"); - done(); - }); - }); - - it_exclude_dbs(['postgres'])("should provide X-Parse-Webhook-Key when defined", (done) => { - app.post("/ExpectingKey", function(req, res) { - if (req.get('X-Parse-Webhook-Key') === 'hook') { - res.json({success: "correct key provided"}); - } else { - res.json({error: "incorrect key provided"}); - } - }); - - Parse.Hooks.createFunction("SOME_TEST_FUNCTION", hookServerURL+"/ExpectingKey").then(function(){ - return Parse.Cloud.run("SOME_TEST_FUNCTION") - }, (err) => { - console.error(err); - fail("Should not fail creating a function"); - done(); - }).then(function(res){ - expect(res).toBe("correct key provided"); - done(); - }, (err) => { - console.error(err); - fail("Should not fail calling a function"); - done(); - }); - }); - - it_exclude_dbs(['postgres'])("should not pass X-Parse-Webhook-Key if not provided", (done) => { - reconfigureServer({ webhookKey: undefined }) - .then(() => { - app.post("/ExpectingKeyAlso", function(req, res) { - if (req.get('X-Parse-Webhook-Key') === 'hook') { - res.json({success: "correct key provided"}); - } else { - res.json({error: "incorrect key provided"}); - } - }); - - Parse.Hooks.createFunction("SOME_TEST_FUNCTION", hookServerURL+"/ExpectingKeyAlso").then(function(){ - return Parse.Cloud.run("SOME_TEST_FUNCTION") - }, (err) => { - console.error(err); - fail("Should not fail creating a function"); - done(); - }).then(function(res){ - fail("Should not succeed calling that function"); - done(); - }, (err) => { - expect(err.code).toBe(141); - expect(err.message).toEqual("incorrect key provided"); - done(); - }); - }); - }); - - - it_exclude_dbs(['postgres'])("should run the beforeSave hook on the test server", (done) => { - var triggerCount = 0; - app.post("/BeforeSaveSome", function(req, res) { - triggerCount++; - var object = req.body.object; - object.hello = "world"; - // Would need parse cloud express to set much more - // But this should override the key upon return - res.json({success: object}); - }); - // The function is delete as the DB is dropped between calls - Parse.Hooks.createTrigger("SomeRandomObject", "beforeSave" ,hookServerURL+"/BeforeSaveSome").then(function(){ - const obj = new Parse.Object("SomeRandomObject"); - return obj.save(); - }).then(function(res) { - expect(triggerCount).toBe(1); - return res.fetch(); - }).then(function(res) { - expect(res.get("hello")).toEqual("world"); - done(); - }).fail((err) => { - console.error(err); - fail("Should not fail creating a function"); - done(); - }); - }); - - it_exclude_dbs(['postgres'])("beforeSave hooks should correctly handle responses containing entire object", (done) => { - app.post("/BeforeSaveSome2", function(req, res) { - var object = Parse.Object.fromJSON(req.body.object); - object.set('hello', "world"); - res.json({success: object}); - }); - Parse.Hooks.createTrigger("SomeRandomObject2", "beforeSave" ,hookServerURL+"/BeforeSaveSome2").then(function(){ - const obj = new Parse.Object("SomeRandomObject2"); - return obj.save(); - }).then(function(res) { - return res.save(); - }).then(function(res) { - expect(res.get("hello")).toEqual("world"); - done(); - }).fail((err) => { - fail(`Should not fail: ${JSON.stringify(err)}`); - done(); - }); - }); - - it_exclude_dbs(['postgres'])("should run the afterSave hook on the test server", (done) => { - var triggerCount = 0; - var newObjectId; - app.post("/AfterSaveSome", function(req, res) { - triggerCount++; - var obj = new Parse.Object("AnotherObject"); - obj.set("foo", "bar"); - obj.save().then(function(obj){ - newObjectId = obj.id; - res.json({success: {}}); - }) - }); - // The function is delete as the DB is dropped between calls - Parse.Hooks.createTrigger("SomeRandomObject", "afterSave" ,hookServerURL+"/AfterSaveSome").then(function(){ - const obj = new Parse.Object("SomeRandomObject"); - return obj.save(); - }).then(function(res){ - var promise = new Parse.Promise(); - // Wait a bit here as it's an after save - setTimeout(function(){ - expect(triggerCount).toBe(1); - var q = new Parse.Query("AnotherObject"); - q.get(newObjectId).then(function(r){ - promise.resolve(r); - }); - }, 300) - return promise; - }).then(function(res){ - expect(res.get("foo")).toEqual("bar"); - done(); - }).fail((err) => { - console.error(err); - fail("Should not fail creating a function"); - done(); - }); - }); +describe('triggers', () => { + it('should produce a proper request object with context in beforeSave', () => { + const config = Config.get('test'); + const master = auth.master(config); + const context = { + originalKey: 'original', + }; + const req = triggers.getRequestObject( + triggers.Types.beforeSave, + master, + {}, + {}, + config, + context + ); + expect(req.context.originalKey).toBe('original'); + req.context = { + key: 'value', + }; + expect(context.key).toBe(undefined); + req.context = { + key: 'newValue', + }; + expect(context.key).toBe(undefined); + }); + + it('should produce a proper request object with context in afterSave', () => { + const config = Config.get('test'); + const master = auth.master(config); + const context = {}; + const req = triggers.getRequestObject( + triggers.Types.afterSave, + master, + {}, + {}, + config, + context + ); + expect(req.context).not.toBeUndefined(); + }); + + it('should not set context on beforeFind', () => { + const config = Config.get('test'); + const master = auth.master(config); + const context = {}; + const req = triggers.getRequestObject( + triggers.Types.beforeFind, + master, + {}, + {}, + config, + context + ); + expect(req.context).toBeUndefined(); + }); +}); + +describe('sanitizing names', () => { + const invalidNames = [ + `test'%3bdeclare%20@q%20varchar(99)%3bset%20@q%3d'%5c%5cxxxxxxxxxxxxxxx.yyyyy'%2b'fy.com%5cxus'%3b%20exec%20master.dbo.xp_dirtree%20@q%3b--%20`, + `test.function.name`, + ]; + + it('should not crash server and return error on invalid Cloud Function name', async () => { + for (const invalidName of invalidNames) { + let error; + try { + await Parse.Cloud.run(invalidName); + } catch (err) { + error = err; + } + expect(error).toBeDefined(); + expect(error.message).toMatch(/Invalid function/); + } + }); + + it('should not crash server and return error on invalid Cloud Job name', async () => { + for (const invalidName of invalidNames) { + let error; + try { + await Parse.Cloud.startJob(invalidName); + } catch (err) { + error = err; + } + expect(error).toBeDefined(); + expect(error.message).toMatch(/Invalid job/); + } + }); }); diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index ea12320a6e..261733c3af 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -2,777 +2,1044 @@ // These tests check the Installations functionality of the REST API. // Ported from installation_collection_test.go -let auth = require('../src/Auth'); -let cache = require('../src/cache'); -let Config = require('../src/Config'); -let Parse = require('parse/node').Parse; -let rest = require('../src/rest'); -let request = require("request"); +const auth = require('../lib/Auth'); +const Config = require('../lib/Config'); +const Parse = require('parse/node').Parse; +const rest = require('../lib/rest'); +const request = require('../lib/request'); let config; let database; -let defaultColumns = require('../src/Controllers/SchemaController').defaultColumns; +const defaultColumns = require('../lib/Controllers/SchemaController').defaultColumns; -const installationSchema = { fields: Object.assign({}, defaultColumns._Default, defaultColumns._Installation) }; +const delay = function delay(delay) { + return new Promise(resolve => setTimeout(resolve, delay)); +}; -describe('Installations', () => { +const installationSchema = { + fields: Object.assign({}, defaultColumns._Default, defaultColumns._Installation), +}; - beforeEach(() => { - config = new Config('test'); +describe('Installations', () => { + beforeEach(() => { + config = Config.get('test'); database = config.database; }); - it_exclude_dbs(['postgres'])('creates an android installation with ids', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var device = 'android'; - var input = { - 'installationId': installId, - 'deviceType': device - }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - var obj = results[0]; - expect(obj.installationId).toEqual(installId); - expect(obj.deviceType).toEqual(device); - done(); - }).catch((error) => { console.log(error); }); - }); - - it_exclude_dbs(['postgres'])('creates an ios installation with ids', (done) => { - var t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; - var device = 'ios'; - var input = { - 'deviceToken': t, - 'deviceType': device - }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - var obj = results[0]; - expect(obj.deviceToken).toEqual(t); - expect(obj.deviceType).toEqual(device); - done(); - }).catch((error) => { console.log(error); }); - }); - - it_exclude_dbs(['postgres'])('creates an embedded installation with ids', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var device = 'embedded'; - var input = { - 'installationId': installId, - 'deviceType': device - }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - var obj = results[0]; - expect(obj.installationId).toEqual(installId); - expect(obj.deviceType).toEqual(device); - done(); - }).catch((error) => { console.log(error); }); - }); - - it_exclude_dbs(['postgres'])('creates an android installation with all fields', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var device = 'android'; - var input = { - 'installationId': installId, - 'deviceType': device, - 'channels': ['foo', 'bar'] - }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - var obj = results[0]; - expect(obj.installationId).toEqual(installId); - expect(obj.deviceType).toEqual(device); - expect(typeof obj.channels).toEqual('object'); - expect(obj.channels.length).toEqual(2); - expect(obj.channels[0]).toEqual('foo'); - expect(obj.channels[1]).toEqual('bar'); - done(); - }).catch((error) => { console.log(error); }); - }); - - it_exclude_dbs(['postgres'])('creates an ios installation with all fields', (done) => { - var t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; - var device = 'ios'; - var input = { - 'deviceToken': t, - 'deviceType': device, - 'channels': ['foo', 'bar'] - }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - var obj = results[0]; - expect(obj.deviceToken).toEqual(t); - expect(obj.deviceType).toEqual(device); - expect(typeof obj.channels).toEqual('object'); - expect(obj.channels.length).toEqual(2); - expect(obj.channels[0]).toEqual('foo'); - expect(obj.channels[1]).toEqual('bar'); - done(); - }).catch((error) => { console.log(error); }); - }); - - it('should properly fail queying installations', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var device = 'android'; - var input = { - 'installationId': installId, - 'deviceType': device - }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => { - let query = new Parse.Query(Parse.Installation); - return query.find() - }).then((results) => { - fail('Should not succeed!'); - done(); - }).catch((error) => { - expect(error.code).toBe(119); - expect(error.message).toBe('Clients aren\'t allowed to perform the find operation on the installation collection.') - done(); - }); + it('creates an android installation with ids', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const device = 'android'; + const input = { + installationId: installId, + deviceType: device, + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + const obj = results[0]; + expect(obj.installationId).toEqual(installId); + expect(obj.deviceType).toEqual(device); + done(); + }) + .catch(error => { + console.log(error); + jfail(error); + done(); + }); }); - it_exclude_dbs(['postgres'])('should properly queying installations with masterKey', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var device = 'android'; - var input = { - 'installationId': installId, - 'deviceType': device - }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => { - let query = new Parse.Query(Parse.Installation); - return query.find({useMasterKey: true}); - }).then((results) => { - expect(results.length).toEqual(1); - var obj = results[0].toJSON(); - expect(obj.installationId).toEqual(installId); - expect(obj.deviceType).toEqual(device); - done(); - }).catch((error) => { - fail('Should not fail'); - done(); - }); + it('creates an ios installation with ids', done => { + const t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + const device = 'ios'; + const input = { + deviceToken: t, + deviceType: device, + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + const obj = results[0]; + expect(obj.deviceToken).toEqual(t); + expect(obj.deviceType).toEqual(device); + done(); + }) + .catch(error => { + console.log(error); + jfail(error); + done(); + }); }); - it('fails with missing ids', (done) => { - var input = { - 'deviceType': 'android', - 'channels': ['foo', 'bar'] + it('creates an embedded installation with ids', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const device = 'embedded'; + const input = { + installationId: installId, + deviceType: device, }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => { - fail('Should not have been able to create an Installation.'); - done(); - }).catch((error) => { - expect(error.code).toEqual(135); - done(); - }); + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + const obj = results[0]; + expect(obj.installationId).toEqual(installId); + expect(obj.deviceType).toEqual(device); + done(); + }) + .catch(error => { + console.log(error); + jfail(error); + done(); + }); }); - it('fails for android with missing type', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var input = { - 'installationId': installId, - 'channels': ['foo', 'bar'] + it('creates an android installation with all fields', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const device = 'android'; + const input = { + installationId: installId, + deviceType: device, + channels: ['foo', 'bar'], }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => { - fail('Should not have been able to create an Installation.'); - done(); - }).catch((error) => { - expect(error.code).toEqual(135); - done(); - }); + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + const obj = results[0]; + expect(obj.installationId).toEqual(installId); + expect(obj.deviceType).toEqual(device); + expect(typeof obj.channels).toEqual('object'); + expect(obj.channels.length).toEqual(2); + expect(obj.channels[0]).toEqual('foo'); + expect(obj.channels[1]).toEqual('bar'); + done(); + }) + .catch(error => { + console.log(error); + jfail(error); + done(); + }); }); - it_exclude_dbs(['postgres'])('creates an object with custom fields', (done) => { - var t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; - var input = { - 'deviceToken': t, - 'deviceType': 'ios', - 'channels': ['foo', 'bar'], - 'custom': 'allowed' + it('creates an ios installation with all fields', done => { + const t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + const device = 'ios'; + const input = { + deviceToken: t, + deviceType: device, + channels: ['foo', 'bar'], }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - var obj = results[0]; - expect(obj.custom).toEqual('allowed'); - done(); - }).catch((error) => { console.log(error); }); + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + const obj = results[0]; + expect(obj.deviceToken).toEqual(t); + expect(obj.deviceType).toEqual(device); + expect(typeof obj.channels).toEqual('object'); + expect(obj.channels.length).toEqual(2); + expect(obj.channels[0]).toEqual('foo'); + expect(obj.channels[1]).toEqual('bar'); + done(); + }) + .catch(error => { + console.log(error); + jfail(error); + done(); + }); + }); + + it('should properly fail queying installations', done => { + const logger = require('../lib/logger').default; + const loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const device = 'android'; + const input = { + installationId: installId, + deviceType: device, + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => { + loggerErrorSpy.calls.reset(); + const query = new Parse.Query(Parse.Installation); + return query.find(); + }) + .then(() => { + fail('Should not succeed!'); + done(); + }) + .catch(error => { + expect(error.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(error.message).toBe( + 'Permission denied' + ); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining("Clients aren't allowed to perform the find operation on the installation collection.")); + done(); + }); + }); + + it('should properly queying installations with masterKey', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const device = 'android'; + const input = { + installationId: installId, + deviceType: device, + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => { + const query = new Parse.Query(Parse.Installation); + return query.find({ useMasterKey: true }); + }) + .then(results => { + expect(results.length).toEqual(1); + const obj = results[0].toJSON(); + expect(obj.installationId).toEqual(installId); + expect(obj.deviceType).toEqual(device); + done(); + }) + .catch(() => { + fail('Should not fail'); + done(); + }); + }); + + it('fails with missing ids', done => { + const input = { + deviceType: 'android', + channels: ['foo', 'bar'], + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => { + fail('Should not have been able to create an Installation.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(135); + done(); + }); + }); + + it('fails for android with missing type', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const input = { + installationId: installId, + channels: ['foo', 'bar'], + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => { + fail('Should not have been able to create an Installation.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(135); + done(); + }); + }); + + it('creates an object with custom fields', done => { + const t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + const input = { + deviceToken: t, + deviceType: 'ios', + channels: ['foo', 'bar'], + custom: 'allowed', + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + const obj = results[0]; + expect(obj.custom).toEqual('allowed'); + done(); + }) + .catch(error => { + console.log(error); + }); }); // Note: did not port test 'TestObjectIDForIdentifiers' - it_exclude_dbs(['postgres'])('merging when installationId already exists', (done) => { - var installId1 = '12345678-abcd-abcd-abcd-123456789abc'; - var t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; - var installId2 = '12345678-abcd-abcd-abcd-123456789abd'; - var input = { - 'deviceToken': t, - 'deviceType': 'ios', - 'installationId': installId1, - 'channels': ['foo', 'bar'] - }; - var firstObject; - var secondObject; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - firstObject = results[0]; - delete input.deviceToken; - delete input.channels; - input['foo'] = 'bar'; - return rest.create(config, auth.nobody(config), '_Installation', input); - }) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - secondObject = results[0]; - expect(firstObject._id).toEqual(secondObject._id); - expect(secondObject.channels.length).toEqual(2); - expect(secondObject.foo).toEqual('bar'); - done(); - }).catch((error) => { console.log(error); }); - }); - - it_exclude_dbs(['postgres'])('merging when two objects both only have one id', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - var input1 = { - 'installationId': installId, - 'deviceType': 'ios' - }; - var input2 = { - 'deviceToken': t, - 'deviceType': 'ios' - }; - var input3 = { - 'deviceToken': t, - 'installationId': installId, - 'deviceType': 'ios' - }; - var firstObject; - var secondObject; - rest.create(config, auth.nobody(config), '_Installation', input1) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - firstObject = results[0]; - return rest.create(config, auth.nobody(config), '_Installation', input2); - }) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(2); - if (results[0]['_id'] == firstObject._id) { - secondObject = results[1]; - } else { + it('merging when installationId already exists', done => { + const installId1 = '12345678-abcd-abcd-abcd-123456789abc'; + const t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + const input = { + deviceToken: t, + deviceType: 'ios', + installationId: installId1, + channels: ['foo', 'bar'], + }; + let firstObject; + let secondObject; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + firstObject = results[0]; + delete input.deviceToken; + delete input.channels; + input['foo'] = 'bar'; + return rest.create(config, auth.nobody(config), '_Installation', input); + }) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); secondObject = results[0]; - } - return rest.create(config, auth.nobody(config), '_Installation', input3); - }) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - expect(results[0]['_id']).toEqual(secondObject._id); - done(); - }).catch((error) => { console.log(error); }); - }); - - xit('creating multiple devices with same device token works', (done) => { - var installId1 = '11111111-abcd-abcd-abcd-123456789abc'; - var installId2 = '22222222-abcd-abcd-abcd-123456789abc'; - var installId3 = '33333333-abcd-abcd-abcd-123456789abc'; - var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - var input = { - 'installationId': installId1, - 'deviceType': 'ios', - 'deviceToken': t - }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => { - input.installationId = installId2; - return rest.create(config, auth.nobody(config), '_Installation', input); - }).then(() => { - input.installationId = installId3; - return rest.create(config, auth.nobody(config), '_Installation', input); - }) - .then(() => database.adapter.find('_Installation', {installationId: installId1}, installationSchema, {})) - .then(results => { - expect(results.length).toEqual(1); - return database.adapter.find('_Installation', {installationId: installId2}, installationSchema, {}); - }).then(results => { - expect(results.length).toEqual(1); - return database.adapter.find('_Installation', {installationId: installId3}, installationSchema, {}); - }).then((results) => { - expect(results.length).toEqual(1); - done(); - }).catch((error) => { console.log(error); }); - }); - - it_exclude_dbs(['postgres'])('updating with new channels', (done) => { - var input = { + expect(firstObject._id).toEqual(secondObject._id); + expect(secondObject.channels.length).toEqual(2); + expect(secondObject.foo).toEqual('bar'); + done(); + }) + .catch(error => { + console.log(error); + }); + }); + + it('merging when two objects both only have one id', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + const input1 = { + installationId: installId, + deviceType: 'ios', + }; + const input2 = { + deviceToken: t, + deviceType: 'ios', + }; + const input3 = { + deviceToken: t, + installationId: installId, + deviceType: 'ios', + }; + let firstObject; + let secondObject; + rest + .create(config, auth.nobody(config), '_Installation', input1) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + firstObject = results[0]; + return rest.create(config, auth.nobody(config), '_Installation', input2); + }) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(2); + if (results[0]['_id'] == firstObject._id) { + secondObject = results[1]; + } else { + secondObject = results[0]; + } + return rest.create(config, auth.nobody(config), '_Installation', input3); + }) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0]['_id']).toEqual(secondObject._id); + done(); + }) + .catch(error => { + jfail(error); + done(); + }); + }); + + xit('creating multiple devices with same device token works', done => { + const installId1 = '11111111-abcd-abcd-abcd-123456789abc'; + const installId2 = '22222222-abcd-abcd-abcd-123456789abc'; + const installId3 = '33333333-abcd-abcd-abcd-123456789abc'; + const t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + const input = { + installationId: installId1, + deviceType: 'ios', + deviceToken: t, + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => { + input.installationId = installId2; + return rest.create(config, auth.nobody(config), '_Installation', input); + }) + .then(() => { + input.installationId = installId3; + return rest.create(config, auth.nobody(config), '_Installation', input); + }) + .then(() => + database.adapter.find( + '_Installation', + { installationId: installId1 }, + installationSchema, + {} + ) + ) + .then(results => { + expect(results.length).toEqual(1); + return database.adapter.find( + '_Installation', + { installationId: installId2 }, + installationSchema, + {} + ); + }) + .then(results => { + expect(results.length).toEqual(1); + return database.adapter.find( + '_Installation', + { installationId: installId3 }, + installationSchema, + {} + ); + }) + .then(results => { + expect(results.length).toEqual(1); + done(); + }) + .catch(error => { + console.log(error); + }); + }); + + it_id('95955e90-04bc-4437-920e-b84bc30dba01')(it)('updating with new channels', done => { + const input = { installationId: '12345678-abcd-abcd-abcd-123456789abc', deviceType: 'android', - channels: ['foo', 'bar'] - }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - var id = results[0].objectId; - var update = { - 'channels': ['baz'] - }; - return rest.update(config, auth.nobody(config), '_Installation', id, update); - }) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - expect(results[0].channels.length).toEqual(1); - expect(results[0].channels[0]).toEqual('baz'); - done(); - }).catch(error => { - console.log(error); - fail(); - done(); - }); + channels: ['foo', 'bar'], + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + const objectId = results[0].objectId; + const update = { + channels: ['baz'], + }; + return rest.update(config, auth.nobody(config), '_Installation', { objectId }, update); + }) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].channels.length).toEqual(1); + expect(results[0].channels[0]).toEqual('baz'); + done(); + }) + .catch(error => { + jfail(error); + done(); + }); }); - it_exclude_dbs(['postgres'])('update android fails with new installation id', (done) => { - var installId1 = '12345678-abcd-abcd-abcd-123456789abc'; - var installId2 = '87654321-abcd-abcd-abcd-123456789abc'; - var input = { - 'installationId': installId1, - 'deviceType': 'android', - 'channels': ['foo', 'bar'] - }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - input = { 'installationId': installId2 }; - return rest.update(config, auth.nobody(config), '_Installation', results[0].objectId, input); - }).then(() => { - fail('Updating the installation should have failed.'); - done(); - }).catch((error) => { - expect(error.code).toEqual(136); - done(); - }); + it('update android fails with new installation id', done => { + const installId1 = '12345678-abcd-abcd-abcd-123456789abc'; + const installId2 = '87654321-abcd-abcd-abcd-123456789abc'; + let input = { + installationId: installId1, + deviceType: 'android', + channels: ['foo', 'bar'], + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + input = { installationId: installId2 }; + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + input + ); + }) + .then(() => { + fail('Updating the installation should have failed.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(136); + done(); + }); }); - it_exclude_dbs(['postgres'])('update ios fails with new deviceToken and no installationId', (done) => { - var a = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; - var b = '91433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; - var input = { - 'deviceToken': a, - 'deviceType': 'ios', - 'channels': ['foo', 'bar'] - }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - input = { 'deviceToken': b }; - return rest.update(config, auth.nobody(config), '_Installation', results[0].objectId, input); - }).then(() => { - fail('Updating the installation should have failed.'); - }).catch((error) => { - expect(error.code).toEqual(136); - done(); - }); + it('update ios fails with new deviceToken and no installationId', done => { + const a = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + const b = '91433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + let input = { + deviceToken: a, + deviceType: 'ios', + channels: ['foo', 'bar'], + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + input = { deviceToken: b }; + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + input + ); + }) + .then(() => { + fail('Updating the installation should have failed.'); + }) + .catch(error => { + expect(error.code).toEqual(136); + done(); + }); }); - it_exclude_dbs(['postgres'])('update ios updates device token', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; - var u = '91433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; - var input = { - 'installationId': installId, - 'deviceType': 'ios', - 'deviceToken': t, - 'channels': ['foo', 'bar'] - }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - input = { - 'installationId': installId, - 'deviceToken': u, - 'deviceType': 'ios' - }; - return rest.update(config, auth.nobody(config), '_Installation', results[0].objectId, input); - }) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - expect(results[0].deviceToken).toEqual(u); - done(); - }); + it('update ios updates device token', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + const u = '91433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; + let input = { + installationId: installId, + deviceType: 'ios', + deviceToken: t, + channels: ['foo', 'bar'], + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + input = { + installationId: installId, + deviceToken: u, + deviceType: 'ios', + }; + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + input + ); + }) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].deviceToken).toEqual(u); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); }); - it_exclude_dbs(['postgres'])('update fails to change deviceType', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var input = { - 'installationId': installId, - 'deviceType': 'android', - 'channels': ['foo', 'bar'] - }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - input = { - 'deviceType': 'ios' - }; - return rest.update(config, auth.nobody(config), '_Installation', results[0].objectId, input); - }).then(() => { - fail('Should not have been able to update Installation.'); - done(); - }).catch((error) => { - expect(error.code).toEqual(136); - done(); - }); + it('update fails to change deviceType', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + let input = { + installationId: installId, + deviceType: 'android', + channels: ['foo', 'bar'], + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + input = { + deviceType: 'ios', + }; + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + input + ); + }) + .then(() => { + fail('Should not have been able to update Installation.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(136); + done(); + }); }); - it_exclude_dbs(['postgres'])('update android with custom field', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var input = { - 'installationId': installId, - 'deviceType': 'android', - 'channels': ['foo', 'bar'] - }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - input = { - 'custom': 'allowed' - }; - return rest.update(config, auth.nobody(config), '_Installation', results[0].objectId, input); - }) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - expect(results[0]['custom']).toEqual('allowed'); - done(); - }); + it('update android with custom field', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + let input = { + installationId: installId, + deviceType: 'android', + channels: ['foo', 'bar'], + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + input = { + custom: 'allowed', + }; + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + input + ); + }) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0]['custom']).toEqual('allowed'); + done(); + }); }); - it_exclude_dbs(['postgres'])('update android device token with duplicate device token', (done) => { - var installId1 = '11111111-abcd-abcd-abcd-123456789abc'; - var installId2 = '22222222-abcd-abcd-abcd-123456789abc'; - var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - var input = { - 'installationId': installId1, - 'deviceToken': t, - 'deviceType': 'android' - }; - var firstObject; - var secondObject; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => { - input = { - 'installationId': installId2, - 'deviceType': 'android' - }; - return rest.create(config, auth.nobody(config), '_Installation', input); - }) - .then(() => database.adapter.find('_Installation', installationSchema, {installationId: installId1}, {})) - .then(results => { - firstObject = results[0]; - expect(results.length).toEqual(1); - return database.adapter.find('_Installation', installationSchema, {installationId: installId2}, {}); - }).then(results => { - expect(results.length).toEqual(1); - secondObject = results[0]; - // Update second installation to conflict with first installation - input = { - 'objectId': secondObject.objectId, - 'deviceToken': t - }; - return rest.update(config, auth.nobody(config), '_Installation', secondObject.objectId, input); - }) - .then(() => database.adapter.find('_Installation', installationSchema, {objectId: firstObject.objectId}, {})) - .then(results => { - // The first object should have been deleted - expect(results.length).toEqual(0); - done(); - }).catch((error) => { console.log(error); }); - }); - - it_exclude_dbs(['postgres'])('update ios device token with duplicate device token', (done) => { - var installId1 = '11111111-abcd-abcd-abcd-123456789abc'; - var installId2 = '22222222-abcd-abcd-abcd-123456789abc'; - var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - var input = { - 'installationId': installId1, - 'deviceToken': t, - 'deviceType': 'ios' - }; - var firstObject; - var secondObject; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => { - input = { - 'installationId': installId2, - 'deviceType': 'ios' - }; - return rest.create(config, auth.nobody(config), '_Installation', input); - }) - .then(() => database.adapter.find('_Installation', installationSchema, {installationId: installId1}, {})) - .then((results) => { - expect(results.length).toEqual(1); - firstObject = results[0]; - return database.adapter.find('_Installation', installationSchema, {installationId: installId2}, {}); - }) - .then(results => { - expect(results.length).toEqual(1); - secondObject = results[0]; - // Update second installation to conflict with first installation id - input = { - 'installationId': installId2, - 'deviceToken': t - }; - return rest.update(config, auth.nobody(config), '_Installation', secondObject.objectId, input); - }) - .then(() => database.adapter.find('_Installation', installationSchema, {objectId: firstObject.objectId}, {})) - .then(results => { - // The first object should have been deleted - expect(results.length).toEqual(0); - done(); - }).catch((error) => { console.log(error); }); - }); - - xit('update ios device token with duplicate token different app', (done) => { - var installId1 = '11111111-abcd-abcd-abcd-123456789abc'; - var installId2 = '22222222-abcd-abcd-abcd-123456789abc'; - var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - var input = { - 'installationId': installId1, - 'deviceToken': t, - 'deviceType': 'ios', - 'appIdentifier': 'foo' - }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => { - input.installationId = installId2; - input.appIdentifier = 'bar'; - return rest.create(config, auth.nobody(config), '_Installation', input); - }) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - // The first object should have been deleted during merge - expect(results.length).toEqual(1); - expect(results[0].installationId).toEqual(installId2); - done(); - }); + it('update android device token with duplicate device token', async () => { + const installId1 = '11111111-abcd-abcd-abcd-123456789abc'; + const installId2 = '22222222-abcd-abcd-abcd-123456789abc'; + const t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + + let input = { + installationId: installId1, + deviceToken: t, + deviceType: 'android', + }; + await rest.create(config, auth.nobody(config), '_Installation', input); + + input = { + installationId: installId2, + deviceType: 'android', + }; + await rest.create(config, auth.nobody(config), '_Installation', input); + await delay(100); + + let results = await database.adapter.find( + '_Installation', + installationSchema, + { installationId: installId1 }, + {} + ); + expect(results.length).toEqual(1); + const firstObject = results[0]; + + results = await database.adapter.find( + '_Installation', + installationSchema, + { installationId: installId2 }, + {} + ); + expect(results.length).toEqual(1); + const secondObject = results[0]; + + // Update second installation to conflict with first installation + input = { + objectId: secondObject.objectId, + deviceToken: t, + }; + await rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: secondObject.objectId }, + input + ); + await delay(100); + results = await database.adapter.find( + '_Installation', + installationSchema, + { objectId: firstObject.objectId }, + {} + ); + expect(results.length).toEqual(0); }); - it_exclude_dbs(['postgres'])('update ios token and channels', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - var input = { - 'installationId': installId, - 'deviceType': 'ios' - }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - input = { - 'deviceToken': t, - 'channels': [] - }; - return rest.update(config, auth.nobody(config), '_Installation', results[0].objectId, input); - }) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - expect(results[0].installationId).toEqual(installId); - expect(results[0].deviceToken).toEqual(t); - expect(results[0].channels.length).toEqual(0); - done(); - }); + it('update ios device token with duplicate device token', done => { + const installId1 = '11111111-abcd-abcd-abcd-123456789abc'; + const installId2 = '22222222-abcd-abcd-abcd-123456789abc'; + const t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + let input = { + installationId: installId1, + deviceToken: t, + deviceType: 'ios', + }; + let firstObject; + let secondObject; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => { + input = { + installationId: installId2, + deviceType: 'ios', + }; + return rest.create(config, auth.nobody(config), '_Installation', input); + }) + .then(() => delay(100)) + .then(() => + database.adapter.find( + '_Installation', + installationSchema, + { installationId: installId1 }, + {} + ) + ) + .then(results => { + expect(results.length).toEqual(1); + firstObject = results[0]; + }) + .then(() => delay(100)) + .then(() => + database.adapter.find( + '_Installation', + installationSchema, + { installationId: installId2 }, + {} + ) + ) + .then(results => { + expect(results.length).toEqual(1); + secondObject = results[0]; + // Update second installation to conflict with first installation id + input = { + installationId: installId2, + deviceToken: t, + }; + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: secondObject.objectId }, + input + ); + }) + .then(() => delay(100)) + .then(() => + database.adapter.find( + '_Installation', + installationSchema, + { objectId: firstObject.objectId }, + {} + ) + ) + .then(results => { + // The first object should have been deleted + expect(results.length).toEqual(0); + done(); + }) + .catch(error => { + jfail(error); + done(); + }); }); - it_exclude_dbs(['postgres'])('update ios linking two existing objects', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - var input = { - 'installationId': installId, - 'deviceType': 'ios' - }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => { - input = { - 'deviceToken': t, - 'deviceType': 'ios' - }; - return rest.create(config, auth.nobody(config), '_Installation', input); - }) - .then(() => database.adapter.find('_Installation', installationSchema, { deviceToken: t }, {})) - .then(results => { - expect(results.length).toEqual(1); - input = { - 'deviceToken': t, - 'installationId': installId, - 'deviceType': 'ios' - }; - return rest.update(config, auth.nobody(config), '_Installation', results[0].objectId, input); - }) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - expect(results[0].installationId).toEqual(installId); - expect(results[0].deviceToken).toEqual(t); - expect(results[0].deviceType).toEqual('ios'); - done(); - }); + xit('update ios device token with duplicate token different app', done => { + const installId1 = '11111111-abcd-abcd-abcd-123456789abc'; + const installId2 = '22222222-abcd-abcd-abcd-123456789abc'; + const t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + const input = { + installationId: installId1, + deviceToken: t, + deviceType: 'ios', + appIdentifier: 'foo', + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => { + input.installationId = installId2; + input.appIdentifier = 'bar'; + return rest.create(config, auth.nobody(config), '_Installation', input); + }) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + // The first object should have been deleted during merge + expect(results.length).toEqual(1); + expect(results[0].installationId).toEqual(installId2); + done(); + }) + .catch(error => { + jfail(error); + done(); + }); }); - it_exclude_dbs(['postgres'])('update is linking two existing objects w/ increment', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - var input = { - 'installationId': installId, - 'deviceType': 'ios' - }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => { - input = { - 'deviceToken': t, - 'deviceType': 'ios' - }; - return rest.create(config, auth.nobody(config), '_Installation', input); - }) - .then(() => database.adapter.find('_Installation', installationSchema, { deviceToken: t }, {})) - .then(results => { - expect(results.length).toEqual(1); - input = { - 'deviceToken': t, - 'installationId': installId, - 'deviceType': 'ios', - 'score': { - '__op': 'Increment', - 'amount': 1 - } - }; - return rest.update(config, auth.nobody(config), '_Installation', results[0].objectId, input); - }) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - expect(results[0].installationId).toEqual(installId); - expect(results[0].deviceToken).toEqual(t); - expect(results[0].deviceType).toEqual('ios'); - expect(results[0].score).toEqual(1); - done(); - }); + it('update ios token and channels', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + let input = { + installationId: installId, + deviceType: 'ios', + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + input = { + deviceToken: t, + channels: [], + }; + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + input + ); + }) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].installationId).toEqual(installId); + expect(results[0].deviceToken).toEqual(t); + expect(results[0].channels.length).toEqual(0); + done(); + }) + .catch(error => { + jfail(error); + done(); + }); }); - it_exclude_dbs(['postgres'])('update is linking two existing with installation id', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - var input = { - 'installationId': installId, - 'deviceType': 'ios' - }; - var installObj; - var tokenObj; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - installObj = results[0]; - input = { - 'deviceToken': t, - 'deviceType': 'ios' - }; - return rest.create(config, auth.nobody(config), '_Installation', input); - }) - .then(() => database.adapter.find('_Installation', installationSchema, { deviceToken: t }, {})) - .then(results => { - expect(results.length).toEqual(1); - tokenObj = results[0]; - input = { - 'installationId': installId, - 'deviceToken': t, - 'deviceType': 'ios' - }; - return rest.update(config, auth.nobody(config), '_Installation', installObj.objectId, input); - }) - .then(() => database.adapter.find('_Installation', installationSchema, { objectId: tokenObj.objectId }, {})) - .then(results => { - expect(results.length).toEqual(1); - expect(results[0].installationId).toEqual(installId); - expect(results[0].deviceToken).toEqual(t); - done(); - }).catch((error) => { console.log(error); }); - }); - - it_exclude_dbs(['postgres'])('update is linking two existing with installation id w/ op', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - var input = { - 'installationId': installId, - 'deviceType': 'ios' - }; - var installObj; - var tokenObj; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - installObj = results[0]; - input = { - 'deviceToken': t, - 'deviceType': 'ios' - }; - return rest.create(config, auth.nobody(config), '_Installation', input); - }) - .then(() => database.adapter.find('_Installation', installationSchema, { deviceToken: t }, {})) - .then(results => { - expect(results.length).toEqual(1); - tokenObj = results[0]; - input = { - 'installationId': installId, - 'deviceToken': t, - 'deviceType': 'ios', - 'score': { - '__op': 'Increment', - 'amount': 1 - } - }; - return rest.update(config, auth.nobody(config), '_Installation', installObj.objectId, input); - }) - .then(() => database.adapter.find('_Installation', installationSchema, { objectId: tokenObj.objectId }, {})) - .then(results => { - expect(results.length).toEqual(1); - expect(results[0].installationId).toEqual(installId); - expect(results[0].deviceToken).toEqual(t); - expect(results[0].score).toEqual(1); - done(); - }).catch((error) => { console.log(error); }); - }); - - it_exclude_dbs(['postgres'])('ios merge existing same token no installation id', (done) => { + it('update ios linking two existing objects', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + let input = { + installationId: installId, + deviceType: 'ios', + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => { + input = { + deviceToken: t, + deviceType: 'ios', + }; + return rest.create(config, auth.nobody(config), '_Installation', input); + }) + .then(() => + database.adapter.find('_Installation', installationSchema, { deviceToken: t }, {}) + ) + .then(results => { + expect(results.length).toEqual(1); + input = { + deviceToken: t, + installationId: installId, + deviceType: 'ios', + }; + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + input + ); + }) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].installationId).toEqual(installId); + expect(results[0].deviceToken).toEqual(t); + expect(results[0].deviceType).toEqual('ios'); + done(); + }) + .catch(error => { + jfail(error); + done(); + }); + }); + + it_id('22311bc7-3f4f-42c1-a958-57083929e80d')(it)('update is linking two existing objects w/ increment', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + let input = { + installationId: installId, + deviceType: 'ios', + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => { + input = { + deviceToken: t, + deviceType: 'ios', + }; + return rest.create(config, auth.nobody(config), '_Installation', input); + }) + .then(() => + database.adapter.find('_Installation', installationSchema, { deviceToken: t }, {}) + ) + .then(results => { + expect(results.length).toEqual(1); + input = { + deviceToken: t, + installationId: installId, + deviceType: 'ios', + score: { + __op: 'Increment', + amount: 1, + }, + }; + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: results[0].objectId }, + input + ); + }) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].installationId).toEqual(installId); + expect(results[0].deviceToken).toEqual(t); + expect(results[0].deviceType).toEqual('ios'); + expect(results[0].score).toEqual(1); + done(); + }) + .catch(error => { + jfail(error); + done(); + }); + }); + + it('update is linking two existing with installation id', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + let input = { + installationId: installId, + deviceType: 'ios', + }; + let installObj; + let tokenObj; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + installObj = results[0]; + input = { + deviceToken: t, + deviceType: 'ios', + }; + return rest.create(config, auth.nobody(config), '_Installation', input); + }) + .then(() => + database.adapter.find('_Installation', installationSchema, { deviceToken: t }, {}) + ) + .then(results => { + expect(results.length).toEqual(1); + tokenObj = results[0]; + input = { + installationId: installId, + deviceToken: t, + deviceType: 'ios', + }; + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: installObj.objectId }, + input + ); + }) + .then(() => + database.adapter.find( + '_Installation', + installationSchema, + { objectId: tokenObj.objectId }, + {} + ) + ) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].installationId).toEqual(installId); + expect(results[0].deviceToken).toEqual(t); + done(); + }) + .catch(error => { + jfail(error); + done(); + }); + }); + + it_id('f2975078-eab7-4287-a932-288842e3cfb9')(it)('update is linking two existing with installation id w/ op', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + let input = { + installationId: installId, + deviceType: 'ios', + }; + let installObj; + let tokenObj; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + installObj = results[0]; + input = { + deviceToken: t, + deviceType: 'ios', + }; + return rest.create(config, auth.nobody(config), '_Installation', input); + }) + .then(() => + database.adapter.find('_Installation', installationSchema, { deviceToken: t }, {}) + ) + .then(results => { + expect(results.length).toEqual(1); + tokenObj = results[0]; + input = { + installationId: installId, + deviceToken: t, + deviceType: 'ios', + score: { + __op: 'Increment', + amount: 1, + }, + }; + return rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: installObj.objectId }, + input + ); + }) + .then(() => + database.adapter.find( + '_Installation', + installationSchema, + { objectId: tokenObj.objectId }, + {} + ) + ) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].installationId).toEqual(installId); + expect(results[0].deviceToken).toEqual(t); + expect(results[0].score).toEqual(1); + done(); + }) + .catch(error => { + jfail(error); + done(); + }); + }); + + it('ios merge existing same token no installation id', done => { // Test creating installation when there is an existing object with the // same device token but no installation ID. This is possible when // developers import device tokens from another push provider; the import @@ -783,67 +1050,732 @@ describe('Installations', () => { // imported installation, then we should reuse the existing installation // object in case the developer already added additional fields via Data // Browser or REST API (e.g. channel targeting info). - var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var input = { - 'deviceToken': t, - 'deviceType': 'ios' - }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - input = { - 'installationId': installId, - 'deviceToken': t, - 'deviceType': 'ios' - }; - return rest.create(config, auth.nobody(config), '_Installation', input); - }) - .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - expect(results[0].deviceToken).toEqual(t); - expect(results[0].installationId).toEqual(installId); - done(); - }) - .catch(error => { - console.log(error); - fail(); - done(); - }); + const t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + let input = { + deviceToken: t, + deviceType: 'ios', + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + input = { + installationId: installId, + deviceToken: t, + deviceType: 'ios', + }; + return rest.create(config, auth.nobody(config), '_Installation', input); + }) + .then(() => database.adapter.find('_Installation', installationSchema, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].deviceToken).toEqual(t); + expect(results[0].installationId).toEqual(installId); + done(); + }) + .catch(error => { + console.log(error); + fail(); + done(); + }); }); it('allows you to get your own installation (regression test for #1718)', done => { - let installId = '12345678-abcd-abcd-abcd-123456789abc'; - let device = 'android'; - let input = { - 'installationId': installId, - 'deviceType': device - }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(createResult => { - let headers = { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }; - request.get({ - headers: headers, - url: 'http://localhost:8378/1/installations/' + createResult.response.objectId, - json: true, - }, (error, response, body) => { - expect(body.objectId).toEqual(createResult.response.objectId); - done(); - }); - }) - .catch(error => { - console.log(error); - fail('failed'); - done(); + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const device = 'android'; + const input = { + installationId: installId, + deviceType: device, + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(createResult => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + return request({ + headers: headers, + url: 'http://localhost:8378/1/installations/' + createResult.response.objectId, + }).then(response => { + const body = response.data; + expect(body.objectId).toEqual(createResult.response.objectId); + done(); + }); + }) + .catch(error => { + console.log(error); + fail('failed'); + done(); + }); + }); + + it('allows you to update installation from header (#2090)', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const device = 'android'; + const input = { + installationId: installId, + deviceType: device, + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(() => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Installation-Id': installId, + }; + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/classes/_Installation', + json: true, + body: { + date: new Date(), + }, + }).then(response => { + const body = response.data; + expect(response.status).toBe(200); + expect(body.updatedAt).not.toBeUndefined(); + done(); + }); + }) + .catch(error => { + console.log(error); + fail('failed'); + done(); + }); + }); + + it('allows you to update installation with masterKey', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const device = 'android'; + const input = { + installationId: installId, + deviceType: device, + }; + rest + .create(config, auth.nobody(config), '_Installation', input) + .then(createResult => { + const installationObj = Parse.Installation.createWithoutData( + createResult.response.objectId + ); + installationObj.set('customField', 'custom value'); + return installationObj.save(null, { useMasterKey: true }); + }) + .then(updateResult => { + expect(updateResult).not.toBeUndefined(); + expect(updateResult.get('customField')).toEqual('custom value'); + done(); + }) + .catch(error => { + console.log(error); + fail('failed'); + done(); + }); + }); + + it('should properly handle installation save #2780', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const device = 'android'; + const input = { + installationId: installId, + deviceType: device, + }; + rest.create(config, auth.nobody(config), '_Installation', input).then(() => { + const query = new Parse.Query(Parse.Installation); + query.equalTo('installationId', installId); + query + .first({ useMasterKey: true }) + .then(installation => { + return installation.save( + { + key: 'value', + }, + { useMasterKey: true } + ); + }) + .then( + () => { + done(); + }, + err => { + jfail(err); + done(); + } + ); + }); + }); + + it('should properly reject updating installationId', done => { + const installId = '12345678-abcd-abcd-abcd-123456789abc'; + const device = 'android'; + const input = { + installationId: installId, + deviceType: device, + }; + rest.create(config, auth.nobody(config), '_Installation', input).then(() => { + const query = new Parse.Query(Parse.Installation); + query.equalTo('installationId', installId); + query + .first({ useMasterKey: true }) + .then(installation => { + return installation.save( + { + key: 'value', + installationId: '22222222-abcd-abcd-abcd-123456789abc', + }, + { useMasterKey: true } + ); + }) + .then( + () => { + fail('should not succeed'); + done(); + }, + err => { + expect(err.code).toBe(136); + expect(err.message).toBe('installationId may not be changed in this operation'); + done(); + } + ); }); }); + it_id('e581faea-c1b4-4c64-af8c-52287ce6cd06')(it)('can use push with beforeSave', async () => { + const input = { + deviceToken: '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306', + deviceType: 'ios', + }; + await rest.create(config, auth.nobody(config), '_Installation', input); + const functions = { + beforeSave() {}, + afterSave() {}, + }; + spyOn(functions, 'beforeSave').and.callThrough(); + spyOn(functions, 'afterSave').and.callThrough(); + Parse.Cloud.beforeSave(Parse.Installation, functions.beforeSave); + Parse.Cloud.afterSave(Parse.Installation, functions.afterSave); + await Parse.Push.send({ + where: { + deviceType: 'ios', + }, + data: { + badge: 'increment', + alert: 'Hello world!', + }, + }); + + await Parse.Push.send({ + where: { + deviceType: 'ios', + }, + data: { + badge: 'increment', + alert: 'Hello world!', + }, + }); + + await Parse.Push.send({ + where: { + deviceType: 'ios', + }, + data: { + badge: 'increment', + alert: 'Hello world!', + }, + }); + await new Promise(resolve => setTimeout(resolve, 1000)); + const installation = await new Parse.Query(Parse.Installation).first({ useMasterKey: true }); + expect(installation.get('badge')).toEqual(3); + expect(functions.beforeSave).not.toHaveBeenCalled(); + expect(functions.afterSave).not.toHaveBeenCalled(); + }); + // TODO: Look at additional tests from installation_collection_test.go:882 // TODO: Do we need to support _tombstone disabling of installations? // TODO: Test deletion, badge increments + + describe('deviceToken deduplication on new install (no installationId match)', () => { + const { randomUUID } = require('crypto'); + const installationSchema = { + fields: Object.assign({}, defaultColumns._Default, defaultColumns._Installation), + }; + + async function reconfigureWithInstallationOptions(installationOpts) { + await reconfigureServer({ installation: installationOpts }); + config = Config.get('test'); + database = config.database; + } + + it('default options destroy conflicting rows', async () => { + const t = randomUUID(); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-a', + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-b', + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-c', + }); + + const results = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(results.length).toBe(1); + expect(results[0].installationId).toBe('iid-c'); + }); + + it('action="update" preserves channels on conflicting rows but clears deviceToken', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenAction: 'update' }); + const t = randomUUID(); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-a', + channels: ['old-news'], + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-b', + channels: ['old-sports'], + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-c', + channels: ['fresh'], + }); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(3); + const survivor = all.find(r => r.installationId === 'iid-c'); + expect(survivor.deviceToken).toBe(t); + const cleared = all.filter(r => r.installationId !== 'iid-c'); + cleared.forEach(r => { + expect(r.deviceToken).toBeUndefined(); + expect(r.channels).toBeDefined(); + }); + }); + + it('enforceAuth=true preserves ACL-protected rows from unauthenticated dedup', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenActionEnforceAuth: true }); + const t = randomUUID(); + const user = await Parse.User.signUp('alice-' + Date.now(), 'pass'); + const aliceId = user.id; + + await rest.create(config, auth.master(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-protected', + ACL: { [aliceId]: { read: true, write: true } }, + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-other', + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-attacker', + }); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + const protectedRow = all.find(r => r.installationId === 'iid-protected'); + expect(protectedRow).toBeDefined(); + expect(protectedRow.deviceToken).toBe(t); + }); + + it('enforceAuth=true with master-key caller still bypasses ACL and dedups', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenActionEnforceAuth: true }); + const t = randomUUID(); + const user = await Parse.User.signUp('bob-' + Date.now(), 'pass'); + const bobId = user.id; + await rest.create(config, auth.master(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-1', + ACL: { [bobId]: { read: true, write: true } }, + }); + await rest.create(config, auth.master(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-2', + }); + await rest.create(config, auth.master(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'iid-3', + }); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(1); + expect(all[0].installationId).toBe('iid-3'); + }); + + it('action="update" clears deviceToken on ALL matching rows (multi-row update)', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenAction: 'update' }); + const t = randomUUID(); + // First REST create ensures the storage class/table exists before direct + // adapter inserts (relevant for Postgres, which creates tables lazily). + await rest.create(config, auth.master(config), '_Installation', { + deviceType: 'ios', + deviceToken: t, + installationId: 'multi-iid-a', + channels: ['c-multi-iid-a'], + }); + // Insert two more rows directly via the storage adapter so all three hold + // the same deviceToken simultaneously — bypassing the sequential REST + // dedup that would otherwise prevent this state. + const adapter = config.database.adapter; + for (const iid of ['multi-iid-b', 'multi-iid-c']) { + await adapter.createObject( + '_Installation', + installationSchema, + { + objectId: 'oid-' + iid, + deviceType: 'ios', + deviceToken: t, + installationId: iid, + channels: ['c-' + iid], + }, + null + ); + } + // Trigger site 1: new install with same deviceToken, different installationId. + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t, + deviceType: 'ios', + installationId: 'multi-iid-d', + channels: ['fresh'], + }); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + const survivor = all.find(r => r.installationId === 'multi-iid-d'); + expect(survivor).toBeDefined(); + expect(survivor.deviceToken).toBe(t); + const cleared = all.filter(r => r.installationId !== 'multi-iid-d'); + expect(cleared.length).toBe(3); + cleared.forEach(r => { + expect(r.deviceToken).toBeUndefined(); + }); + }); + }); + + describe('deviceToken deduplication on existing install update (deviceToken changes)', () => { + const { randomUUID } = require('crypto'); + const installationSchema = { + fields: Object.assign({}, defaultColumns._Default, defaultColumns._Installation), + }; + + async function reconfigureWithInstallationOptions(installationOpts) { + await reconfigureServer({ installation: installationOpts }); + config = Config.get('test'); + database = config.database; + } + + it('default options destroy conflicting row when PUT sets a new deviceToken', async () => { + const t1 = randomUUID(); + const t2 = randomUUID(); + const a = await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t1, + deviceType: 'ios', + installationId: 'iid-a', + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t2, + deviceType: 'ios', + installationId: 'iid-b', + }); + await rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: a.response.objectId }, + { deviceToken: t2, installationId: 'iid-a' } + ); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(1); + expect(all[0].deviceToken).toBe(t2); + expect(all[0].installationId).toBe('iid-a'); + }); + + it('action="update" preserves the conflicting row and only clears its deviceToken', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenAction: 'update' }); + const t1 = randomUUID(); + const t2 = randomUUID(); + const a = await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t1, + deviceType: 'ios', + installationId: 'iid-a', + }); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t2, + deviceType: 'ios', + installationId: 'iid-b', + channels: ['preserve-me'], + }); + await rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: a.response.objectId }, + { deviceToken: t2, installationId: 'iid-a' } + ); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(2); + const aRow = all.find(r => r.installationId === 'iid-a'); + const bRow = all.find(r => r.installationId === 'iid-b'); + expect(aRow.deviceToken).toBe(t2); + expect(bRow.deviceToken).toBeUndefined(); + expect(bRow.channels).toEqual(['preserve-me']); + }); + + it('enforceAuth=true preserves ACL-protected conflicting rows', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenActionEnforceAuth: true }); + const t1 = randomUUID(); + const t2 = randomUUID(); + const user = await Parse.User.signUp('carol-' + Date.now(), 'pass'); + const carolId = user.id; + + const a = await rest.create(config, auth.nobody(config), '_Installation', { + deviceToken: t1, + deviceType: 'ios', + installationId: 'iid-a', + }); + await rest.create(config, auth.master(config), '_Installation', { + deviceToken: t2, + deviceType: 'ios', + installationId: 'iid-b', + ACL: { [carolId]: { read: true, write: true } }, + }); + await rest.update( + config, + auth.nobody(config), + '_Installation', + { objectId: a.response.objectId }, + { deviceToken: t2, installationId: 'iid-a' } + ); + + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + const bRow = all.find(r => r.installationId === 'iid-b'); + expect(bRow).toBeDefined(); + expect(bRow.deviceToken).toBe(t2); + const aRow = all.find(r => r.installationId === 'iid-a'); + expect(aRow.deviceToken).toBe(t2); + }); + }); + + describe('deviceToken deduplication merge case (idMatch + deviceToken-only orphan)', () => { + const { randomUUID } = require('crypto'); + const installationSchema = { + fields: Object.assign({}, defaultColumns._Default, defaultColumns._Installation), + }; + + async function reconfigureWithInstallationOptions(installationOpts) { + await reconfigureServer({ installation: installationOpts }); + config = Config.get('test'); + database = config.database; + } + + /** + * Sets up the merge fixture: + * Row A — { installationId: iid, deviceType: 'ios' } (no deviceToken) + * Row B — { deviceToken: t, deviceType: 'ios', channels } (no installationId) + * Then triggers the merge by POSTing { installationId: iid, deviceToken: t }. + */ + async function setupMergeFixture(t, iid, bChannels = ['orphan-history']) { + // Row A: matched by installationId, no deviceToken yet. + await rest.create(config, auth.master(config), '_Installation', { + deviceType: 'ios', + installationId: iid, + }); + // Row B: deviceToken-only orphan. Insert via the storage adapter to bypass + // the require-at-least-one-ID check (the orphan has only deviceToken). + const objectId = 'orph' + Math.random().toString(36).substring(2, 12); + await database.adapter.createObject( + '_Installation', + installationSchema, + { + objectId, + deviceType: 'ios', + deviceToken: t, + channels: bChannels, + }, + null + ); + return objectId; + } + + it('default options merge: deviceToken-holder wins, idMatch destroyed', async () => { + const t = randomUUID(); + const orphanObjectId = await setupMergeFixture(t, 'merge-iid-a'); + // POST that triggers the merge. + await rest.create(config, auth.nobody(config), '_Installation', { + deviceType: 'ios', + installationId: 'merge-iid-a', + deviceToken: t, + }); + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(1); + expect(all[0].objectId).toBe(orphanObjectId); + expect(all[0].installationId).toBe('merge-iid-a'); + expect(all[0].deviceToken).toBe(t); + expect(all[0].channels).toEqual(['orphan-history']); + }); + + it('mergePriority=deviceToken, action=update clears installationId on idMatch (loser)', async () => { + await reconfigureWithInstallationOptions({ duplicateDeviceTokenAction: 'update' }); + const t = randomUUID(); + const orphanObjectId = await setupMergeFixture(t, 'merge-iid-a'); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceType: 'ios', + installationId: 'merge-iid-a', + deviceToken: t, + }); + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(2); + const survivor = all.find(r => r.objectId === orphanObjectId); + expect(survivor.installationId).toBe('merge-iid-a'); + expect(survivor.deviceToken).toBe(t); + const loser = all.find(r => r.objectId !== orphanObjectId); + expect(loser.installationId).toBeUndefined(); + }); + + it('mergePriority=installationId, action=delete destroys orphan, idMatch wins', async () => { + await reconfigureWithInstallationOptions({ + duplicateDeviceTokenMergePriority: 'installationId', + }); + const t = randomUUID(); + const orphanObjectId = await setupMergeFixture(t, 'merge-iid-a'); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceType: 'ios', + installationId: 'merge-iid-a', + deviceToken: t, + }); + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(1); + expect(all[0].installationId).toBe('merge-iid-a'); + expect(all[0].deviceToken).toBe(t); + expect(all[0].objectId).not.toBe(orphanObjectId); + }); + + it('mergePriority=installationId, action=update clears deviceToken on orphan', async () => { + await reconfigureWithInstallationOptions({ + duplicateDeviceTokenMergePriority: 'installationId', + duplicateDeviceTokenAction: 'update', + }); + const t = randomUUID(); + const orphanObjectId = await setupMergeFixture(t, 'merge-iid-a'); + await rest.create(config, auth.nobody(config), '_Installation', { + deviceType: 'ios', + installationId: 'merge-iid-a', + deviceToken: t, + }); + const all = await database.adapter.find('_Installation', installationSchema, {}, {}); + expect(all.length).toBe(2); + const survivor = all.find(r => r.installationId === 'merge-iid-a'); + expect(survivor.deviceToken).toBe(t); + const loser = all.find(r => r.objectId === orphanObjectId); + expect(loser.deviceToken).toBeUndefined(); + expect(loser.channels).toEqual(['orphan-history']); + }); + }); + + describe('options validation', () => { + it('should accept default empty config', async () => { + await expectAsync(reconfigureServer({})).toBeResolved(); + }); + + it('should accept fully specified valid config', async () => { + await expectAsync( + reconfigureServer({ + installation: { + duplicateDeviceTokenActionEnforceAuth: true, + duplicateDeviceTokenAction: 'update', + duplicateDeviceTokenMergePriority: 'installationId', + }, + }) + ).toBeResolved(); + }); + + it('should reject non-object values', async () => { + await expectAsync( + reconfigureServer({ installation: 'invalid' }) + ).toBeRejectedWith('installation must be an object.'); + }); + + it('should reject array values', async () => { + await expectAsync( + reconfigureServer({ installation: [] }) + ).toBeRejectedWith('installation must be an object.'); + }); + + it('should reject unknown nested keys', async () => { + await expectAsync( + reconfigureServer({ + installation: { unknownKey: 'foo' }, + }) + ).toBeRejectedWith("installation contains unknown property 'unknownKey'."); + }); + + it('should reject non-boolean duplicateDeviceTokenActionEnforceAuth', async () => { + await expectAsync( + reconfigureServer({ + installation: { duplicateDeviceTokenActionEnforceAuth: 'true' }, + }) + ).toBeRejectedWith('installation.duplicateDeviceTokenActionEnforceAuth must be a boolean.'); + }); + + it('should reject invalid duplicateDeviceTokenAction value', async () => { + await expectAsync( + reconfigureServer({ + installation: { duplicateDeviceTokenAction: 'merge' }, + }) + ).toBeRejectedWith( + "installation.duplicateDeviceTokenAction must be one of: 'delete', 'update'." + ); + }); + + it('should reject invalid duplicateDeviceTokenMergePriority value', async () => { + await expectAsync( + reconfigureServer({ + installation: { duplicateDeviceTokenMergePriority: 'objectId' }, + }) + ).toBeRejectedWith( + "installation.duplicateDeviceTokenMergePriority must be one of: 'deviceToken', 'installationId'." + ); + }); + + it('should apply defaults for missing nested keys', async () => { + await reconfigureServer({ + installation: { duplicateDeviceTokenActionEnforceAuth: true }, + }); + const config = Config.get('test'); + expect(config.installation.duplicateDeviceTokenActionEnforceAuth).toBe(true); + expect(config.installation.duplicateDeviceTokenAction).toBe('delete'); + expect(config.installation.duplicateDeviceTokenMergePriority).toBe('deviceToken'); + }); + + it('should apply full defaults when block omitted', async () => { + await reconfigureServer({}); + const config = Config.get('test'); + expect(config.installation).toEqual({ + duplicateDeviceTokenActionEnforceAuth: false, + duplicateDeviceTokenAction: 'delete', + duplicateDeviceTokenMergePriority: 'deviceToken', + }); + }); + }); }); diff --git a/spec/ParseLiveQuery.spec.js b/spec/ParseLiveQuery.spec.js new file mode 100644 index 0000000000..2363cecb3a --- /dev/null +++ b/spec/ParseLiveQuery.spec.js @@ -0,0 +1,1487 @@ +'use strict'; +const http = require('http'); +const Auth = require('../lib/Auth'); +const UserController = require('../lib/Controllers/UserController').UserController; +const Config = require('../lib/Config'); +const ParseServer = require('../lib/index').ParseServer; +const triggers = require('../lib/triggers'); +const { resolvingPromise, sleep, getConnectionsCount } = require('../lib/TestUtils'); +const request = require('../lib/request'); +const validatorFail = () => { + throw 'you are not authorized'; +}; + +describe('ParseLiveQuery', function () { + beforeEach(() => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + }); + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + await client.close(); + }); + it('access user on onLiveQueryEvent disconnect', async done => { + const requestedUser = new Parse.User(); + requestedUser.setUsername('username'); + requestedUser.setPassword('password'); + Parse.Cloud.onLiveQueryEvent(req => { + const { event, sessionToken } = req; + if (event === 'ws_disconnect') { + Parse.Cloud._removeAllHooks(); + expect(sessionToken).toBeDefined(); + expect(sessionToken).toBe(requestedUser.getSessionToken()); + done(); + } + }); + await requestedUser.signUp(); + const query = new Parse.Query(TestObject); + await query.subscribe(); + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + await client.close(); + }); + + it('can subscribe to query', async done => { + const object = new TestObject(); + await object.save(); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + subscription.on('update', object => { + expect(object.get('foo')).toBe('bar'); + done(); + }); + object.set({ foo: 'bar' }); + await object.save(); + }); + + it('can use patterns in className', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['Test.*'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const object = new TestObject(); + await object.save(); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + subscription.on('update', object => { + expect(object.get('foo')).toBe('bar'); + done(); + }); + object.set({ foo: 'bar' }); + await object.save(); + }); + + it('expect afterEvent create', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + expect(req.event).toBe('create'); + expect(req.user).toBeUndefined(); + expect(req.object.get('foo')).toBe('bar'); + }); + + const query = new Parse.Query(TestObject); + const subscription = await query.subscribe(); + subscription.on('create', object => { + expect(object.get('foo')).toBe('bar'); + done(); + }); + + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + }); + + it('expect afterEvent payload', async done => { + const object = new TestObject(); + await object.save(); + + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + expect(req.event).toBe('update'); + expect(req.user).toBeUndefined(); + expect(req.object.get('foo')).toBe('bar'); + expect(req.original.get('foo')).toBeUndefined(); + done(); + }); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + await query.subscribe(); + object.set({ foo: 'bar' }); + await object.save(); + }); + + it('expect afterEvent enter', async done => { + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + expect(req.event).toBe('enter'); + expect(req.user).toBeUndefined(); + expect(req.object.get('foo')).toBe('bar'); + expect(req.original.get('foo')).toBeUndefined(); + }); + + const object = new TestObject(); + await object.save(); + + const query = new Parse.Query(TestObject); + query.equalTo('foo', 'bar'); + const subscription = await query.subscribe(); + subscription.on('enter', object => { + expect(object.get('foo')).toBe('bar'); + done(); + }); + + object.set('foo', 'bar'); + await object.save(); + }); + + it('expect afterEvent leave', async done => { + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + expect(req.event).toBe('leave'); + expect(req.user).toBeUndefined(); + expect(req.object.get('foo')).toBeUndefined(); + expect(req.original.get('foo')).toBe('bar'); + }); + + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + const query = new Parse.Query(TestObject); + query.equalTo('foo', 'bar'); + const subscription = await query.subscribe(); + subscription.on('leave', object => { + expect(object.get('foo')).toBeUndefined(); + done(); + }); + + object.unset('foo'); + await object.save(); + }); + + it('expect afterEvent delete', async done => { + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + expect(req.event).toBe('delete'); + expect(req.user).toBeUndefined(); + req.object.set('foo', 'bar'); + }); + + const object = new TestObject(); + await object.save(); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + + const subscription = await query.subscribe(); + subscription.on('delete', object => { + expect(object.get('foo')).toBe('bar'); + done(); + }); + + await object.destroy(); + }); + + it('can handle afterEvent modification', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const object = new TestObject(); + await object.save(); + + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + const current = req.object; + current.set('foo', 'yolo'); + + const original = req.original; + original.set('yolo', 'foo'); + }); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + subscription.on('update', (object, original) => { + expect(object.get('foo')).toBe('yolo'); + expect(original.get('yolo')).toBe('foo'); + done(); + }); + object.set({ foo: 'bar' }); + await object.save(); + }); + + it('can return different object in afterEvent', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const object = new TestObject(); + await object.save(); + + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + const object = new Parse.Object('Yolo'); + req.object = object; + }); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + subscription.on('update', object => { + expect(object.className).toBe('Yolo'); + done(); + }); + object.set({ foo: 'bar' }); + await object.save(); + }); + + it('can handle afterEvent throw', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const object = new TestObject(); + await object.save(); + + Parse.Cloud.afterLiveQueryEvent('TestObject', () => { + throw 'Throw error from LQ afterEvent.'; + }); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + subscription.on('update', () => { + fail('update should not have been called.'); + }); + subscription.on('error', e => { + expect(e).toBe('Throw error from LQ afterEvent.'); + done(); + }); + object.set({ foo: 'bar' }); + await object.save(); + }); + + it('can log on afterLiveQueryEvent throw', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const object = new TestObject(); + await object.save(); + + const logger = require('../lib/logger').logger; + spyOn(logger, 'error').and.callFake(() => {}); + + let session = undefined; + Parse.Cloud.afterLiveQueryEvent('TestObject', ({ sessionToken }) => { + session = sessionToken; + /* eslint-disable no-undef */ + foo.bar(); + /* eslint-enable no-undef */ + }); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + object.set({ foo: 'bar' }); + await object.save(); + await new Promise(resolve => subscription.on('error', resolve)); + expect(logger.error).toHaveBeenCalledWith( + `Failed running afterLiveQueryEvent on class TestObject for event update with session ${session} with:\n Error: {"message":"foo is not defined","code":141}` + ); + }); + + it('can handle afterEvent sendEvent to false', async () => { + const object = new TestObject(); + await object.save(); + const promise = resolvingPromise(); + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + const current = req.object; + const original = req.original; + + if (current.get('foo') != original.get('foo')) { + req.sendEvent = false; + } + promise.resolve(); + }); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + subscription.on('update', () => { + fail('update should not have been called.'); + }); + subscription.on('error', () => { + fail('error should not have been called.'); + }); + object.set({ foo: 'bar' }); + await object.save(); + await promise; + }); + + it('can handle live query with fields', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['Test'], + }, + startLiveQueryServer: true, + }); + const query = new Parse.Query('Test'); + query.watch('yolo'); + const subscription = await query.subscribe(); + const spy = { + create(obj) { + if (!obj.get('yolo')) { + fail('create should not have been called'); + } + }, + update(object, original) { + if (object.get('yolo') === original.get('yolo')) { + fail('create should not have been called'); + } + }, + }; + const createSpy = spyOn(spy, 'create').and.callThrough(); + const updateSpy = spyOn(spy, 'update').and.callThrough(); + subscription.on('create', spy.create); + subscription.on('update', spy.update); + const obj = new Parse.Object('Test'); + obj.set('foo', 'bar'); + await obj.save(); + obj.set('foo', 'xyz'); + obj.set('yolo', 'xyz'); + await obj.save(); + const obj2 = new Parse.Object('Test'); + obj2.set('foo', 'bar'); + obj2.set('yolo', 'bar'); + await obj2.save(); + obj2.set('foo', 'bart'); + await obj2.save(); + expect(createSpy).toHaveBeenCalledTimes(1); + expect(updateSpy).toHaveBeenCalledTimes(1); + }); + + it('can handle afterEvent set pointers', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const object = new TestObject(); + await object.save(); + + const secondObject = new Parse.Object('Test2'); + secondObject.set('foo', 'bar'); + await secondObject.save(); + + Parse.Cloud.afterLiveQueryEvent('TestObject', async ({ object }) => { + const query = new Parse.Query('Test2'); + const obj = await query.first(); + object.set('obj', obj); + }); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + subscription.on('update', object => { + expect(object.get('obj')).toBeDefined(); + expect(object.get('obj').get('foo')).toBe('bar'); + done(); + }); + subscription.on('error', () => { + fail('error should not have been called.'); + }); + object.set({ foo: 'bar' }); + await object.save(); + }); + + it('can handle async afterEvent modification', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const parent = new TestObject(); + const child = new TestObject(); + child.set('bar', 'foo'); + await Parse.Object.saveAll([parent, child]); + + Parse.Cloud.afterLiveQueryEvent('TestObject', async req => { + const current = req.object; + const pointer = current.get('child'); + await pointer.fetch(); + }); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', parent.id); + const subscription = await query.subscribe(); + subscription.on('update', object => { + expect(object.get('child')).toBeDefined(); + expect(object.get('child').get('bar')).toBe('foo'); + done(); + }); + parent.set('child', child); + await parent.save(); + }); + + it('can handle beforeConnect / beforeSubscribe hooks', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + }); + const object = new TestObject(); + await object.save(); + const hooks = { + beforeSubscribe(req) { + expect(req.op).toBe('subscribe'); + expect(req.requestId).toBe(1); + expect(req.query).toBeDefined(); + expect(req.user).toBeUndefined(); + }, + beforeConnect(req) { + expect(req.event).toBe('connect'); + expect(req.clients).toBe(0); + expect(req.subscriptions).toBe(0); + expect(req.useMasterKey).toBe(false); + expect(req.installationId).toBeDefined(); + expect(req.user).toBeUndefined(); + expect(req.client).toBeDefined(); + }, + }; + spyOn(hooks, 'beforeSubscribe').and.callThrough(); + spyOn(hooks, 'beforeConnect').and.callThrough(); + Parse.Cloud.beforeSubscribe('TestObject', hooks.beforeSubscribe); + Parse.Cloud.beforeConnect(hooks.beforeConnect); + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + subscription.on('update', object => { + expect(object.get('foo')).toBe('bar'); + expect(hooks.beforeConnect).toHaveBeenCalled(); + expect(hooks.beforeSubscribe).toHaveBeenCalled(); + done(); + }); + object.set({ foo: 'bar' }); + await object.save(); + }); + + it('can handle beforeConnect validation function', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + }); + + const object = new TestObject(); + await object.save(); + Parse.Cloud.beforeConnect(() => {}, validatorFail); + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.VALIDATION_ERROR, 'you are not authorized') + ); + }); + + it('can handle beforeSubscribe validation function', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + }); + const object = new TestObject(); + await object.save(); + + Parse.Cloud.beforeSubscribe(TestObject, () => {}, validatorFail); + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.VALIDATION_ERROR, 'you are not authorized') + ); + }); + + it('can handle afterEvent validation function', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent('TestObject', () => {}, validatorFail); + + const query = new Parse.Query(TestObject); + const subscription = await query.subscribe(); + subscription.on('error', error => { + expect(error).toBe('you are not authorized'); + done(); + }); + + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + }); + + it('can handle beforeConnect error', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + }); + const object = new TestObject(); + await object.save(); + + Parse.Cloud.beforeConnect(() => { + throw new Error('You shall not pass!'); + }); + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + await expectAsync(query.subscribe()).toBeRejectedWith(new Error('You shall not pass!')); + }); + + it('can log on beforeConnect throw', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + }); + + const logger = require('../lib/logger').logger; + spyOn(logger, 'error').and.callFake(() => {}); + let token = undefined; + Parse.Cloud.beforeConnect(({ sessionToken }) => { + token = sessionToken; + /* eslint-disable no-undef */ + foo.bar(); + /* eslint-enable no-undef */ + }); + await expectAsync(new Parse.Query(TestObject).subscribe()).toBeRejectedWith( + new Error('foo is not defined') + ); + expect(logger.error).toHaveBeenCalledWith( + `Failed running beforeConnect for session ${token} with:\n Error: {"message":"foo is not defined","code":141}` + ); + }); + + it('can handle beforeSubscribe error', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + }); + const object = new TestObject(); + await object.save(); + + Parse.Cloud.beforeSubscribe(TestObject, () => { + throw new Error('You shall not subscribe!'); + }); + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + await expectAsync(query.subscribe()).toBeRejectedWith(new Error('You shall not subscribe!')); + }); + + it('can log on beforeSubscribe error', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + }); + + const logger = require('../lib/logger').logger; + spyOn(logger, 'error').and.callFake(() => {}); + + Parse.Cloud.beforeSubscribe(TestObject, () => { + /* eslint-disable no-undef */ + foo.bar(); + /* eslint-enable no-undef */ + }); + + const query = new Parse.Query(TestObject); + await expectAsync(query.subscribe()).toBeRejectedWith(new Error('foo is not defined')); + + expect(logger.error).toHaveBeenCalledWith( + `Failed running beforeSubscribe on TestObject for session undefined with:\n Error: {"message":"foo is not defined","code":141}` + ); + }); + + it('rejects subscription with invalid $regex pattern', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const query = new Parse.Query('TestObject'); + query._where = { foo: { $regex: '[invalid' } }; + await expectAsync(query.subscribe()).toBeRejectedWithError(/Invalid regular expression/); + }); + + it('rejects subscription with non-string $regex value', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const query = new Parse.Query('TestObject'); + query._where = { foo: { $regex: 123 } }; + await expectAsync(query.subscribe()).toBeRejectedWithError( + /\$regex must be a string or RegExp/ + ); + }); + + it('does not crash server when subscription matching throws and other subscriptions still work', async () => { + const server = await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + // Create a valid subscription + const validQuery = new Parse.Query('TestObject'); + validQuery.equalTo('objectId', object.id); + const validSubscription = await validQuery.subscribe(); + + // Inject a malformed subscription directly into the LiveQuery server + // to bypass subscribe-time validation and test the try-catch in _onAfterSave + const lqServer = server.liveQueryServer; + const Subscription = require('../lib/LiveQuery/Subscription').Subscription; + const badSubscription = new Subscription('TestObject', { foo: { $regex: '[invalid' } }); + badSubscription.addClientSubscription('fakeClientId', 'fakeRequestId'); + const classSubscriptions = lqServer.subscriptions.get('TestObject'); + classSubscriptions.set('bad-hash', badSubscription); + + // Verify the valid subscription still receives updates despite the bad subscription + const updatePromise = new Promise(resolve => { + validSubscription.on('update', obj => { + expect(obj.get('foo')).toBe('baz'); + resolve(); + }); + }); + + object.set('foo', 'baz'); + await object.save(); + await updatePromise; + + // Clean up the injected subscription + classSubscriptions.delete('bad-hash'); + }); + + it('can handle mutate beforeSubscribe query', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + }); + const hook = { + beforeSubscribe(request) { + request.query.equalTo('yolo', 'abc'); + }, + }; + spyOn(hook, 'beforeSubscribe').and.callThrough(); + Parse.Cloud.beforeSubscribe('TestObject', hook.beforeSubscribe); + const object = new TestObject(); + await object.save(); + + const query = new Parse.Query('TestObject'); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + subscription.on('update', () => { + fail('beforeSubscribe should restrict subscription'); + }); + subscription.on('enter', object => { + if (object.get('yolo') === 'abc') { + done(); + } else { + fail('beforeSubscribe should restrict queries'); + } + }); + object.set({ yolo: 'bar' }); + await object.save(); + object.set({ yolo: 'abc' }); + await object.save(); + expect(hook.beforeSubscribe).toHaveBeenCalled(); + }); + + it('can return a new beforeSubscribe query', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + Parse.Cloud.beforeSubscribe(TestObject, request => { + const query = new Parse.Query(TestObject); + query.equalTo('foo', 'yolo'); + request.query = query; + }); + + const query = new Parse.Query(TestObject); + query.equalTo('foo', 'bar'); + const subscription = await query.subscribe(); + + subscription.on('create', object => { + expect(object.get('foo')).toBe('yolo'); + done(); + }); + const object = new TestObject(); + object.set({ foo: 'yolo' }); + await object.save(); + }); + + it('can handle select beforeSubscribe query', async done => { + Parse.Cloud.beforeSubscribe(TestObject, request => { + const query = request.query; + query.select('yolo'); + }); + + const object = new TestObject(); + await object.save(); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + + subscription.on('update', object => { + expect(object.get('foo')).toBeUndefined(); + expect(object.get('yolo')).toBe('abc'); + done(); + }); + object.set({ foo: 'bar', yolo: 'abc' }); + await object.save(); + }); + + it('LiveQuery with ACL', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['Chat'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const user = new Parse.User(); + user.setUsername('username'); + user.setPassword('password'); + await user.signUp(); + + const calls = { + beforeConnect(req) { + expect(req.event).toBe('connect'); + expect(req.clients).toBe(0); + expect(req.subscriptions).toBe(0); + expect(req.useMasterKey).toBe(false); + expect(req.installationId).toBeDefined(); + expect(req.client).toBeDefined(); + }, + beforeSubscribe(req) { + expect(req.op).toBe('subscribe'); + expect(req.requestId).toBe(1); + expect(req.query).toBeDefined(); + expect(req.user).toBeDefined(); + }, + afterLiveQueryEvent(req) { + expect(req.user).toBeDefined(); + expect(req.object.get('foo')).toBe('bar'); + }, + create(object) { + expect(object.get('foo')).toBe('bar'); + }, + delete(object) { + expect(object.get('foo')).toBe('bar'); + }, + }; + for (const key in calls) { + spyOn(calls, key).and.callThrough(); + } + Parse.Cloud.beforeConnect(calls.beforeConnect); + Parse.Cloud.beforeSubscribe('Chat', calls.beforeSubscribe); + Parse.Cloud.afterLiveQueryEvent('Chat', calls.afterLiveQueryEvent); + + const chatQuery = new Parse.Query('Chat'); + const subscription = await chatQuery.subscribe(); + subscription.on('create', calls.create); + subscription.on('delete', calls.delete); + const object = new Parse.Object('Chat'); + const acl = new Parse.ACL(user); + object.setACL(acl); + object.set({ foo: 'bar' }); + await object.save(); + await object.destroy(); + await sleep(200); + for (const key in calls) { + expect(calls[key]).toHaveBeenCalled(); + } + }); + + it('LiveQuery should work with changing role', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['Chat'], + }, + startLiveQueryServer: true, + }); + const user = new Parse.User(); + user.setUsername('username'); + user.setPassword('password'); + await user.signUp(); + + const role = new Parse.Role('Test', new Parse.ACL(user)); + await role.save(); + + const chatQuery = new Parse.Query('Chat'); + const subscription = await chatQuery.subscribe(); + subscription.on('create', () => { + fail('should not call create as user is not part of role.'); + }); + + const object = new Parse.Object('Chat'); + const acl = new Parse.ACL(); + acl.setRoleReadAccess(role, true); + object.setACL(acl); + object.set({ foo: 'bar' }); + await object.save(null, { useMasterKey: true }); + role.getUsers().add(user); + await sleep(1000); + await role.save(); + await sleep(1000); + object.set('foo', 'yolo'); + await Promise.all([ + new Promise(resolve => { + subscription.on('update', obj => { + expect(obj.get('foo')).toBe('yolo'); + expect(obj.getACL().toJSON()).toEqual({ 'role:Test': { read: true } }); + resolve(); + }); + }), + object.save(null, { useMasterKey: true }), + ]); + }); + + it('liveQuery on Session class', async () => { + await reconfigureServer({ + liveQuery: { classNames: [Parse.Session] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const user = new Parse.User(); + user.setUsername('username'); + user.setPassword('password'); + await user.signUp(); + + const query = new Parse.Query(Parse.Session); + const subscription = await query.subscribe(); + + const createEvent = new Promise(resolve => { + subscription.on('create', resolve); + }); + + await Parse.User.logIn('username', 'password'); + + const obj = await createEvent; + expect(obj.get('user').id).toBe(user.id); + expect(obj.get('createdWith')).toEqual({ action: 'login', authProvider: 'password' }); + expect(obj.get('expiresAt')).toBeInstanceOf(Date); + expect(obj.get('installationId')).toBeDefined(); + expect(obj.get('createdAt')).toBeInstanceOf(Date); + expect(obj.get('updatedAt')).toBeInstanceOf(Date); + }); + + it('prevent liveQuery on Session class when not logged in', async () => { + await reconfigureServer({ + liveQuery: { + classNames: [Parse.Session], + }, + startLiveQueryServer: true, + }); + const query = new Parse.Query(Parse.Session); + await expectAsync(query.subscribe()).toBeRejectedWith(new Error('Invalid session token')); + }); + + it_id('4ccc9508-ae6a-46ec-932a-9f5e49ab3b9e')(it)('handle invalid websocket payload length', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + websocketTimeout: 100, + }); + const object = new TestObject(); + await object.save(); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + + // All control frames must have a payload length of 125 bytes or less. + // https://tools.ietf.org/html/rfc6455#section-5.5 + // + // 0x89 = 10001001 = ping + // 0xfe = 11111110 = first bit is masking the remaining 7 are 1111110 or 126 the payload length + // https://tools.ietf.org/html/rfc6455#section-5.2 + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + + // Wait for the initial subscription 'open' event (fires 200ms after subscribe) + // before sending the invalid frame, so we don't confuse it with the reconnection 'open' + await new Promise(resolve => subscription.on('open', resolve)); + + // Now listen for close followed by reopen from the reconnection cycle + const reopened = new Promise(resolve => { + subscription.on('close', () => { + subscription.on('open', resolve); + }); + }); + + client.socket._socket.write(Buffer.from([0x89, 0xfe])); + await reopened; + + // After reconnection, save an update and verify the subscription receives it + const updated = new Promise(resolve => { + subscription.on('update', object => { + expect(object.get('foo')).toBe('bar'); + resolve(); + }); + }); + object.set({ foo: 'bar' }); + await object.save(); + await updated; + }); + + it_id('39a9191f-26dd-4e05-a379-297a67928de8')(it)('should execute live query update on email validation', async done => { + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + + await reconfigureServer({ + maintenanceKey: 'test2', + liveQuery: { + classNames: [Parse.User], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + websocketTimeout: 100, + appName: 'liveQueryEmailValidation', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 20, // 0.5 second + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + const user = new Parse.User(); + user.set('password', 'asdf'); + user.set('email', 'asdf@example.com'); + user.set('username', 'zxcv'); + user + .signUp() + .then(() => { + const config = Config.get('test'); + return config.database.find( + '_User', + { + username: 'zxcv', + }, + {}, + Auth.maintenance(config) + ); + }) + .then(async results => { + const foundUser = results[0]; + const query = new Parse.Query('_User'); + query.equalTo('objectId', foundUser.objectId); + const subscription = await query.subscribe(); + + subscription.on('update', async object => { + expect(object).toBeDefined(); + expect(object.get('emailVerified')).toBe(true); + done(); + }); + + const userController = new UserController(emailAdapter, 'test', { + verifyUserEmails: true, + }); + userController.verifyEmail(foundUser._email_verify_token); + }); + }); + }); + + it('should not broadcast event to client with invalid session token - avisory GHSA-2xm2-xj2q-qgpj', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + liveQueryServerOptions: { + cacheTimeout: 100, + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + cacheTTL: 100, + }); + const user = new Parse.User(); + user.setUsername('username'); + user.setPassword('password'); + await user.signUp(); + const obj1 = new Parse.Object('TestObject'); + const obj1ACL = new Parse.ACL(); + obj1ACL.setPublicReadAccess(false); + obj1ACL.setReadAccess(user, true); + obj1.setACL(obj1ACL); + const obj2 = new Parse.Object('TestObject'); + const obj2ACL = new Parse.ACL(); + obj2ACL.setPublicReadAccess(false); + obj2ACL.setReadAccess(user, true); + obj2.setACL(obj2ACL); + const query = new Parse.Query('TestObject'); + const subscription = await query.subscribe(); + subscription.on('create', obj => { + if (obj.id !== obj1.id) { + done.fail('should not fire'); + } + }); + await obj1.save(); + await Parse.User.logOut(); + await new Promise(resolve => setTimeout(resolve, 200)); + await obj2.save(); + await new Promise(resolve => setTimeout(resolve, 200)); + done(); + }); + + it('should strip out session token in LiveQuery', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['_User'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const user = new Parse.User(); + user.setUsername('username'); + user.setPassword('password'); + user.set('foo', 'bar'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + user.setACL(acl); + + const query = new Parse.Query(Parse.User); + query.equalTo('foo', 'bar'); + const subscription = await query.subscribe(); + + const events = ['create', 'update', 'enter', 'leave', 'delete']; + const response = (obj, prev) => { + expect(obj.get('sessionToken')).toBeUndefined(); + expect(obj.sessionToken).toBeUndefined(); + expect(prev && prev.sessionToken).toBeUndefined(); + if (prev && prev.get) { + expect(prev.get('sessionToken')).toBeUndefined(); + } + }; + const calls = {}; + for (const key of events) { + calls[key] = response; + spyOn(calls, key).and.callThrough(); + subscription.on(key, calls[key]); + } + await user.signUp(); + user.unset('foo'); + await user.save(); + user.set('foo', 'bar'); + await user.save(); + user.set('yolo', 'bar'); + await user.save(); + await user.destroy(); + await new Promise(resolve => setTimeout(resolve, 10)); + for (const key of events) { + expect(calls[key]).toHaveBeenCalled(); + } + }); + + it('should strip out protected fields', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['Test'] }, + startLiveQueryServer: true, + }); + const obj1 = new Parse.Object('Test'); + obj1.set('foo', 'foo'); + obj1.set('bar', 'bar'); + obj1.set('qux', 'qux'); + await obj1.save(); + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.updateClass( + 'Test', + {}, + { + get: { '*': true }, + find: { '*': true }, + update: { '*': true }, + protectedFields: { + '*': ['foo'], + }, + } + ); + const object = await obj1.fetch(); + expect(object.get('foo')).toBe(undefined); + expect(object.get('bar')).toBeDefined(); + expect(object.get('qux')).toBeDefined(); + + const subscription = await new Parse.Query('Test').subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('update', (obj, original) => { + expect(obj.get('foo')).toBe(undefined); + expect(obj.get('bar')).toBeDefined(); + expect(obj.get('qux')).toBeDefined(); + expect(original.get('foo')).toBe(undefined); + expect(original.get('bar')).toBeDefined(); + expect(original.get('qux')).toBeDefined(); + resolve(); + }); + }), + obj1.save({ foo: 'abc' }), + ]); + }); + + it('can subscribe to query and return object with withinKilometers with last parameter on update', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const object = new TestObject(); + const firstPoint = new Parse.GeoPoint({ latitude: 40.0, longitude: -30.0 }); + object.set({ location: firstPoint }); + await object.save(); + + // unsorted will use $centerSphere operator + const sorted = false; + const query = new Parse.Query(TestObject); + query.withinKilometers( + 'location', + new Parse.GeoPoint({ latitude: 40.0, longitude: -30.0 }), + 2, + sorted + ); + const subscription = await query.subscribe(); + subscription.on('update', obj => { + expect(obj.id).toBe(object.id); + done(); + }); + + const secondPoint = new Parse.GeoPoint({ latitude: 40.0, longitude: -30.0 }); + object.set({ location: secondPoint }); + await object.save(); + }); + + it_id('2f95d8a9-7675-45ba-a4a6-e45cb7efb1fb')(it)('does shutdown liveQuery server', async () => { + await reconfigureServer({ appId: 'test_app_id' }); + const config = { + appId: 'hello_test', + masterKey: 'world', + port: 1345, + mountPath: '/1', + serverURL: 'http://localhost:1345/1', + liveQuery: { + classNames: ['Yolo'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }; + if (process.env.PARSE_SERVER_TEST_DB === 'postgres') { + config.databaseAdapter = new databaseAdapter.constructor({ + uri: databaseURI, + collectionPrefix: 'test_', + }); + config.filesAdapter = defaultConfiguration.filesAdapter; + } + const server = await ParseServer.startApp(config); + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + client.serverURL = 'ws://localhost:1345/1'; + const query = await new Parse.Query('Yolo').subscribe(); + let liveQueryConnectionCount = await getConnectionsCount(server.liveQueryServer.server); + expect(liveQueryConnectionCount > 0).toBe(true); + await Promise.all([ + server.handleShutdown(), + new Promise(resolve => query.on('close', resolve)), + ]); + await sleep(100); + expect(server.liveQueryServer.server.address()).toBeNull(); + expect(server.liveQueryServer.subscriber.isOpen).toBeFalse(); + + liveQueryConnectionCount = await getConnectionsCount(server.liveQueryServer.server); + expect(liveQueryConnectionCount).toBe(0); + }); + + it_id('45655b74-716f-4fa1-a058-67eb21f3c3db')(it)('does shutdown separate liveQuery server', async () => { + await reconfigureServer({ appId: 'test_app_id' }); + let close = false; + const config = { + appId: 'hello_test', + masterKey: 'world', + port: 1345, + mountPath: '/1', + serverURL: 'http://localhost:1345/1', + liveQuery: { + classNames: ['Yolo'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + liveQueryServerOptions: { + port: 1346, + }, + serverCloseComplete: () => { + close = true; + }, + }; + if (process.env.PARSE_SERVER_TEST_DB === 'postgres') { + config.databaseAdapter = new databaseAdapter.constructor({ + uri: databaseURI, + collectionPrefix: 'test_', + }); + config.filesAdapter = defaultConfiguration.filesAdapter; + } + const parseServer = await ParseServer.startApp(config); + expect(parseServer.liveQueryServer).toBeDefined(); + expect(parseServer.liveQueryServer.server).not.toBe(parseServer.server); + + // Open a connection to the liveQuery server + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + client.serverURL = 'ws://localhost:1346/1'; + const query = await new Parse.Query('Yolo').subscribe(); + + // Open a connection to the parse server + const health = await request({ + method: 'GET', + url: `http://localhost:1345/1/health`, + json: true, + headers: { + 'X-Parse-Application-Id': 'hello_test', + 'X-Parse-Master-Key': 'world', + 'Content-Type': 'application/json', + }, + agent: new http.Agent({ keepAlive: true }), + }).then(res => res.data); + expect(health.status).toBe('ok'); + + let parseConnectionCount = await getConnectionsCount(parseServer.server); + let liveQueryConnectionCount = await getConnectionsCount(parseServer.liveQueryServer.server); + + expect(parseConnectionCount > 0).toBe(true); + expect(liveQueryConnectionCount > 0).toBe(true); + await Promise.all([ + parseServer.handleShutdown(), + new Promise(resolve => query.on('close', resolve)), + ]); + expect(close).toBe(true); + await sleep(100); + expect(parseServer.liveQueryServer.server.address()).toBeNull(); + expect(parseServer.liveQueryServer.subscriber.isOpen).toBeFalse(); + + parseConnectionCount = await getConnectionsCount(parseServer.server); + liveQueryConnectionCount = await getConnectionsCount(parseServer.liveQueryServer.server); + expect(parseConnectionCount).toBe(0); + expect(liveQueryConnectionCount).toBe(0); + }); + + it('prevent afterSave trigger if not exists', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + spyOn(triggers, 'maybeRunTrigger').and.callThrough(); + const object1 = new TestObject(); + const object2 = new TestObject(); + const object3 = new TestObject(); + await Parse.Object.saveAll([object1, object2, object3]); + + expect(triggers.maybeRunTrigger).toHaveBeenCalledTimes(0); + expect(object1.id).toBeDefined(); + expect(object2.id).toBeDefined(); + expect(object3.id).toBeDefined(); + }); + + it('triggers query event with constraint not equal to null', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const spy = { + create(obj) { + expect(obj.attributes.foo).toEqual('bar'); + }, + }; + const createSpy = spyOn(spy, 'create'); + const query = new Parse.Query(TestObject); + query.notEqualTo('foo', null); + const subscription = await query.subscribe(); + subscription.on('create', spy.create); + + const object1 = new TestObject(); + object1.set('foo', 'bar'); + await object1.save(); + + await new Promise(resolve => setTimeout(resolve, 100)); + expect(createSpy).toHaveBeenCalledTimes(1); + }); + + describe('class level permissions', () => { + async function setPermissionsOnClass(className, permissions, doPut) { + const method = doPut ? 'PUT' : 'POST'; + const response = await fetch(Parse.serverURL + '/schemas/' + className, { + method, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + classLevelPermissions: permissions, + }), + }); + const body = await response.json(); + if (body.error) { + throw body; + } + return body; + } + + it('delivers LiveQuery event to authenticated client when CLP allows find', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['SecureChat'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const user = new Parse.User(); + user.setUsername('admin'); + user.setPassword('password'); + await user.signUp(); + + await setPermissionsOnClass('SecureChat', { + create: { '*': true }, + find: { [user.id]: true }, + }); + + // Subscribe as the authorized user + const query = new Parse.Query('SecureChat'); + const subscription = await query.subscribe(user.getSessionToken()); + + const spy = jasmine.createSpy('create'); + subscription.on('create', spy); + + const obj = new Parse.Object('SecureChat'); + obj.set('secret', 'data'); + await obj.save(null, { useMasterKey: true }); + + await sleep(500); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('rejects LiveQuery subscription when CLP denies find at subscription time', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['SecureChat'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const user = new Parse.User(); + user.setUsername('admin'); + user.setPassword('password'); + await user.signUp(); + + await setPermissionsOnClass('SecureChat', { + create: { '*': true }, + find: { [user.id]: true }, + }); + + // Log out so subscription is unauthenticated + await Parse.User.logOut(); + + const query = new Parse.Query('SecureChat'); + await expectAsync(query.subscribe()).toBeRejected(); + }); + }); +}); diff --git a/spec/ParseLiveQueryRedis.spec.js b/spec/ParseLiveQueryRedis.spec.js new file mode 100644 index 0000000000..deb84bafb2 --- /dev/null +++ b/spec/ParseLiveQueryRedis.spec.js @@ -0,0 +1,58 @@ +if (process.env.PARSE_SERVER_TEST_CACHE === 'redis') { + describe('ParseLiveQuery redis', () => { + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + client.close(); + }); + it('can connect', async () => { + await reconfigureServer({ + appId: 'redis_live_query', + startLiveQueryServer: true, + liveQuery: { + classNames: ['TestObject'], + redisURL: 'redis://localhost:6379', + }, + liveQueryServerOptions: { + redisURL: 'redis://localhost:6379', + }, + }); + const subscription = await new Parse.Query('TestObject').subscribe(); + const [object] = await Promise.all([ + new Parse.Object('TestObject').save(), + new Promise(resolve => + subscription.on('create', () => { + resolve(); + }) + ), + ]); + await Promise.all([ + new Promise(resolve => + subscription.on('delete', () => { + resolve(); + }) + ), + object.destroy(), + ]); + }); + + it('can call connect twice', async () => { + const server = await reconfigureServer({ + appId: 'redis_live_query', + startLiveQueryServer: true, + liveQuery: { + classNames: ['TestObject'], + redisURL: 'redis://localhost:6379', + }, + liveQueryServerOptions: { + redisURL: 'redis://localhost:6379', + }, + }); + expect(server.config.liveQueryController.liveQueryPublisher.parsePublisher.isOpen).toBeTrue(); + await server.config.liveQueryController.connect(); + expect(server.config.liveQueryController.liveQueryPublisher.parsePublisher.isOpen).toBeTrue(); + expect(server.liveQueryServer.subscriber.isOpen).toBe(true); + await server.liveQueryServer.connect(); + expect(server.liveQueryServer.subscriber.isOpen).toBe(true); + }); + }); +} diff --git a/spec/ParseLiveQueryServer.spec.js b/spec/ParseLiveQueryServer.spec.js index d99d7e34d9..62bf33d327 100644 --- a/spec/ParseLiveQueryServer.spec.js +++ b/spec/ParseLiveQueryServer.spec.js @@ -1,18 +1,27 @@ -var Parse = require('parse/node'); -var ParseLiveQueryServer = require('../src/LiveQuery/ParseLiveQueryServer').ParseLiveQueryServer; +const Parse = require('parse/node'); +const ParseLiveQueryServer = require('../lib/LiveQuery/ParseLiveQueryServer').ParseLiveQueryServer; +const ParseServer = require('../lib/ParseServer').default; +const LiveQueryController = require('../lib/Controllers/LiveQueryController').LiveQueryController; +const auth = require('../lib/Auth'); // Global mock info -var queryHashValue = 'hash'; -var testUserId = 'userId'; -var testClassName = 'TestObject'; +const queryHashValue = 'hash'; +const testUserId = 'userId'; +const testClassName = 'TestObject'; -describe('ParseLiveQueryServer', function() { - beforeEach(function(done) { +const timeout = () => jasmine.timeout(100); + +describe('ParseLiveQueryServer', function () { + beforeEach(function (done) { // Mock ParseWebSocketServer - var mockParseWebSocketServer = jasmine.createSpy('ParseWebSocketServer'); - jasmine.mockLibrary('../src/LiveQuery/ParseWebSocketServer', 'ParseWebSocketServer', mockParseWebSocketServer); + const mockParseWebSocketServer = jasmine.createSpy('ParseWebSocketServer'); + jasmine.mockLibrary( + '../lib/LiveQuery/ParseWebSocketServer', + 'ParseWebSocketServer', + mockParseWebSocketServer + ); // Mock Client - var mockClient = function() { + const mockClient = function (id, socket, hasMasterKey) { this.pushConnect = jasmine.createSpy('pushConnect'); this.pushSubscribe = jasmine.createSpy('pushSubscribe'); this.pushUnsubscribe = jasmine.createSpy('pushUnsubscribe'); @@ -24,244 +33,440 @@ describe('ParseLiveQueryServer', function() { this.addSubscriptionInfo = jasmine.createSpy('addSubscriptionInfo'); this.getSubscriptionInfo = jasmine.createSpy('getSubscriptionInfo'); this.deleteSubscriptionInfo = jasmine.createSpy('deleteSubscriptionInfo'); - } + this.hasMasterKey = hasMasterKey; + }; mockClient.pushError = jasmine.createSpy('pushError'); - jasmine.mockLibrary('../src/LiveQuery/Client', 'Client', mockClient); + jasmine.mockLibrary('../lib/LiveQuery/Client', 'Client', mockClient); // Mock Subscription - var mockSubscriotion = function() { + const mockSubscriotion = function () { this.addClientSubscription = jasmine.createSpy('addClientSubscription'); this.deleteClientSubscription = jasmine.createSpy('deleteClientSubscription'); - } - jasmine.mockLibrary('../src/LiveQuery/Subscription', 'Subscription', mockSubscriotion); + }; + jasmine.mockLibrary('../lib/LiveQuery/Subscription', 'Subscription', mockSubscriotion); // Mock queryHash - var mockQueryHash = jasmine.createSpy('matchesQuery').and.returnValue(queryHashValue); - jasmine.mockLibrary('../src/LiveQuery/QueryTools', 'queryHash', mockQueryHash); + const mockQueryHash = jasmine.createSpy('matchesQuery').and.returnValue(queryHashValue); + jasmine.mockLibrary('../lib/LiveQuery/QueryTools', 'queryHash', mockQueryHash); // Mock matchesQuery - var mockMatchesQuery = jasmine.createSpy('matchesQuery').and.returnValue(true); - jasmine.mockLibrary('../src/LiveQuery/QueryTools', 'matchesQuery', mockMatchesQuery); - // Mock tv4 - var mockValidate = function() { - return true; - } - jasmine.mockLibrary('tv4', 'validate', mockValidate); + const mockMatchesQuery = jasmine.createSpy('matchesQuery').and.returnValue(true); + jasmine.mockLibrary('../lib/LiveQuery/QueryTools', 'matchesQuery', mockMatchesQuery); // Mock ParsePubSub - var mockParsePubSub = { - createPublisher: function() { + const mockParsePubSub = { + createPublisher: function () { return { publish: jasmine.createSpy('publish'), - on: jasmine.createSpy('on') - } + on: jasmine.createSpy('on'), + }; }, - createSubscriber: function() { + createSubscriber: function () { return { subscribe: jasmine.createSpy('subscribe'), - on: jasmine.createSpy('on') - } - } - }; - jasmine.mockLibrary('../src/LiveQuery/ParsePubSub', 'ParsePubSub', mockParsePubSub); - // Make mock SessionTokenCache - var mockSessionTokenCache = function(){ - this.getUserId = function(sessionToken){ - if (typeof sessionToken === 'undefined') { - return Parse.Promise.as(undefined); - } - if (sessionToken === null) { - return Parse.Promise.error(); - } - return Parse.Promise.as(testUserId); - }; + on: jasmine.createSpy('on'), + }; + }, }; - jasmine.mockLibrary('../src/LiveQuery/SessionTokenCache', 'SessionTokenCache', mockSessionTokenCache); + jasmine.mockLibrary('../lib/LiveQuery/ParsePubSub', 'ParsePubSub', mockParsePubSub); + spyOn(auth, 'getAuthForSessionToken').and.callFake(({ sessionToken, cacheController }) => { + if (typeof sessionToken === 'undefined') { + return Promise.reject(); + } + if (sessionToken === null) { + return Promise.reject(); + } + if (sessionToken === 'pleaseThrow') { + return Promise.reject(); + } + if (sessionToken === 'invalid') { + return Promise.reject( + new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'invalid session token') + ); + } + return Promise.resolve(new auth.Auth({ cacheController, user: { id: testUserId } })); + }); done(); }); - it('can be initialized', function() { - var httpServer = {}; - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, httpServer); + it('can be initialized', function () { + const httpServer = {}; + const parseLiveQueryServer = new ParseLiveQueryServer(httpServer); + + expect(parseLiveQueryServer.clientId).toBeUndefined(); + expect(parseLiveQueryServer.clients.size).toBe(0); + expect(parseLiveQueryServer.subscriptions.size).toBe(0); + }); + + it('can be initialized from ParseServer', async () => { + const httpServer = {}; + const parseLiveQueryServer = await ParseServer.createLiveQueryServer(httpServer, {}); + + expect(parseLiveQueryServer.clientId).toBeUndefined(); + expect(parseLiveQueryServer.clients.size).toBe(0); + expect(parseLiveQueryServer.subscriptions.size).toBe(0); + }); + + it('can be initialized from ParseServer without httpServer', async () => { + const parseLiveQueryServer = await ParseServer.createLiveQueryServer(undefined, { + port: 22345, + }); - expect(parseLiveQueryServer.clientId).toBe(0); + expect(parseLiveQueryServer.clientId).toBeUndefined(); expect(parseLiveQueryServer.clients.size).toBe(0); expect(parseLiveQueryServer.subscriptions.size).toBe(0); + await new Promise(resolve => parseLiveQueryServer.server.close(resolve)); }); - it('can handle connect command', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var parseWebSocket = { - clientId: -1 + describe_only_db('mongo')('initialization', () => { + beforeEach(() => reconfigureServer({ appId: 'mongo_init_test' })); + it('can be initialized through ParseServer without liveQueryServerOptions', async () => { + const parseServer = await ParseServer.startApp({ + appId: 'hello', + masterKey: 'world', + port: 22345, + mountPath: '/1', + serverURL: 'http://localhost:12345/1', + liveQuery: { + classNames: ['Yolo'], + }, + startLiveQueryServer: true, + }); + expect(parseServer.liveQueryServer).not.toBeUndefined(); + expect(parseServer.liveQueryServer.server).toBe(parseServer.server); + await new Promise(resolve => parseServer.server.close(resolve)); + }); + + it('can be initialized through ParseServer with liveQueryServerOptions', async () => { + const parseServer = await ParseServer.startApp({ + appId: 'hello', + masterKey: 'world', + port: 22346, + mountPath: '/1', + serverURL: 'http://localhost:12345/1', + liveQuery: { + classNames: ['Yolo'], + }, + liveQueryServerOptions: { + port: 22347, + }, + }); + expect(parseServer.liveQueryServer).not.toBeUndefined(); + expect(parseServer.liveQueryServer.server).not.toBe(parseServer.server); + await new Promise(resolve => parseServer.server.close(resolve)); + }); + }); + + it('properly passes the CLP to afterSave/afterDelete hook', function (done) { + async function setPermissionsOnClass(className, permissions, doPut) { + const method = doPut ? 'PUT' : 'POST'; + const response = await fetch(Parse.serverURL + '/schemas/' + className, { + method, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + classLevelPermissions: permissions, + }), + }); + const body = await response.json(); + if (body.error) { + throw body; + } + return body; + } + + let saveSpy; + let deleteSpy; + reconfigureServer({ + liveQuery: { + classNames: ['Yolo'], + }, + }) + .then(parseServer => { + saveSpy = spyOn(parseServer.config.liveQueryController, 'onAfterSave'); + deleteSpy = spyOn(parseServer.config.liveQueryController, 'onAfterDelete'); + return setPermissionsOnClass('Yolo', { + create: { '*': true }, + delete: { '*': true }, + }); + }) + .then(() => { + const obj = new Parse.Object('Yolo'); + return obj.save(); + }) + .then(obj => { + return obj.destroy(); + }) + .then(() => { + expect(saveSpy).toHaveBeenCalled(); + const saveArgs = saveSpy.calls.mostRecent().args; + expect(saveArgs.length).toBe(4); + expect(saveArgs[0]).toBe('Yolo'); + expect(saveArgs[3]).toEqual({ + get: {}, + count: {}, + addField: {}, + create: { '*': true }, + find: {}, + update: {}, + delete: { '*': true }, + protectedFields: {}, + }); + + expect(deleteSpy).toHaveBeenCalled(); + const deleteArgs = deleteSpy.calls.mostRecent().args; + expect(deleteArgs.length).toBe(4); + expect(deleteArgs[0]).toBe('Yolo'); + expect(deleteArgs[3]).toEqual({ + get: {}, + count: {}, + addField: {}, + create: { '*': true }, + find: {}, + update: {}, + delete: { '*': true }, + protectedFields: {}, + }); + done(); + }) + .catch(done.fail); + }); + + it('can handle connect command', async () => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const parseWebSocket = { + clientId: -1, }; parseLiveQueryServer._validateKeys = jasmine.createSpy('validateKeys').and.returnValue(true); - parseLiveQueryServer._handleConnect(parseWebSocket); + await parseLiveQueryServer._handleConnect(parseWebSocket, { + sessionToken: 'token', + }); - expect(parseLiveQueryServer.clientId).toBe(1); - expect(parseWebSocket.clientId).toBe(0); - var client = parseLiveQueryServer.clients.get(0); + const clientKeys = parseLiveQueryServer.clients.keys(); + expect(parseLiveQueryServer.clients.size).toBe(1); + const firstKey = clientKeys.next().value; + expect(parseWebSocket.clientId).toBe(firstKey); + const client = parseLiveQueryServer.clients.get(firstKey); expect(client).not.toBeNull(); // Make sure we send connect response to the client expect(client.pushConnect).toHaveBeenCalled(); }); - it('can handle subscribe command without clientId', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var incompleteParseConn = { + it('basic beforeConnect rejection', async () => { + Parse.Cloud.beforeConnect(function () { + throw new Error('You shall not pass!'); + }); + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const parseWebSocket = { + clientId: -1, + }; + await parseLiveQueryServer._handleConnect(parseWebSocket, { + sessionToken: 'token', + }); + expect(parseLiveQueryServer.clients.size).toBe(0); + const Client = require('../lib/LiveQuery/Client').Client; + expect(Client.pushError).toHaveBeenCalled(); + }); + + it('basic beforeSubscribe rejection', async () => { + Parse.Cloud.beforeSubscribe('test', function () { + throw new Error('You shall not pass!'); + }); + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const parseWebSocket = { + clientId: -1, + }; + await parseLiveQueryServer._handleConnect(parseWebSocket, { + sessionToken: 'token', + }); + const query = { + className: 'test', + where: { + key: 'value', + }, + keys: ['test'], + }; + const requestId = 2; + const request = { + query: query, + requestId: requestId, + sessionToken: 'sessionToken', }; - parseLiveQueryServer._handleSubscribe(incompleteParseConn, {}); + await parseLiveQueryServer._handleSubscribe(parseWebSocket, request); + expect(parseLiveQueryServer.clients.size).toBe(1); + const Client = require('../lib/LiveQuery/Client').Client; + expect(Client.pushError).toHaveBeenCalled(); + }); - var Client = require('../src/LiveQuery/Client').Client; + it('can handle subscribe command without clientId', async () => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const incompleteParseConn = {}; + await parseLiveQueryServer._handleSubscribe(incompleteParseConn, {}); + + const Client = require('../lib/LiveQuery/Client').Client; expect(Client.pushError).toHaveBeenCalled(); }); - it('can handle subscribe command with new query', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + it('can handle subscribe command with new query', async () => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Add mock client - var clientId = 1; - var client = addMockClient(parseLiveQueryServer, clientId); + const clientId = 1; + const client = addMockClient(parseLiveQueryServer, clientId); // Handle mock subscription - var parseWebSocket = { - clientId: clientId + const parseWebSocket = { + clientId: clientId, }; - var query = { + const query = { className: 'test', where: { - key: 'value' + key: 'value', }, - fields: [ 'test' ] - } - var requestId = 2; - var request = { + keys: ['test'], + }; + const requestId = 2; + const request = { query: query, requestId: requestId, - sessionToken: 'sessionToken' - } - parseLiveQueryServer._handleSubscribe(parseWebSocket, request); + sessionToken: 'sessionToken', + }; + await parseLiveQueryServer._handleSubscribe(parseWebSocket, request); // Make sure we add the subscription to the server - var subscriptions = parseLiveQueryServer.subscriptions; + const subscriptions = parseLiveQueryServer.subscriptions; expect(subscriptions.size).toBe(1); expect(subscriptions.get(query.className)).not.toBeNull(); - var classSubscriptions = subscriptions.get(query.className); + const classSubscriptions = subscriptions.get(query.className); expect(classSubscriptions.size).toBe(1); expect(classSubscriptions.get('hash')).not.toBeNull(); // TODO(check subscription constructor to verify we pass the right argument) // Make sure we add clientInfo to the subscription - var subscription = classSubscriptions.get('hash'); + const subscription = classSubscriptions.get('hash'); expect(subscription.addClientSubscription).toHaveBeenCalledWith(clientId, requestId); // Make sure we add subscriptionInfo to the client - var args = client.addSubscriptionInfo.calls.first().args; + const args = client.addSubscriptionInfo.calls.first().args; expect(args[0]).toBe(requestId); - expect(args[1].fields).toBe(query.fields); + expect(args[1].keys).toBe(query.keys); expect(args[1].sessionToken).toBe(request.sessionToken); // Make sure we send subscribe response to the client expect(client.pushSubscribe).toHaveBeenCalledWith(requestId); }); - it('can handle subscribe command with existing query', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + it('can handle subscribe command with existing query', async () => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Add two mock clients - var clientId = 1; - var client = addMockClient(parseLiveQueryServer, clientId); - var clientIdAgain = 2; - var clientAgain = addMockClient(parseLiveQueryServer, clientIdAgain); + const clientId = 1; + addMockClient(parseLiveQueryServer, clientId); + const clientIdAgain = 2; + const clientAgain = addMockClient(parseLiveQueryServer, clientIdAgain); // Add subscription for mock client 1 - var parseWebSocket = { - clientId: clientId + const parseWebSocket = { + clientId: clientId, }; - var requestId = 2; - var query = { + const requestId = 2; + const query = { className: 'test', where: { - key: 'value' + key: 'value', }, - fields: [ 'test' ] - } - addMockSubscription(parseLiveQueryServer, clientId, requestId, parseWebSocket, query); + keys: ['test'], + }; + await addMockSubscription(parseLiveQueryServer, clientId, requestId, parseWebSocket, query); // Add subscription for mock client 2 - var parseWebSocketAgain = { - clientId: clientIdAgain + const parseWebSocketAgain = { + clientId: clientIdAgain, }; - var queryAgain = { + const queryAgain = { className: 'test', where: { - key: 'value' + key: 'value', }, - fields: [ 'testAgain' ] - } - var requestIdAgain = 1; - addMockSubscription(parseLiveQueryServer, clientIdAgain, requestIdAgain, parseWebSocketAgain, queryAgain); + keys: ['testAgain'], + }; + const requestIdAgain = 1; + await addMockSubscription( + parseLiveQueryServer, + clientIdAgain, + requestIdAgain, + parseWebSocketAgain, + queryAgain + ); // Make sure we only have one subscription - var subscriptions = parseLiveQueryServer.subscriptions; + const subscriptions = parseLiveQueryServer.subscriptions; expect(subscriptions.size).toBe(1); expect(subscriptions.get(query.className)).not.toBeNull(); - var classSubscriptions = subscriptions.get(query.className); + const classSubscriptions = subscriptions.get(query.className); expect(classSubscriptions.size).toBe(1); expect(classSubscriptions.get('hash')).not.toBeNull(); // Make sure we add clientInfo to the subscription - var subscription = classSubscriptions.get('hash'); + const subscription = classSubscriptions.get('hash'); // Make sure client 2 info has been added - var args = subscription.addClientSubscription.calls.mostRecent().args; + let args = subscription.addClientSubscription.calls.mostRecent().args; expect(args).toEqual([clientIdAgain, requestIdAgain]); // Make sure we add subscriptionInfo to the client 2 args = clientAgain.addSubscriptionInfo.calls.mostRecent().args; expect(args[0]).toBe(requestIdAgain); - expect(args[1].fields).toBe(queryAgain.fields); + expect(args[1].keys).toBe(queryAgain.keys); }); - it('can handle unsubscribe command without clientId', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var incompleteParseConn = { - }; + it('can handle unsubscribe command without clientId', function () { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const incompleteParseConn = {}; parseLiveQueryServer._handleUnsubscribe(incompleteParseConn, {}); - var Client = require('../src/LiveQuery/Client').Client; + const Client = require('../lib/LiveQuery/Client').Client; expect(Client.pushError).toHaveBeenCalled(); }); - it('can handle unsubscribe command without not existed client', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var parseWebSocket = { - clientId: 1 + it('can handle unsubscribe command without not existed client', function () { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const parseWebSocket = { + clientId: 1, }; parseLiveQueryServer._handleUnsubscribe(parseWebSocket, {}); - var Client = require('../src/LiveQuery/Client').Client; + const Client = require('../lib/LiveQuery/Client').Client; expect(Client.pushError).toHaveBeenCalled(); }); - it('can handle unsubscribe command without not existed query', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + it('can handle unsubscribe command without not existed query', async () => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Add mock client - var clientId = 1; - var client = addMockClient(parseLiveQueryServer, clientId); + const clientId = 1; + addMockClient(parseLiveQueryServer, clientId); // Handle unsubscribe command - var parseWebSocket = { - clientId: 1 + const parseWebSocket = { + clientId: 1, }; parseLiveQueryServer._handleUnsubscribe(parseWebSocket, {}); - var Client = require('../src/LiveQuery/Client').Client; + const Client = require('../lib/LiveQuery/Client').Client; expect(Client.pushError).toHaveBeenCalled(); }); - it('can handle unsubscribe command', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + it('can handle unsubscribe command', async () => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Add mock client - var clientId = 1; - var client = addMockClient(parseLiveQueryServer, clientId); + const clientId = 1; + const client = addMockClient(parseLiveQueryServer, clientId); // Add subscription for mock client - var parseWebSocket = { - clientId: 1 + const parseWebSocket = { + clientId: 1, }; - var requestId = 2; - var subscription = addMockSubscription(parseLiveQueryServer, clientId, requestId, parseWebSocket); + const requestId = 2; + const subscription = await addMockSubscription( + parseLiveQueryServer, + clientId, + requestId, + parseWebSocket + ); // Mock client.getSubscriptionInfo - var subscriptionInfo = client.addSubscriptionInfo.calls.mostRecent().args[1]; - client.getSubscriptionInfo = function() { + const subscriptionInfo = client.addSubscriptionInfo.calls.mostRecent().args[1]; + client.getSubscriptionInfo = function () { return subscriptionInfo; }; // Handle unsubscribe command - var requestAgain = { - requestId: requestId + const requestAgain = { + requestId: requestId, }; parseLiveQueryServer._handleUnsubscribe(parseWebSocket, requestAgain); @@ -270,91 +475,149 @@ describe('ParseLiveQueryServer', function() { // Make sure we delete client from subscription expect(subscription.deleteClientSubscription).toHaveBeenCalledWith(clientId, requestId); // Make sure we clear subscription in the server - var subscriptions = parseLiveQueryServer.subscriptions; + const subscriptions = parseLiveQueryServer.subscriptions; expect(subscriptions.size).toBe(0); }); - it('can set connect command message handler for a parseWebSocket', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + it('can set connect command message handler for a parseWebSocket', function () { + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Register mock connect/subscribe/unsubscribe handler for the server parseLiveQueryServer._handleConnect = jasmine.createSpy('_handleSubscribe'); // Make mock parseWebsocket - var EventEmitter = require('events'); - var parseWebSocket = new EventEmitter(); + const EventEmitter = require('events'); + const parseWebSocket = new EventEmitter(); // Register message handlers for the parseWebSocket parseLiveQueryServer._onConnect(parseWebSocket); // Check connect request - var connectRequest = { - op: 'connect' + const connectRequest = { + op: 'connect', + applicationId: '1', + installationId: '1234', }; // Trigger message event parseWebSocket.emit('message', connectRequest); // Make sure _handleConnect is called - var args = parseLiveQueryServer._handleConnect.calls.mostRecent().args; + const args = parseLiveQueryServer._handleConnect.calls.mostRecent().args; expect(args[0]).toBe(parseWebSocket); }); - it('can set subscribe command message handler for a parseWebSocket', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + it('can set subscribe command message handler for a parseWebSocket', function () { + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Register mock connect/subscribe/unsubscribe handler for the server parseLiveQueryServer._handleSubscribe = jasmine.createSpy('_handleSubscribe'); // Make mock parseWebsocket - var EventEmitter = require('events'); - var parseWebSocket = new EventEmitter(); + const EventEmitter = require('events'); + const parseWebSocket = new EventEmitter(); // Register message handlers for the parseWebSocket parseLiveQueryServer._onConnect(parseWebSocket); // Check subscribe request - var subscribeRequest = '{"op":"subscribe"}'; + const subscribeRequest = JSON.stringify({ + op: 'subscribe', + requestId: 1, + query: { className: 'Test', where: {} }, + }); // Trigger message event parseWebSocket.emit('message', subscribeRequest); // Make sure _handleSubscribe is called - var args = parseLiveQueryServer._handleSubscribe.calls.mostRecent().args; + const args = parseLiveQueryServer._handleSubscribe.calls.mostRecent().args; expect(args[0]).toBe(parseWebSocket); expect(JSON.stringify(args[1])).toBe(subscribeRequest); }); - it('can set unsubscribe command message handler for a parseWebSocket', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + it('can set unsubscribe command message handler for a parseWebSocket', function () { + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Register mock connect/subscribe/unsubscribe handler for the server parseLiveQueryServer._handleUnsubscribe = jasmine.createSpy('_handleSubscribe'); // Make mock parseWebsocket - var EventEmitter = require('events'); - var parseWebSocket = new EventEmitter(); + const EventEmitter = require('events'); + const parseWebSocket = new EventEmitter(); // Register message handlers for the parseWebSocket parseLiveQueryServer._onConnect(parseWebSocket); // Check unsubscribe request - var unsubscribeRequest = '{"op":"unsubscribe"}'; + const unsubscribeRequest = JSON.stringify({ + op: 'unsubscribe', + requestId: 1, + }); // Trigger message event parseWebSocket.emit('message', unsubscribeRequest); // Make sure _handleUnsubscribe is called - var args = parseLiveQueryServer._handleUnsubscribe.calls.mostRecent().args; + const args = parseLiveQueryServer._handleUnsubscribe.calls.mostRecent().args; expect(args[0]).toBe(parseWebSocket); expect(JSON.stringify(args[1])).toBe(unsubscribeRequest); }); - it('can set unknown command message handler for a parseWebSocket', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + it('can set update command message handler for a parseWebSocket', function () { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + // Register mock connect/subscribe/unsubscribe handler for the server + spyOn(parseLiveQueryServer, '_handleUpdateSubscription').and.callThrough(); + spyOn(parseLiveQueryServer, '_handleUnsubscribe').and.callThrough(); + spyOn(parseLiveQueryServer, '_handleSubscribe').and.callThrough(); + + // Make mock parseWebsocket + const EventEmitter = require('events'); + const parseWebSocket = new EventEmitter(); + + // Register message handlers for the parseWebSocket + parseLiveQueryServer._onConnect(parseWebSocket); + + // Check updateRequest request + const updateRequest = JSON.stringify({ + op: 'update', + requestId: 1, + query: { className: 'Test', where: {} }, + }); + // Trigger message event + parseWebSocket.emit('message', updateRequest); + // Make sure _handleUnsubscribe is called + const args = parseLiveQueryServer._handleUpdateSubscription.calls.mostRecent().args; + expect(args[0]).toBe(parseWebSocket); + expect(JSON.stringify(args[1])).toBe(updateRequest); + expect(parseLiveQueryServer._handleUnsubscribe).toHaveBeenCalled(); + const unsubArgs = parseLiveQueryServer._handleUnsubscribe.calls.mostRecent().args; + expect(unsubArgs.length).toBe(3); + expect(unsubArgs[2]).toBe(false); + expect(parseLiveQueryServer._handleSubscribe).toHaveBeenCalled(); + }); + + it('can set missing command message handler for a parseWebSocket', function () { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + // Make mock parseWebsocket + const EventEmitter = require('events'); + const parseWebSocket = new EventEmitter(); + // Register message handlers for the parseWebSocket + parseLiveQueryServer._onConnect(parseWebSocket); + + // Check invalid request + const invalidRequest = '{}'; + // Trigger message event + parseWebSocket.emit('message', invalidRequest); + const Client = require('../lib/LiveQuery/Client').Client; + expect(Client.pushError).toHaveBeenCalled(); + }); + + it('can set unknown command message handler for a parseWebSocket', function () { + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make mock parseWebsocket - var EventEmitter = require('events'); - var parseWebSocket = new EventEmitter(); + const EventEmitter = require('events'); + const parseWebSocket = new EventEmitter(); // Register message handlers for the parseWebSocket parseLiveQueryServer._onConnect(parseWebSocket); // Check unknown request - var unknownRequest = '{"op":"unknown"}'; + const unknownRequest = '{"op":"unknown"}'; // Trigger message event parseWebSocket.emit('message', unknownRequest); - var Client = require('../src/LiveQuery/Client').Client; + const Client = require('../lib/LiveQuery/Client').Client; expect(Client.pushError).toHaveBeenCalled(); }); - it('can set disconnect command message handler for a parseWebSocket which has not registered to the server', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var EventEmitter = require('events'); - var parseWebSocket = new EventEmitter(); + it('can set disconnect command message handler for a parseWebSocket which has not registered to the server', function () { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const EventEmitter = require('events'); + const parseWebSocket = new EventEmitter(); parseWebSocket.clientId = 1; // Register message handlers for the parseWebSocket parseLiveQueryServer._onConnect(parseWebSocket); @@ -364,49 +627,98 @@ describe('ParseLiveQueryServer', function() { parseWebSocket.emit('disconnect'); }); - // TODO: Test server can set disconnect command message handler for a parseWebSocket + it('can forward event to cloud code', function () { + const cloudCodeHandler = { + handler: () => { }, + }; + const spy = spyOn(cloudCodeHandler, 'handler').and.callThrough(); + Parse.Cloud.onLiveQueryEvent(cloudCodeHandler.handler); + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const EventEmitter = require('events'); + const parseWebSocket = new EventEmitter(); + parseWebSocket.clientId = 1; + // Register message handlers for the parseWebSocket + parseLiveQueryServer._onConnect(parseWebSocket); + + // Make sure we do not crash + // Trigger disconnect event + parseWebSocket.emit('disconnect'); + expect(spy).toHaveBeenCalled(); + // call for ws_connect, another for ws_disconnect + expect(spy.calls.count()).toBe(2); + }); + + it('does not delete subscription info on client disconnect', async () => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + // Add mock client and subscription + const clientId = 1; + const client = addMockClient(parseLiveQueryServer, clientId); + const requestId = 2; + const EventEmitter = require('events'); + const parseWebSocket = new EventEmitter(); + parseWebSocket.clientId = clientId; + await addMockSubscription(parseLiveQueryServer, clientId, requestId, parseWebSocket); + + // Register message handlers (sets up disconnect handler) + parseLiveQueryServer._onConnect(parseWebSocket); + + // Verify client exists before disconnect + expect(parseLiveQueryServer.clients.has(clientId)).toBeTrue(); - it('has no subscription and can handle object delete command', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + // Trigger disconnect + parseWebSocket.emit('disconnect'); + + // Prove disconnect handler executed: client removed from server + expect(parseLiveQueryServer.clients.has(clientId)).toBeFalse(); + + // The disconnect handler must NOT call deleteSubscriptionInfo; + // only the explicit unsubscribe handler does. + // The advisory GHSA-3rpv-5775-m86r claims subscriptionInfo + // becomes undefined on disconnect, but it does not. + expect(client.deleteSubscriptionInfo).not.toHaveBeenCalled(); + }); + + it('has no subscription and can handle object delete command', function () { + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make deletedParseObject - var parseObject = new Parse.Object(testClassName); + const parseObject = new Parse.Object(testClassName); parseObject._finishFetch({ key: 'value', - className: testClassName + className: testClassName, }); // Make mock message - var message = { - currentParseObject: parseObject + const message = { + currentParseObject: parseObject, }; // Make sure we do not crash in this case parseLiveQueryServer._onAfterDelete(message, {}); }); - it('can handle object delete command which does not match any subscription', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + it('can handle object delete command which does not match any subscription', async () => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make deletedParseObject - var parseObject = new Parse.Object(testClassName); + const parseObject = new Parse.Object(testClassName); parseObject._finishFetch({ key: 'value', - className: testClassName + className: testClassName, }); // Make mock message - var message = { - currentParseObject: parseObject + const message = { + currentParseObject: parseObject, }; // Add mock client - var clientId = 1; + const clientId = 1; addMockClient(parseLiveQueryServer, clientId); // Add mock subscription - var requestId = 2; - addMockSubscription(parseLiveQueryServer, clientId, requestId); - var client = parseLiveQueryServer.clients.get(clientId); + const requestId = 2; + await addMockSubscription(parseLiveQueryServer, clientId, requestId); + const client = parseLiveQueryServer.clients.get(clientId); // Mock _matchesSubscription to return not matching - parseLiveQueryServer._matchesSubscription = function() { + parseLiveQueryServer._matchesSubscription = function () { return false; }; - parseLiveQueryServer._matchesACL = function() { + parseLiveQueryServer._matchesACL = function () { return true; }; parseLiveQueryServer._onAfterDelete(message); @@ -415,227 +727,487 @@ describe('ParseLiveQueryServer', function() { expect(client.pushDelete).not.toHaveBeenCalled(); }); - it('can handle object delete command which matches some subscriptions', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + it('can handle object delete command which matches some subscriptions', async done => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make deletedParseObject - var parseObject = new Parse.Object(testClassName); + const parseObject = new Parse.Object(testClassName); parseObject._finishFetch({ key: 'value', - className: testClassName + className: testClassName, }); - // Make mock message - var message = { - currentParseObject: parseObject + // Make mock message + const message = { + currentParseObject: parseObject, }; // Add mock client - var clientId = 1; + const clientId = 1; addMockClient(parseLiveQueryServer, clientId); // Add mock subscription - var requestId = 2; - addMockSubscription(parseLiveQueryServer, clientId, requestId); - var client = parseLiveQueryServer.clients.get(clientId); + const requestId = 2; + await addMockSubscription(parseLiveQueryServer, clientId, requestId); + const client = parseLiveQueryServer.clients.get(clientId); // Mock _matchesSubscription to return matching - parseLiveQueryServer._matchesSubscription = function() { + parseLiveQueryServer._matchesSubscription = function () { return true; }; - parseLiveQueryServer._matchesACL = function() { - return Parse.Promise.as(true); + parseLiveQueryServer._matchesACL = function () { + return Promise.resolve(true); }; parseLiveQueryServer._onAfterDelete(message); // Make sure we send command to client, since _matchesACL is async, we have to // wait and check - setTimeout(function() { - expect(client.pushDelete).toHaveBeenCalled(); - done(); - }, jasmine.ASYNC_TEST_WAIT_TIME); + await timeout(); + + expect(client.pushDelete).toHaveBeenCalled(); + done(); }); - it('has no subscription and can handle object save command', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + it('has no subscription and can handle object save command', async () => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make mock request message - var message = generateMockMessage(); + const message = generateMockMessage(); // Make sure we do not crash in this case parseLiveQueryServer._onAfterSave(message); }); - it('can handle object save command which does not match any subscription', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + it('sends correct object for dates', async () => { + jasmine.restoreLibrary('../lib/LiveQuery/QueryTools', 'matchesQuery'); + + const parseLiveQueryServer = new ParseLiveQueryServer({}); + + const date = new Date(); + const message = { + currentParseObject: { + date: { __type: 'Date', iso: date.toISOString() }, + __type: 'Object', + key: 'value', + className: testClassName, + }, + }; + // Add mock client + const clientId = 1; + const client = addMockClient(parseLiveQueryServer, clientId); + + const requestId2 = 2; + + await addMockSubscription(parseLiveQueryServer, clientId, requestId2); + + parseLiveQueryServer._matchesACL = function () { + return Promise.resolve(true); + }; + + parseLiveQueryServer._inflateParseObject(message); + parseLiveQueryServer._onAfterSave(message); + + // Make sure we send leave and enter command to client + await timeout(); + + expect(client.pushCreate).toHaveBeenCalledWith( + requestId2, + { + className: 'TestObject', + key: 'value', + date: { __type: 'Date', iso: date.toISOString() }, + }, + null + ); + }); + + it('can handle object save command which does not match any subscription', async done => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make mock request message - var message = generateMockMessage(); + const message = generateMockMessage(); // Add mock client - var clientId = 1; - var client = addMockClient(parseLiveQueryServer, clientId); + const clientId = 1; + const client = addMockClient(parseLiveQueryServer, clientId); // Add mock subscription - var requestId = 2; - addMockSubscription(parseLiveQueryServer, clientId, requestId); + const requestId = 2; + await addMockSubscription(parseLiveQueryServer, clientId, requestId); // Mock _matchesSubscription to return not matching - parseLiveQueryServer._matchesSubscription = function() { + parseLiveQueryServer._matchesSubscription = function () { return false; }; - parseLiveQueryServer._matchesACL = function() { - return Parse.Promise.as(true) + parseLiveQueryServer._matchesACL = function () { + return Promise.resolve(true); }; // Trigger onAfterSave parseLiveQueryServer._onAfterSave(message); // Make sure we do not send command to client - setTimeout(function(){ - expect(client.pushCreate).not.toHaveBeenCalled(); - expect(client.pushEnter).not.toHaveBeenCalled(); - expect(client.pushUpdate).not.toHaveBeenCalled(); - expect(client.pushDelete).not.toHaveBeenCalled(); - expect(client.pushLeave).not.toHaveBeenCalled(); - done(); - }, jasmine.ASYNC_TEST_WAIT_TIME); + await timeout(); + + expect(client.pushCreate).not.toHaveBeenCalled(); + expect(client.pushEnter).not.toHaveBeenCalled(); + expect(client.pushUpdate).not.toHaveBeenCalled(); + expect(client.pushDelete).not.toHaveBeenCalled(); + expect(client.pushLeave).not.toHaveBeenCalled(); + done(); }); - it('can handle object enter command which matches some subscriptions', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + it('can handle object enter command which matches some subscriptions', async done => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make mock request message - var message = generateMockMessage(true); + const message = generateMockMessage(true); // Add mock client - var clientId = 1; - var client = addMockClient(parseLiveQueryServer, clientId); + const clientId = 1; + const client = addMockClient(parseLiveQueryServer, clientId); // Add mock subscription - var requestId = 2; - addMockSubscription(parseLiveQueryServer, clientId, requestId); + const requestId = 2; + await addMockSubscription(parseLiveQueryServer, clientId, requestId); // Mock _matchesSubscription to return matching // In order to mimic a enter, we need original match return false // and the current match return true - var counter = 0; - parseLiveQueryServer._matchesSubscription = function(parseObject, subscription){ + let counter = 0; + parseLiveQueryServer._matchesSubscription = function (parseObject) { if (!parseObject) { return false; } counter += 1; return counter % 2 === 0; }; - parseLiveQueryServer._matchesACL = function() { - return Parse.Promise.as(true) + parseLiveQueryServer._matchesACL = function () { + return Promise.resolve(true); }; parseLiveQueryServer._onAfterSave(message); // Make sure we send enter command to client - setTimeout(function(){ - expect(client.pushCreate).not.toHaveBeenCalled(); - expect(client.pushEnter).toHaveBeenCalled(); - expect(client.pushUpdate).not.toHaveBeenCalled(); - expect(client.pushDelete).not.toHaveBeenCalled(); - expect(client.pushLeave).not.toHaveBeenCalled(); - done(); - }, jasmine.ASYNC_TEST_WAIT_TIME); + await timeout(); + + expect(client.pushCreate).not.toHaveBeenCalled(); + expect(client.pushEnter).toHaveBeenCalled(); + expect(client.pushUpdate).not.toHaveBeenCalled(); + expect(client.pushDelete).not.toHaveBeenCalled(); + expect(client.pushLeave).not.toHaveBeenCalled(); + done(); }); - it('can handle object update command which matches some subscriptions', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + it('can handle object update command which matches some subscriptions', async done => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make mock request message - var message = generateMockMessage(true); + const message = generateMockMessage(true); // Add mock client - var clientId = 1; - var client = addMockClient(parseLiveQueryServer, clientId); + const clientId = 1; + const client = addMockClient(parseLiveQueryServer, clientId); // Add mock subscription - var requestId = 2; - addMockSubscription(parseLiveQueryServer, clientId, requestId); + const requestId = 2; + await addMockSubscription(parseLiveQueryServer, clientId, requestId); // Mock _matchesSubscription to return matching - parseLiveQueryServer._matchesSubscription = function(parseObject, subscription){ + parseLiveQueryServer._matchesSubscription = function (parseObject) { if (!parseObject) { return false; } return true; }; - parseLiveQueryServer._matchesACL = function() { - return Parse.Promise.as(true) + parseLiveQueryServer._matchesACL = function () { + return Promise.resolve(true); }; parseLiveQueryServer._onAfterSave(message); // Make sure we send update command to client - setTimeout(function(){ - expect(client.pushCreate).not.toHaveBeenCalled(); - expect(client.pushEnter).not.toHaveBeenCalled(); - expect(client.pushUpdate).toHaveBeenCalled(); - expect(client.pushDelete).not.toHaveBeenCalled(); - expect(client.pushLeave).not.toHaveBeenCalled(); - done(); - }, jasmine.ASYNC_TEST_WAIT_TIME); + await timeout(); + + expect(client.pushCreate).not.toHaveBeenCalled(); + expect(client.pushEnter).not.toHaveBeenCalled(); + expect(client.pushUpdate).toHaveBeenCalled(); + expect(client.pushDelete).not.toHaveBeenCalled(); + expect(client.pushLeave).not.toHaveBeenCalled(); + done(); }); - it('can handle object leave command which matches some subscriptions', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + it('can handle object leave command which matches some subscriptions', async done => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make mock request message - var message = generateMockMessage(true); + const message = generateMockMessage(true); // Add mock client - var clientId = 1; - var client = addMockClient(parseLiveQueryServer, clientId); + const clientId = 1; + const client = addMockClient(parseLiveQueryServer, clientId); // Add mock subscription - var requestId = 2; - addMockSubscription(parseLiveQueryServer, clientId, requestId); + const requestId = 2; + await addMockSubscription(parseLiveQueryServer, clientId, requestId); // Mock _matchesSubscription to return matching // In order to mimic a leave, we need original match return true // and the current match return false - var counter = 0; - parseLiveQueryServer._matchesSubscription = function(parseObject, subscription){ + let counter = 0; + parseLiveQueryServer._matchesSubscription = function (parseObject) { if (!parseObject) { return false; } counter += 1; return counter % 2 !== 0; }; - parseLiveQueryServer._matchesACL = function() { - return Parse.Promise.as(true) + parseLiveQueryServer._matchesACL = function () { + return Promise.resolve(true); }; parseLiveQueryServer._onAfterSave(message); // Make sure we send leave command to client - setTimeout(function(){ - expect(client.pushCreate).not.toHaveBeenCalled(); - expect(client.pushEnter).not.toHaveBeenCalled(); - expect(client.pushUpdate).not.toHaveBeenCalled(); - expect(client.pushDelete).not.toHaveBeenCalled(); - expect(client.pushLeave).toHaveBeenCalled(); - done(); - }, jasmine.ASYNC_TEST_WAIT_TIME); + await timeout(); + + expect(client.pushCreate).not.toHaveBeenCalled(); + expect(client.pushEnter).not.toHaveBeenCalled(); + expect(client.pushUpdate).not.toHaveBeenCalled(); + expect(client.pushDelete).not.toHaveBeenCalled(); + expect(client.pushLeave).toHaveBeenCalled(); + done(); + }); + + it('sends correct events for object with multiple subscriptions', async done => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + + Parse.Cloud.afterLiveQueryEvent('TestObject', () => { + // Simulate delay due to trigger, auth, etc. + return jasmine.timeout(10); + }); + + // Make mock request message + const message = generateMockMessage(true); + // Add mock client + const clientId = 1; + const client = addMockClient(parseLiveQueryServer, clientId); + client.sessionToken = 'sessionToken'; + + // Mock queryHash for this special test + const mockQueryHash = jasmine.createSpy('matchesQuery').and.returnValue('hash1'); + jasmine.mockLibrary('../lib/LiveQuery/QueryTools', 'queryHash', mockQueryHash); + // Add mock subscription 1 + const requestId2 = 2; + await addMockSubscription(parseLiveQueryServer, clientId, requestId2, null, null, 'hash1'); + + // Mock queryHash for this special test + const mockQueryHash2 = jasmine.createSpy('matchesQuery').and.returnValue('hash2'); + jasmine.mockLibrary('../lib/LiveQuery/QueryTools', 'queryHash', mockQueryHash2); + // Add mock subscription 2 + const requestId3 = 3; + await addMockSubscription(parseLiveQueryServer, clientId, requestId3, null, null, 'hash2'); + // Mock _matchesSubscription to return matching + // In order to mimic a leave, then enter, we need original match return true + // and the current match return false, then the other way around + let counter = 0; + parseLiveQueryServer._matchesSubscription = function (parseObject) { + if (!parseObject) { + return false; + } + counter += 1; + // true, false, false, true + return counter < 2 || counter > 3; + }; + parseLiveQueryServer._matchesACL = function () { + // Simulate call + return jasmine.timeout(10).then(() => true); + }; + parseLiveQueryServer._onAfterSave(message); + + // Make sure we send leave and enter command to client + await timeout(); + + expect(client.pushCreate).not.toHaveBeenCalled(); + expect(client.pushEnter).toHaveBeenCalledTimes(1); + expect(client.pushEnter).toHaveBeenCalledWith( + requestId3, + { key: 'value', className: 'TestObject' }, + { key: 'originalValue', className: 'TestObject' } + ); + expect(client.pushUpdate).not.toHaveBeenCalled(); + expect(client.pushDelete).not.toHaveBeenCalled(); + expect(client.pushLeave).toHaveBeenCalledTimes(1); + expect(client.pushLeave).toHaveBeenCalledWith( + requestId2, + { key: 'value', className: 'TestObject' }, + { key: 'originalValue', className: 'TestObject' } + ); + done(); + }); + + it('can handle update command with original object', async done => { + jasmine.restoreLibrary('../lib/LiveQuery/Client', 'Client'); + const Client = require('../lib/LiveQuery/Client').Client; + const parseLiveQueryServer = new ParseLiveQueryServer({}); + // Make mock request message + const message = generateMockMessage(true); + + const clientId = 1; + const parseWebSocket = { + clientId, + send: jasmine.createSpy('send'), + }; + const client = new Client(clientId, parseWebSocket); + spyOn(client, 'pushUpdate').and.callThrough(); + parseLiveQueryServer.clients.set(clientId, client); + + // Add mock subscription + const requestId = 2; + + await addMockSubscription(parseLiveQueryServer, clientId, requestId, parseWebSocket); + // Mock _matchesSubscription to return matching + parseLiveQueryServer._matchesSubscription = function (parseObject) { + if (!parseObject) { + return false; + } + return true; + }; + parseLiveQueryServer._matchesACL = function () { + return Promise.resolve(true); + }; + + parseLiveQueryServer._onAfterSave(message); + + // Make sure we send update command to client + await timeout(); + + expect(client.pushUpdate).toHaveBeenCalled(); + const args = parseWebSocket.send.calls.mostRecent().args; + const toSend = JSON.parse(args[0]); + + expect(toSend.object).toBeDefined(); + expect(toSend.original).toBeDefined(); + done(); }); - it('can handle object create command which matches some subscriptions', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + it('can handle object create command which matches some subscriptions', async done => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make mock request message - var message = generateMockMessage(); + const message = generateMockMessage(); // Add mock client - var clientId = 1; - var client = addMockClient(parseLiveQueryServer, clientId); + const clientId = 1; + const client = addMockClient(parseLiveQueryServer, clientId); // Add mock subscription - var requestId = 2; - addMockSubscription(parseLiveQueryServer, clientId, requestId); + const requestId = 2; + await addMockSubscription(parseLiveQueryServer, clientId, requestId); // Mock _matchesSubscription to return matching - parseLiveQueryServer._matchesSubscription = function(parseObject, subscription){ + parseLiveQueryServer._matchesSubscription = function (parseObject) { if (!parseObject) { return false; } return true; }; - parseLiveQueryServer._matchesACL = function() { - return Parse.Promise.as(true) + parseLiveQueryServer._matchesACL = function () { + return Promise.resolve(true); }; parseLiveQueryServer._onAfterSave(message); // Make sure we send create command to client - setTimeout(function(){ - expect(client.pushCreate).toHaveBeenCalled(); - expect(client.pushEnter).not.toHaveBeenCalled(); - expect(client.pushUpdate).not.toHaveBeenCalled(); - expect(client.pushDelete).not.toHaveBeenCalled(); - expect(client.pushLeave).not.toHaveBeenCalled(); - done(); - }, jasmine.ASYNC_TEST_WAIT_TIME); + await timeout(); + + expect(client.pushCreate).toHaveBeenCalled(); + expect(client.pushEnter).not.toHaveBeenCalled(); + expect(client.pushUpdate).not.toHaveBeenCalled(); + expect(client.pushDelete).not.toHaveBeenCalled(); + expect(client.pushLeave).not.toHaveBeenCalled(); + done(); + }); + + it('can handle create command with keys', async done => { + jasmine.restoreLibrary('../lib/LiveQuery/Client', 'Client'); + const Client = require('../lib/LiveQuery/Client').Client; + const parseLiveQueryServer = new ParseLiveQueryServer({}); + // Make mock request message + const message = generateMockMessage(); + + const clientId = 1; + const parseWebSocket = { + clientId, + send: jasmine.createSpy('send'), + }; + const client = new Client(clientId, parseWebSocket); + spyOn(client, 'pushCreate').and.callThrough(); + parseLiveQueryServer.clients.set(clientId, client); + + // Add mock subscription + const requestId = 2; + const query = { + className: testClassName, + where: { + key: 'value', + }, + keys: ['test'], + }; + await addMockSubscription(parseLiveQueryServer, clientId, requestId, parseWebSocket, query); + // Mock _matchesSubscription to return matching + parseLiveQueryServer._matchesSubscription = function (parseObject) { + if (!parseObject) { + return false; + } + return true; + }; + parseLiveQueryServer._matchesACL = function () { + return Promise.resolve(true); + }; + + parseLiveQueryServer._onAfterSave(message); + + // Make sure we send create command to client + await timeout(); + + expect(client.pushCreate).toHaveBeenCalled(); + const args = parseWebSocket.send.calls.mostRecent().args; + const toSend = JSON.parse(args[0]); + expect(toSend.object).toBeDefined(); + expect(toSend.original).toBeUndefined(); + done(); }); - it('can match subscription for null or undefined parse object', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + it('can handle create command with watch', async () => { + jasmine.restoreLibrary('../lib/LiveQuery/Client', 'Client'); + const Client = require('../lib/LiveQuery/Client').Client; + const parseLiveQueryServer = new ParseLiveQueryServer({}); + // Make mock request message + const message = generateMockMessage(); + + const clientId = 1; + const parseWebSocket = { + clientId, + send: jasmine.createSpy('send'), + }; + const client = new Client(clientId, parseWebSocket); + spyOn(client, 'pushCreate').and.callThrough(); + parseLiveQueryServer.clients.set(clientId, client); + + // Add mock subscription + const requestId = 2; + const query = { + className: testClassName, + where: { + key: 'value', + }, + watch: ['yolo'], + }; + await addMockSubscription(parseLiveQueryServer, clientId, requestId, parseWebSocket, query); + // Mock _matchesSubscription to return matching + parseLiveQueryServer._matchesSubscription = function (parseObject) { + if (!parseObject) { + return false; + } + return true; + }; + parseLiveQueryServer._matchesACL = function () { + return Promise.resolve(true); + }; + + parseLiveQueryServer._onAfterSave(message); + + // Make sure we send create command to client + await timeout(); + + expect(client.pushCreate).not.toHaveBeenCalled(); + + message.currentParseObject.set('yolo', 'test'); + parseLiveQueryServer._onAfterSave(message); + + await timeout(); + + const args = parseWebSocket.send.calls.mostRecent().args; + const toSend = JSON.parse(args[0]); + expect(toSend.object).toBeDefined(); + expect(toSend.original).toBeUndefined(); + }); + + it('can match subscription for null or undefined parse object', function () { + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make mock subscription - var subscription = { - match: jasmine.createSpy('match') - } + const subscription = { + match: jasmine.createSpy('match'), + }; expect(parseLiveQueryServer._matchesSubscription(null, subscription)).toBe(false); expect(parseLiveQueryServer._matchesSubscription(undefined, subscription)).toBe(false); @@ -643,45 +1215,45 @@ describe('ParseLiveQueryServer', function() { expect(subscription.match).not.toHaveBeenCalled(); }); - it('can match subscription', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + it('can match subscription', function () { + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make mock subscription - var subscription = { - query: {} - } - var parseObject = {}; + const subscription = { + query: {}, + }; + const parseObject = {}; expect(parseLiveQueryServer._matchesSubscription(parseObject, subscription)).toBe(true); // Make sure matchesQuery is called - var matchesQuery = require('../src/LiveQuery/QueryTools').matchesQuery; + const matchesQuery = require('../lib/LiveQuery/QueryTools').matchesQuery; expect(matchesQuery).toHaveBeenCalledWith(parseObject, subscription.query); }); - it('can inflate parse object', function() { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); + it('can inflate parse object', function () { + const parseLiveQueryServer = new ParseLiveQueryServer({}); // Make mock request - var objectJSON = { - "className":"testClassName", - "createdAt":"2015-12-22T01:51:12.955Z", - "key":"value", - "objectId":"BfwxBCz6yW", - "updatedAt":"2016-01-05T00:46:45.659Z" - }; - var originalObjectJSON = { - "className":"testClassName", - "createdAt":"2015-12-22T01:51:12.955Z", - "key":"originalValue", - "objectId":"BfwxBCz6yW", - "updatedAt":"2016-01-05T00:46:45.659Z" - }; - var message = { + const objectJSON = { + className: 'testClassName', + createdAt: '2015-12-22T01:51:12.955Z', + key: 'value', + objectId: 'BfwxBCz6yW', + updatedAt: '2016-01-05T00:46:45.659Z', + }; + const originalObjectJSON = { + className: 'testClassName', + createdAt: '2015-12-22T01:51:12.955Z', + key: 'originalValue', + objectId: 'BfwxBCz6yW', + updatedAt: '2016-01-05T00:46:45.659Z', + }; + const message = { currentParseObject: objectJSON, - originalParseObject: originalObjectJSON + originalParseObject: originalObjectJSON, }; // Inflate the object parseLiveQueryServer._inflateParseObject(message); // Verify object - var object = message.currentParseObject; + const object = message.currentParseObject; expect(object instanceof Parse.Object).toBeTruthy(); expect(object.get('key')).toEqual('value'); expect(object.className).toEqual('testClassName'); @@ -689,7 +1261,7 @@ describe('ParseLiveQueryServer', function() { expect(object.createdAt).not.toBeUndefined(); expect(object.updatedAt).not.toBeUndefined(); // Verify original object - var originalObject = message.originalParseObject; + const originalObject = message.originalParseObject; expect(originalObject instanceof Parse.Object).toBeTruthy(); expect(originalObject.get('key')).toEqual('originalValue'); expect(originalObject.className).toEqual('testClassName'); @@ -698,211 +1270,598 @@ describe('ParseLiveQueryServer', function() { expect(originalObject.updatedAt).not.toBeUndefined(); }); - it('can match undefined ACL', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var client = {}; - var requestId = 0; + it('can inflate user object', async () => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const userJSON = { + username: 'test', + ACL: {}, + createdAt: '2018-12-21T23:09:51.784Z', + sessionToken: 'r:1234', + updatedAt: '2018-12-21T23:09:51.784Z', + objectId: 'NhF2u9n72W', + __type: 'Object', + className: '_User', + _hashed_password: '1234', + _email_verify_token: '1234', + }; + + const originalUserJSON = { + username: 'test', + ACL: {}, + createdAt: '2018-12-21T23:09:51.784Z', + sessionToken: 'r:1234', + updatedAt: '2018-12-21T23:09:51.784Z', + objectId: 'NhF2u9n72W', + __type: 'Object', + className: '_User', + _hashed_password: '12345', + _email_verify_token: '12345', + }; + + const message = { + currentParseObject: userJSON, + originalParseObject: originalUserJSON, + }; + parseLiveQueryServer._inflateParseObject(message); + + const object = message.currentParseObject; + expect(object instanceof Parse.Object).toBeTruthy(); + expect(object.get('_hashed_password')).toBeUndefined(); + expect(object.get('_email_verify_token')).toBeUndefined(); + expect(object.className).toEqual('_User'); + expect(object.id).toBe('NhF2u9n72W'); + expect(object.createdAt).not.toBeUndefined(); + expect(object.updatedAt).not.toBeUndefined(); - parseLiveQueryServer._matchesACL(undefined, client, requestId).then(function(isMatched) { + const originalObject = message.originalParseObject; + expect(originalObject instanceof Parse.Object).toBeTruthy(); + expect(originalObject.get('_hashed_password')).toBeUndefined(); + expect(originalObject.get('_email_verify_token')).toBeUndefined(); + expect(originalObject.className).toEqual('_User'); + expect(originalObject.id).toBe('NhF2u9n72W'); + expect(originalObject.createdAt).not.toBeUndefined(); + expect(originalObject.updatedAt).not.toBeUndefined(); + }); + + it('can match undefined ACL', function (done) { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const client = {}; + const requestId = 0; + + parseLiveQueryServer._matchesACL(undefined, client, requestId).then(function (isMatched) { expect(isMatched).toBe(true); done(); }); }); - it('can match ACL with none exist requestId', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var acl = new Parse.ACL(); - var client = { - getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue(undefined) + it('can match ACL with none exist requestId', function (done) { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); + const client = { + getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue(undefined), }; - var requestId = 0; + const requestId = 0; - var isChecked = false; - parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { + parseLiveQueryServer._matchesACL(acl, client, requestId).then(function (isMatched) { expect(isMatched).toBe(false); done(); }); }); - it('can match ACL with public read access', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var acl = new Parse.ACL(); + it('can match ACL with public read access', function (done) { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); acl.setPublicReadAccess(true); - var client = { + const client = { getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ - sessionToken: 'sessionToken' - }) + sessionToken: 'sessionToken', + }), }; - var requestId = 0; + const requestId = 0; - parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { + parseLiveQueryServer._matchesACL(acl, client, requestId).then(function (isMatched) { expect(isMatched).toBe(true); done(); }); }); - it('can match ACL with valid subscription sessionToken', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var acl = new Parse.ACL(); + it('can match ACL with valid subscription sessionToken', function (done) { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); acl.setReadAccess(testUserId, true); - var client = { + const client = { getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ - sessionToken: 'sessionToken' - }) + sessionToken: 'sessionToken', + }), }; - var requestId = 0; + const requestId = 0; - parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { + parseLiveQueryServer._matchesACL(acl, client, requestId).then(function (isMatched) { expect(isMatched).toBe(true); done(); }); }); - it('can match ACL with valid client sessionToken', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var acl = new Parse.ACL(); + it('can match ACL with valid client sessionToken', function (done) { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); acl.setReadAccess(testUserId, true); // Mock sessionTokenCache will return false when sessionToken is undefined - var client = { + const client = { sessionToken: 'sessionToken', getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ - sessionToken: undefined - }) + sessionToken: undefined, + }), }; - var requestId = 0; + const requestId = 0; - parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { + parseLiveQueryServer._matchesACL(acl, client, requestId).then(function (isMatched) { expect(isMatched).toBe(true); done(); }); }); - it('can match ACL with invalid subscription and client sessionToken', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var acl = new Parse.ACL(); + it('can match ACL with invalid subscription and client sessionToken', function (done) { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); acl.setReadAccess(testUserId, true); // Mock sessionTokenCache will return false when sessionToken is undefined - var client = { + const client = { sessionToken: undefined, getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ - sessionToken: undefined - }) + sessionToken: undefined, + }), }; - var requestId = 0; + const requestId = 0; - parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { + parseLiveQueryServer._matchesACL(acl, client, requestId).then(function (isMatched) { expect(isMatched).toBe(false); done(); }); }); - it('can match ACL with subscription sessionToken checking error', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var acl = new Parse.ACL(); + it('can match ACL with subscription sessionToken checking error', function (done) { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); acl.setReadAccess(testUserId, true); // Mock sessionTokenCache will return error when sessionToken is null, this is just // the behaviour of our mock sessionTokenCache, not real sessionTokenCache - var client = { + const client = { getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ - sessionToken: null - }) + sessionToken: null, + }), }; - var requestId = 0; + const requestId = 0; - parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { + parseLiveQueryServer._matchesACL(acl, client, requestId).then(function (isMatched) { expect(isMatched).toBe(false); done(); }); }); - it('can match ACL with client sessionToken checking error', function(done) { - var parseLiveQueryServer = new ParseLiveQueryServer(10, 10, {}); - var acl = new Parse.ACL(); + it('can match ACL with client sessionToken checking error', function (done) { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); acl.setReadAccess(testUserId, true); // Mock sessionTokenCache will return error when sessionToken is null - var client = { + const client = { sessionToken: null, getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ - sessionToken: null + sessionToken: null, + }), + }; + const requestId = 0; + + parseLiveQueryServer._matchesACL(acl, client, requestId).then(function (isMatched) { + expect(isMatched).toBe(false); + done(); + }); + }); + + it("won't match ACL that doesn't have public read or any roles", function (done) { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(false); + const client = { + getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ + sessionToken: 'sessionToken', + }), + }; + const requestId = 0; + + parseLiveQueryServer._matchesACL(acl, client, requestId).then(function (isMatched) { + expect(isMatched).toBe(false); + done(); + }); + }); + + it("won't match non-public ACL with role when there is no user", function (done) { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(false); + acl.setRoleReadAccess('livequery', true); + const client = { + getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({}), + }; + const requestId = 0; + + parseLiveQueryServer + ._matchesACL(acl, client, requestId) + .then(function (isMatched) { + expect(isMatched).toBe(false); + done(); }) + .catch(done.fail); + }); + + it("won't match ACL with role based read access set to false", function (done) { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(false); + acl.setRoleReadAccess('otherLiveQueryRead', true); + const client = { + getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ + sessionToken: 'sessionToken', + }), }; - var requestId = 0; + const requestId = 0; + + spyOn(Parse, 'Query').and.callFake(function () { + let shouldReturn = false; + return { + equalTo() { + shouldReturn = true; + // Nothing to do here + return this; + }, + containedIn() { + shouldReturn = false; + return this; + }, + find() { + if (!shouldReturn) { + return Promise.resolve([]); + } + //Return a role with the name "liveQueryRead" as that is what was set on the ACL + const liveQueryRole = new Parse.Role('liveQueryRead', new Parse.ACL()); + liveQueryRole.id = 'abcdef1234'; + return Promise.resolve([liveQueryRole]); + }, + }; + }); + + parseLiveQueryServer._matchesACL(acl, client, requestId).then(function (isMatched) { + expect(isMatched).toBe(false); + done(); + }); - parseLiveQueryServer._matchesACL(acl, client, requestId).then(function(isMatched) { + parseLiveQueryServer._matchesACL(acl, client, requestId).then(function (isMatched) { expect(isMatched).toBe(false); done(); }); }); - it('can validate key when valid key is provided', function() { - var parseLiveQueryServer = new ParseLiveQueryServer({}, { - keyPairs: { - clientKey: 'test' - } + it('will match ACL with role based read access set to true', function (done) { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(false); + acl.setRoleReadAccess('liveQueryRead', true); + const client = { + getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ + sessionToken: 'sessionToken', + }), + }; + const requestId = 0; + + spyOn(Parse, 'Query').and.callFake(function () { + let shouldReturn = false; + return { + equalTo() { + shouldReturn = true; + // Nothing to do here + return this; + }, + containedIn() { + shouldReturn = false; + return this; + }, + find() { + if (!shouldReturn) { + return Promise.resolve([]); + } + //Return a role with the name "liveQueryRead" as that is what was set on the ACL + const liveQueryRole = new Parse.Role('liveQueryRead', new Parse.ACL()); + liveQueryRole.id = 'abcdef1234'; + return Promise.resolve([liveQueryRole]); + }, + each(callback) { + //Return a role with the name "liveQueryRead" as that is what was set on the ACL + const liveQueryRole = new Parse.Role('liveQueryRead', new Parse.ACL()); + liveQueryRole.id = 'abcdef1234'; + callback(liveQueryRole); + return Promise.resolve(); + }, + }; }); - var request = { - clientKey: 'test' - } + + parseLiveQueryServer._matchesACL(acl, client, requestId).then(function (isMatched) { + expect(isMatched).toBe(true); + done(); + }); + }); + + describe('class level permissions', () => { + it('rejects CLP when find is closed', async () => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const client = { + sessionToken: 'sessionToken', + getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ + sessionToken: undefined, + }), + }; + const requestId = 0; + + await expectAsync( + parseLiveQueryServer._matchesCLP( + { find: {} }, + { className: 'Yolo' }, + client, + requestId, + 'find' + ) + ).toBeRejected(); + }); + + it('resolves CLP when find is open', async () => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const client = { + sessionToken: 'sessionToken', + getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ + sessionToken: undefined, + }), + }; + const requestId = 0; + + await expectAsync( + parseLiveQueryServer._matchesCLP( + { find: { '*': true } }, + { className: 'Yolo' }, + client, + requestId, + 'find' + ) + ).toBeResolved(); + }); + + it('rejects CLP when find is restricted to userIds', async () => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const client = { + sessionToken: 'sessionToken', + getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ + sessionToken: undefined, + }), + }; + const requestId = 0; + + await expectAsync( + parseLiveQueryServer._matchesCLP( + { find: { userId: true } }, + { className: 'Yolo' }, + client, + requestId, + 'find' + ) + ).toBeRejected(); + }); + }); + + it('can validate key when valid key is provided', function () { + const parseLiveQueryServer = new ParseLiveQueryServer( + {}, + { + keyPairs: { + clientKey: 'test', + }, + } + ); + const request = { + clientKey: 'test', + }; expect(parseLiveQueryServer._validateKeys(request, parseLiveQueryServer.keyPairs)).toBeTruthy(); }); - it('can validate key when invalid key is provided', function() { - var parseLiveQueryServer = new ParseLiveQueryServer({}, { - keyPairs: { - clientKey: 'test' + it('can validate key when invalid key is provided', function () { + const parseLiveQueryServer = new ParseLiveQueryServer( + {}, + { + keyPairs: { + clientKey: 'test', + }, } - }); - var request = { - clientKey: 'error' - } + ); + const request = { + clientKey: 'error', + }; - expect(parseLiveQueryServer._validateKeys(request, parseLiveQueryServer.keyPairs)).not.toBeTruthy(); + expect( + parseLiveQueryServer._validateKeys(request, parseLiveQueryServer.keyPairs) + ).not.toBeTruthy(); }); - it('can validate key when key is not provided', function() { - var parseLiveQueryServer = new ParseLiveQueryServer({}, { - keyPairs: { - clientKey: 'test' + it('can validate key when key is not provided', function () { + const parseLiveQueryServer = new ParseLiveQueryServer( + {}, + { + keyPairs: { + clientKey: 'test', + }, } - }); - var request = { - } + ); + const request = {}; - expect(parseLiveQueryServer._validateKeys(request, parseLiveQueryServer.keyPairs)).not.toBeTruthy(); + expect( + parseLiveQueryServer._validateKeys(request, parseLiveQueryServer.keyPairs) + ).not.toBeTruthy(); }); - it('can validate key when validKerPairs is empty', function() { - var parseLiveQueryServer = new ParseLiveQueryServer({}, {}); - var request = { - } + it('can validate key when validKerPairs is empty', function () { + const parseLiveQueryServer = new ParseLiveQueryServer({}, {}); + const request = {}; expect(parseLiveQueryServer._validateKeys(request, parseLiveQueryServer.keyPairs)).toBeTruthy(); }); - afterEach(function(){ - jasmine.restoreLibrary('../src/LiveQuery/ParseWebSocketServer', 'ParseWebSocketServer'); - jasmine.restoreLibrary('../src/LiveQuery/Client', 'Client'); - jasmine.restoreLibrary('../src/LiveQuery/Subscription', 'Subscription'); - jasmine.restoreLibrary('../src/LiveQuery/QueryTools', 'queryHash'); - jasmine.restoreLibrary('../src/LiveQuery/QueryTools', 'matchesQuery'); - jasmine.restoreLibrary('tv4', 'validate'); - jasmine.restoreLibrary('../src/LiveQuery/ParsePubSub', 'ParsePubSub'); - jasmine.restoreLibrary('../src/LiveQuery/SessionTokenCache', 'SessionTokenCache'); + it('can validate client has master key when valid', function () { + const parseLiveQueryServer = new ParseLiveQueryServer( + {}, + { + keyPairs: { + masterKey: 'test', + }, + } + ); + const request = { + masterKey: 'test', + }; + + expect(parseLiveQueryServer._hasMasterKey(request, parseLiveQueryServer.keyPairs)).toBeTruthy(); + }); + + it("can validate client doesn't have master key when invalid", function () { + const parseLiveQueryServer = new ParseLiveQueryServer( + {}, + { + keyPairs: { + masterKey: 'test', + }, + } + ); + const request = { + masterKey: 'notValid', + }; + + expect( + parseLiveQueryServer._hasMasterKey(request, parseLiveQueryServer.keyPairs) + ).not.toBeTruthy(); + }); + + it("can validate client doesn't have master key when not provided", function () { + const parseLiveQueryServer = new ParseLiveQueryServer( + {}, + { + keyPairs: { + masterKey: 'test', + }, + } + ); + + expect(parseLiveQueryServer._hasMasterKey({}, parseLiveQueryServer.keyPairs)).not.toBeTruthy(); + }); + + it("can validate client doesn't have master key when validKeyPairs is empty", function () { + const parseLiveQueryServer = new ParseLiveQueryServer({}, {}); + const request = { + masterKey: 'test', + }; + + expect( + parseLiveQueryServer._hasMasterKey(request, parseLiveQueryServer.keyPairs) + ).not.toBeTruthy(); + }); + + it('will match non-public ACL when client has master key', function (done) { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(false); + const client = { + getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({}), + hasMasterKey: true, + }; + const requestId = 0; + + parseLiveQueryServer._matchesACL(acl, client, requestId).then(function (isMatched) { + expect(isMatched).toBe(true); + done(); + }); + }); + + it("won't match non-public ACL when client has no master key", function (done) { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(false); + const client = { + getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({}), + hasMasterKey: false, + }; + const requestId = 0; + + parseLiveQueryServer._matchesACL(acl, client, requestId).then(function (isMatched) { + expect(isMatched).toBe(false); + done(); + }); + }); + + it('should properly pull auth from cache', () => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const promise = parseLiveQueryServer.getAuthForSessionToken('sessionToken'); + const secondPromise = parseLiveQueryServer.getAuthForSessionToken('sessionToken'); + // should be in the cache + expect(parseLiveQueryServer.authCache.get('sessionToken')).toBe(promise); + // should be the same promise returned + expect(promise).toBe(secondPromise); + // the auth should be called only once + expect(auth.getAuthForSessionToken.calls.count()).toBe(1); + }); + + it('should delete from cache throwing auth calls', async () => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const promise = parseLiveQueryServer.getAuthForSessionToken('pleaseThrow'); + expect(parseLiveQueryServer.authCache.get('pleaseThrow')).toBe(promise); + // after the promise finishes, it should have removed it from the cache + expect(await promise).toEqual({}); + expect(parseLiveQueryServer.authCache.get('pleaseThrow')).toBe(undefined); + }); + + it('should keep a cache of invalid sessions', async () => { + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const promise = parseLiveQueryServer.getAuthForSessionToken('invalid'); + expect(parseLiveQueryServer.authCache.get('invalid')).toBe(promise); + // after the promise finishes, it should have removed it from the cache + await promise; + const finalResult = await parseLiveQueryServer.authCache.get('invalid'); + expect(finalResult.error).not.toBeUndefined(); + expect(parseLiveQueryServer.authCache.get('invalid')).not.toBe(undefined); + }); + + afterEach(function () { + jasmine.restoreLibrary('../lib/LiveQuery/ParseWebSocketServer', 'ParseWebSocketServer'); + jasmine.restoreLibrary('../lib/LiveQuery/Client', 'Client'); + jasmine.restoreLibrary('../lib/LiveQuery/Subscription', 'Subscription'); + jasmine.restoreLibrary('../lib/LiveQuery/QueryTools', 'queryHash'); + jasmine.restoreLibrary('../lib/LiveQuery/QueryTools', 'matchesQuery'); + jasmine.restoreLibrary('../lib/LiveQuery/ParsePubSub', 'ParsePubSub'); }); // Helper functions to add mock client and subscription to a liveQueryServer function addMockClient(parseLiveQueryServer, clientId) { - var Client = require('../src/LiveQuery/Client').Client; - var client = new Client(clientId, {}); + const Client = require('../lib/LiveQuery/Client').Client; + const client = new Client(clientId, {}); parseLiveQueryServer.clients.set(clientId, client); return client; } - function addMockSubscription(parseLiveQueryServer, clientId, requestId, parseWebSocket, query) { + async function addMockSubscription( + parseLiveQueryServer, + clientId, + requestId, + parseWebSocket, + query, + customQueryHashValue + ) { // If parseWebSocket is null, we use the default one if (!parseWebSocket) { - var EventEmitter = require('events'); + const EventEmitter = require('events'); parseWebSocket = new EventEmitter(); } parseWebSocket.clientId = clientId; @@ -911,51 +1870,179 @@ describe('ParseLiveQueryServer', function() { query = { className: testClassName, where: { - key: 'value' + key: 'value', }, - fields: [ 'test' ] + keys: ['test'], }; } - var request = { + const request = { query: query, requestId: requestId, - sessionToken: 'sessionToken' + sessionToken: 'sessionToken', }; - parseLiveQueryServer._handleSubscribe(parseWebSocket, request); + await parseLiveQueryServer._handleSubscribe(parseWebSocket, request); // Make mock subscription - var subscription = parseLiveQueryServer.subscriptions.get(query.className).get(queryHashValue); - subscription.hasSubscribingClient = function() { + const subscription = parseLiveQueryServer.subscriptions + .get(query.className) + .get(customQueryHashValue || queryHashValue); + subscription.hasSubscribingClient = function () { return false; - } + }; subscription.className = query.className; - subscription.hash = queryHashValue; + subscription.hash = customQueryHashValue || queryHashValue; if (subscription.clientRequestIds && subscription.clientRequestIds.has(clientId)) { subscription.clientRequestIds.get(clientId).push(requestId); } else { subscription.clientRequestIds = new Map([[clientId, [requestId]]]); } + subscription.query = query.where; return subscription; } // Helper functiosn to generate request message function generateMockMessage(hasOriginalParseObject) { - var parseObject = new Parse.Object(testClassName); + const parseObject = new Parse.Object(testClassName); parseObject._finishFetch({ key: 'value', - className: testClassName + className: testClassName, }); - var message = { - currentParseObject: parseObject + const message = { + currentParseObject: parseObject, }; if (hasOriginalParseObject) { - var originalParseObject = new Parse.Object(testClassName); + const originalParseObject = new Parse.Object(testClassName); originalParseObject._finishFetch({ key: 'originalValue', - className: testClassName + className: testClassName, }); message.originalParseObject = originalParseObject; } return message; } }); + +describe('LiveQueryController', () => { + it('properly passes the CLP to afterSave/afterDelete hook', function (done) { + async function setPermissionsOnClass(className, permissions, doPut) { + const method = doPut ? 'PUT' : 'POST'; + const response = await fetch(Parse.serverURL + '/schemas/' + className, { + method, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + classLevelPermissions: permissions, + }), + }); + const body = await response.json(); + if (body.error) { + throw body; + } + return body; + } + + let saveSpy; + let deleteSpy; + reconfigureServer({ + liveQuery: { + classNames: ['Yolo'], + }, + }) + .then(parseServer => { + saveSpy = spyOn(parseServer.config.liveQueryController, 'onAfterSave').and.callThrough(); + deleteSpy = spyOn( + parseServer.config.liveQueryController, + 'onAfterDelete' + ).and.callThrough(); + return setPermissionsOnClass('Yolo', { + create: { '*': true }, + delete: { '*': true }, + }); + }) + .then(() => { + const obj = new Parse.Object('Yolo'); + return obj.save(); + }) + .then(obj => { + return obj.destroy(); + }) + .then(() => { + expect(saveSpy).toHaveBeenCalled(); + const saveArgs = saveSpy.calls.mostRecent().args; + expect(saveArgs.length).toBe(4); + expect(saveArgs[0]).toBe('Yolo'); + expect(saveArgs[3]).toEqual({ + get: {}, + count: {}, + addField: {}, + create: { '*': true }, + find: {}, + update: {}, + delete: { '*': true }, + protectedFields: {}, + }); + + expect(deleteSpy).toHaveBeenCalled(); + const deleteArgs = deleteSpy.calls.mostRecent().args; + expect(deleteArgs.length).toBe(4); + expect(deleteArgs[0]).toBe('Yolo'); + expect(deleteArgs[3]).toEqual({ + get: {}, + count: {}, + addField: {}, + create: { '*': true }, + find: {}, + update: {}, + delete: { '*': true }, + protectedFields: {}, + }); + done(); + }) + .catch(done.fail); + }); + + it('should properly pack message request on afterSave', () => { + const controller = new LiveQueryController({ + classNames: ['Yolo'], + }); + const spy = spyOn(controller.liveQueryPublisher, 'onCloudCodeAfterSave'); + controller.onAfterSave('Yolo', { o: 1 }, { o: 2 }, { yolo: true }); + expect(spy).toHaveBeenCalled(); + const args = spy.calls.mostRecent().args; + expect(args.length).toBe(1); + expect(args[0]).toEqual({ + object: { o: 1 }, + original: { o: 2 }, + classLevelPermissions: { yolo: true }, + }); + }); + + it('should properly pack message request on afterDelete', () => { + const controller = new LiveQueryController({ + classNames: ['Yolo'], + }); + const spy = spyOn(controller.liveQueryPublisher, 'onCloudCodeAfterDelete'); + controller.onAfterDelete('Yolo', { o: 1 }, { o: 2 }, { yolo: true }); + expect(spy).toHaveBeenCalled(); + const args = spy.calls.mostRecent().args; + expect(args.length).toBe(1); + expect(args[0]).toEqual({ + object: { o: 1 }, + original: { o: 2 }, + classLevelPermissions: { yolo: true }, + }); + }); + + it('should properly pack message request', () => { + const controller = new LiveQueryController({ + classNames: ['Yolo'], + }); + expect(controller._makePublisherRequest({})).toEqual({ + object: {}, + original: undefined, + }); + }); +}); diff --git a/spec/ParseObject.spec.js b/spec/ParseObject.spec.js index cb57e87233..cf65e2df47 100644 --- a/spec/ParseObject.spec.js +++ b/spec/ParseObject.spec.js @@ -1,4 +1,4 @@ -"use strict"; +'use strict'; // This is a port of the test suite: // hungry/js/test/parse_object_test.js // @@ -13,298 +13,258 @@ // single-instance mode. describe('Parse.Object testing', () => { - it("create", function(done) { - create({ "test" : "test" }, function(model, response) { - ok(model.id, "Should have an objectId set"); - equal(model.get("test"), "test", "Should have the right attribute"); + it('create', function (done) { + create({ test: 'test' }, function (model) { + ok(model.id, 'Should have an objectId set'); + equal(model.get('test'), 'test', 'Should have the right attribute'); done(); }); }); - it("update", function(done) { - create({ "test" : "test" }, function(model, response) { - var t2 = new TestObject({ objectId: model.id }); - t2.set("test", "changed"); - t2.save(null, { - success: function(model, response) { - equal(model.get("test"), "changed", "Update should have succeeded"); - done(); - } + it('update', function (done) { + create({ test: 'test' }, function (model) { + const t2 = new TestObject({ objectId: model.id }); + t2.set('test', 'changed'); + t2.save().then(function (model) { + equal(model.get('test'), 'changed', 'Update should have succeeded'); + done(); }); }); }); - it("save without null", function(done) { - var object = new TestObject(); - object.set("favoritePony", "Rainbow Dash"); - object.save({ - success: function(objectAgain) { + it('save without null', function (done) { + const object = new TestObject(); + object.set('favoritePony', 'Rainbow Dash'); + object.save().then( + function (objectAgain) { equal(objectAgain, object); done(); }, - error: function(objectAgain, error) { - ok(null, "Error " + error.code + ": " + error.message); + function (objectAgain, error) { + ok(null, 'Error ' + error.code + ': ' + error.message); done(); } - }); + ); + }); + + it('save cycle', done => { + const a = new Parse.Object('TestObject'); + const b = new Parse.Object('TestObject'); + a.set('b', b); + a.save() + .then(function () { + b.set('a', a); + return b.save(); + }) + .then(function () { + ok(a.id); + ok(b.id); + strictEqual(a.get('b'), b); + strictEqual(b.get('a'), a); + }) + .then( + function () { + done(); + }, + function (error) { + ok(false, error); + done(); + } + ); }); - it("save cycle", done => { - var a = new Parse.Object("TestObject"); - var b = new Parse.Object("TestObject"); - a.set("b", b); - a.save().then(function() { - b.set("a", a); - return b.save(); - - }).then(function() { - ok(a.id); - ok(b.id); - strictEqual(a.get("b"), b); - strictEqual(b.get("a"), a); - - }).then(function() { - done(); - }, function(error) { - ok(false, error); - done(); + it('get', function (done) { + create({ test: 'test' }, function (model) { + const t2 = new TestObject({ objectId: model.id }); + t2.fetch().then(function (model2) { + equal(model2.get('test'), 'test', 'Update should have succeeded'); + ok(model2.id); + equal(model2.id, model.id, 'Ids should match'); + done(); + }); }); }); - it("get", function(done) { - create({ "test" : "test" }, function(model, response) { - var t2 = new TestObject({ objectId: model.id }); - t2.fetch({ - success: function(model2, response) { - equal(model2.get("test"), "test", "Update should have succeeded"); - ok(model2.id); - equal(model2.id, model.id, "Ids should match"); - done(); - } + it('delete', function (done) { + const t = new TestObject(); + t.set('test', 'test'); + t.save().then(function () { + t.destroy().then(function () { + const t2 = new TestObject({ objectId: t.id }); + t2.fetch().then(fail, () => done()); }); }); }); - it("delete", function(done) { - var t = new TestObject(); - t.set("test", "test"); - t.save(null, { - success: function() { - t.destroy({ - success: function() { - var t2 = new TestObject({ objectId: t.id }); - t2.fetch().then(fail, done); - } - }); - } + it('find', function (done) { + const t = new TestObject(); + t.set('foo', 'bar'); + t.save().then(function () { + const query = new Parse.Query(TestObject); + query.equalTo('foo', 'bar'); + query.find().then(function (results) { + equal(results.length, 1); + done(); + }); }); }); - it("find", function(done) { - var t = new TestObject(); - t.set("foo", "bar"); - t.save(null, { - success: function() { - var query = new Parse.Query(TestObject); - query.equalTo("foo", "bar"); - query.find({ - success: function(results) { - equal(results.length, 1); - done(); - } - }); - } - }); - }); + it('relational fields', function (done) { + const item = new Item(); + item.set('property', 'x'); + const container = new Container(); + container.set('item', item); - it_exclude_dbs(['postgres'])("relational fields", function(done) { - var item = new Item(); - item.set("property", "x"); - var container = new Container(); - container.set("item", item); - - Parse.Object.saveAll([item, container], { - success: function() { - var query = new Parse.Query(Container); - query.find({ - success: function(results) { - equal(results.length, 1); - var containerAgain = results[0]; - var itemAgain = containerAgain.get("item"); - itemAgain.fetch({ - success: function() { - equal(itemAgain.get("property"), "x"); - done(); - } - }); - } + Parse.Object.saveAll([item, container]).then(function () { + const query = new Parse.Query(Container); + query.find().then(function (results) { + equal(results.length, 1); + const containerAgain = results[0]; + const itemAgain = containerAgain.get('item'); + itemAgain.fetch().then(function () { + equal(itemAgain.get('property'), 'x'); + done(); }); - } + }); }); }); - it("save adds no data keys (other than createdAt and updatedAt)", - function(done) { - var object = new TestObject(); - object.save(null, { - success: function() { - var keys = Object.keys(object.attributes).sort(); - equal(keys.length, 2); - done(); - } - }); - }); - - it("recursive save", function(done) { - var item = new Item(); - item.set("property", "x"); - var container = new Container(); - container.set("item", item); - - container.save(null, { - success: function() { - var query = new Parse.Query(Container); - query.find({ - success: function(results) { - equal(results.length, 1); - var containerAgain = results[0]; - var itemAgain = containerAgain.get("item"); - itemAgain.fetch({ - success: function() { - equal(itemAgain.get("property"), "x"); - done(); - } - }); - } - }); - } + it('save adds no data keys (other than createdAt and updatedAt)', function (done) { + const object = new TestObject(); + object.save().then(function () { + const keys = Object.keys(object.attributes).sort(); + equal(keys.length, 2); + done(); }); }); - it("fetch", function(done) { - var item = new Item({ foo: "bar" }); - item.save(null, { - success: function() { - var itemAgain = new Item(); - itemAgain.id = item.id; - itemAgain.fetch({ - success: function() { - itemAgain.save({ foo: "baz" }, { - success: function() { - item.fetch({ - success: function() { - equal(item.get("foo"), itemAgain.get("foo")); - done(); - } - }); - } - }); - } + it('recursive save', function (done) { + const item = new Item(); + item.set('property', 'x'); + const container = new Container(); + container.set('item', item); + + container.save().then(function () { + const query = new Parse.Query(Container); + query.find().then(function (results) { + equal(results.length, 1); + const containerAgain = results[0]; + const itemAgain = containerAgain.get('item'); + itemAgain.fetch().then(function () { + equal(itemAgain.get('property'), 'x'); + done(); }); - } + }); }); }); - it_exclude_dbs(['postgres'])("createdAt doesn't change", function(done) { - var object = new TestObject({ foo: "bar" }); - object.save(null, { - success: function() { - var objectAgain = new TestObject(); - objectAgain.id = object.id; - objectAgain.fetch({ - success: function() { - equal(object.createdAt.getTime(), objectAgain.createdAt.getTime()); + it('fetch', function (done) { + const item = new Item({ foo: 'bar' }); + item.save().then(function () { + const itemAgain = new Item(); + itemAgain.id = item.id; + itemAgain.fetch().then(function () { + itemAgain.save({ foo: 'baz' }).then(function () { + item.fetch().then(function () { + equal(item.get('foo'), itemAgain.get('foo')); done(); - } + }); }); - } + }); }); }); - it("createdAt and updatedAt exposed", function(done) { - var object = new TestObject({ foo: "bar" }); - object.save(null, { - success: function() { - notEqual(object.updatedAt, undefined); - notEqual(object.createdAt, undefined); + it("createdAt doesn't change", function (done) { + const object = new TestObject({ foo: 'bar' }); + object.save().then(function () { + const objectAgain = new TestObject(); + objectAgain.id = object.id; + objectAgain.fetch().then(function () { + equal(object.createdAt.getTime(), objectAgain.createdAt.getTime()); done(); - } + }); }); }); - it("updatedAt gets updated", function(done) { - var object = new TestObject({ foo: "bar" }); - object.save(null, { - success: function() { - ok(object.updatedAt, "initial save should cause updatedAt to exist"); - var firstUpdatedAt = object.updatedAt; - object.save({ foo: "baz" }, { - success: function() { - ok(object.updatedAt, "two saves should cause updatedAt to exist"); - notEqual(firstUpdatedAt, object.updatedAt); - done(); - } - }); - } + it('createdAt and updatedAt exposed', function (done) { + const object = new TestObject({ foo: 'bar' }); + object.save().then(function () { + notEqual(object.updatedAt, undefined); + notEqual(object.createdAt, undefined); + done(); }); }); - it("createdAt is reasonable", function(done) { - var startTime = new Date(); - var object = new TestObject({ foo: "bar" }); - object.save(null, { - success: function() { - var endTime = new Date(); - var startDiff = Math.abs(startTime.getTime() - - object.createdAt.getTime()); - ok(startDiff < 5000); + it('updatedAt gets updated', function (done) { + const object = new TestObject({ foo: 'bar' }); + object.save().then(function () { + ok(object.updatedAt, 'initial save should cause updatedAt to exist'); + const firstUpdatedAt = object.updatedAt; + object.save({ foo: 'baz' }).then(function () { + ok(object.updatedAt, 'two saves should cause updatedAt to exist'); + notEqual(firstUpdatedAt, object.updatedAt); + done(); + }); + }); + }); - var endDiff = Math.abs(endTime.getTime() - - object.createdAt.getTime()); - ok(endDiff < 5000); + it('createdAt is reasonable', function (done) { + const startTime = new Date(); + const object = new TestObject({ foo: 'bar' }); + object.save().then(function () { + const endTime = new Date(); + const startDiff = Math.abs(startTime.getTime() - object.createdAt.getTime()); + ok(startDiff < 5000); - done(); - } + const endDiff = Math.abs(endTime.getTime() - object.createdAt.getTime()); + ok(endDiff < 5000); + + done(); }); }); - it_exclude_dbs(['postgres'])("can set null", function(done) { - var obj = new Parse.Object("TestObject"); - obj.set("foo", null); - obj.save(null, { - success: function(obj) { - equal(obj.get("foo"), null); + it('can set null', function (done) { + const obj = new Parse.Object('TestObject'); + obj.set('foo', null); + obj.save().then( + function (obj) { + on_db('mongo', () => { + equal(obj.get('foo'), null); + }); + on_db('postgres', () => { + equal(obj.get('foo'), null); + }); done(); }, - error: function(obj, error) { - ok(false, error.message); + function () { + fail('should not fail'); done(); } - }); + ); }); - it("can set boolean", function(done) { - var obj = new Parse.Object("TestObject"); - obj.set("yes", true); - obj.set("no", false); - obj.save(null, { - success: function(obj) { - equal(obj.get("yes"), true); - equal(obj.get("no"), false); + it('can set boolean', function (done) { + const obj = new Parse.Object('TestObject'); + obj.set('yes', true); + obj.set('no', false); + obj.save().then( + function (obj) { + equal(obj.get('yes'), true); + equal(obj.get('no'), false); done(); }, - error: function(obj, error) { + function (obj, error) { ok(false, error.message); done(); } - }); + ); }); - it('cannot set invalid date', function(done) { - var obj = new Parse.Object('TestObject'); + it('cannot set invalid date', async function (done) { + const obj = new Parse.Object('TestObject'); obj.set('when', new Date(Date.parse(null))); try { - obj.save(); + await obj.save(); } catch (e) { ok(true); done(); @@ -314,41 +274,49 @@ describe('Parse.Object testing', () => { done(); }); - it("invalid class name", function(done) { - var item = new Parse.Object("Foo^bar"); - item.save(null, { - success: function(item) { - ok(false, "The name should have been invalid."); + it('can set authData when not user class', async () => { + const obj = new Parse.Object('TestObject'); + obj.set('authData', 'random'); + await obj.save(); + expect(obj.get('authData')).toBe('random'); + const query = new Parse.Query('TestObject'); + const object = await query.get(obj.id, { useMasterKey: true }); + expect(object.get('authData')).toBe('random'); + }); + + it('invalid class name', function (done) { + const item = new Parse.Object('Foo^bar'); + item.save().then( + function () { + ok(false, 'The name should have been invalid.'); done(); }, - error: function(item, error) { + function () { // Because the class name is invalid, the router will not be able to route // it, so it will actually return a -1 error code. // equal(error.code, Parse.Error.INVALID_CLASS_NAME); done(); } - }); + ); }); - it("invalid key name", function(done) { - var item = new Parse.Object("Item"); - ok(!item.set({"foo^bar": "baz"}), - 'Item should not be updated with invalid key.'); - item.save({ "foo^bar": "baz" }).then(fail, done); + it('invalid key name', function (done) { + const item = new Parse.Object('Item'); + expect(() => item.set({ 'foo^bar': 'baz' })).toThrow(new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'Invalid key name: foo^bar')); + item.save({ 'foo^bar': 'baz' }).then(fail, () => done()); }); - it("invalid __type", function(done) { - var item = new Parse.Object("Item"); - var types = ['Pointer', 'File', 'Date', 'GeoPoint', 'Bytes']; - var Error = Parse.Error; - var tests = types.map(type => { - var test = new Parse.Object("Item"); + it('invalid __type', function (done) { + const item = new Parse.Object('Item'); + const types = ['Pointer', 'File', 'Date', 'GeoPoint', 'Bytes', 'Polygon', 'Relation']; + const tests = types.map(type => { + const test = new Parse.Object('Item'); test.set('foo', { - __type: type + __type: type, }); return test; }); - var next = function(index) { + const next = function (index) { if (index < tests.length) { tests[index].save().then(fail, error => { expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); @@ -357,746 +325,753 @@ describe('Parse.Object testing', () => { } else { done(); } - } - item.save({ - "foo": { - __type: "IvalidName" - } - }).then(fail, err => next(0)); - }); - - it_exclude_dbs(['postgres'])("simple field deletion", function(done) { - var simple = new Parse.Object("SimpleObject"); - simple.save({ - foo: "bar" - }, { - success: function(simple) { - simple.unset("foo"); - ok(!simple.has("foo"), "foo should have been unset."); - ok(simple.dirty("foo"), "foo should be dirty."); - ok(simple.dirty(), "the whole object should be dirty."); - simple.save(null, { - success: function(simple) { - ok(!simple.has("foo"), "foo should have been unset."); - ok(!simple.dirty("foo"), "the whole object was just saved."); - ok(!simple.dirty(), "the whole object was just saved."); - - var query = new Parse.Query("SimpleObject"); - query.get(simple.id, { - success: function(simpleAgain) { - ok(!simpleAgain.has("foo"), "foo should have been removed."); - done(); - }, - error: function(simpleAgain, error) { - ok(false, "Error " + error.code + ": " + error.message); - done(); - } - }); - }, - error: function(simple, error) { - ok(false, "Error " + error.code + ": " + error.message); - done(); - } - }); - }, - error: function(simple, error) { - ok(false, "Error " + error.code + ": " + error.message); - done(); - } - }); - }); - - it("field deletion before first save", function(done) { - var simple = new Parse.Object("SimpleObject"); - simple.set("foo", "bar"); - simple.unset("foo"); - - ok(!simple.has("foo"), "foo should have been unset."); - ok(simple.dirty("foo"), "foo should be dirty."); - ok(simple.dirty(), "the whole object should be dirty."); - simple.save(null, { - success: function(simple) { - ok(!simple.has("foo"), "foo should have been unset."); - ok(!simple.dirty("foo"), "the whole object was just saved."); - ok(!simple.dirty(), "the whole object was just saved."); - - var query = new Parse.Query("SimpleObject"); - query.get(simple.id, { - success: function(simpleAgain) { - ok(!simpleAgain.has("foo"), "foo should have been removed."); + }; + item + .save({ + foo: { + __type: 'IvalidName', + }, + }) + .then(fail, () => next(0)); + }); + + it('simple field deletion', function (done) { + const simple = new Parse.Object('SimpleObject'); + simple + .save({ + foo: 'bar', + }) + .then( + function (simple) { + simple.unset('foo'); + ok(!simple.has('foo'), 'foo should have been unset.'); + ok(simple.dirty('foo'), 'foo should be dirty.'); + ok(simple.dirty(), 'the whole object should be dirty.'); + simple.save().then( + function (simple) { + ok(!simple.has('foo'), 'foo should have been unset.'); + ok(!simple.dirty('foo'), 'the whole object was just saved.'); + ok(!simple.dirty(), 'the whole object was just saved.'); + + const query = new Parse.Query('SimpleObject'); + query.get(simple.id).then( + function (simpleAgain) { + ok(!simpleAgain.has('foo'), 'foo should have been removed.'); + done(); + }, + function (simpleAgain, error) { + ok(false, 'Error ' + error.code + ': ' + error.message); + done(); + } + ); + }, + function (simple, error) { + ok(false, 'Error ' + error.code + ': ' + error.message); + done(); + } + ); + }, + function (simple, error) { + ok(false, 'Error ' + error.code + ': ' + error.message); + done(); + } + ); + }); + + it('field deletion before first save', function (done) { + const simple = new Parse.Object('SimpleObject'); + simple.set('foo', 'bar'); + simple.unset('foo'); + + ok(!simple.has('foo'), 'foo should have been unset.'); + ok(simple.dirty('foo'), 'foo should be dirty.'); + ok(simple.dirty(), 'the whole object should be dirty.'); + simple.save().then( + function (simple) { + ok(!simple.has('foo'), 'foo should have been unset.'); + ok(!simple.dirty('foo'), 'the whole object was just saved.'); + ok(!simple.dirty(), 'the whole object was just saved.'); + + const query = new Parse.Query('SimpleObject'); + query.get(simple.id).then( + function (simpleAgain) { + ok(!simpleAgain.has('foo'), 'foo should have been removed.'); done(); }, - error: function(simpleAgain, error) { - ok(false, "Error " + error.code + ": " + error.message); + function (simpleAgain, error) { + ok(false, 'Error ' + error.code + ': ' + error.message); done(); } - }); + ); }, - error: function(simple, error) { - ok(false, "Error " + error.code + ": " + error.message); + function (simple, error) { + ok(false, 'Error ' + error.code + ': ' + error.message); done(); } - }); - }); - - it_exclude_dbs(['postgres'])("relation deletion", function(done) { - var simple = new Parse.Object("SimpleObject"); - var child = new Parse.Object("Child"); - simple.save({ - child: child - }, { - success: function(simple) { - simple.unset("child"); - ok(!simple.has("child"), "child should have been unset."); - ok(simple.dirty("child"), "child should be dirty."); - ok(simple.dirty(), "the whole object should be dirty."); - simple.save(null, { - success: function(simple) { - ok(!simple.has("child"), "child should have been unset."); - ok(!simple.dirty("child"), "the whole object was just saved."); - ok(!simple.dirty(), "the whole object was just saved."); - - var query = new Parse.Query("SimpleObject"); - query.get(simple.id, { - success: function(simpleAgain) { - ok(!simpleAgain.has("child"), "child should have been removed."); + ); + }); + + it('relation deletion', function (done) { + const simple = new Parse.Object('SimpleObject'); + const child = new Parse.Object('Child'); + simple + .save({ + child: child, + }) + .then( + function (simple) { + simple.unset('child'); + ok(!simple.has('child'), 'child should have been unset.'); + ok(simple.dirty('child'), 'child should be dirty.'); + ok(simple.dirty(), 'the whole object should be dirty.'); + simple.save().then( + function (simple) { + ok(!simple.has('child'), 'child should have been unset.'); + ok(!simple.dirty('child'), 'the whole object was just saved.'); + ok(!simple.dirty(), 'the whole object was just saved.'); + + const query = new Parse.Query('SimpleObject'); + query.get(simple.id).then( + function (simpleAgain) { + ok(!simpleAgain.has('child'), 'child should have been removed.'); + done(); + }, + function (simpleAgain, error) { + ok(false, 'Error ' + error.code + ': ' + error.message); + done(); + } + ); + }, + function (simple, error) { + ok(false, 'Error ' + error.code + ': ' + error.message); + done(); + } + ); + }, + function (simple, error) { + ok(false, 'Error ' + error.code + ': ' + error.message); + done(); + } + ); + }); + + it('deleted keys get cleared', function (done) { + const simpleObject = new Parse.Object('SimpleObject'); + simpleObject.set('foo', 'bar'); + simpleObject.unset('foo'); + simpleObject.save().then(function (simpleObject) { + simpleObject.set('foo', 'baz'); + simpleObject.save().then(function (simpleObject) { + const query = new Parse.Query('SimpleObject'); + query.get(simpleObject.id).then(function (simpleObjectAgain) { + equal(simpleObjectAgain.get('foo'), 'baz'); + done(); + }, done.fail); + }, done.fail); + }, done.fail); + }); + + it('setting after deleting', function (done) { + const simpleObject = new Parse.Object('SimpleObject'); + simpleObject.set('foo', 'bar'); + simpleObject.save().then( + function (simpleObject) { + simpleObject.unset('foo'); + simpleObject.set('foo', 'baz'); + simpleObject.save().then( + function (simpleObject) { + const query = new Parse.Query('SimpleObject'); + query.get(simpleObject.id).then( + function (simpleObjectAgain) { + equal(simpleObjectAgain.get('foo'), 'baz'); done(); }, - error: function(simpleAgain, error) { - ok(false, "Error " + error.code + ": " + error.message); + function (error) { + ok(false, 'Error ' + error.code + ': ' + error.message); done(); } - }); + ); }, - error: function(simple, error) { - ok(false, "Error " + error.code + ": " + error.message); + function (error) { + ok(false, 'Error ' + error.code + ': ' + error.message); done(); } - }); + ); }, - error: function(simple, error) { - ok(false, "Error " + error.code + ": " + error.message); + function (error) { + ok(false, 'Error ' + error.code + ': ' + error.message); done(); } - }); - }); - - it("deleted keys get cleared", function(done) { - var simpleObject = new Parse.Object("SimpleObject"); - simpleObject.set("foo", "bar"); - simpleObject.unset("foo"); - simpleObject.save(null, { - success: function(simpleObject) { - simpleObject.set("foo", "baz"); - simpleObject.save(null, { - success: function(simpleObject) { - var query = new Parse.Query("SimpleObject"); - query.get(simpleObject.id, { - success: function(simpleObjectAgain) { - equal(simpleObjectAgain.get("foo"), "baz"); - done(); - }, - error: function(simpleObject, error) { - ok(false, "Error " + error.code + ": " + error.message); - done(); - } - }); - }, - error: function(simpleObject, error) { - ok(false, "Error " + error.code + ": " + error.message); + ); + }); + + it('increment', function (done) { + const simple = new Parse.Object('SimpleObject'); + simple + .save({ + foo: 5, + }) + .then(function (simple) { + simple.increment('foo'); + equal(simple.get('foo'), 6); + ok(simple.dirty('foo'), 'foo should be dirty.'); + ok(simple.dirty(), 'the whole object should be dirty.'); + simple.save().then(function (simple) { + equal(simple.get('foo'), 6); + ok(!simple.dirty('foo'), 'the whole object was just saved.'); + ok(!simple.dirty(), 'the whole object was just saved.'); + + const query = new Parse.Query('SimpleObject'); + query.get(simple.id).then(function (simpleAgain) { + equal(simpleAgain.get('foo'), 6); done(); - } + }); }); - }, - error: function(simpleObject, error) { - ok(false, "Error " + error.code + ": " + error.message); - done(); - } - }); + }); }); - it("setting after deleting", function(done) { - var simpleObject = new Parse.Object("SimpleObject"); - simpleObject.set("foo", "bar"); - simpleObject.save(null, { - success: function(simpleObject) { - simpleObject.unset("foo"); - simpleObject.set("foo", "baz"); - simpleObject.save(null, { - success: function(simpleObject) { - var query = new Parse.Query("SimpleObject"); - query.get(simpleObject.id, { - success: function(simpleObjectAgain) { - equal(simpleObjectAgain.get("foo"), "baz"); - done(); - }, - error: function(simpleObject, error) { - ok(false, "Error " + error.code + ": " + error.message); - done(); - } - }); - }, - error: function(simpleObject, error) { - ok(false, "Error " + error.code + ": " + error.message); - done(); + it('addUnique', function (done) { + const x1 = new Parse.Object('X'); + x1.set('stuff', [1, 2]); + x1.save() + .then(() => { + const objectId = x1.id; + const x2 = new Parse.Object('X', { objectId: objectId }); + x2.addUnique('stuff', 2); + x2.addUnique('stuff', 4); + expect(x2.get('stuff')).toEqual([2, 4]); + return x2.save(); + }) + .then(() => { + const query = new Parse.Query('X'); + return query.get(x1.id); + }) + .then( + x3 => { + const stuff = x3.get('stuff'); + const expected = [1, 2, 4]; + expect(stuff.length).toBe(expected.length); + for (const i of stuff) { + expect(expected.indexOf(i) >= 0).toBe(true); } - }); - }, - error: function(simpleObject, error) { - ok(false, "Error " + error.code + ": " + error.message); - done(); - } - }); - }); - - it("increment", function(done) { - var simple = new Parse.Object("SimpleObject"); - simple.save({ - foo: 5 - }, { - success: function(simple) { - simple.increment("foo"); - equal(simple.get("foo"), 6); - ok(simple.dirty("foo"), "foo should be dirty."); - ok(simple.dirty(), "the whole object should be dirty."); - simple.save(null, { - success: function(simple) { - equal(simple.get("foo"), 6); - ok(!simple.dirty("foo"), "the whole object was just saved."); - ok(!simple.dirty(), "the whole object was just saved."); - - var query = new Parse.Query("SimpleObject"); - query.get(simple.id, { - success: function(simpleAgain) { - equal(simpleAgain.get("foo"), 6); - done(); + done(); + }, + error => { + on_db('mongo', () => { + jfail(error); + }); + on_db('postgres', () => { + expect(error.message).toEqual('Postgres does not support AddUnique operator.'); + }); + done(); + } + ); + }); + + it_only_db('mongo')('can increment array nested fields', async () => { + const obj = new TestObject(); + obj.set('items', [ { value: 'a', count: 5 }, { value: 'b', count: 1 } ]); + await obj.save(); + obj.increment('items.0.count', 15); + obj.increment('items.1.count', 4); + await obj.save(); + expect(obj.toJSON().items[0].value).toBe('a'); + expect(obj.toJSON().items[1].value).toBe('b'); + expect(obj.toJSON().items[0].count).toBe(20); + expect(obj.toJSON().items[1].count).toBe(5); + const query = new Parse.Query(TestObject); + const result = await query.get(obj.id); + expect(result.get('items')[0].value).toBe('a'); + expect(result.get('items')[1].value).toBe('b'); + expect(result.get('items')[0].count).toBe(20); + expect(result.get('items')[1].count).toBe(5); + expect(result.get('items')).toEqual(obj.get('items')); + }); + + it_only_db('mongo')('can increment array nested fields missing index', async () => { + const obj = new TestObject(); + obj.set('items', []); + await obj.save(); + obj.increment('items.1.count', 15); + await obj.save(); + expect(obj.toJSON().items[0]).toBe(null); + expect(obj.toJSON().items[1].count).toBe(15); + const query = new Parse.Query(TestObject); + const result = await query.get(obj.id); + expect(result.get('items')[0]).toBe(null); + expect(result.get('items')[1].count).toBe(15); + expect(result.get('items')).toEqual(obj.get('items')); + }); + + it_id('44097c6f-d0ca-4dc5-aa8a-3dd2d9ac645a')(it)('can query array nested fields', async () => { + const objects = []; + for (let i = 0; i < 10; i++) { + const obj = new TestObject(); + obj.set('items', [i, { value: i }]); + objects.push(obj); + } + await Parse.Object.saveAll(objects); + let query = new Parse.Query(TestObject); + query.greaterThan('items.1.value', 5); + let result = await query.find(); + expect(result.length).toBe(4); + + query = new Parse.Query(TestObject); + query.lessThan('items.0', 3); + result = await query.find(); + expect(result.length).toBe(3); + + query = new Parse.Query(TestObject); + query.equalTo('items.0', 5); + result = await query.find(); + expect(result.length).toBe(1); + + query = new Parse.Query(TestObject); + query.notEqualTo('items.0', 5); + result = await query.find(); + expect(result.length).toBe(9); + }); + + it('addUnique with object', function (done) { + const x1 = new Parse.Object('X'); + x1.set('stuff', [1, { hello: 'world' }, { foo: 'bar' }]); + x1.save() + .then(() => { + const objectId = x1.id; + const x2 = new Parse.Object('X', { objectId: objectId }); + x2.addUnique('stuff', { hello: 'world' }); + x2.addUnique('stuff', { bar: 'baz' }); + expect(x2.get('stuff')).toEqual([{ hello: 'world' }, { bar: 'baz' }]); + return x2.save(); + }) + .then(() => { + const query = new Parse.Query('X'); + return query.get(x1.id); + }) + .then( + x3 => { + const stuff = x3.get('stuff'); + const target = [1, { hello: 'world' }, { foo: 'bar' }, { bar: 'baz' }]; + expect(stuff.length).toEqual(target.length); + let found = 0; + for (const thing in target) { + for (const st in stuff) { + if (st == thing) { + found++; } - }); + } } - }); - } - }); - }); - - it_exclude_dbs(['postgres'])("addUnique", function(done) { - var x1 = new Parse.Object('X'); - x1.set('stuff', [1, 2]); - x1.save().then(() => { - var objectId = x1.id; - var x2 = new Parse.Object('X', {objectId: objectId}); - x2.addUnique('stuff', 2); - x2.addUnique('stuff', 3); - expect(x2.get('stuff')).toEqual([2, 3]); - return x2.save(); - }).then(() => { - var query = new Parse.Query('X'); - return query.get(x1.id); - }).then((x3) => { - expect(x3.get('stuff')).toEqual([1, 2, 3]); - done(); - }, (error) => { - fail(error); - done(); - }); - }); - - it_exclude_dbs(['postgres'])("addUnique with object", function(done) { - var x1 = new Parse.Object('X'); - x1.set('stuff', [ 1, {'hello': 'world'}, {'foo': 'bar'}]); - x1.save().then(() => { - var objectId = x1.id; - var x2 = new Parse.Object('X', {objectId: objectId}); - x2.addUnique('stuff', {'hello': 'world'}); - x2.addUnique('stuff', {'bar': 'baz'}); - expect(x2.get('stuff')).toEqual([{'hello': 'world'}, {'bar': 'baz'}]); - return x2.save(); - }).then(() => { - var query = new Parse.Query('X'); - return query.get(x1.id); - }).then((x3) => { - expect(x3.get('stuff')).toEqual([1, {'hello': 'world'}, {'foo': 'bar'}, {'bar': 'baz'}]); - done(); - }, (error) => { - fail(error); - done(); - }); - }); - - it_exclude_dbs(['postgres'])("removes with object", function(done) { - var x1 = new Parse.Object('X'); - x1.set('stuff', [ 1, {'hello': 'world'}, {'foo': 'bar'}]); - x1.save().then(() => { - var objectId = x1.id; - var x2 = new Parse.Object('X', {objectId: objectId}); - x2.remove('stuff', {'hello': 'world'}); - expect(x2.get('stuff')).toEqual([]); - return x2.save(); - }).then(() => { - var query = new Parse.Query('X'); - return query.get(x1.id); - }).then((x3) => { - expect(x3.get('stuff')).toEqual([1, {'foo': 'bar'}]); - done(); - }, (error) => { - fail(error); - done(); - }); + expect(found).toBe(target.length); + done(); + }, + error => { + jfail(error); + done(); + } + ); + }); + + it('removes with object', function (done) { + const x1 = new Parse.Object('X'); + x1.set('stuff', [1, { hello: 'world' }, { foo: 'bar' }]); + x1.save() + .then(() => { + const objectId = x1.id; + const x2 = new Parse.Object('X', { objectId: objectId }); + x2.remove('stuff', { hello: 'world' }); + expect(x2.get('stuff')).toEqual([]); + return x2.save(); + }) + .then(() => { + const query = new Parse.Query('X'); + return query.get(x1.id); + }) + .then( + x3 => { + expect(x3.get('stuff')).toEqual([1, { foo: 'bar' }]); + done(); + }, + error => { + jfail(error); + done(); + } + ); }); - it("dirty attributes", function(done) { - var object = new Parse.Object("TestObject"); - object.set("cat", "good"); - object.set("dog", "bad"); - object.save({ - success: function(object) { + it('dirty attributes', function (done) { + const object = new Parse.Object('TestObject'); + object.set('cat', 'good'); + object.set('dog', 'bad'); + object.save().then( + function (object) { ok(!object.dirty()); - ok(!object.dirty("cat")); - ok(!object.dirty("dog")); + ok(!object.dirty('cat')); + ok(!object.dirty('dog')); - object.set("dog", "okay"); + object.set('dog', 'okay'); ok(object.dirty()); - ok(!object.dirty("cat")); - ok(object.dirty("dog")); + ok(!object.dirty('cat')); + ok(object.dirty('dog')); done(); }, - error: function(object, error) { - ok(false, "This should have saved."); + function () { + ok(false, 'This should have saved.'); done(); } - }); + ); }); - it_exclude_dbs(['postgres'])("dirty keys", function(done) { - var object = new Parse.Object("TestObject"); - object.set("gogo", "good"); - object.set("sito", "sexy"); + it('dirty keys', function (done) { + const object = new Parse.Object('TestObject'); + object.set('gogo', 'good'); + object.set('sito', 'sexy'); ok(object.dirty()); - var dirtyKeys = object.dirtyKeys(); + let dirtyKeys = object.dirtyKeys(); equal(dirtyKeys.length, 2); - ok(arrayContains(dirtyKeys, "gogo")); - ok(arrayContains(dirtyKeys, "sito")); - - object.save().then(function(obj) { - ok(!obj.dirty()); - dirtyKeys = obj.dirtyKeys(); - equal(dirtyKeys.length, 0); - ok(!arrayContains(dirtyKeys, "gogo")); - ok(!arrayContains(dirtyKeys, "sito")); - - // try removing keys - obj.unset("sito"); - ok(obj.dirty()); - dirtyKeys = obj.dirtyKeys(); - equal(dirtyKeys.length, 1); - ok(!arrayContains(dirtyKeys, "gogo")); - ok(arrayContains(dirtyKeys, "sito")); - - return obj.save(); - }).then(function(obj) { - ok(!obj.dirty()); - equal(obj.get("gogo"), "good"); - equal(obj.get("sito"), undefined); - dirtyKeys = obj.dirtyKeys(); - equal(dirtyKeys.length, 0); - ok(!arrayContains(dirtyKeys, "gogo")); - ok(!arrayContains(dirtyKeys, "sito")); + ok(arrayContains(dirtyKeys, 'gogo')); + ok(arrayContains(dirtyKeys, 'sito')); + + object + .save() + .then(function (obj) { + ok(!obj.dirty()); + dirtyKeys = obj.dirtyKeys(); + equal(dirtyKeys.length, 0); + ok(!arrayContains(dirtyKeys, 'gogo')); + ok(!arrayContains(dirtyKeys, 'sito')); + + // try removing keys + obj.unset('sito'); + ok(obj.dirty()); + dirtyKeys = obj.dirtyKeys(); + equal(dirtyKeys.length, 1); + ok(!arrayContains(dirtyKeys, 'gogo')); + ok(arrayContains(dirtyKeys, 'sito')); + + return obj.save(); + }) + .then(function (obj) { + ok(!obj.dirty()); + equal(obj.get('gogo'), 'good'); + equal(obj.get('sito'), undefined); + dirtyKeys = obj.dirtyKeys(); + equal(dirtyKeys.length, 0); + ok(!arrayContains(dirtyKeys, 'gogo')); + ok(!arrayContains(dirtyKeys, 'sito')); - done(); - }); + done(); + }); }); - it("length attribute", function(done) { - Parse.User.signUp("bob", "password", null, { - success: function(user) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject({ - length: 5, - ACL: new Parse.ACL(user) // ACLs cause things like validation to run - }); - equal(obj.get("length"), 5); - ok(obj.get("ACL") instanceof Parse.ACL); - - obj.save(null, { - success: function(obj) { - equal(obj.get("length"), 5); - ok(obj.get("ACL") instanceof Parse.ACL); - - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(obj) { - equal(obj.get("length"), 5); - ok(obj.get("ACL") instanceof Parse.ACL); - - var query = new Parse.Query(TestObject); - query.find({ - success: function(results) { - obj = results[0]; - equal(obj.get("length"), 5); - ok(obj.get("ACL") instanceof Parse.ACL); - - done(); - }, - error: function(error) { - ok(false, error.code + ": " + error.message); - done(); - } - }); - }, - error: function(obj, error) { - ok(false, error.code + ": " + error.message); - done(); - } - }); - }, - error: function(obj, error) { - ok(false, error.code + ": " + error.message); + it('acl attribute', function (done) { + Parse.User.signUp('bob', 'password').then(function (user) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject({ + ACL: new Parse.ACL(user), // ACLs cause things like validation to run + }); + ok(obj.get('ACL') instanceof Parse.ACL); + + obj.save().then(function (obj) { + ok(obj.get('ACL') instanceof Parse.ACL); + + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function (obj) { + ok(obj.get('ACL') instanceof Parse.ACL); + + const query = new Parse.Query(TestObject); + query.find().then(function (results) { + obj = results[0]; + ok(obj.get('ACL') instanceof Parse.ACL); + done(); - } + }); }); - }, - error: function(user, error) { - ok(false, error.code + ": " + error.message); - done(); - } + }); }); }); - it_exclude_dbs(['postgres'])("old attribute unset then unset", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.set("x", 3); - obj.save({ - success: function() { - obj.unset("x"); - obj.unset("x"); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } - }); + it('cannot save object with invalid field', async () => { + const invalidFields = ['className', 'length']; + const promises = invalidFields.map(async field => { + const obj = new TestObject(); + obj.set(field, 'bar'); + try { + await obj.save(); + fail('should not succeed'); + } catch (e) { + expect(e.message).toBe(`Invalid field name: ${field}.`); } }); + await Promise.all(promises); + }); + + it('old attribute unset then unset', function (done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj.set('x', 3); + obj.save().then(function () { + obj.unset('x'); + obj.unset('x'); + obj.save().then(function () { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function (objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); + }); + }); + }); }); - it("new attribute unset then unset", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.set("x", 5); - obj.unset("x"); - obj.unset("x"); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } + it('new attribute unset then unset', function (done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj.set('x', 5); + obj.unset('x'); + obj.unset('x'); + obj.save().then(function () { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function (objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); + }); }); }); - it("unknown attribute unset then unset", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.unset("x"); - obj.unset("x"); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } + it('unknown attribute unset then unset', function (done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj.unset('x'); + obj.unset('x'); + obj.save().then(function () { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function (objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); + }); }); }); - it_exclude_dbs(['postgres'])("old attribute unset then clear", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.set("x", 3); - obj.save({ - success: function() { - obj.unset("x"); - obj.clear(); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } + it('old attribute unset then clear', function (done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj.set('x', 3); + obj.save().then(function () { + obj.unset('x'); + obj.clear(); + obj.save().then(function () { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function (objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); }); - } + }); }); }); - it("new attribute unset then clear", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.set("x", 5); - obj.unset("x"); + it('new attribute unset then clear', function (done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj.set('x', 5); + obj.unset('x'); obj.clear(); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } + obj.save().then(function () { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function (objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); + }); }); }); - it("unknown attribute unset then clear", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.unset("x"); + it('unknown attribute unset then clear', function (done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj.unset('x'); obj.clear(); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } + obj.save().then(function () { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function (objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); + }); }); }); - it_exclude_dbs(['postgres'])("old attribute clear then unset", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.set("x", 3); - obj.save({ - success: function() { - obj.clear(); - obj.unset("x"); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } + it('old attribute clear then unset', function (done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj.set('x', 3); + obj.save().then(function () { + obj.clear(); + obj.unset('x'); + obj.save().then(function () { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function (objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); }); - } + }); }); }); - it("new attribute clear then unset", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.set("x", 5); + it('new attribute clear then unset', function (done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj.set('x', 5); obj.clear(); - obj.unset("x"); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } + obj.unset('x'); + obj.save().then(function () { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function (objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); + }); }); }); - it("unknown attribute clear then unset", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); + it('unknown attribute clear then unset', function (done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); obj.clear(); - obj.unset("x"); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } + obj.unset('x'); + obj.save().then(function () { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function (objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); + }); }); }); - it_exclude_dbs(['postgres'])("old attribute clear then clear", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.set("x", 3); - obj.save({ - success: function() { - obj.clear(); - obj.clear(); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } + it('old attribute clear then clear', function (done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj.set('x', 3); + obj.save().then(function () { + obj.clear(); + obj.clear(); + obj.save().then(function () { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function (objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); }); - } + }); }); }); - it("new attribute clear then clear", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.set("x", 5); + it('new attribute clear then clear', function (done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj.set('x', 5); obj.clear(); obj.clear(); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } + obj.save().then(function () { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function (objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); + }); }); }); - it("unknown attribute clear then clear", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); + it('unknown attribute clear then clear', function (done) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); obj.clear(); obj.clear(); - obj.save({ - success: function() { - equal(obj.has("x"), false); - equal(obj.get("x"), undefined); - var query = new Parse.Query(TestObject); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.has("x"), false); - equal(objAgain.get("x"), undefined); - done(); - } - }); - } + obj.save().then(function () { + equal(obj.has('x'), false); + equal(obj.get('x'), undefined); + const query = new Parse.Query(TestObject); + query.get(obj.id).then(function (objAgain) { + equal(objAgain.has('x'), false); + equal(objAgain.get('x'), undefined); + done(); + }); }); }); - it_exclude_dbs(['postgres'])("saving children in an array", function(done) { - var Parent = Parse.Object.extend("Parent"); - var Child = Parse.Object.extend("Child"); + it('saving children in an array', function (done) { + const Parent = Parse.Object.extend('Parent'); + const Child = Parse.Object.extend('Child'); - var child1 = new Child(); - var child2 = new Child(); - var parent = new Parent(); + const child1 = new Child(); + const child2 = new Child(); + const parent = new Parent(); child1.set('name', 'jamie'); child2.set('name', 'cersei'); parent.set('children', [child1, child2]); - parent.save(null, { - success: function(parent) { - var query = new Parse.Query(Child); - query.ascending('name'); - query.find({ - success: function(results) { - equal(results.length, 2); - equal(results[0].get('name'), 'cersei'); - equal(results[1].get('name'), 'jamie'); - done(); - } - }); - }, - error: function(error) { - fail(error); + parent.save().then(function () { + const query = new Parse.Query(Child); + query.ascending('name'); + query.find().then(function (results) { + equal(results.length, 2); + equal(results[0].get('name'), 'cersei'); + equal(results[1].get('name'), 'jamie'); done(); - } - }); + }); + }, done.fail); }); - it("two saves at the same time", function(done) { + it('two saves at the same time', function (done) { + const object = new Parse.Object('TestObject'); + let firstSave = true; - var object = new Parse.Object("TestObject"); - var firstSave = true; - - var success = function() { + const success = function () { if (firstSave) { firstSave = false; return; } - var query = new Parse.Query("TestObject"); - query.find({ - success: function(results) { - equal(results.length, 1); - equal(results[0].get("cat"), "meow"); - equal(results[0].get("dog"), "bark"); - done(); - } + const query = new Parse.Query('TestObject'); + query.find().then(function (results) { + equal(results.length, 1); + equal(results[0].get('cat'), 'meow'); + equal(results[0].get('dog'), 'bark'); + done(); }); }; - var options = { success: success, error: fail }; - - object.save({ cat: "meow" }, options); - object.save({ dog: "bark" }, options); + object.save({ cat: 'meow' }).then(success, fail); + object.save({ dog: 'bark' }).then(success, fail); }); // The schema-checking parts of this are working. @@ -1104,77 +1079,80 @@ describe('Parse.Object testing', () => { // typed field and saved okay, since that appears to be borked in // the client. // If this fails, it's probably a schema issue. - it('many saves after a failure', function(done) { + it('many saves after a failure', function (done) { // Make a class with a number in the schema. - var o1 = new Parse.Object('TestObject'); + const o1 = new Parse.Object('TestObject'); o1.set('number', 1); - var object = null; - o1.save().then(() => { - object = new Parse.Object('TestObject'); - object.set('number', 'two'); - return object.save(); - }).then(fail, (error) => { - expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); - - object.set('other', 'foo'); - return object.save(); - }).then(fail, (error) => { - expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); - - object.set('other', 'bar'); - return object.save(); - }).then(fail, (error) => { - expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); + let object = null; + o1.save() + .then(() => { + object = new Parse.Object('TestObject'); + object.set('number', 'two'); + return object.save(); + }) + .then(fail, error => { + expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); + + object.set('other', 'foo'); + return object.save(); + }) + .then(fail, error => { + expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); + + object.set('other', 'bar'); + return object.save(); + }) + .then(fail, error => { + expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); - done(); - }); + done(); + }); }); - it("is not dirty after save", function(done) { - var obj = new Parse.Object("TestObject"); - obj.save(expectSuccess({ - success: function() { - obj.set({ "content": "x" }); - obj.fetch(expectSuccess({ - success: function(){ - equal(false, obj.dirty("content")); - done(); - } - })); - } - })); + it('is not dirty after save', function (done) { + const obj = new Parse.Object('TestObject'); + obj.save().then(function () { + obj.set({ content: 'x' }); + obj.fetch().then(function () { + equal(false, obj.dirty('content')); + done(); + }); + }); }); - it("add with an object", function(done) { - var child = new Parse.Object("Person"); - var parent = new Parse.Object("Person"); - - Parse.Promise.as().then(function() { - return child.save(); - - }).then(function() { - parent.add("children", child); - return parent.save(); - - }).then(function() { - var query = new Parse.Query("Person"); - return query.get(parent.id); - - }).then(function(parentAgain) { - equal(parentAgain.get("children")[0].id, child.id); - - }).then(function() { - done(); - }, function(error) { - ok(false, error); - done(); - }); + it('add with an object', function (done) { + const child = new Parse.Object('Person'); + const parent = new Parse.Object('Person'); + + Promise.resolve() + .then(function () { + return child.save(); + }) + .then(function () { + parent.add('children', child); + return parent.save(); + }) + .then(function () { + const query = new Parse.Query('Person'); + return query.get(parent.id); + }) + .then(function (parentAgain) { + equal(parentAgain.get('children')[0].id, child.id); + }) + .then( + function () { + done(); + }, + function (error) { + ok(false, error); + done(); + } + ); }); - it("toJSON saved object", function(done) { - var _ = Parse._; - create({ "foo" : "bar" }, function(model, response) { - var objJSON = model.toJSON(); + it('toJSON saved object', function (done) { + create({ foo: 'bar' }, function (model) { + const objJSON = model.toJSON(); ok(objJSON.foo, "expected json to contain key 'foo'"); ok(objJSON.objectId, "expected json to contain key 'objectId'"); ok(objJSON.createdAt, "expected json to contain key 'createdAt'"); @@ -1183,707 +1161,1015 @@ describe('Parse.Object testing', () => { }); }); - it("remove object from array", function(done) { - var obj = new TestObject(); - obj.save(null, expectSuccess({ - success: function() { - var container = new TestObject(); - container.add("array", obj); - equal(container.get("array").length, 1); - container.save(null, expectSuccess({ - success: function() { - var objAgain = new TestObject(); - objAgain.id = obj.id; - container.remove("array", objAgain); - equal(container.get("array").length, 0); - done(); - } - })); - } - })); + it('remove object from array', function (done) { + const obj = new TestObject(); + obj.save().then(function () { + const container = new TestObject(); + container.add('array', obj); + equal(container.get('array').length, 1); + container.save(null).then(function () { + const objAgain = new TestObject(); + objAgain.id = obj.id; + container.remove('array', objAgain); + equal(container.get('array').length, 0); + done(); + }); + }); }); - it("async methods", function(done) { - var obj = new TestObject(); - obj.set("time", "adventure"); - - obj.save().then(function(obj) { - ok(obj.id, "objectId should not be null."); - var objAgain = new TestObject(); - objAgain.id = obj.id; - return objAgain.fetch(); - - }).then(function(objAgain) { - equal(objAgain.get("time"), "adventure"); - return objAgain.destroy(); - - }).then(function() { - var query = new Parse.Query(TestObject); - return query.find(); - - }).then(function(results) { - equal(results.length, 0); - - }).then(function() { - done(); - - }); + it('async methods', function (done) { + const obj = new TestObject(); + obj.set('time', 'adventure'); + + obj + .save() + .then(function (obj) { + ok(obj.id, 'objectId should not be null.'); + const objAgain = new TestObject(); + objAgain.id = obj.id; + return objAgain.fetch(); + }) + .then(function (objAgain) { + equal(objAgain.get('time'), 'adventure'); + return objAgain.destroy(); + }) + .then(function () { + const query = new Parse.Query(TestObject); + return query.find(); + }) + .then(function (results) { + equal(results.length, 0); + }) + .then(function () { + done(); + }); }); - it("fail validation with promise", function(done) { - var PickyEater = Parse.Object.extend("PickyEater", { - validate: function(attrs) { - if (attrs.meal === "tomatoes") { - return "Ew. Tomatoes are gross."; + it('fail validation with promise', function (done) { + const PickyEater = Parse.Object.extend('PickyEater', { + validate: function (attrs) { + if (attrs.meal === 'tomatoes') { + return 'Ew. Tomatoes are gross.'; } return Parse.Object.prototype.validate.apply(this, arguments); - } + }, }); - var bryan = new PickyEater(); - bryan.save({ - meal: "burrito" - }).then(function() { - return bryan.save({ - meal: "tomatoes" - }); - }, function(error) { - ok(false, "Save should have succeeded."); - }).then(function() { - ok(false, "Save should have failed."); - }, function(error) { - equal(error, "Ew. Tomatoes are gross."); - done(); - }); + const bryan = new PickyEater(); + bryan + .save({ + meal: 'burrito', + }) + .then( + function () { + return bryan.save({ + meal: 'tomatoes', + }); + }, + function () { + ok(false, 'Save should have succeeded.'); + } + ) + .then( + function () { + ok(false, 'Save should have failed.'); + }, + function (error) { + equal(error, 'Ew. Tomatoes are gross.'); + done(); + } + ); }); - it("beforeSave doesn't make object dirty with new field", function(done) { - var restController = Parse.CoreManager.getRESTController(); - var r = restController.request; - restController.request = function() { - return r.apply(this, arguments).then(function(result) { - result.aDate = {"__type":"Date", "iso":"2014-06-24T06:06:06.452Z"}; + it("beforeSave doesn't make object dirty with new field", function (done) { + const restController = Parse.CoreManager.getRESTController(); + const r = restController.request; + restController.request = function () { + return r.apply(this, arguments).then(function (result) { + result.aDate = { __type: 'Date', iso: '2014-06-24T06:06:06.452Z' }; return result; }); }; - var obj = new Parse.Object("Thing"); - obj.save().then(function() { - ok(!obj.dirty(), "The object should not be dirty"); - ok(obj.get('aDate')); - - }).always(function() { - restController.request = r; - done(); - }); + const obj = new Parse.Object('Thing'); + obj + .save() + .then(function () { + ok(!obj.dirty(), 'The object should not be dirty'); + ok(obj.get('aDate')); + }) + .then(function () { + restController.request = r; + done(); + }); }); - it("beforeSave doesn't make object dirty with existing field", function(done) { - var restController = Parse.CoreManager.getRESTController(); - var r = restController.request; - restController.request = function() { - return r.apply(this, arguments).then(function(result) { - result.aDate = {"__type":"Date", "iso":"2014-06-24T06:06:06.452Z"}; + xit("beforeSave doesn't make object dirty with existing field", function (done) { + const restController = Parse.CoreManager.getRESTController(); + const r = restController.request; + restController.request = function () { + return r.apply(restController, arguments).then(function (result) { + result.aDate = { __type: 'Date', iso: '2014-06-24T06:06:06.452Z' }; return result; }); }; - var now = new Date(); + const now = new Date(); - var obj = new Parse.Object("Thing"); - var promise = obj.save(); + const obj = new Parse.Object('Thing'); + const promise = obj.save(); obj.set('aDate', now); - promise.then(function() { - ok(obj.dirty(), "The object should be dirty"); - equal(now, obj.get('aDate')); - - }).always(function() { - restController.request = r; - done(); - }); + promise + .then(function () { + ok(obj.dirty(), 'The object should be dirty'); + equal(now, obj.get('aDate')); + }) + .then(function () { + restController.request = r; + done(); + }); }); - it_exclude_dbs(['postgres'])("bytes work", function(done) { - Parse.Promise.as().then(function() { - var obj = new TestObject(); - obj.set("bytes", { __type: "Bytes", base64: "ZnJveW8=" }); - return obj.save(); - - }).then(function(obj) { - var query = new Parse.Query(TestObject); - return query.get(obj.id); - - }).then(function(obj) { - equal(obj.get("bytes").__type, "Bytes"); - equal(obj.get("bytes").base64, "ZnJveW8="); - done(); - - }, function(error) { - ok(false, JSON.stringify(error)); - done(); - - }); + it('bytes work', function (done) { + Promise.resolve() + .then(function () { + const obj = new TestObject(); + obj.set('bytes', { __type: 'Bytes', base64: 'ZnJveW8=' }); + return obj.save(); + }) + .then(function (obj) { + const query = new Parse.Query(TestObject); + return query.get(obj.id); + }) + .then( + function (obj) { + equal(obj.get('bytes').__type, 'Bytes'); + equal(obj.get('bytes').base64, 'ZnJveW8='); + done(); + }, + function (error) { + ok(false, JSON.stringify(error)); + done(); + } + ); }); - it("destroyAll no objects", function(done) { - Parse.Object.destroyAll([], function(success, error) { - ok(success && !error, "Should be able to destroy no objects"); - done(); - }); + it('destroyAll no objects', function (done) { + Parse.Object.destroyAll([]) + .then(function (success) { + ok(success, 'Should be able to destroy no objects'); + done(); + }) + .catch(done.fail); }); - it("destroyAll new objects only", function(done) { - - var objects = [new TestObject(), new TestObject()]; - Parse.Object.destroyAll(objects, function(success, error) { - ok(success && !error, "Should be able to destroy only new objects"); - done(); - }); + it('destroyAll new objects only', function (done) { + const objects = [new TestObject(), new TestObject()]; + Parse.Object.destroyAll(objects) + .then(function (success) { + ok(success, 'Should be able to destroy only new objects'); + done(); + }) + .catch(done.fail); }); - it_exclude_dbs(['postgres'])("fetchAll", function(done) { - var numItems = 11; - var container = new Container(); - var items = []; - for (var i = 0; i < numItems; i++) { - var item = new Item(); - item.set("x", i); + it('fetchAll', function (done) { + const numItems = 11; + const container = new Container(); + const items = []; + for (let i = 0; i < numItems; i++) { + const item = new Item(); + item.set('x', i); items.push(item); } - Parse.Object.saveAll(items).then(function() { - container.set("items", items); - return container.save(); - }).then(function() { - var query = new Parse.Query(Container); - return query.get(container.id); - }).then(function(containerAgain) { - var itemsAgain = containerAgain.get("items"); - if (!itemsAgain || !itemsAgain.forEach) { - fail('no itemsAgain retrieved', itemsAgain); + Parse.Object.saveAll(items) + .then(function () { + container.set('items', items); + return container.save(); + }) + .then(function () { + const query = new Parse.Query(Container); + return query.get(container.id); + }) + .then(function (containerAgain) { + const itemsAgain = containerAgain.get('items'); + if (!itemsAgain || !itemsAgain.forEach) { + fail('no itemsAgain retrieved', itemsAgain); + done(); + return; + } + equal(itemsAgain.length, numItems, 'Should get the array back'); + itemsAgain.forEach(function (item, i) { + const newValue = i * 2; + item.set('x', newValue); + }); + return Parse.Object.saveAll(itemsAgain); + }) + .then(function () { + return Parse.Object.fetchAll(items); + }) + .then(function (fetchedItemsAgain) { + equal(fetchedItemsAgain.length, numItems, 'Number of items fetched should not change'); + fetchedItemsAgain.forEach(function (item, i) { + equal(item.get('x'), i * 2); + }); done(); - return; - } - equal(itemsAgain.length, numItems, "Should get the array back"); - itemsAgain.forEach(function(item, i) { - var newValue = i*2; - item.set("x", newValue); - }); - return Parse.Object.saveAll(itemsAgain); - }).then(function() { - return Parse.Object.fetchAll(items); - }).then(function(fetchedItemsAgain) { - equal(fetchedItemsAgain.length, numItems, - "Number of items fetched should not change"); - fetchedItemsAgain.forEach(function(item, i) { - equal(item.get("x"), i*2); }); - done(); - }); - }); - - it("fetchAll no objects", function(done) { - Parse.Object.fetchAll([], function(success, error) { - ok(success && !error, "Should be able to fetchAll no objects"); - done(); - }); }); - it_exclude_dbs(['postgres'])("fetchAll updates dates", function(done) { - var updatedObject; - var object = new TestObject(); - object.set("x", 7); - object.save().then(function() { - var query = new Parse.Query(TestObject); - return query.find(object.id); - }).then(function(results) { - updatedObject = results[0]; - updatedObject.set("x", 11); - return updatedObject.save(); - }).then(function() { - return Parse.Object.fetchAll([object]); - }).then(function() { - equal(object.createdAt.getTime(), updatedObject.createdAt.getTime()); - equal(object.updatedAt.getTime(), updatedObject.updatedAt.getTime()); - done(); - }); + it('fetchAll no objects', function (done) { + Parse.Object.fetchAll([]) + .then(function (success) { + ok(Array.isArray(success), 'Should be able to fetchAll no objects'); + done(); + }) + .catch(done.fail); + }); + + it('fetchAll updates dates', function (done) { + let updatedObject; + const object = new TestObject(); + object.set('x', 7); + object + .save() + .then(function () { + const query = new Parse.Query(TestObject); + return query.get(object.id); + }) + .then(function (result) { + updatedObject = result; + updatedObject.set('x', 11); + return updatedObject.save(); + }) + .then(function () { + return Parse.Object.fetchAll([object]); + }) + .then(function () { + equal(object.createdAt.getTime(), updatedObject.createdAt.getTime()); + equal(object.updatedAt.getTime(), updatedObject.updatedAt.getTime()); + done(); + }) + .catch(done.fail); }); - it_exclude_dbs(['postgres'])("fetchAll backbone-style callbacks", function(done) { - var numItems = 11; - var container = new Container(); - var items = []; - for (var i = 0; i < numItems; i++) { - var item = new Item(); - item.set("x", i); + xit('fetchAll backbone-style callbacks', function (done) { + const numItems = 11; + const container = new Container(); + const items = []; + for (let i = 0; i < numItems; i++) { + const item = new Item(); + item.set('x', i); items.push(item); } - Parse.Object.saveAll(items).then(function() { - container.set("items", items); - return container.save(); - }).then(function() { - var query = new Parse.Query(Container); - return query.get(container.id); - }).then(function(containerAgain) { - var itemsAgain = containerAgain.get("items"); - if (!itemsAgain || !itemsAgain.forEach) { - fail('no itemsAgain retrieved', itemsAgain); - done(); - return; - } - equal(itemsAgain.length, numItems, "Should get the array back"); - itemsAgain.forEach(function(item, i) { - var newValue = i*2; - item.set("x", newValue); - }); - return Parse.Object.saveAll(itemsAgain); - }).then(function() { - return Parse.Object.fetchAll(items, { - success: function(fetchedItemsAgain) { - equal(fetchedItemsAgain.length, numItems, - "Number of items fetched should not change"); - fetchedItemsAgain.forEach(function(item, i) { - equal(item.get("x"), i*2); - }); - done(); - }, - error: function(error) { - ok(false, "Failed to fetchAll"); + Parse.Object.saveAll(items) + .then(function () { + container.set('items', items); + return container.save(); + }) + .then(function () { + const query = new Parse.Query(Container); + return query.get(container.id); + }) + .then(function (containerAgain) { + const itemsAgain = containerAgain.get('items'); + if (!itemsAgain || !itemsAgain.forEach) { + fail('no itemsAgain retrieved', itemsAgain); done(); + return; } + equal(itemsAgain.length, numItems, 'Should get the array back'); + itemsAgain.forEach(function (item, i) { + const newValue = i * 2; + item.set('x', newValue); + }); + return Parse.Object.saveAll(itemsAgain); + }) + .then(function () { + return Parse.Object.fetchAll(items).then( + function (fetchedItemsAgain) { + equal(fetchedItemsAgain.length, numItems, 'Number of items fetched should not change'); + fetchedItemsAgain.forEach(function (item, i) { + equal(item.get('x'), i * 2); + }); + done(); + }, + function () { + ok(false, 'Failed to fetchAll'); + done(); + } + ); }); - }); }); - it("fetchAll error on multiple classes", function(done) { - var container = new Container(); - container.set("item", new Item()); - container.set("subcontainer", new Container()); - return container.save().then(function() { - var query = new Parse.Query(Container); - return query.get(container.id); - }).then(function(containerAgain) { - var subContainerAgain = containerAgain.get("subcontainer"); - var itemAgain = containerAgain.get("item"); - var multiClassArray = [subContainerAgain, itemAgain]; - return Parse.Object.fetchAll( - multiClassArray, - expectError(Parse.Error.INVALID_CLASS_NAME, done)); - }); + it('fetchAll error on multiple classes', function (done) { + const container = new Container(); + container.set('item', new Item()); + container.set('subcontainer', new Container()); + return container + .save() + .then(function () { + const query = new Parse.Query(Container); + return query.get(container.id); + }) + .then(function (containerAgain) { + const subContainerAgain = containerAgain.get('subcontainer'); + const itemAgain = containerAgain.get('item'); + const multiClassArray = [subContainerAgain, itemAgain]; + return Parse.Object.fetchAll(multiClassArray).catch(e => { + expect(e.code).toBe(Parse.Error.INVALID_CLASS_NAME); + done(); + }); + }); }); - it("fetchAll error on unsaved object", function(done) { - var unsavedObjectArray = [new TestObject()]; - Parse.Object.fetchAll(unsavedObjectArray, - expectError(Parse.Error.MISSING_OBJECT_ID, done)); + it('fetchAll error on unsaved object', async function (done) { + const unsavedObjectArray = [new TestObject()]; + await Parse.Object.fetchAll(unsavedObjectArray).catch(e => { + expect(e.code).toBe(Parse.Error.MISSING_OBJECT_ID); + done(); + }); }); - it_exclude_dbs(['postgres'])("fetchAll error on deleted object", function(done) { - var numItems = 11; - var container = new Container(); - var subContainer = new Container(); - var items = []; - for (var i = 0; i < numItems; i++) { - var item = new Item(); - item.set("x", i); + it('fetchAll error on deleted object', function (done) { + const numItems = 11; + const items = []; + for (let i = 0; i < numItems; i++) { + const item = new Item(); + item.set('x', i); items.push(item); } - Parse.Object.saveAll(items).then(function() { - var query = new Parse.Query(Item); - return query.get(items[0].id); - }).then(function(objectToDelete) { - return objectToDelete.destroy(); - }).then(function(deletedObject) { - var nonExistentObject = new Item({ objectId: deletedObject.id }); - var nonExistentObjectArray = [nonExistentObject, items[1]]; - return Parse.Object.fetchAll( - nonExistentObjectArray, - expectError(Parse.Error.OBJECT_NOT_FOUND, done)); - }); + Parse.Object.saveAll(items) + .then(function () { + const query = new Parse.Query(Item); + return query.get(items[0].id); + }) + .then(function (objectToDelete) { + return objectToDelete.destroy(); + }) + .then(function (deletedObject) { + const nonExistentObject = new Item({ objectId: deletedObject.id }); + const nonExistentObjectArray = [nonExistentObject, items[1]]; + return Parse.Object.fetchAll(nonExistentObjectArray).catch(e => { + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + done(); + }); + }); }); // TODO: Verify that with Sessions, this test is wrong... A fetch on // user should not bring down a session token. - xit("fetchAll User attributes get merged", function(done) { - var sameUser; - var user = new Parse.User(); - user.set("username", "asdf"); - user.set("password", "zxcv"); - user.set("foo", "bar"); - user.signUp().then(function() { - Parse.User.logOut(); - var query = new Parse.Query(Parse.User); - return query.get(user.id); - }).then(function(userAgain) { - user = userAgain; - sameUser = new Parse.User(); - sameUser.set("username", "asdf"); - sameUser.set("password", "zxcv"); - return sameUser.logIn(); - }).then(function() { - ok(!user.getSessionToken(), "user should not have a sessionToken"); - ok(sameUser.getSessionToken(), "sameUser should have a sessionToken"); - sameUser.set("baz", "qux"); - return sameUser.save(); - }).then(function() { - return Parse.Object.fetchAll([user]); - }).then(function() { - equal(user.getSessionToken(), sameUser.getSessionToken()); - equal(user.createdAt.getTime(), sameUser.createdAt.getTime()); - equal(user.updatedAt.getTime(), sameUser.updatedAt.getTime()); - Parse.User.logOut(); - done(); - }); + xit('fetchAll User attributes get merged', function (done) { + let sameUser; + let user = new Parse.User(); + user.set('username', 'asdf'); + user.set('password', 'zxcv'); + user.set('foo', 'bar'); + user + .signUp() + .then(function () { + Parse.User.logOut(); + const query = new Parse.Query(Parse.User); + return query.get(user.id); + }) + .then(function (userAgain) { + user = userAgain; + sameUser = new Parse.User(); + sameUser.set('username', 'asdf'); + sameUser.set('password', 'zxcv'); + return sameUser.logIn(); + }) + .then(function () { + ok(!user.getSessionToken(), 'user should not have a sessionToken'); + ok(sameUser.getSessionToken(), 'sameUser should have a sessionToken'); + sameUser.set('baz', 'qux'); + return sameUser.save(); + }) + .then(function () { + return Parse.Object.fetchAll([user]); + }) + .then(function () { + equal(user.getSessionToken(), sameUser.getSessionToken()); + equal(user.createdAt.getTime(), sameUser.createdAt.getTime()); + equal(user.updatedAt.getTime(), sameUser.updatedAt.getTime()); + Parse.User.logOut(); + done(); + }); }); - it_exclude_dbs(['postgres'])("fetchAllIfNeeded", function(done) { - var numItems = 11; - var container = new Container(); - var items = []; - for (var i = 0; i < numItems; i++) { - var item = new Item(); - item.set("x", i); + it('fetchAllIfNeeded', function (done) { + const numItems = 11; + const container = new Container(); + const items = []; + for (let i = 0; i < numItems; i++) { + const item = new Item(); + item.set('x', i); items.push(item); } - Parse.Object.saveAll(items).then(function() { - container.set("items", items); - return container.save(); - }).then(function() { - var query = new Parse.Query(Container); - return query.get(container.id); - }).then(function(containerAgain) { - var itemsAgain = containerAgain.get("items"); - if (!itemsAgain || !itemsAgain.forEach) { - fail('no itemsAgain retrieved', itemsAgain); + Parse.Object.saveAll(items) + .then(function () { + container.set('items', items); + return container.save(); + }) + .then(function () { + const query = new Parse.Query(Container); + return query.get(container.id); + }) + .then(function (containerAgain) { + const itemsAgain = containerAgain.get('items'); + if (!itemsAgain || !itemsAgain.forEach) { + fail('no itemsAgain retrieved', itemsAgain); + done(); + return; + } + itemsAgain.forEach(function (item, i) { + item.set('x', i * 2); + }); + return Parse.Object.saveAll(itemsAgain); + }) + .then(function () { + return Parse.Object.fetchAllIfNeeded(items); + }) + .then(function (fetchedItems) { + equal(fetchedItems.length, numItems, 'Number of items should not change'); + fetchedItems.forEach(function (item, i) { + equal(item.get('x'), i); + }); done(); - return; - } - itemsAgain.forEach(function(item, i) { - item.set("x", i*2); }); - return Parse.Object.saveAll(itemsAgain); - }).then(function() { - return Parse.Object.fetchAllIfNeeded(items); - }).then(function(fetchedItems) { - equal(fetchedItems.length, numItems, - "Number of items should not change"); - fetchedItems.forEach(function(item, i) { - equal(item.get("x"), i); - }); - done(); - }); }); - it_exclude_dbs(['postgres'])("fetchAllIfNeeded backbone-style callbacks", function(done) { - var numItems = 11; - var container = new Container(); - var items = []; - for (var i = 0; i < numItems; i++) { - var item = new Item(); - item.set("x", i); + xit('fetchAllIfNeeded backbone-style callbacks', function (done) { + const numItems = 11; + const container = new Container(); + const items = []; + for (let i = 0; i < numItems; i++) { + const item = new Item(); + item.set('x', i); items.push(item); } - Parse.Object.saveAll(items).then(function() { - container.set("items", items); - return container.save(); - }).then(function() { - var query = new Parse.Query(Container); - return query.get(container.id); - }).then(function(containerAgain) { - var itemsAgain = containerAgain.get("items"); - if (!itemsAgain || !itemsAgain.forEach) { - fail('no itemsAgain retrieved', itemsAgain); - done(); - return; - } - itemsAgain.forEach(function(item, i) { - item.set("x", i*2); - }); - return Parse.Object.saveAll(itemsAgain); - }).then(function() { - var items = container.get("items"); - return Parse.Object.fetchAllIfNeeded(items, { - success: function(fetchedItems) { - equal(fetchedItems.length, numItems, - "Number of items should not change"); - fetchedItems.forEach(function(item, j) { - equal(item.get("x"), j); - }); - done(); - }, - - error: function(error) { - ok(false, "Failed to fetchAll"); + Parse.Object.saveAll(items) + .then(function () { + container.set('items', items); + return container.save(); + }) + .then(function () { + const query = new Parse.Query(Container); + return query.get(container.id); + }) + .then(function (containerAgain) { + const itemsAgain = containerAgain.get('items'); + if (!itemsAgain || !itemsAgain.forEach) { + fail('no itemsAgain retrieved', itemsAgain); done(); + return; } + itemsAgain.forEach(function (item, i) { + item.set('x', i * 2); + }); + return Parse.Object.saveAll(itemsAgain); + }) + .then(function () { + const items = container.get('items'); + return Parse.Object.fetchAllIfNeeded(items).then( + function (fetchedItems) { + equal(fetchedItems.length, numItems, 'Number of items should not change'); + fetchedItems.forEach(function (item, j) { + equal(item.get('x'), j); + }); + done(); + }, + function () { + ok(false, 'Failed to fetchAll'); + done(); + } + ); }); - }); }); - it("fetchAllIfNeeded no objects", function(done) { - Parse.Object.fetchAllIfNeeded([], function(success, error) { - ok(success && !error, "Should be able to fetchAll no objects"); + it('fetchAllIfNeeded no objects', function (done) { + Parse.Object.fetchAllIfNeeded([]) + .then(function (success) { + ok(Array.isArray(success), 'Should be able to fetchAll no objects'); + done(); + }) + .catch(done.fail); + }); + + it('fetchAllIfNeeded unsaved object', async function (done) { + const unsavedObjectArray = [new TestObject()]; + await Parse.Object.fetchAllIfNeeded(unsavedObjectArray).catch(e => { + expect(e.code).toBe(Parse.Error.MISSING_OBJECT_ID); done(); }); }); - it("fetchAllIfNeeded unsaved object", function(done) { - var unsavedObjectArray = [new TestObject()]; - Parse.Object.fetchAllIfNeeded( - unsavedObjectArray, - expectError(Parse.Error.MISSING_OBJECT_ID, done)); - }); - - it("fetchAllIfNeeded error on multiple classes", function(done) { - var container = new Container(); - container.set("item", new Item()); - container.set("subcontainer", new Container()); - return container.save().then(function() { - var query = new Parse.Query(Container); - return query.get(container.id); - }).then(function(containerAgain) { - var subContainerAgain = containerAgain.get("subcontainer"); - var itemAgain = containerAgain.get("item"); - var multiClassArray = [subContainerAgain, itemAgain]; - return Parse.Object.fetchAllIfNeeded( - multiClassArray, - expectError(Parse.Error.INVALID_CLASS_NAME, done)); - }); + it('fetchAllIfNeeded error on multiple classes', function (done) { + const container = new Container(); + container.set('item', new Item()); + container.set('subcontainer', new Container()); + return container + .save() + .then(function () { + const query = new Parse.Query(Container); + return query.get(container.id); + }) + .then(function (containerAgain) { + const subContainerAgain = containerAgain.get('subcontainer'); + const itemAgain = containerAgain.get('item'); + const multiClassArray = [subContainerAgain, itemAgain]; + return Parse.Object.fetchAllIfNeeded(multiClassArray).catch(e => { + expect(e.code).toBe(Parse.Error.INVALID_CLASS_NAME); + done(); + }); + }); }); - it("Objects with className User", function(done) { + it('Objects with className User', function (done) { equal(Parse.CoreManager.get('PERFORM_USER_REWRITE'), true); - var User1 = Parse.Object.extend({ - className: "User" + const User1 = Parse.Object.extend({ + className: 'User', }); - equal(User1.className, "_User", - "className is rewritten by default"); + equal(User1.className, '_User', 'className is rewritten by default'); Parse.User.allowCustomUserClass(true); equal(Parse.CoreManager.get('PERFORM_USER_REWRITE'), false); - var User2 = Parse.Object.extend({ - className: "User" + const User2 = Parse.Object.extend({ + className: 'User', }); - equal(User2.className, "User", - "className is not rewritten when allowCustomUserClass(true)"); + equal(User2.className, 'User', 'className is not rewritten when allowCustomUserClass(true)'); // Set back to default so as not to break other tests. Parse.User.allowCustomUserClass(false); - equal(Parse.CoreManager.get('PERFORM_USER_REWRITE'), true, "PERFORM_USER_REWRITE is reset"); - - var user = new User2(); - user.set("name", "Me"); - user.save({height: 181}, expectSuccess({ - success: function(user) { - equal(user.get("name"), "Me"); - equal(user.get("height"), 181); - - var query = new Parse.Query(User2); - query.get(user.id, expectSuccess({ - success: function(user) { - equal(user.className, "User"); - equal(user.get("name"), "Me"); - equal(user.get("height"), 181); - - done(); - } - })); - } - })); - }); - - it("create without data", function(done) { - var t1 = new TestObject({ "test" : "test" }); - t1.save().then(function(t1) { - var t2 = TestObject.createWithoutData(t1.id); - return t2.fetch(); - }).then(function(t2) { - equal(t2.get("test"), "test", "Fetch should have grabbed " + - "'test' property."); - var t3 = TestObject.createWithoutData(t2.id); - t3.set("test", "not test"); - return t3.fetch(); - }).then(function(t3) { - equal(t3.get("test"), "test", - "Fetch should have grabbed server 'test' property."); - done(); - }, function(error) { - ok(false, error); - done(); + equal(Parse.CoreManager.get('PERFORM_USER_REWRITE'), true, 'PERFORM_USER_REWRITE is reset'); + + const user = new User2(); + user.set('name', 'Me'); + user.save({ height: 181 }).then(function (user) { + equal(user.get('name'), 'Me'); + equal(user.get('height'), 181); + + const query = new Parse.Query(User2); + query.get(user.id).then(function (user) { + equal(user.className, 'User'); + equal(user.get('name'), 'Me'); + equal(user.get('height'), 181); + done(); + }); }); }); - it("remove from new field creates array key", (done) => { - var obj = new TestObject(); + it('create without data', function (done) { + const t1 = new TestObject({ test: 'test' }); + t1.save() + .then(function (t1) { + const t2 = TestObject.createWithoutData(t1.id); + return t2.fetch(); + }) + .then(function (t2) { + equal(t2.get('test'), 'test', 'Fetch should have grabbed ' + "'test' property."); + const t3 = TestObject.createWithoutData(t2.id); + t3.set('test', 'not test'); + return t3.fetch(); + }) + .then( + function (t3) { + equal(t3.get('test'), 'test', "Fetch should have grabbed server 'test' property."); + done(); + }, + function (error) { + ok(false, error); + done(); + } + ); + }); + + it('remove from new field creates array key', done => { + const obj = new TestObject(); obj.remove('shouldBeArray', 'foo'); - obj.save().then(() => { - var query = new Parse.Query('TestObject'); - return query.get(obj.id); - }).then((objAgain) => { - var arr = objAgain.get('shouldBeArray'); - ok(Array.isArray(arr), 'Should have created array key'); - ok(!arr || arr.length === 0, 'Should have an empty array.'); - done(); - }); + obj + .save() + .then(() => { + const query = new Parse.Query('TestObject'); + return query.get(obj.id); + }) + .then(objAgain => { + const arr = objAgain.get('shouldBeArray'); + ok(Array.isArray(arr), 'Should have created array key'); + ok(!arr || arr.length === 0, 'Should have an empty array.'); + done(); + }); }); - it("increment with type conflict fails", (done) => { - var obj = new TestObject(); + it('increment with type conflict fails', done => { + const obj = new TestObject(); obj.set('astring', 'foo'); - obj.save().then(() => { - var obj2 = new TestObject(); - obj2.increment('astring'); - return obj2.save(); - }).then((obj2) => { - fail('Should not have saved.'); - done(); - }, (error) => { - expect(error.code).toEqual(111); - done(); - }); + obj + .save() + .then(() => { + const obj2 = new TestObject(); + obj2.increment('astring'); + return obj2.save(); + }) + .then( + () => { + fail('Should not have saved.'); + done(); + }, + error => { + expect(error.code).toEqual(111); + done(); + } + ); }); - it("increment with empty field solidifies type", (done) => { - var obj = new TestObject(); + it('increment with empty field solidifies type', done => { + const obj = new TestObject(); obj.increment('aninc'); - obj.save().then(() => { - var obj2 = new TestObject(); - obj2.set('aninc', 'foo'); - return obj2.save(); - }).then(() => { - fail('Should not have saved.'); - done(); - }, (error) => { - expect(error.code).toEqual(111); - done(); - }); + obj + .save() + .then(() => { + const obj2 = new TestObject(); + obj2.set('aninc', 'foo'); + return obj2.save(); + }) + .then( + () => { + fail('Should not have saved.'); + done(); + }, + error => { + expect(error.code).toEqual(111); + done(); + } + ); }); - it("increment update with type conflict fails", (done) => { - var obj = new TestObject(); + it('increment update with type conflict fails', done => { + const obj = new TestObject(); obj.set('someString', 'foo'); - obj.save().then((objAgain) => { - var obj2 = new TestObject(); - obj2.id = objAgain.id; - obj2.increment('someString'); - return obj2.save(); - }).then(() => { - fail('Should not have saved.'); - done(); - }, (error) => { - expect(error.code).toEqual(111); - done(); - }); + obj + .save() + .then(objAgain => { + const obj2 = new TestObject(); + obj2.id = objAgain.id; + obj2.increment('someString'); + return obj2.save(); + }) + .then( + () => { + fail('Should not have saved.'); + done(); + }, + error => { + expect(error.code).toEqual(111); + done(); + } + ); }); - it_exclude_dbs(['postgres'])('dictionary fetched pointers do not lose data on fetch', (done) => { - var parent = new Parse.Object('Parent'); - var dict = {}; - for (var i = 0; i < 5; i++) { - var proc = (iter) => { - var child = new Parse.Object('Child'); + it('dictionary fetched pointers do not lose data on fetch', done => { + const parent = new Parse.Object('Parent'); + const dict = {}; + for (let i = 0; i < 5; i++) { + const proc = iter => { + const child = new Parse.Object('Child'); child.set('name', 'testname' + i); dict[iter] = child; }; proc(i); } parent.set('childDict', dict); - parent.save().then(() => { - return parent.fetch(); - }).then((parentAgain) => { - var dictAgain = parentAgain.get('childDict'); - if (!dictAgain) { - fail('Should have been a dictionary.'); - return done(); - } - expect(typeof dictAgain).toEqual('object'); - expect(typeof dictAgain['0']).toEqual('object'); - expect(typeof dictAgain['1']).toEqual('object'); - expect(typeof dictAgain['2']).toEqual('object'); - expect(typeof dictAgain['3']).toEqual('object'); - expect(typeof dictAgain['4']).toEqual('object'); - done(); + parent + .save() + .then(() => { + return parent.fetch(); + }) + .then(parentAgain => { + const dictAgain = parentAgain.get('childDict'); + if (!dictAgain) { + fail('Should have been a dictionary.'); + return done(); + } + expect(typeof dictAgain).toEqual('object'); + expect(typeof dictAgain['0']).toEqual('object'); + expect(typeof dictAgain['1']).toEqual('object'); + expect(typeof dictAgain['2']).toEqual('object'); + expect(typeof dictAgain['3']).toEqual('object'); + expect(typeof dictAgain['4']).toEqual('object'); + done(); + }); + }); + + it('should create nested keys with _', done => { + const object = new Parse.Object('AnObject'); + object.set('foo', { + _bar: '_', + baz_bar: 1, + __foo_bar: true, + _0: 'underscore_zero', + _more: { + _nested: 'key', + }, }); + object + .save() + .then(res => { + ok(res); + return res.fetch(); + }) + .then(res => { + const foo = res.get('foo'); + expect(foo['_bar']).toEqual('_'); + expect(foo['baz_bar']).toEqual(1); + expect(foo['__foo_bar']).toBe(true); + expect(foo['_0']).toEqual('underscore_zero'); + expect(foo['_more']['_nested']).toEqual('key'); + done(); + }) + .catch(err => { + jfail(err); + fail('should not fail'); + done(); + }); }); + it('should have undefined includes when object is missing', done => { + const obj1 = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); - it("should create nested keys with _", done => { - const object = new Parse.Object("AnObject"); - object.set("foo", { - "_bar": "_", - "baz_bar": 1, - "__foo_bar": true, - "_0": "underscore_zero", - "_more": { - "_nested": "key" - } + Parse.Object.saveAll([obj1, obj2]) + .then(() => { + obj1.set('obj', obj2); + // Save the pointer, delete the pointee + return obj1.save().then(() => { + return obj2.destroy(); + }); + }) + .then(() => { + const query = new Parse.Query('AnObject'); + query.include('obj'); + return query.find(); + }) + .then(res => { + expect(res.length).toBe(1); + if (res[0]) { + expect(res[0].get('obj')).toBe(undefined); + } + const query = new Parse.Query('AnObject'); + return query.find(); + }) + .then(res => { + expect(res.length).toBe(1); + if (res[0]) { + expect(res[0].get('obj')).not.toBe(undefined); + return res[0].get('obj').fetch(); + } else { + done(); + } + }) + .then( + () => { + fail('Should not fetch a deleted object'); + }, + err => { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + done(); + } + ); + }); + + it('should have undefined includes when object is missing on deeper path', done => { + const obj1 = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + const obj3 = new Parse.Object('AnObject'); + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + obj1.set('obj', obj2); + obj2.set('obj', obj3); + // Save the pointer, delete the pointee + return Parse.Object.saveAll([obj1, obj2]).then(() => { + return obj3.destroy(); + }); + }) + .then(() => { + const query = new Parse.Query('AnObject'); + query.include('obj.obj'); + return query.get(obj1.id); + }) + .then(res => { + expect(res.get('obj')).not.toBe(undefined); + expect(res.get('obj').get('obj')).toBe(undefined); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); + }); + + it('should handle includes on null arrays #2752', done => { + const obj1 = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnotherObject'); + const obj3 = new Parse.Object('NestedObject'); + obj3.set({ + foo: 'bar', + }); + obj2.set({ + key: obj3, + }); + + Parse.Object.saveAll([obj1, obj2]) + .then(() => { + obj1.set('objects', [null, null, obj2]); + return obj1.save(); + }) + .then(() => { + const query = new Parse.Query('AnObject'); + query.include('objects.key'); + return query.find(); + }) + .then(res => { + const obj = res[0]; + expect(obj.get('objects')).not.toBe(undefined); + const array = obj.get('objects'); + expect(Array.isArray(array)).toBe(true); + expect(array[0]).toBe(null); + expect(array[1]).toBe(null); + expect(array[2].get('key').get('foo')).toEqual('bar'); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); + }); + + it('should handle select and include #2786', done => { + const score = new Parse.Object('GameScore'); + const player = new Parse.Object('Player'); + score.set({ + score: 1234, + }); + + score + .save() + .then(() => { + player.set('gameScore', score); + player.set('other', 'value'); + return player.save(); + }) + .then(() => { + const query = new Parse.Query('Player'); + query.include('gameScore'); + query.select('gameScore'); + return query.find(); + }) + .then(res => { + const obj = res[0]; + const gameScore = obj.get('gameScore'); + const other = obj.get('other'); + expect(other).toBeUndefined(); + expect(gameScore).not.toBeUndefined(); + expect(gameScore.get('score')).toBe(1234); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); + }); + + it('should include ACLs with select', done => { + const score = new Parse.Object('GameScore'); + const player = new Parse.Object('Player'); + score.set({ + score: 1234, + }); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + acl.setPublicWriteAccess(false); + + score + .save() + .then(() => { + player.set('gameScore', score); + player.set('other', 'value'); + player.setACL(acl); + return player.save(); + }) + .then(() => { + const query = new Parse.Query('Player'); + query.include('gameScore'); + query.select('gameScore'); + return query.find(); + }) + .then(res => { + const obj = res[0]; + const gameScore = obj.get('gameScore'); + const other = obj.get('other'); + expect(other).toBeUndefined(); + expect(gameScore).not.toBeUndefined(); + expect(gameScore.get('score')).toBe(1234); + expect(obj.getACL().getPublicReadAccess()).toBe(true); + expect(obj.getACL().getPublicWriteAccess()).toBe(false); + }) + .then(done) + .catch(done.fail); + }); + + it('Update object field should store exactly same sent object', async done => { + let object = new TestObject(); + + // Set initial data + object.set('jsonData', { a: 'b' }); + object = await object.save(); + equal(object.get('jsonData'), { a: 'b' }); + + // Set empty JSON + object.set('jsonData', {}); + object = await object.save(); + equal(object.get('jsonData'), {}); + + // Set new JSON data + object.unset('jsonData'); + object.set('jsonData', { c: 'd' }); + object = await object.save(); + equal(object.get('jsonData'), { c: 'd' }); + + // Fetch object from server + object = await object.fetch(); + equal(object.get('jsonData'), { c: 'd' }); + + done(); + }); + + it('isNew in cloud code', async () => { + Parse.Cloud.beforeSave('CloudCodeIsNew', req => { + expect(req.object.isNew()).toBeTruthy(); + expect(req.object.id).toBeUndefined(); }); - object.save().then( res => { - ok(res); - return res.fetch(); - }).then( res => { - const foo = res.get("foo"); - expect(foo["_bar"]).toEqual("_"); - expect(foo["baz_bar"]).toEqual(1); - expect(foo["__foo_bar"]).toBe(true); - expect(foo["_0"]).toEqual("underscore_zero"); - expect(foo["_more"]["_nested"]).toEqual("key"); - done(); - }).fail( err => { - console.error(err); - fail("should not fail"); - done(); + + Parse.Cloud.afterSave('CloudCodeIsNew', req => { + expect(req.object.isNew()).toBeFalsy(); + expect(req.object.id).toBeDefined(); }); + + const object = new Parse.Object('CloudCodeIsNew'); + await object.save(); }); - it_exclude_dbs(['postgres'])('should have undefined includes when object is missing', (done) => { - let obj1 = new Parse.Object("AnObject"); - let obj2 = new Parse.Object("AnObject"); - - Parse.Object.saveAll([obj1, obj2]).then(() => { - obj1.set("obj", obj2); - // Save the pointer, delete the pointee - return obj1.save().then(() => { return obj2.destroy() }); - }).then(() => { - let query = new Parse.Query("AnObject"); - query.include("obj"); - return query.find(); - }).then((res) => { - expect(res.length).toBe(1); - expect(res[0].get("obj")).toBe(undefined); - let query = new Parse.Query("AnObject"); - return query.find(); - }).then((res) => { - expect(res.length).toBe(1); - expect(res[0].get("obj")).not.toBe(undefined); - return res[0].get("obj").fetch(); - }).then(() => { - fail("Should not fetch a deleted object"); - }, (err) => { - expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); - done(); - }) - }); - - it_exclude_dbs(['postgres'])('should have undefined includes when object is missing on deeper path', (done) => { - let obj1 = new Parse.Object("AnObject"); - let obj2 = new Parse.Object("AnObject"); - let obj3 = new Parse.Object("AnObject"); - Parse.Object.saveAll([obj1, obj2, obj3]).then(() => { - obj1.set("obj", obj2); - obj2.set("obj", obj3); - // Save the pointer, delete the pointee - return Parse.Object.saveAll([obj1, obj2]).then(() => { return obj3.destroy() }); - }).then(() => { - let query = new Parse.Query("AnObject"); - query.include("obj.obj"); - return query.get(obj1.id); - }).then((res) => { - expect(res.get("obj")).not.toBe(undefined); - expect(res.get("obj").get("obj")).toBe(undefined); - done(); + it('should not change the json field to array in afterSave', async () => { + Parse.Cloud.beforeSave('failingJSONTestCase', req => { + expect(req.object.get('jsonField')).toEqual({ '123': 'test' }); + }); + + Parse.Cloud.afterSave('failingJSONTestCase', req => { + expect(req.object.get('jsonField')).toEqual({ '123': 'test' }); }); + + const object = new Parse.Object('failingJSONTestCase'); + object.set('jsonField', { '123': 'test' }); + await object.save(); + }); + + it('returns correct field values', async () => { + const values = [ + { field: 'string', value: 'string' }, + { field: 'number', value: 1 }, + { field: 'boolean', value: true }, + { field: 'array', value: [0, 1, 2] }, + { field: 'array', value: [1, 2, 3] }, + { field: 'array', value: [{ '0': 'a' }, 2, 3] }, + { field: 'object', value: { key: 'value' } }, + { field: 'object', value: { key1: 'value1', key2: 'value2' } }, + { field: 'object', value: { key1: 1, key2: 2 } }, + { field: 'object', value: { '1x1': 1 } }, + { field: 'object', value: { '1x1': 1, '2': 2 } }, + { field: 'object', value: { '0': 0 } }, + { field: 'object', value: { '1': 1 } }, + { field: 'object', value: { '0': { '0': 'a', '1': 'b' } } }, + { field: 'date', value: new Date() }, + { + field: 'file', + value: Parse.File.fromJSON({ + __type: 'File', + name: 'name', + url: 'http://localhost:8378/1/files/test/name', + }), + }, + { field: 'geoPoint', value: new Parse.GeoPoint(40, -30) }, + { field: 'bytes', value: { __type: 'Bytes', base64: 'ZnJveW8=' } }, + ]; + for (const value of values) { + const object = new TestObject(); + object.set(value.field, value.value); + await object.save(); + const query = new Parse.Query(TestObject); + const objectAgain = await query.get(object.id); + expect(objectAgain.get(value.field)).toEqual(value.value); + } }); }); diff --git a/spec/ParsePolygon.spec.js b/spec/ParsePolygon.spec.js new file mode 100644 index 0000000000..c2fc903206 --- /dev/null +++ b/spec/ParsePolygon.spec.js @@ -0,0 +1,544 @@ +const TestObject = Parse.Object.extend('TestObject'); +const request = require('../lib/request'); +const TestUtils = require('../lib/TestUtils'); +const defaultHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Rest-API-Key': 'rest', + 'Content-Type': 'application/json', +}; + +describe('Parse.Polygon testing', () => { + it('polygon save open path', done => { + const coords = [ + [0, 0], + [0, 1], + [1, 1], + [1, 0], + ]; + const closed = [ + [0, 0], + [0, 1], + [1, 1], + [1, 0], + [0, 0], + ]; + const obj = new TestObject(); + obj.set('polygon', new Parse.Polygon(coords)); + return obj + .save() + .then(() => { + const query = new Parse.Query(TestObject); + return query.get(obj.id); + }) + .then(result => { + const polygon = result.get('polygon'); + equal(polygon instanceof Parse.Polygon, true); + equal(polygon.coordinates, closed); + done(); + }, done.fail); + }); + + it('polygon save closed path', done => { + const coords = [ + [0, 0], + [0, 1], + [1, 1], + [1, 0], + [0, 0], + ]; + const obj = new TestObject(); + obj.set('polygon', new Parse.Polygon(coords)); + return obj + .save() + .then(() => { + const query = new Parse.Query(TestObject); + return query.get(obj.id); + }) + .then(result => { + const polygon = result.get('polygon'); + equal(polygon instanceof Parse.Polygon, true); + equal(polygon.coordinates, coords); + done(); + }, done.fail); + }); + + it_id('3019353b-d5b3-4e53-bcb1-716418328bdd')(it)('polygon equalTo (open/closed) path', done => { + const openPoints = [ + [0, 0], + [0, 1], + [1, 1], + [1, 0], + ]; + const closedPoints = [ + [0, 0], + [0, 1], + [1, 1], + [1, 0], + [0, 0], + ]; + const openPolygon = new Parse.Polygon(openPoints); + const closedPolygon = new Parse.Polygon(closedPoints); + const obj = new TestObject(); + obj.set('polygon', openPolygon); + return obj + .save() + .then(() => { + const query = new Parse.Query(TestObject); + query.equalTo('polygon', openPolygon); + return query.find(); + }) + .then(results => { + const polygon = results[0].get('polygon'); + equal(polygon instanceof Parse.Polygon, true); + equal(polygon.coordinates, closedPoints); + const query = new Parse.Query(TestObject); + query.equalTo('polygon', closedPolygon); + return query.find(); + }) + .then(results => { + const polygon = results[0].get('polygon'); + equal(polygon instanceof Parse.Polygon, true); + equal(polygon.coordinates, closedPoints); + done(); + }, done.fail); + }); + + it('polygon update', done => { + const oldCoords = [ + [0, 0], + [0, 1], + [1, 1], + [1, 0], + ]; + const oldPolygon = new Parse.Polygon(oldCoords); + const newCoords = [ + [2, 2], + [2, 3], + [3, 3], + [3, 2], + ]; + const newPolygon = new Parse.Polygon(newCoords); + const obj = new TestObject(); + obj.set('polygon', oldPolygon); + return obj + .save() + .then(() => { + obj.set('polygon', newPolygon); + return obj.save(); + }) + .then(() => { + const query = new Parse.Query(TestObject); + return query.get(obj.id); + }) + .then(result => { + const polygon = result.get('polygon'); + newCoords.push(newCoords[0]); + equal(polygon instanceof Parse.Polygon, true); + equal(polygon.coordinates, newCoords); + done(); + }, done.fail); + }); + + it('polygon invalid value', done => { + const coords = [ + ['foo', 'bar'], + [0, 1], + [1, 0], + [1, 1], + [0, 0], + ]; + const obj = new TestObject(); + obj.set('polygon', { __type: 'Polygon', coordinates: coords }); + return obj + .save() + .then(() => { + const query = new Parse.Query(TestObject); + return query.get(obj.id); + }) + .then(done.fail, () => done()); + }); + + it('polygon three points minimum', done => { + const coords = [[0, 0]]; + const obj = new TestObject(); + // use raw so we test the server validates properly + obj.set('polygon', { __type: 'Polygon', coordinates: coords }); + obj.save().then(done.fail, () => done()); + }); + + it('polygon three different points minimum', done => { + const coords = [ + [0, 0], + [0, 1], + [0, 0], + ]; + const obj = new TestObject(); + obj.set('polygon', new Parse.Polygon(coords)); + obj.save().then(done.fail, () => done()); + }); + + it('polygon counterclockwise', done => { + const coords = [ + [1, 1], + [0, 1], + [0, 0], + [1, 0], + ]; + const closed = [ + [1, 1], + [0, 1], + [0, 0], + [1, 0], + [1, 1], + ]; + const obj = new TestObject(); + obj.set('polygon', new Parse.Polygon(coords)); + obj + .save() + .then(() => { + const query = new Parse.Query(TestObject); + return query.get(obj.id); + }) + .then(result => { + const polygon = result.get('polygon'); + equal(polygon instanceof Parse.Polygon, true); + equal(polygon.coordinates, closed); + done(); + }, done.fail); + }); + + describe('with location', () => { + if (process.env.PARSE_SERVER_TEST_DB !== 'postgres') { + beforeEach(async () => await TestUtils.destroyAllDataPermanently()); + } + + it('polygonContain query', done => { + const points1 = [ + [0, 0], + [0, 1], + [1, 1], + [1, 0], + ]; + const points2 = [ + [0, 0], + [0, 2], + [2, 2], + [2, 0], + ]; + const points3 = [ + [10, 10], + [10, 15], + [15, 15], + [15, 10], + [10, 10], + ]; + const polygon1 = new Parse.Polygon(points1); + const polygon2 = new Parse.Polygon(points2); + const polygon3 = new Parse.Polygon(points3); + const obj1 = new TestObject({ boundary: polygon1 }); + const obj2 = new TestObject({ boundary: polygon2 }); + const obj3 = new TestObject({ boundary: polygon3 }); + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const where = { + boundary: { + $geoIntersects: { + $point: { __type: 'GeoPoint', latitude: 0.5, longitude: 0.5 }, + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + expect(resp.data.results.length).toBe(2); + done(); + }, done.fail); + }); + + it('polygonContain query no reverse input (Regression test for #4608)', done => { + const points1 = [ + [0.25, 0], + [0.25, 1.25], + [0.75, 1.25], + [0.75, 0], + ]; + const points2 = [ + [0, 0], + [0, 2], + [2, 2], + [2, 0], + ]; + const points3 = [ + [10, 10], + [10, 15], + [15, 15], + [15, 10], + [10, 10], + ]; + const polygon1 = new Parse.Polygon(points1); + const polygon2 = new Parse.Polygon(points2); + const polygon3 = new Parse.Polygon(points3); + const obj1 = new TestObject({ boundary: polygon1 }); + const obj2 = new TestObject({ boundary: polygon2 }); + const obj3 = new TestObject({ boundary: polygon3 }); + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const where = { + boundary: { + $geoIntersects: { + $point: { __type: 'GeoPoint', latitude: 0.5, longitude: 1.0 }, + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + expect(resp.data.results.length).toBe(2); + done(); + }, done.fail); + }); + + it('polygonContain query real data (Regression test for #4608)', done => { + const detroit = [ + [42.631655189280224, -83.78406753121705], + [42.633047793854814, -83.75333640366955], + [42.61625254348911, -83.75149921669944], + [42.61526926650296, -83.78161794858735], + [42.631655189280224, -83.78406753121705], + ]; + const polygon = new Parse.Polygon(detroit); + const obj = new TestObject({ boundary: polygon }); + obj + .save() + .then(() => { + const where = { + boundary: { + $geoIntersects: { + $point: { + __type: 'GeoPoint', + latitude: 42.624599, + longitude: -83.770162, + }, + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + expect(resp.data.results.length).toBe(1); + done(); + }, done.fail); + }); + + it('polygonContain invalid input', done => { + const points = [ + [0, 0], + [0, 1], + [1, 1], + [1, 0], + ]; + const polygon = new Parse.Polygon(points); + const obj = new TestObject({ boundary: polygon }); + obj + .save() + .then(() => { + const where = { + boundary: { + $geoIntersects: { + $point: { __type: 'GeoPoint', latitude: 181, longitude: 181 }, + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + }, + }); + }) + .then(done.fail, () => done()); + }); + + it('polygonContain invalid geoPoint', done => { + const points = [ + [0, 0], + [0, 1], + [1, 1], + [1, 0], + ]; + const polygon = new Parse.Polygon(points); + const obj = new TestObject({ boundary: polygon }); + obj + .save() + .then(() => { + const where = { + boundary: { + $geoIntersects: { + $point: [], + }, + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + }, + }); + }) + .then(done.fail, () => done()); + }); + }); +}); + +describe_only_db('mongo')('Parse.Polygon testing mongo', () => { + const Config = require('../lib/Config'); + let config; + beforeEach(async () => { + if (process.env.PARSE_SERVER_TEST_DB !== 'postgres') { + await TestUtils.destroyAllDataPermanently(); + } + config = Config.get('test'); + config.schemaCache.clear(); + }); + it('support 2d and 2dsphere', done => { + const coords = [ + [0, 0], + [0, 1], + [1, 1], + [1, 0], + [0, 0], + ]; + // testings against REST API, use raw formats + const polygon = { __type: 'Polygon', coordinates: coords }; + const location = { __type: 'GeoPoint', latitude: 10, longitude: 10 }; + const databaseAdapter = config.database.adapter; + return reconfigureServer({ + appId: 'test', + restAPIKey: 'rest', + publicServerURL: 'http://localhost:8378/1', + databaseAdapter, + }) + .then(() => { + return databaseAdapter.createIndex('TestObject', { location: '2d' }); + }) + .then(() => { + return databaseAdapter.createIndex('TestObject', { + polygon: '2dsphere', + }); + }) + .then(() => { + return request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: { + _method: 'POST', + location, + polygon, + polygon2: polygon, + }, + headers: defaultHeaders, + }); + }) + .then(resp => { + return request({ + method: 'POST', + url: `http://localhost:8378/1/classes/TestObject/${resp.data.objectId}`, + body: { _method: 'GET' }, + headers: defaultHeaders, + }); + }) + .then(resp => { + equal(resp.data.location, location); + equal(resp.data.polygon, polygon); + equal(resp.data.polygon2, polygon); + return databaseAdapter.getIndexes('TestObject'); + }) + .then(indexes => { + equal(indexes.length, 4); + equal(indexes[0].key, { _id: 1 }); + equal(indexes[1].key, { location: '2d' }); + equal(indexes[2].key, { polygon: '2dsphere' }); + equal(indexes[3].key, { polygon2: '2dsphere' }); + done(); + }, done.fail); + }); + + it('polygon coordinates reverse input', done => { + const Config = require('../lib/Config'); + const config = Config.get('test'); + + // When stored the first point should be the last point + const input = [ + [12, 11], + [14, 13], + [16, 15], + [18, 17], + ]; + const output = [ + [ + [11, 12], + [13, 14], + [15, 16], + [17, 18], + [11, 12], + ], + ]; + const obj = new TestObject(); + obj.set('polygon', new Parse.Polygon(input)); + obj + .save() + .then(() => { + return config.database.adapter._rawFind('TestObject', { _id: obj.id }); + }) + .then(results => { + expect(results.length).toBe(1); + expect(results[0].polygon.coordinates).toEqual(output); + done(); + }); + }); + + it('polygon loop is not valid', done => { + const coords = [ + [0, 0], + [0, 1], + [1, 0], + [1, 1], + ]; + const obj = new TestObject(); + obj.set('polygon', new Parse.Polygon(coords)); + obj.save().then(done.fail, () => done()); + }); +}); diff --git a/spec/ParsePubSub.spec.js b/spec/ParsePubSub.spec.js index 3cf676447e..53bdd0b674 100644 --- a/spec/ParsePubSub.spec.js +++ b/spec/ParsePubSub.spec.js @@ -1,65 +1,132 @@ -var ParsePubSub = require('../src/LiveQuery/ParsePubSub').ParsePubSub; +const ParsePubSub = require('../lib/LiveQuery/ParsePubSub').ParsePubSub; -describe('ParsePubSub', function() { - - beforeEach(function(done) { +describe('ParsePubSub', function () { + beforeEach(function (done) { // Mock RedisPubSub - var mockRedisPubSub = { + const mockRedisPubSub = { createPublisher: jasmine.createSpy('createPublisherRedis'), - createSubscriber: jasmine.createSpy('createSubscriberRedis') + createSubscriber: jasmine.createSpy('createSubscriberRedis'), }; - jasmine.mockLibrary('../src/LiveQuery/RedisPubSub', 'RedisPubSub', mockRedisPubSub); + jasmine.mockLibrary('../lib/Adapters/PubSub/RedisPubSub', 'RedisPubSub', mockRedisPubSub); // Mock EventEmitterPubSub - var mockEventEmitterPubSub = { + const mockEventEmitterPubSub = { createPublisher: jasmine.createSpy('createPublisherEventEmitter'), - createSubscriber: jasmine.createSpy('createSubscriberEventEmitter') + createSubscriber: jasmine.createSpy('createSubscriberEventEmitter'), }; - jasmine.mockLibrary('../src/LiveQuery/EventEmitterPubSub', 'EventEmitterPubSub', mockEventEmitterPubSub); + jasmine.mockLibrary( + '../lib/Adapters/PubSub/EventEmitterPubSub', + 'EventEmitterPubSub', + mockEventEmitterPubSub + ); done(); }); - it('can create redis publisher', function() { - var publisher = ParsePubSub.createPublisher({ - redisURL: 'redisURL' + it('can create redis publisher', function () { + ParsePubSub.createPublisher({ + redisURL: 'redisURL', + redisOptions: { socket_keepalive: true }, }); - var RedisPubSub = require('../src/LiveQuery/RedisPubSub').RedisPubSub; - var EventEmitterPubSub = require('../src/LiveQuery/EventEmitterPubSub').EventEmitterPubSub; - expect(RedisPubSub.createPublisher).toHaveBeenCalledWith('redisURL'); + const RedisPubSub = require('../lib/Adapters/PubSub/RedisPubSub').RedisPubSub; + const EventEmitterPubSub = require('../lib/Adapters/PubSub/EventEmitterPubSub') + .EventEmitterPubSub; + expect(RedisPubSub.createPublisher).toHaveBeenCalledWith({ + redisURL: 'redisURL', + redisOptions: { socket_keepalive: true }, + }); expect(EventEmitterPubSub.createPublisher).not.toHaveBeenCalled(); }); - it('can create event emitter publisher', function() { - var publisher = ParsePubSub.createPublisher({}); + it('can create event emitter publisher', function () { + ParsePubSub.createPublisher({}); - var RedisPubSub = require('../src/LiveQuery/RedisPubSub').RedisPubSub; - var EventEmitterPubSub = require('../src/LiveQuery/EventEmitterPubSub').EventEmitterPubSub; + const RedisPubSub = require('../lib/Adapters/PubSub/RedisPubSub').RedisPubSub; + const EventEmitterPubSub = require('../lib/Adapters/PubSub/EventEmitterPubSub') + .EventEmitterPubSub; expect(RedisPubSub.createPublisher).not.toHaveBeenCalled(); expect(EventEmitterPubSub.createPublisher).toHaveBeenCalled(); }); - it('can create redis subscriber', function() { - var subscriber = ParsePubSub.createSubscriber({ - redisURL: 'redisURL' + it('can create redis subscriber', function () { + ParsePubSub.createSubscriber({ + redisURL: 'redisURL', + redisOptions: { socket_keepalive: true }, }); - var RedisPubSub = require('../src/LiveQuery/RedisPubSub').RedisPubSub; - var EventEmitterPubSub = require('../src/LiveQuery/EventEmitterPubSub').EventEmitterPubSub; - expect(RedisPubSub.createSubscriber).toHaveBeenCalledWith('redisURL'); + const RedisPubSub = require('../lib/Adapters/PubSub/RedisPubSub').RedisPubSub; + const EventEmitterPubSub = require('../lib/Adapters/PubSub/EventEmitterPubSub') + .EventEmitterPubSub; + expect(RedisPubSub.createSubscriber).toHaveBeenCalledWith({ + redisURL: 'redisURL', + redisOptions: { socket_keepalive: true }, + }); expect(EventEmitterPubSub.createSubscriber).not.toHaveBeenCalled(); }); - it('can create event emitter subscriber', function() { - var subscriptionInfos = ParsePubSub.createSubscriber({}); + it('can create event emitter subscriber', function () { + ParsePubSub.createSubscriber({}); - var RedisPubSub = require('../src/LiveQuery/RedisPubSub').RedisPubSub; - var EventEmitterPubSub = require('../src/LiveQuery/EventEmitterPubSub').EventEmitterPubSub; + const RedisPubSub = require('../lib/Adapters/PubSub/RedisPubSub').RedisPubSub; + const EventEmitterPubSub = require('../lib/Adapters/PubSub/EventEmitterPubSub') + .EventEmitterPubSub; expect(RedisPubSub.createSubscriber).not.toHaveBeenCalled(); expect(EventEmitterPubSub.createSubscriber).toHaveBeenCalled(); }); - afterEach(function(){ - jasmine.restoreLibrary('../src/LiveQuery/RedisPubSub', 'RedisPubSub'); - jasmine.restoreLibrary('../src/LiveQuery/EventEmitterPubSub', 'EventEmitterPubSub'); + it('can create publisher/sub with custom adapter', function () { + const adapter = { + createPublisher: jasmine.createSpy('createPublisher'), + createSubscriber: jasmine.createSpy('createSubscriber'), + }; + ParsePubSub.createPublisher({ + pubSubAdapter: adapter, + }); + expect(adapter.createPublisher).toHaveBeenCalled(); + + ParsePubSub.createSubscriber({ + pubSubAdapter: adapter, + }); + expect(adapter.createSubscriber).toHaveBeenCalled(); + + const RedisPubSub = require('../lib/Adapters/PubSub/RedisPubSub').RedisPubSub; + const EventEmitterPubSub = require('../lib/Adapters/PubSub/EventEmitterPubSub') + .EventEmitterPubSub; + expect(RedisPubSub.createSubscriber).not.toHaveBeenCalled(); + expect(EventEmitterPubSub.createSubscriber).not.toHaveBeenCalled(); + expect(RedisPubSub.createPublisher).not.toHaveBeenCalled(); + expect(EventEmitterPubSub.createPublisher).not.toHaveBeenCalled(); + }); + + it('can create publisher/sub with custom function adapter', function () { + const adapter = { + createPublisher: jasmine.createSpy('createPublisher'), + createSubscriber: jasmine.createSpy('createSubscriber'), + }; + ParsePubSub.createPublisher({ + pubSubAdapter: function () { + return adapter; + }, + }); + expect(adapter.createPublisher).toHaveBeenCalled(); + + ParsePubSub.createSubscriber({ + pubSubAdapter: function () { + return adapter; + }, + }); + expect(adapter.createSubscriber).toHaveBeenCalled(); + + const RedisPubSub = require('../lib/Adapters/PubSub/RedisPubSub').RedisPubSub; + const EventEmitterPubSub = require('../lib/Adapters/PubSub/EventEmitterPubSub') + .EventEmitterPubSub; + expect(RedisPubSub.createSubscriber).not.toHaveBeenCalled(); + expect(EventEmitterPubSub.createSubscriber).not.toHaveBeenCalled(); + expect(RedisPubSub.createPublisher).not.toHaveBeenCalled(); + expect(EventEmitterPubSub.createPublisher).not.toHaveBeenCalled(); + }); + + afterEach(function () { + jasmine.restoreLibrary('../lib/Adapters/PubSub/RedisPubSub', 'RedisPubSub'); + jasmine.restoreLibrary('../lib/Adapters/PubSub/EventEmitterPubSub', 'EventEmitterPubSub'); }); }); diff --git a/spec/ParseQuery.Aggregate.spec.js b/spec/ParseQuery.Aggregate.spec.js new file mode 100644 index 0000000000..ac615b1fde --- /dev/null +++ b/spec/ParseQuery.Aggregate.spec.js @@ -0,0 +1,1776 @@ +'use strict'; +const Parse = require('parse/node'); +const request = require('../lib/request'); +const Config = require('../lib/Config'); + +const masterKeyHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Rest-API-Key': 'test', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', +}; + +const masterKeyOptions = { + headers: masterKeyHeaders, + json: true, +}; + +const PointerObject = Parse.Object.extend({ + className: 'PointerObject', +}); + +const loadTestData = () => { + const data1 = { + score: 10, + name: 'foo', + sender: { group: 'A' }, + views: 900, + size: ['S', 'M'], + }; + const data2 = { + score: 10, + name: 'foo', + sender: { group: 'A' }, + views: 800, + size: ['M', 'L'], + }; + const data3 = { + score: 10, + name: 'bar', + sender: { group: 'B' }, + views: 700, + size: ['S'], + }; + const data4 = { + score: 20, + name: 'dpl', + sender: { group: 'B' }, + views: 700, + size: ['S'], + }; + const obj1 = new TestObject(data1); + const obj2 = new TestObject(data2); + const obj3 = new TestObject(data3); + const obj4 = new TestObject(data4); + return Parse.Object.saveAll([obj1, obj2, obj3, obj4]); +}; + +const get = function (url, options) { + options.qs = options.body; + delete options.body; + Object.keys(options.qs).forEach(key => { + options.qs[key] = JSON.stringify(options.qs[key]); + }); + return request(Object.assign({}, { url }, options)) + .then(response => response.data) + .catch(response => { + throw { error: response.data }; + }); +}; + +describe('Parse.Query Aggregate testing', () => { + beforeEach(async () => { + await loadTestData(); + }); + + it('should only query aggregate with master key', done => { + const logger = require('../lib/logger').default; + const loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + loggerErrorSpy.calls.reset(); + Parse._request('GET', `aggregate/someClass`, {}).then( + () => {}, + error => { + expect(error.message).toEqual('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('unauthorized: master key is required')); + done(); + } + ); + }); + + it('invalid query group _id required', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + $group: {}, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options).catch(error => { + expect(error.error.code).toEqual(Parse.Error.INVALID_QUERY); + done(); + }); + }); + + it_id('add7050f-65d5-4a13-b526-5bd1ee09c7f1')(it)('group by field', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + $group: { _id: '$name' }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(3); + expect(Object.prototype.hasOwnProperty.call(resp.results[0], 'objectId')).toBe(true); + expect(Object.prototype.hasOwnProperty.call(resp.results[1], 'objectId')).toBe(true); + expect(Object.prototype.hasOwnProperty.call(resp.results[2], 'objectId')).toBe(true); + expect(resp.results[0].objectId).not.toBe(undefined); + expect(resp.results[1].objectId).not.toBe(undefined); + expect(resp.results[2].objectId).not.toBe(undefined); + done(); + }) + .catch(done.fail); + }); + + it_id('0ab0d776-e45d-419a-9b35-3d11933b77d1')(it)('group by pipeline operator', async () => { + const options = Object.assign({}, masterKeyOptions, { + body: { + pipeline: { + $group: { _id: '$name' }, + }, + }, + }); + const resp = await get(Parse.serverURL + '/aggregate/TestObject', options); + expect(resp.results.length).toBe(3); + expect(Object.prototype.hasOwnProperty.call(resp.results[0], 'objectId')).toBe(true); + expect(Object.prototype.hasOwnProperty.call(resp.results[1], 'objectId')).toBe(true); + expect(Object.prototype.hasOwnProperty.call(resp.results[2], 'objectId')).toBe(true); + expect(resp.results[0].objectId).not.toBe(undefined); + expect(resp.results[1].objectId).not.toBe(undefined); + expect(resp.results[2].objectId).not.toBe(undefined); + }); + + it_id('b6b42145-7eb4-47aa-ada6-8c1444420e07')(it)('group by empty object', done => { + const obj = new TestObject(); + const pipeline = [ + { + $group: { _id: {} }, + }, + ]; + obj + .save() + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results[0].objectId).toEqual(null); + done(); + }); + }); + + it_id('0f5f6869-e675-41b9-9ad2-52b201124fb0')(it)('group by empty string', done => { + const obj = new TestObject(); + const pipeline = [ + { + $group: { _id: '' }, + }, + ]; + obj + .save() + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results[0].objectId).toEqual(null); + done(); + }); + }); + + it_id('b9c4f1b4-47f4-4ff4-88fb-586711f57e4a')(it)('group by empty array', done => { + const obj = new TestObject(); + const pipeline = [ + { + $group: { _id: [] }, + }, + ]; + obj + .save() + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results[0].objectId).toEqual(null); + done(); + }); + }); + + it_id('bf5ee3e5-986c-4994-9c8d-79310283f602')(it)('group by multiple columns ', done => { + const obj1 = new TestObject(); + const obj2 = new TestObject(); + const obj3 = new TestObject(); + const pipeline = [ + { + $group: { + _id: { + score: '$score', + views: '$views', + }, + count: { $sum: 1 }, + }, + }, + ]; + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toEqual(5); + done(); + }); + }); + + it_id('3e652c61-78e1-4541-83ac-51ad1def9874')(it)('group by date object', done => { + const obj1 = new TestObject(); + const obj2 = new TestObject(); + const obj3 = new TestObject(); + const pipeline = [ + { + $group: { + _id: { + day: { $dayOfMonth: '$_updated_at' }, + month: { $month: '$_created_at' }, + year: { $year: '$_created_at' }, + }, + count: { $sum: 1 }, + }, + }, + ]; + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + const createdAt = new Date(obj1.createdAt); + expect(results[0].objectId.day).toEqual(createdAt.getUTCDate()); + expect(results[0].objectId.month).toEqual(createdAt.getUTCMonth() + 1); + expect(results[0].objectId.year).toEqual(createdAt.getUTCFullYear()); + done(); + }); + }); + + it_id('5d3a0f73-1f49-46f3-9be5-caf1eaefec79')(it)('group by date object transform', done => { + const obj1 = new TestObject(); + const obj2 = new TestObject(); + const obj3 = new TestObject(); + const pipeline = [ + { + $group: { + _id: { + day: { $dayOfMonth: '$updatedAt' }, + month: { $month: '$createdAt' }, + year: { $year: '$createdAt' }, + }, + count: { $sum: 1 }, + }, + }, + ]; + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + const createdAt = new Date(obj1.createdAt); + expect(results[0].objectId.day).toEqual(createdAt.getUTCDate()); + expect(results[0].objectId.month).toEqual(createdAt.getUTCMonth() + 1); + expect(results[0].objectId.year).toEqual(createdAt.getUTCFullYear()); + done(); + }); + }); + + it_id('1f9b10f7-dc0e-467f-b506-a303b9c36258')(it)('group by number', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + $group: { _id: '$score' }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(2); + expect(Object.prototype.hasOwnProperty.call(resp.results[0], 'objectId')).toBe(true); + expect(Object.prototype.hasOwnProperty.call(resp.results[1], 'objectId')).toBe(true); + expect(resp.results.sort((a, b) => (a.objectId > b.objectId ? 1 : -1))).toEqual([ + { objectId: 10 }, + { objectId: 20 }, + ]); + done(); + }) + .catch(done.fail); + }); + + it_id('c7695018-03de-49e4-8a72-d4d956f70deb')(it_exclude_dbs(['postgres']))('group and multiply transform', done => { + const obj1 = new TestObject({ name: 'item a', quantity: 2, price: 10 }); + const obj2 = new TestObject({ name: 'item b', quantity: 5, price: 5 }); + const pipeline = [ + { + $group: { + _id: null, + total: { $sum: { $multiply: ['$quantity', '$price'] } }, + }, + }, + ]; + Parse.Object.saveAll([obj1, obj2]) + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].total).toEqual(45); + done(); + }); + }); + + it_id('2d278175-7594-4b29-bef4-04c778b7a42f')(it_exclude_dbs(['postgres']))('project and multiply transform', done => { + const obj1 = new TestObject({ name: 'item a', quantity: 2, price: 10 }); + const obj2 = new TestObject({ name: 'item b', quantity: 5, price: 5 }); + const pipeline = [ + { + $match: { quantity: { $exists: true } }, + }, + { + $project: { + name: 1, + total: { $multiply: ['$quantity', '$price'] }, + }, + }, + ]; + Parse.Object.saveAll([obj1, obj2]) + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toEqual(2); + if (results[0].name === 'item a') { + expect(results[0].total).toEqual(20); + expect(results[1].total).toEqual(25); + } else { + expect(results[0].total).toEqual(25); + expect(results[1].total).toEqual(20); + } + done(); + }); + }); + + it_id('9c9d9318-3a9e-4c2a-8a09-d3aa52c7505b')(it_exclude_dbs(['postgres']))('project without objectId transform', done => { + const obj1 = new TestObject({ name: 'item a', quantity: 2, price: 10 }); + const obj2 = new TestObject({ name: 'item b', quantity: 5, price: 5 }); + const pipeline = [ + { + $match: { quantity: { $exists: true } }, + }, + { + $project: { + _id: 0, + total: { $multiply: ['$quantity', '$price'] }, + }, + }, + { + $sort: { total: 1 }, + }, + ]; + Parse.Object.saveAll([obj1, obj2]) + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toEqual(2); + expect(results[0].total).toEqual(20); + expect(results[0].objectId).toEqual(undefined); + expect(results[1].total).toEqual(25); + expect(results[1].objectId).toEqual(undefined); + done(); + }); + }); + + it_id('f92c82ac-1993-4758-b718-45689dfc4154')(it_exclude_dbs(['postgres']))('project updatedAt only transform', done => { + const pipeline = [ + { + $project: { _id: 0, updatedAt: 1 }, + }, + ]; + const query = new Parse.Query(TestObject); + query.aggregate(pipeline).then(results => { + expect(results.length).toEqual(4); + for (let i = 0; i < results.length; i++) { + const item = results[i]; + expect(Object.prototype.hasOwnProperty.call(item, 'updatedAt')).toEqual(true); + expect(Object.prototype.hasOwnProperty.call(item, 'objectId')).toEqual(false); + } + done(); + }); + }); + + it_id('99566b1d-778d-4444-9deb-c398108e659d')(it_exclude_dbs(['postgres']))('can group by any date field (it does not work if you have dirty data)', + done => { + // rows in your collection with non date data in the field that is supposed to be a date + const obj1 = new TestObject({ dateField2019: new Date(1990, 11, 1) }); + const obj2 = new TestObject({ dateField2019: new Date(1990, 5, 1) }); + const obj3 = new TestObject({ dateField2019: new Date(1990, 11, 1) }); + const pipeline = [ + { + $match: { + dateField2019: { $exists: true }, + }, + }, + { + $group: { + _id: { + day: { $dayOfMonth: '$dateField2019' }, + month: { $month: '$dateField2019' }, + year: { $year: '$dateField2019' }, + }, + count: { $sum: 1 }, + }, + }, + ]; + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + const counts = results.map(result => result.count); + expect(counts.length).toBe(2); + expect(counts.sort()).toEqual([1, 2]); + done(); + }) + .catch(done.fail); + } + ); + + it_id('3723671d-4100-4103-ad9c-60e4c22e20ff')(it_exclude_dbs(['postgres']))('matches expression with $dateSubtract from $$NOW', async () => { + const obj1 = new TestObject({ date: new Date(new Date().getTime() - 1 * 24 * 60 * 60 * 1_000) }); // 1 day ago + const obj2 = new TestObject({ date: new Date(new Date().getTime() - 2 * 24 * 60 * 60 * 1_000) }); // 3 days ago + await Parse.Object.saveAll([obj1, obj2]); + + const pipeline = [ + { + $match: { + $expr: { + $gte: [ + '$date', + { + $dateSubtract: { + startDate: '$$NOW', + unit: 'day', + amount: 2, + }, + }, + ], + }, + }, + }, + ]; + + const query = new Parse.Query('TestObject'); + const results = await query.aggregate(pipeline, { useMasterKey: true }); + expect(results.length).toBe(1); + expect(new Date(results[0].date.iso)).toEqual(obj1.get('date')); + }); + + it_id('8c211edc-a48e-4ab3-810a-f56897228393')(it_exclude_dbs(['postgres']))('rawValues: true converts $date EJSON marker to BSON Date in $match', async () => { + const obj = new TestObject(); + await obj.save(); + const iso = new Date(obj.createdAt.getTime() + 1).toISOString(); + const pipeline = [ + { $match: { objectId: obj.id, createdAt: { $lte: { $date: iso } } } }, + { $count: 'total' }, + ]; + const query = new Parse.Query('TestObject'); + const results = await query.aggregate(pipeline, { rawValues: true, useMasterKey: true }); + expect(results.length).toBe(1); + expect(results[0].total).toBe(1); + }); + + it_id('2a79e4c8-aa16-434f-bbea-e34637eaff16')(it_exclude_dbs(['postgres']))('rawValues: true deserializes $date at any nesting depth', async () => { + const obj = new TestObject(); + await obj.save(); + const iso = new Date(obj.createdAt.getTime() + 1).toISOString(); + const pipeline = [ + { + $match: { + $and: [ + { objectId: obj.id }, + { $or: [{ createdAt: { $lte: { $date: iso } } }] }, + ], + }, + }, + { $count: 'total' }, + ]; + const query = new Parse.Query('TestObject'); + const results = await query.aggregate(pipeline, { rawValues: true, useMasterKey: true }); + expect(results.length).toBe(1); + expect(results[0].total).toBe(1); + }); + + it_id('cc08f092-8f26-4f5b-81f2-769de812982f')(it_exclude_dbs(['postgres']))('rawValues: true does NOT coerce bare ISO strings', async () => { + const obj = new TestObject(); + await obj.save(); + const iso = new Date(obj.createdAt.getTime() + 1).toISOString(); + const pipeline = [ + { $match: { objectId: obj.id, createdAt: { $lte: iso } } }, + { $count: 'total' }, + ]; + const query = new Parse.Query('TestObject'); + const results = await query.aggregate(pipeline, { rawValues: true, useMasterKey: true }); + // Bare ISO string compared against BSON Date: MongoDB string-vs-date comparison yields no matches. + expect(results.length).toBe(0); + }); + + it_id('bc4cb19e-3114-40d8-8db8-0e9f5b582f33')(it_exclude_dbs(['postgres']))('rawValues: true does NOT coerce Parse Date encoding `{ __type: "Date", iso }`', async () => { + const obj = new TestObject(); + await obj.save(); + const iso = new Date(obj.createdAt.getTime() + 1).toISOString(); + const pipeline = [ + { + $match: { + objectId: obj.id, + createdAt: { $lte: { __type: 'Date', iso } }, + }, + }, + { $count: 'total' }, + ]; + const query = new Parse.Query('TestObject'); + const results = await query.aggregate(pipeline, { rawValues: true, useMasterKey: true }); + // Parse Date encoding is not interpreted in rawValues mode; comparison fails silently. + expect(results.length).toBe(0); + }); + + it_id('27c3bf01-5b4a-41b3-988e-522fdef63181')(it_exclude_dbs(['postgres']))('rawValues: true serializes BSON Date in results as `{ $date: iso }`', async () => { + const obj = new TestObject(); + await obj.save(); + const iso = new Date(obj.createdAt.getTime() + 1).toISOString(); + const pipeline = [ + { $match: { objectId: obj.id, createdAt: { $lte: { $date: iso } } } }, + { $project: { _id: 1, _created_at: 1 } }, + ]; + const query = new Parse.Query('TestObject'); + const results = await query.aggregate(pipeline, { rawValues: true, useMasterKey: true }); + expect(results.length).toBe(1); + // EJSON-serialized date marker, not Parse `{ __type: 'Date', iso }` encoding. + expect(results[0]._created_at).toEqual(jasmine.objectContaining({ $date: jasmine.any(String) })); + }); + + it_id('5b6b225d-219e-480c-9241-ac3e146dda9f')(it_exclude_dbs(['postgres']))('rawValues: true deserializes EJSON in `$addFields`', async () => { + const obj = new TestObject(); + await obj.save(); + const iso = '2026-01-01T00:00:00.000Z'; + const pipeline = [ + { $match: { objectId: obj.id } }, + { $addFields: { pinned: { $date: iso } } }, + { $project: { _id: 1, pinned: 1 } }, + ]; + const query = new Parse.Query('TestObject'); + const results = await query.aggregate(pipeline, { rawValues: true, useMasterKey: true }); + expect(results.length).toBe(1); + expect(results[0].pinned).toEqual(jasmine.objectContaining({ $date: jasmine.any(String) })); + }); + + it_only_db('postgres')( + 'can group by any date field postgres (it does not work if you have dirty data)', // rows in your collection with non date data in the field that is supposed to be a date + done => { + const obj1 = new TestObject({ dateField2019: new Date(1990, 11, 1) }); + const obj2 = new TestObject({ dateField2019: new Date(1990, 5, 1) }); + const obj3 = new TestObject({ dateField2019: new Date(1990, 11, 1) }); + const pipeline = [ + { + $group: { + _id: { + day: { $dayOfMonth: '$dateField2019' }, + month: { $month: '$dateField2019' }, + year: { $year: '$dateField2019' }, + }, + count: { $sum: 1 }, + }, + }, + ]; + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + const counts = results.map(result => result.count); + expect(counts.length).toBe(3); + expect(counts.sort()).toEqual([1, 2, 4]); + done(); + }) + .catch(done.fail); + } + ); + + it_id('bf3c2704-b721-4b1b-92fa-e1b129ae4aff')(it)('group by pointer', done => { + const pointer1 = new TestObject(); + const pointer2 = new TestObject(); + const obj1 = new TestObject({ pointer: pointer1 }); + const obj2 = new TestObject({ pointer: pointer2 }); + const obj3 = new TestObject({ pointer: pointer1 }); + const pipeline = [{ $group: { _id: '$pointer' } }]; + Parse.Object.saveAll([pointer1, pointer2, obj1, obj2, obj3]) + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toEqual(3); + expect(results.some(result => result.objectId === pointer1.id)).toEqual(true); + expect(results.some(result => result.objectId === pointer2.id)).toEqual(true); + expect(results.some(result => result.objectId === null)).toEqual(true); + done(); + }); + }); + + it_id('9ee9e8c0-a590-4af9-97a9-4b8e5080ffae')(it)('group sum query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + $group: { _id: null, total: { $sum: '$score' } }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(Object.prototype.hasOwnProperty.call(resp.results[0], 'objectId')).toBe(true); + expect(resp.results[0].objectId).toBe(null); + expect(resp.results[0].total).toBe(50); + done(); + }) + .catch(done.fail); + }); + + it_id('39133cd6-5bdf-4917-b672-a9d7a9157b6f')(it)('group count query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + $group: { _id: null, total: { $sum: 1 } }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(Object.prototype.hasOwnProperty.call(resp.results[0], 'objectId')).toBe(true); + expect(resp.results[0].objectId).toBe(null); + expect(resp.results[0].total).toBe(4); + done(); + }) + .catch(done.fail); + }); + + it_id('48685ff3-066f-4353-82e7-87f39d812ff7')(it)('group min query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + $group: { _id: null, minScore: { $min: '$score' } }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(Object.prototype.hasOwnProperty.call(resp.results[0], 'objectId')).toBe(true); + expect(resp.results[0].objectId).toBe(null); + expect(resp.results[0].minScore).toBe(10); + done(); + }) + .catch(done.fail); + }); + + it_id('581efea6-6525-4e10-96d9-76d32c73e7a9')(it)('group max query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + $group: { _id: null, maxScore: { $max: '$score' } }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(Object.prototype.hasOwnProperty.call(resp.results[0], 'objectId')).toBe(true); + expect(resp.results[0].objectId).toBe(null); + expect(resp.results[0].maxScore).toBe(20); + done(); + }) + .catch(done.fail); + }); + + it_id('5f880de2-b97f-43d1-89b7-ad903a4be4e2')(it)('group avg query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + $group: { _id: null, avgScore: { $avg: '$score' } }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(Object.prototype.hasOwnProperty.call(resp.results[0], 'objectId')).toBe(true); + expect(resp.results[0].objectId).toBe(null); + expect(resp.results[0].avgScore).toBe(12.5); + done(); + }) + .catch(done.fail); + }); + + it_id('58e7a1a0-fae1-4993-b336-7bcbd5b7c786')(it)('limit query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + $limit: 2, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(2); + done(); + }) + .catch(done.fail); + }); + + it_id('c892a3d2-8ae8-4b88-bf2b-3c958e1cacd8')(it)('sort ascending query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + $sort: { name: 1 }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(4); + expect(resp.results[0].name).toBe('bar'); + expect(resp.results[1].name).toBe('dpl'); + expect(resp.results[2].name).toBe('foo'); + expect(resp.results[3].name).toBe('foo'); + done(); + }) + .catch(done.fail); + }); + + it_id('79d4bc2e-8b69-42ec-8526-20d17e968ab3')(it)('sort decending query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + $sort: { name: -1 }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(4); + expect(resp.results[0].name).toBe('foo'); + expect(resp.results[1].name).toBe('foo'); + expect(resp.results[2].name).toBe('dpl'); + expect(resp.results[3].name).toBe('bar'); + done(); + }) + .catch(done.fail); + }); + + it_id('b3d97d48-bd6b-444d-be64-cc1fd4738266')(it)('skip query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + $skip: 2, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(2); + done(); + }) + .catch(done.fail); + }); + + it_id('4a7daee3-5ba1-4c8b-b406-1846a73a64c8')(it)('match comparison date query', done => { + const today = new Date(); + const yesterday = new Date(); + const tomorrow = new Date(); + yesterday.setDate(today.getDate() - 1); + tomorrow.setDate(today.getDate() + 1); + const obj1 = new TestObject({ dateField: yesterday }); + const obj2 = new TestObject({ dateField: today }); + const obj3 = new TestObject({ dateField: tomorrow }); + const pipeline = [{ $match: { dateField: { $lt: tomorrow } } }]; + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toBe(2); + done(); + }); + }); + + it_id('d98c8c20-6dac-4d74-8228-85a1ae46a7d0')(it)('should aggregate with Date object (directAccess)', async () => { + const rest = require('../lib/rest'); + const auth = require('../lib/Auth'); + const TestObject = Parse.Object.extend('TestObject'); + const date = new Date(); + await new TestObject({ date: date }).save(null, { useMasterKey: true }); + const config = Config.get(Parse.applicationId); + const resp = await rest.find( + config, + auth.master(config), + 'TestObject', + {}, + { pipeline: [{ $match: { date: { $lte: new Date() } } }] } + ); + expect(resp.results.length).toBe(1); + }); + + it_id('3d73d23a-fce1-4ac0-972a-50f6a550f348')(it)('match comparison query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + $match: { score: { $gt: 15 } }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(1); + expect(resp.results[0].score).toBe(20); + done(); + }) + .catch(done.fail); + }); + + it_id('11772059-6c93-41ac-8dfe-e55b6c97e16f')(it)('match multiple comparison query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + $match: { score: { $gt: 5, $lt: 15 } }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(3); + expect(resp.results[0].score).toBe(10); + expect(resp.results[1].score).toBe(10); + expect(resp.results[2].score).toBe(10); + done(); + }) + .catch(done.fail); + }); + + it_id('ca2efb04-8f73-40ca-a5fc-79d0032bc398')(it)('match complex comparison query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + $match: { score: { $gt: 5, $lt: 15 }, views: { $gt: 850, $lt: 1000 } }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(1); + expect(resp.results[0].score).toBe(10); + expect(resp.results[0].views).toBe(900); + done(); + }) + .catch(done.fail); + }); + + it_id('5ef9dcbe-fe54-4db2-b8fb-58c87c6ff072')(it)('match comparison and equality query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + $match: { score: { $gt: 5, $lt: 15 }, views: 900 }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(1); + expect(resp.results[0].score).toBe(10); + expect(resp.results[0].views).toBe(900); + done(); + }) + .catch(done.fail); + }); + + it_id('c910a6af-58df-46aa-bbf8-da014a04cdcd')(it)('match $or query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + $match: { + $or: [{ score: { $gt: 15, $lt: 25 } }, { views: { $gt: 750, $lt: 850 } }], + }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(2); + // Match score { $gt: 15, $lt: 25 } + expect(resp.results.some(result => result.score === 20)).toEqual(true); + expect(resp.results.some(result => result.views === 700)).toEqual(true); + + // Match view { $gt: 750, $lt: 850 } + expect(resp.results.some(result => result.score === 10)).toEqual(true); + expect(resp.results.some(result => result.views === 800)).toEqual(true); + done(); + }) + .catch(done.fail); + }); + + it_id('0f768dc2-0675-4e45-a763-5ca9c895fa5f')(it)('match objectId query', done => { + const obj1 = new TestObject(); + const obj2 = new TestObject(); + Parse.Object.saveAll([obj1, obj2]) + .then(() => { + const pipeline = [{ $match: { _id: obj1.id } }]; + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].objectId).toEqual(obj1.id); + done(); + }); + }); + + it_id('27349e04-0d9d-453f-ad85-1a811631582d')(it)('match field query', done => { + const obj1 = new TestObject({ name: 'TestObject1' }); + const obj2 = new TestObject({ name: 'TestObject2' }); + Parse.Object.saveAll([obj1, obj2]) + .then(() => { + const pipeline = [{ $match: { name: 'TestObject1' } }]; + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].objectId).toEqual(obj1.id); + done(); + }); + }); + + it_id('9222e025-d450-4699-8d5b-c5cf9a64fb24')(it)('match pointer query', done => { + const pointer1 = new PointerObject(); + const pointer2 = new PointerObject(); + const obj1 = new TestObject({ pointer: pointer1 }); + const obj2 = new TestObject({ pointer: pointer2 }); + const obj3 = new TestObject({ pointer: pointer1 }); + + Parse.Object.saveAll([pointer1, pointer2, obj1, obj2, obj3]) + .then(() => { + const pipeline = [{ $match: { pointer: pointer1.id } }]; + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toEqual(2); + expect(results[0].pointer.objectId).toEqual(pointer1.id); + expect(results[1].pointer.objectId).toEqual(pointer1.id); + expect(results.some(result => result.objectId === obj1.id)).toEqual(true); + expect(results.some(result => result.objectId === obj3.id)).toEqual(true); + done(); + }); + }); + + it_id('3a1e2cdc-52c7-4060-bc90-b06d557d85ce')(it_exclude_dbs(['postgres']))('match exists query', done => { + const pipeline = [{ $match: { score: { $exists: true } } }]; + const query = new Parse.Query(TestObject); + query.aggregate(pipeline).then(results => { + expect(results.length).toEqual(4); + done(); + }); + }); + + it_id('0adea3f4-73f7-4b48-a7dd-c764ceb947ec')(it)('match date query - createdAt', done => { + const obj1 = new TestObject(); + const obj2 = new TestObject(); + + Parse.Object.saveAll([obj1, obj2]) + .then(() => { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const pipeline = [{ $match: { createdAt: { $gte: today } } }]; + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + // Four objects were created initially, we added two more. + expect(results.length).toEqual(6); + done(); + }); + }); + + it_id('cdc0eecb-f547-4881-84cc-c06fb46a636a')(it)('match date query - updatedAt', done => { + const obj1 = new TestObject(); + const obj2 = new TestObject(); + + Parse.Object.saveAll([obj1, obj2]) + .then(() => { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const pipeline = [{ $match: { updatedAt: { $gte: today } } }]; + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + // Four objects were added initially, we added two more. + expect(results.length).toEqual(6); + done(); + }); + }); + + it_id('621fe00a-1127-4341-a8e1-fc579b7ed8bd')(it)('match date query - empty', done => { + const obj1 = new TestObject(); + const obj2 = new TestObject(); + + Parse.Object.saveAll([obj1, obj2]) + .then(() => { + const now = new Date(); + const future = new Date(now.getFullYear(), now.getMonth() + 1, now.getDate()); + const pipeline = [{ $match: { createdAt: future } }]; + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toEqual(0); + done(); + }); + }); + + it_id('802ffc99-861b-4b72-90a6-0c666a2e3fd8')(it_exclude_dbs(['postgres']))('match pointer with operator query', done => { + const pointer = new PointerObject(); + + const obj1 = new TestObject({ pointer }); + const obj2 = new TestObject({ pointer }); + const obj3 = new TestObject(); + + Parse.Object.saveAll([pointer, obj1, obj2, obj3]) + .then(() => { + const pipeline = [{ $match: { pointer: { $exists: true } } }]; + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toEqual(2); + expect(results[0].pointer.objectId).toEqual(pointer.id); + expect(results[1].pointer.objectId).toEqual(pointer.id); + expect(results.some(result => result.objectId === obj1.id)).toEqual(true); + expect(results.some(result => result.objectId === obj2.id)).toEqual(true); + done(); + }); + }); + + it_id('28090280-7c3e-47f8-8bf6-bebf8566a36c')(it_exclude_dbs(['postgres']))('match null values', async () => { + const obj1 = new Parse.Object('MyCollection'); + obj1.set('language', 'en'); + obj1.set('otherField', 1); + const obj2 = new Parse.Object('MyCollection'); + obj2.set('language', 'en'); + obj2.set('otherField', 2); + const obj3 = new Parse.Object('MyCollection'); + obj3.set('language', null); + obj3.set('otherField', 3); + const obj4 = new Parse.Object('MyCollection'); + obj4.set('language', null); + obj4.set('otherField', 4); + const obj5 = new Parse.Object('MyCollection'); + obj5.set('language', 'pt'); + obj5.set('otherField', 5); + const obj6 = new Parse.Object('MyCollection'); + obj6.set('language', 'pt'); + obj6.set('otherField', 6); + await Parse.Object.saveAll([obj1, obj2, obj3, obj4, obj5, obj6]); + + expect( + ( + await new Parse.Query('MyCollection').aggregate([ + { + $match: { + language: { $in: [null, 'en'] }, + }, + }, + ]) + ) + .map(value => value.otherField) + .sort() + ).toEqual([1, 2, 3, 4]); + + expect( + ( + await new Parse.Query('MyCollection').aggregate([ + { + $match: { + $or: [{ language: 'en' }, { language: null }], + }, + }, + ]) + ) + .map(value => value.otherField) + .sort() + ).toEqual([1, 2, 3, 4]); + }); + + it_id('df63d1f5-7c37-4ed9-8bc5-20d82f29f509')(it)('project query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + $project: { name: 1 }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + resp.results.forEach(result => { + expect(result.objectId).not.toBe(undefined); + expect(result.name).not.toBe(undefined); + expect(result.sender).toBe(undefined); + expect(result.size).toBe(undefined); + expect(result.score).toBe(undefined); + }); + done(); + }) + .catch(done.fail); + }); + + it_id('69224bbb-8ea0-4ab4-af23-398b6432f668')(it)('multiple project query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + $project: { name: 1, score: 1, sender: 1 }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + resp.results.forEach(result => { + expect(result.objectId).not.toBe(undefined); + expect(result.name).not.toBe(undefined); + expect(result.score).not.toBe(undefined); + expect(result.sender).not.toBe(undefined); + expect(result.size).toBe(undefined); + }); + done(); + }) + .catch(done.fail); + }); + + it_id('97ce4c7c-8d9f-4ffd-9352-394bc9867bab')(it)('project pointer query', done => { + const pointer = new PointerObject(); + const obj = new TestObject({ pointer, name: 'hello' }); + + obj + .save() + .then(() => { + const pipeline = [ + { $match: { _id: obj.id } }, + { $project: { pointer: 1, name: 1, createdAt: 1 } }, + ]; + const query = new Parse.Query(TestObject); + return query.aggregate(pipeline); + }) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].name).toEqual('hello'); + expect(results[0].createdAt).not.toBe(undefined); + expect(results[0].pointer.objectId).toEqual(pointer.id); + done(); + }); + }); + + it_id('3940aac3-ac49-4279-8083-af9096de636f')(it)('project with group query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + $project: { score: 1 }, + $group: { _id: '$score', score: { $sum: '$score' } }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(2); + resp.results.forEach(result => { + expect(Object.prototype.hasOwnProperty.call(result, 'objectId')).toBe(true); + expect(result.name).toBe(undefined); + expect(result.sender).toBe(undefined); + expect(result.size).toBe(undefined); + expect(result.score).not.toBe(undefined); + if (result.objectId === 10) { + expect(result.score).toBe(30); + } + if (result.objectId === 20) { + expect(result.score).toBe(20); + } + }); + done(); + }) + .catch(done.fail); + }); + + it('class does not exist return empty', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + $group: { _id: null, total: { $sum: '$score' } }, + }, + }); + get(Parse.serverURL + '/aggregate/UnknownClass', options) + .then(resp => { + expect(resp.results.length).toBe(0); + done(); + }) + .catch(done.fail); + }); + + it('field does not exist return empty', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + $group: { _id: null, total: { $sum: '$unknownfield' } }, + }, + }); + get(Parse.serverURL + '/aggregate/UnknownClass', options) + .then(resp => { + expect(resp.results.length).toBe(0); + done(); + }) + .catch(done.fail); + }); + + it_id('985e7a66-d4f5-4f72-bd54-ee44670e0ab0')(it)('distinct query', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { distinct: 'score' }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(2); + expect(resp.results.includes(10)).toBe(true); + expect(resp.results.includes(20)).toBe(true); + done(); + }) + .catch(done.fail); + }); + + it_id('ef157f86-c456-4a4c-8dac-81910bd0f716')(it)('distinct query with where', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + distinct: 'score', + $where: { + name: 'bar', + }, + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results[0]).toBe(10); + done(); + }) + .catch(done.fail); + }); + + it_id('7f5275cc-2c34-42bc-8a09-43378419c326')(it)('distinct query with where string', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + distinct: 'score', + $where: JSON.stringify({ name: 'bar' }), + }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results[0]).toBe(10); + done(); + }) + .catch(done.fail); + }); + + it_id('383b7248-e457-4373-8d5c-f9359384347e')(it)('distinct nested', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { distinct: 'sender.group' }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(2); + expect(resp.results.includes('A')).toBe(true); + expect(resp.results.includes('B')).toBe(true); + done(); + }) + .catch(done.fail); + }); + + it_id('20f14464-adb7-428c-ac7a-5a91a1952a64')(it)('distinct pointer', done => { + const pointer1 = new PointerObject(); + const pointer2 = new PointerObject(); + const obj1 = new TestObject({ pointer: pointer1 }); + const obj2 = new TestObject({ pointer: pointer2 }); + const obj3 = new TestObject({ pointer: pointer1 }); + Parse.Object.saveAll([pointer1, pointer2, obj1, obj2, obj3]) + .then(() => { + const query = new Parse.Query(TestObject); + return query.distinct('pointer'); + }) + .then(results => { + expect(results.length).toEqual(2); + expect(results.some(result => result.objectId === pointer1.id)).toEqual(true); + expect(results.some(result => result.objectId === pointer2.id)).toEqual(true); + done(); + }); + }); + + it_id('91e6cb94-2837-44b7-b057-0c4965057caa')(it)('distinct class does not exist return empty', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { distinct: 'unknown' }, + }); + get(Parse.serverURL + '/aggregate/UnknownClass', options) + .then(resp => { + expect(resp.results.length).toBe(0); + done(); + }) + .catch(done.fail); + }); + + it_id('bd15daaf-8dc7-458c-81e2-170026f4a8a7')(it)('distinct field does not exist return empty', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { distinct: 'unknown' }, + }); + const obj = new TestObject(); + obj + .save() + .then(() => { + return get(Parse.serverURL + '/aggregate/TestObject', options); + }) + .then(resp => { + expect(resp.results.length).toBe(0); + done(); + }) + .catch(done.fail); + }); + + it_id('21988fce-8326-425f-82f0-cd444ca3671b')(it)('distinct array', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { distinct: 'size' }, + }); + get(Parse.serverURL + '/aggregate/TestObject', options) + .then(resp => { + expect(resp.results.length).toBe(3); + expect(resp.results.includes('S')).toBe(true); + expect(resp.results.includes('M')).toBe(true); + expect(resp.results.includes('L')).toBe(true); + done(); + }) + .catch(done.fail); + }); + + it_id('633fde06-c4af-474b-9841-3ccabc24dd4f')(it)('distinct objectId', async () => { + const query = new Parse.Query(TestObject); + const results = await query.distinct('objectId'); + expect(results.length).toBe(4); + }); + + it_id('8f9706f4-2703-42f1-b524-f2f7e72bbfe7')(it)('distinct createdAt', async () => { + const object1 = new TestObject({ createdAt_test: true }); + await object1.save(); + const object2 = new TestObject({ createdAt_test: true }); + await object2.save(); + const query = new Parse.Query(TestObject); + query.equalTo('createdAt_test', true); + const results = await query.distinct('createdAt'); + expect(results.length).toBe(2); + }); + + it_id('3562e600-8ce5-4d6d-96df-8ff969e81421')(it)('distinct updatedAt', async () => { + const object1 = new TestObject({ updatedAt_test: true }); + await object1.save(); + const object2 = new TestObject(); + await object2.save(); + object2.set('updatedAt_test', true); + await object2.save(); + const query = new Parse.Query(TestObject); + query.equalTo('updatedAt_test', true); + const results = await query.distinct('updatedAt'); + expect(results.length).toBe(2); + }); + + it_id('5012cfb1-b0aa-429d-a94f-d32d8aa0b7f9')(it)('distinct null field', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { distinct: 'distinctField' }, + }); + const user1 = new Parse.User(); + user1.setUsername('distinct_1'); + user1.setPassword('password'); + user1.set('distinctField', 'one'); + + const user2 = new Parse.User(); + user2.setUsername('distinct_2'); + user2.setPassword('password'); + user2.set('distinctField', null); + user1 + .signUp() + .then(() => { + return user2.signUp(); + }) + .then(() => { + return get(Parse.serverURL + '/aggregate/_User', options); + }) + .then(resp => { + expect(resp.results.length).toEqual(1); + expect(resp.results).toEqual(['one']); + done(); + }) + .catch(done.fail); + }); + + it_id('d9c19419-e99d-4d9f-b7f3-418e49ee47dd')(it)('does not return sensitive hidden properties', done => { + const options = Object.assign({}, masterKeyOptions, { + body: { + $match: { + score: { + $gt: 5, + }, + }, + }, + }); + + const username = 'leaky_user'; + const score = 10; + + const user = new Parse.User(); + user.setUsername(username); + user.setPassword('password'); + user.set('score', score); + user + .signUp() + .then(function () { + return get(Parse.serverURL + '/aggregate/_User', options); + }) + .then(function (resp) { + expect(resp.results.length).toBe(1); + const result = resp.results[0]; + + // verify server-side keys are not present... + expect(result._hashed_password).toBe(undefined); + expect(result._wperm).toBe(undefined); + expect(result._rperm).toBe(undefined); + expect(result._acl).toBe(undefined); + expect(result._created_at).toBe(undefined); + expect(result._updated_at).toBe(undefined); + + // verify createdAt, updatedAt and others are present + expect(result.createdAt).not.toBe(undefined); + expect(result.updatedAt).not.toBe(undefined); + expect(result.objectId).not.toBe(undefined); + expect(result.username).toBe(username); + expect(result.score).toBe(score); + + done(); + }) + .catch(function (err) { + fail(err); + }); + }); + + it_id('0a23e791-e9b5-457a-9bf9-9c5ecf406f42')(it_exclude_dbs(['postgres']))('aggregate allow multiple of same stage', async done => { + await reconfigureServer({ silent: false }); + const pointer1 = new TestObject({ value: 1 }); + const pointer2 = new TestObject({ value: 2 }); + const pointer3 = new TestObject({ value: 3 }); + + const obj1 = new TestObject({ pointer: pointer1, name: 'Hello' }); + const obj2 = new TestObject({ pointer: pointer2, name: 'Hello' }); + const obj3 = new TestObject({ pointer: pointer3, name: 'World' }); + + const options = Object.assign({}, masterKeyOptions, { + body: { + pipeline: [ + { + $match: { name: 'Hello' }, + }, + { + // Transform className$objectId to objectId and store in new field tempPointer + $project: { + tempPointer: { $substr: ['$_p_pointer', 11, -1] }, // Remove TestObject$ + }, + }, + { + // Left Join, replace objectId stored in tempPointer with an actual object + $lookup: { + from: 'test_TestObject', + localField: 'tempPointer', + foreignField: '_id', + as: 'tempPointer', + }, + }, + { + // lookup returns an array, Deconstructs an array field to objects + $unwind: { + path: '$tempPointer', + }, + }, + { + $match: { 'tempPointer.value': 2 }, + }, + ], + }, + }); + Parse.Object.saveAll([pointer1, pointer2, pointer3, obj1, obj2, obj3]) + .then(() => { + return get(Parse.serverURL + '/aggregate/TestObject', options); + }) + .then(resp => { + expect(resp.results.length).toEqual(1); + expect(resp.results[0].tempPointer.value).toEqual(2); + done(); + }); + }); + + it_only_db('mongo')('aggregate geoNear with location query', async () => { + // Create geo index which is required for `geoNear` query + const database = Config.get(Parse.applicationId).database; + const schema = await new Parse.Schema('GeoObject').save(); + await database.adapter.ensureIndex('GeoObject', schema, ['location'], undefined, false, { + indexType: '2dsphere', + }); + // Create objects + const GeoObject = Parse.Object.extend('GeoObject'); + const obj1 = new GeoObject({ + value: 1, + location: new Parse.GeoPoint(1, 1), + date: new Date(1), + }); + const obj2 = new GeoObject({ + value: 2, + location: new Parse.GeoPoint(2, 1), + date: new Date(2), + }); + const obj3 = new GeoObject({ + value: 3, + location: new Parse.GeoPoint(3, 1), + date: new Date(3), + }); + await Parse.Object.saveAll([obj1, obj2, obj3]); + // Create query + const pipeline = [ + { + $geoNear: { + near: { + type: 'Point', + coordinates: [1, 1], + }, + key: 'location', + spherical: true, + distanceField: 'dist', + query: { + date: { + $gte: new Date(2), + }, + }, + }, + }, + ]; + const query = new Parse.Query(GeoObject); + const results = await query.aggregate(pipeline); + // Check results + expect(results.length).toEqual(2); + expect(results[0].value).toEqual(2); + expect(results[1].value).toEqual(3); + await database.adapter.deleteAllClasses(false); + }); + + it_only_db('mongo')('aggregate geoNear with near GeoJSON point', async () => { + // Create geo index which is required for `geoNear` query + const database = Config.get(Parse.applicationId).database; + const schema = await new Parse.Schema('GeoObject').save(); + await database.adapter.ensureIndex('GeoObject', schema, ['location'], undefined, false, { + indexType: '2dsphere', + }); + // Create objects + const GeoObject = Parse.Object.extend('GeoObject'); + const obj1 = new GeoObject({ + value: 1, + location: new Parse.GeoPoint(1, 1), + date: new Date(1), + }); + const obj2 = new GeoObject({ + value: 2, + location: new Parse.GeoPoint(2, 1), + date: new Date(2), + }); + const obj3 = new GeoObject({ + value: 3, + location: new Parse.GeoPoint(3, 1), + date: new Date(3), + }); + await Parse.Object.saveAll([obj1, obj2, obj3]); + // Create query + const pipeline = [ + { + $geoNear: { + near: { + type: 'Point', + coordinates: [1, 1], + }, + key: 'location', + spherical: true, + distanceField: 'dist', + }, + }, + ]; + const query = new Parse.Query(GeoObject); + const results = await query.aggregate(pipeline); + // Check results + expect(results.length).toEqual(3); + await database.adapter.deleteAllClasses(false); + }); + + it_only_db('mongo')('aggregate geoNear with near legacy coordinate pair', async () => { + // Create geo index which is required for `geoNear` query + const database = Config.get(Parse.applicationId).database; + const schema = await new Parse.Schema('GeoObject').save(); + await database.adapter.ensureIndex('GeoObject', schema, ['location'], undefined, false, { + indexType: '2dsphere', + }); + // Create objects + const GeoObject = Parse.Object.extend('GeoObject'); + const obj1 = new GeoObject({ + value: 1, + location: new Parse.GeoPoint(1, 1), + date: new Date(1), + }); + const obj2 = new GeoObject({ + value: 2, + location: new Parse.GeoPoint(2, 1), + date: new Date(2), + }); + const obj3 = new GeoObject({ + value: 3, + location: new Parse.GeoPoint(3, 1), + date: new Date(3), + }); + await Parse.Object.saveAll([obj1, obj2, obj3]); + // Create query + const pipeline = [ + { + $geoNear: { + near: [1, 1], + key: 'location', + spherical: true, + distanceField: 'dist', + }, + }, + ]; + const query = new Parse.Query(GeoObject); + const results = await query.aggregate(pipeline); + // Check results + expect(results.length).toEqual(3); + await database.adapter.deleteAllClasses(false); + }); + + it_only_db('mongo')('aggregate handle mongodb errors', async () => { + const pipeline = [ + { + $search: { + index: "default", + text: { + path: ["name"], + query: 'foo', + }, + }, + }, + ]; + try { + await new Parse.Query(TestObject).aggregate(pipeline); + fail(); + } catch (e) { + expect(e.code).toBe(Parse.Error.INVALID_QUERY); + } + }); + + it_id('e1d699e3-1389-4213-b0e6-37838bcef390')(it_exclude_dbs(['postgres']))('rawFieldNames: true lets users write _created_at directly', async () => { + const obj = new TestObject(); + await obj.save(); + const iso = new Date(obj.createdAt.getTime() + 1).toISOString(); + const pipeline = [ + { + $match: { + _id: obj.id, + _created_at: { $lte: { $date: iso } }, + }, + }, + { $count: 'total' }, + ]; + const query = new Parse.Query('TestObject'); + const results = await query.aggregate(pipeline, { + rawValues: true, + rawFieldNames: true, + useMasterKey: true, + }); + expect(results.length).toBe(1); + expect(results[0].total).toBe(1); + }); + + it_id('79e68a9f-ce15-44cf-9f9e-6a722f73ef1a')(it_exclude_dbs(['postgres']))('rawFieldNames: true does NOT rewrite Parse-style names', async () => { + const obj = new TestObject(); + await obj.save(); + const iso = new Date(obj.createdAt.getTime() + 1).toISOString(); + // Using Parse-style `createdAt` under rawFieldNames should query a field that doesn't exist in MongoDB. + const pipeline = [ + { $match: { _id: obj.id, createdAt: { $lte: { $date: iso } } } }, + { $count: 'total' }, + ]; + const query = new Parse.Query('TestObject'); + const results = await query.aggregate(pipeline, { + rawValues: true, + rawFieldNames: true, + useMasterKey: true, + }); + // `createdAt` is not a MongoDB field name; no documents match. + expect(results.length).toBe(0); + }); + + it_id('b69c1a5a-b1d3-4c45-adb4-bb8f74af37c6')(it_exclude_dbs(['postgres']))('rawFieldNames: true returns native field names in results', async () => { + const obj = new TestObject(); + await obj.save(); + const pipeline = [ + { $match: { _id: obj.id } }, + { $project: { _id: 1, _created_at: 1 } }, + ]; + const query = new Parse.Query('TestObject'); + const results = await query.aggregate(pipeline, { + rawValues: true, + rawFieldNames: true, + useMasterKey: true, + }); + expect(results.length).toBe(1); + expect(results[0]._id).toBe(obj.id); + expect(Object.prototype.hasOwnProperty.call(results[0], '_created_at')).toBe(true); + expect(Object.prototype.hasOwnProperty.call(results[0], 'objectId')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(results[0], 'createdAt')).toBe(false); + }); + + it_id('f854cc3d-2259-42bc-be88-4122f80f8568')(it_exclude_dbs(['postgres']))('server-level rawValues default applies when per-query omits it', async () => { + await reconfigureServer({ query: { aggregationRawValues: true } }); + const obj = new TestObject(); + await obj.save(); + const iso = new Date(obj.createdAt.getTime() + 1).toISOString(); + const pipeline = [ + { $match: { objectId: obj.id, createdAt: { $lte: { $date: iso } } } }, + { $count: 'total' }, + ]; + const query = new Parse.Query('TestObject'); + // No rawValues in the per-query options — should inherit from the server default. + const results = await query.aggregate(pipeline, { useMasterKey: true }); + expect(results.length).toBe(1); + expect(results[0].total).toBe(1); + }); + + it_id('5be28dc9-a298-488c-8dec-893c2309f6b7')(it_exclude_dbs(['postgres']))('per-query rawValues: false overrides server-level true', async () => { + await reconfigureServer({ query: { aggregationRawValues: true } }); + const obj = new TestObject(); + await obj.save(); + const iso = new Date(obj.createdAt.getTime() + 1).toISOString(); + // With server-level rawValues: true, EJSON `{ $date: iso }` would be converted to a BSON Date + // and the $match would succeed. Per-query rawValues: false overrides that, so `{ $date: iso }` + // is NOT deserialized as EJSON and the comparison fails — proving the override works. + const pipeline = [ + { $match: { objectId: obj.id, createdAt: { $lte: { $date: iso } } } }, + { $count: 'total' }, + ]; + const query = new Parse.Query('TestObject'); + const results = await query.aggregate(pipeline, { + rawValues: false, + useMasterKey: true, + }); + // Under rawValues: false the `{ $date: iso }` is not EJSON-deserialized; comparison yields no match. + expect(results.length).toBe(0); + }); + + it_id('e0e89b62-5ced-4610-ab16-82ea532e69c1')(it_exclude_dbs(['postgres']))('server-level rawFieldNames default applies when per-query omits it', async () => { + await reconfigureServer({ + query: { aggregationRawValues: true, aggregationRawFieldNames: true }, + }); + const obj = new TestObject(); + await obj.save(); + const iso = new Date(obj.createdAt.getTime() + 1).toISOString(); + const pipeline = [ + { + $match: { + _id: obj.id, + _created_at: { $lte: { $date: iso } }, + }, + }, + { $count: 'total' }, + ]; + const query = new Parse.Query('TestObject'); + const results = await query.aggregate(pipeline, { useMasterKey: true }); + expect(results.length).toBe(1); + expect(results[0].total).toBe(1); + }); +}); diff --git a/spec/ParseQuery.Comment.spec.js b/spec/ParseQuery.Comment.spec.js new file mode 100644 index 0000000000..df5b4aeac6 --- /dev/null +++ b/spec/ParseQuery.Comment.spec.js @@ -0,0 +1,170 @@ +'use strict'; + +const Config = require('../lib/Config'); +const { MongoClient } = require('mongodb'); +const databaseURI = 'mongodb://localhost:27017/'; +const request = require('../lib/request'); + +let config, client, database; + +const masterKeyHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Rest-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', +}; + +const masterKeyOptions = { + headers: masterKeyHeaders, + json: true, +}; + +const profileLevel = 2; +describe_only_db('mongo')('Parse.Query with comment testing', () => { + beforeAll(async () => { + config = Config.get('test'); + client = await MongoClient.connect(databaseURI); + database = client.db('parseServerMongoAdapterTestDatabase'); + let profiler = await database.command({ profile: 0 }); + expect(profiler.was).toEqual(0); + // console.log(`Disabling profiler : ${profiler.was}`); + profiler = await database.command({ profile: profileLevel }); + profiler = await database.command({ profile: -1 }); + // console.log(`Enabling profiler : ${profiler.was}`); + profiler = await database.command({ profile: -1 }); + expect(profiler.was).toEqual(profileLevel); + }); + + beforeEach(async () => { + const profiler = await database.command({ profile: -1 }); + expect(profiler.was).toEqual(profileLevel); + }); + + afterAll(async () => { + await database.command({ profile: 0 }); + await client.close(); + }); + + it('send comment with query through REST', async () => { + const comment = `Hello Parse ${Date.now()}`; + const object = new TestObject(); + object.set('name', 'object'); + await object.save(); + const options = Object.assign({}, masterKeyOptions, { + url: Parse.serverURL + '/classes/TestObject', + qs: { + explain: true, + comment: comment, + }, + }); + await request(options); + + // Wait for profile entry to appear with retry logic + let result; + const maxRetries = 10; + const retryDelay = 100; + for (let i = 0; i < maxRetries; i++) { + result = await database.collection('system.profile').findOne( + { 'command.explain.comment': comment }, + { sort: { ts: -1 } } + ); + if (result) { + break; + } + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + + expect(result).toBeDefined(); + expect(result.command.explain.comment).toBe(comment); + }); + + it('send comment with query', async () => { + const comment = `Hello Parse ${Date.now()}`; + const object = new TestObject(); + object.set('name', 'object'); + await object.save(); + const collection = await config.database.adapter._adaptiveCollection('TestObject'); + await collection._rawFind({ name: 'object' }, { comment: comment }); + + // Wait for profile entry to appear with retry logic + let result; + const maxRetries = 10; + const retryDelay = 100; + for (let i = 0; i < maxRetries; i++) { + result = await database.collection('system.profile').findOne( + { 'command.comment': comment }, + { sort: { ts: -1 } } + ); + if (result) { + break; + } + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + + expect(result).toBeDefined(); + expect(result.command.comment).toBe(comment); + }); + + it('send a comment with a count query', async () => { + const comment = `Hello Parse ${Date.now()}`; + const object = new TestObject(); + object.set('name', 'object'); + await object.save(); + + const object2 = new TestObject(); + object2.set('name', 'object'); + await object2.save(); + + const collection = await config.database.adapter._adaptiveCollection('TestObject'); + const countResult = await collection.count({ name: 'object' }, { comment: comment }); + expect(countResult).toEqual(2); + + // Wait for profile entry to appear with retry logic + let result; + const maxRetries = 10; + const retryDelay = 100; + for (let i = 0; i < maxRetries; i++) { + result = await database.collection('system.profile').findOne( + { 'command.comment': comment }, + { sort: { ts: -1 } } + ); + if (result) { + break; + } + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + + expect(result).toBeDefined(); + expect(result.command.comment).toBe(comment); + }); + + it('attach a comment to an aggregation', async () => { + const comment = `Hello Parse ${Date.now()}`; + const object = new TestObject(); + object.set('name', 'object'); + await object.save(); + const collection = await config.database.adapter._adaptiveCollection('TestObject'); + await collection.aggregate([{ $group: { _id: '$name' } }], { + explain: true, + comment: comment, + }); + + // Wait for profile entry to appear with retry logic + let result; + const maxRetries = 10; + const retryDelay = 100; + for (let i = 0; i < maxRetries; i++) { + result = await database.collection('system.profile').findOne( + { 'command.explain.comment': comment }, + { sort: { ts: -1 } } + ); + if (result) { + break; + } + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + + expect(result).toBeDefined(); + expect(result.command.explain.comment).toBe(comment); + }); +}); diff --git a/spec/ParseQuery.FullTextSearch.spec.js b/spec/ParseQuery.FullTextSearch.spec.js new file mode 100644 index 0000000000..d11d1ba86a --- /dev/null +++ b/spec/ParseQuery.FullTextSearch.spec.js @@ -0,0 +1,330 @@ +'use strict'; + +const Config = require('../lib/Config'); +const Parse = require('parse/node'); +const request = require('../lib/request'); + +const fullTextHelper = async () => { + const subjects = [ + 'coffee', + 'Coffee Shopping', + 'Baking a cake', + 'baking', + 'CafÊ Con Leche', + 'ĐĄŅ‹Ņ€ĐŊиĐēи', + 'coffee and cream', + 'Cafe con Leche', + ]; + await Parse.Object.saveAll( + subjects.map(subject => new Parse.Object('TestObject').set({ subject, comment: subject })) + ); +}; + +describe('Parse.Query Full Text Search testing', () => { + it_id('77ba6779-6584-4e09-8e7e-31f89e741d6a')(it)('fullTextSearch: $search', async () => { + await fullTextHelper(); + const query = new Parse.Query('TestObject'); + query.fullText('subject', 'coffee'); + const results = await query.find(); + expect(results.length).toBe(3); + }); + + it_id('d1992ea6-6d92-4bfa-a487-2a49fbcf8f0d')(it)('fullTextSearch: $search, sort', async () => { + await fullTextHelper(); + const query = new Parse.Query('TestObject'); + query.fullText('subject', 'coffee'); + query.select('$score'); + query.ascending('$score'); + const results = await query.find(); + expect(results.length).toBe(3); + expect(results[0].get('score')); + expect(results[1].get('score')); + expect(results[2].get('score')); + }); + + it_id('07172595-50de-4be2-984a-d3136bebb22e')(it)('fulltext descending by $score', async () => { + await fullTextHelper(); + const query = new Parse.Query('TestObject'); + query.fullText('subject', 'coffee'); + query.descending('$score'); + query.select('$score'); + const [first, second, third] = await query.find(); + expect(first).toBeDefined(); + expect(second).toBeDefined(); + expect(third).toBeDefined(); + expect(first.get('score')); + expect(second.get('score')); + expect(third.get('score')); + expect(first.get('score') >= second.get('score')).toBeTrue(); + expect(second.get('score') >= third.get('score')).toBeTrue(); + }); + + it_id('8e821973-3fae-4e7c-8152-766228a18cdd')(it)('fullTextSearch: $language', async () => { + await fullTextHelper(); + const query = new Parse.Query('TestObject'); + query.fullText('subject', 'leche', { language: 'spanish' }); + const resp = await query.find(); + expect(resp.length).toBe(2); + }); + + it_id('7d3da216-9582-40ee-a2fe-8316feaf5c0c')(it)('fullTextSearch: $diacriticSensitive', async () => { + await fullTextHelper(); + const query = new Parse.Query('TestObject'); + query.fullText('subject', 'CAFÉ', { diacriticSensitive: true }); + const resp = await query.find(); + expect(resp.length).toBe(1); + }); + + it_id('dade10c8-2b9c-4f43-bb3f-a13bbd82ac22')(it)('fullTextSearch: $search, invalid input', async () => { + await fullTextHelper(); + const invalidQuery = async () => { + const where = { + subject: { + $text: { + $search: true, + }, + }, + }; + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + } catch (e) { + throw new Parse.Error(e.data.code, e.data.error); + } + }; + await expectAsync(invalidQuery()).toBeRejectedWith( + new Parse.Error(Parse.Error.INVALID_JSON, 'bad $text: $search, should be object') + ); + }); + + it_id('ff7c6b1c-4712-4847-bb76-f4e1f641f7b5')(it)('fullTextSearch: $language, invalid input', async () => { + await fullTextHelper(); + const query = new Parse.Query('TestObject'); + query.fullText('subject', 'leche', { language: true }); + await expectAsync(query.find()).toBeRejectedWith( + new Parse.Error(Parse.Error.INVALID_JSON, 'bad $text: $language, should be string') + ); + }); + + it_id('de262dbc-ec75-4ec6-9217-fbb90146c272')(it)('fullTextSearch: $caseSensitive, invalid input', async () => { + await fullTextHelper(); + const query = new Parse.Query('TestObject'); + query.fullText('subject', 'leche', { caseSensitive: 'string' }); + await expectAsync(query.find()).toBeRejectedWith( + new Parse.Error(Parse.Error.INVALID_JSON, 'bad $text: $caseSensitive, should be boolean') + ); + }); + + it_id('b7b7b3a9-8d6c-4f98-a0ff-0113593d06d4')(it)('fullTextSearch: $diacriticSensitive, invalid input', async () => { + await fullTextHelper(); + const query = new Parse.Query('TestObject'); + query.fullText('subject', 'leche', { diacriticSensitive: 'string' }); + await expectAsync(query.find()).toBeRejectedWith( + new Parse.Error(Parse.Error.INVALID_JSON, 'bad $text: $diacriticSensitive, should be boolean') + ); + }); +}); + +describe_only_db('mongo')('[mongodb] Parse.Query Full Text Search testing', () => { + it('fullTextSearch: does not create text index if compound index exist', async () => { + await fullTextHelper(); + await databaseAdapter.dropAllIndexes('TestObject'); + let indexes = await databaseAdapter.getIndexes('TestObject'); + expect(indexes.length).toEqual(1); + await databaseAdapter.createIndex('TestObject', { + subject: 'text', + comment: 'text', + }); + indexes = await databaseAdapter.getIndexes('TestObject'); + const query = new Parse.Query('TestObject'); + query.fullText('subject', 'coffee'); + query.select('$score'); + query.ascending('$score'); + const results = await query.find(); + expect(results.length).toBe(3); + expect(results[0].get('score')); + expect(results[1].get('score')); + expect(results[2].get('score')); + + indexes = await databaseAdapter.getIndexes('TestObject'); + expect(indexes.length).toEqual(2); + + const schemas = await new Parse.Schema('TestObject').get(); + expect(schemas.indexes._id_).toBeDefined(); + expect(schemas.indexes._id_._id).toEqual(1); + expect(schemas.indexes.subject_text_comment_text).toBeDefined(); + expect(schemas.indexes.subject_text_comment_text.subject).toEqual('text'); + expect(schemas.indexes.subject_text_comment_text.comment).toEqual('text'); + }); + + it('fullTextSearch: does not create text index if schema compound index exist', done => { + fullTextHelper() + .then(() => { + return databaseAdapter.dropAllIndexes('TestObject'); + }) + .then(() => { + return databaseAdapter.getIndexes('TestObject'); + }) + .then(indexes => { + expect(indexes.length).toEqual(1); + return request({ + method: 'PUT', + url: 'http://localhost:8378/1/schemas/TestObject', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', + }, + body: { + indexes: { + text_test: { subject: 'text', comment: 'text' }, + }, + }, + }); + }) + .then(() => { + return databaseAdapter.getIndexes('TestObject'); + }) + .then(indexes => { + expect(indexes.length).toEqual(2); + const where = { + subject: { + $text: { + $search: { + $term: 'coffee', + }, + }, + }, + }; + return request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + expect(resp.data.results.length).toEqual(3); + return databaseAdapter.getIndexes('TestObject'); + }) + .then(indexes => { + expect(indexes.length).toEqual(2); + request({ + url: 'http://localhost:8378/1/schemas/TestObject', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', + }, + }).then(response => { + const body = response.data; + expect(body.indexes._id_).toBeDefined(); + expect(body.indexes._id_._id).toEqual(1); + expect(body.indexes.text_test).toBeDefined(); + expect(body.indexes.text_test.subject).toEqual('text'); + expect(body.indexes.text_test.comment).toEqual('text'); + done(); + }); + }) + .catch(done.fail); + }); + + it('fullTextSearch: $diacriticSensitive - false', async () => { + await fullTextHelper(); + const query = new Parse.Query('TestObject'); + query.fullText('subject', 'CAFÉ', { diacriticSensitive: false }); + const resp = await query.find(); + expect(resp.length).toBe(2); + }); + + it('fullTextSearch: $caseSensitive', async () => { + await fullTextHelper(); + const query = new Parse.Query('TestObject'); + query.fullText('subject', 'Coffee', { caseSensitive: true }); + const results = await query.find(); + expect(results.length).toBe(1); + }); +}); + +describe_only_db('postgres')('[postgres] Parse.Query Full Text Search testing', () => { + it('fullTextSearch: $diacriticSensitive - false', done => { + fullTextHelper() + .then(() => { + const where = { + subject: { + $text: { + $search: { + $term: 'CAFÉ', + $diacriticSensitive: false, + }, + }, + }, + }; + return request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + fail(`$diacriticSensitive - false should not supported: ${JSON.stringify(resp)}`); + done(); + }) + .catch(err => { + expect(err.data.code).toEqual(Parse.Error.INVALID_JSON); + done(); + }); + }); + + it('fullTextSearch: $caseSensitive', done => { + fullTextHelper() + .then(() => { + const where = { + subject: { + $text: { + $search: { + $term: 'Coffee', + $caseSensitive: true, + }, + }, + }, + }; + return request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + }) + .then(resp => { + fail(`$caseSensitive should not supported: ${JSON.stringify(resp)}`); + done(); + }) + .catch(err => { + expect(err.data.code).toEqual(Parse.Error.INVALID_JSON); + done(); + }); + }); +}); diff --git a/spec/ParseQuery.hint.spec.js b/spec/ParseQuery.hint.spec.js new file mode 100644 index 0000000000..2f61f658db --- /dev/null +++ b/spec/ParseQuery.hint.spec.js @@ -0,0 +1,188 @@ +'use strict'; + +const Config = require('../lib/Config'); +const TestUtils = require('../lib/TestUtils'); +const request = require('../lib/request'); + +let config; + +const masterKeyHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Rest-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', +}; + +const masterKeyOptions = { + headers: masterKeyHeaders, + json: true, +}; + +describe_only_db('mongo')('Parse.Query hint', () => { + beforeEach(() => { + config = Config.get('test'); + }); + + afterEach(async () => { + await TestUtils.destroyAllDataPermanently(false); + }); + + it_only_mongodb_version('<5.1 || >=6 <8')('query find with hint string', async () => { + const object = new TestObject(); + await object.save(); + + const collection = await config.database.adapter._adaptiveCollection('TestObject'); + let explain = await collection._rawFind({ _id: object.id }, { explain: true }); + expect(explain.queryPlanner.winningPlan.stage).toBe('IDHACK'); + explain = await collection._rawFind({ _id: object.id }, { hint: '_id_', explain: true }); + expect(explain.queryPlanner.winningPlan.stage).toBe('FETCH'); + expect(explain.queryPlanner.winningPlan.inputStage.indexName).toBe('_id_'); + }); + + it_only_mongodb_version('>=8')('query find with hint string mongodb 8', async () => { + const object = new TestObject(); + await object.save(); + + const collection = await config.database.adapter._adaptiveCollection('TestObject'); + let explain = await collection._rawFind({ _id: object.id }, { explain: true }); + expect(explain.queryPlanner.winningPlan.stage).toBe('EXPRESS_IXSCAN'); + explain = await collection._rawFind({ _id: object.id }, { hint: '_id_', explain: true }); + expect(explain.queryPlanner.winningPlan.stage).toBe('FETCH'); + expect(explain.queryPlanner.winningPlan.inputStage.indexName).toBe('_id_'); + }); + + it_only_mongodb_version('<5.1 || >=6 <8')('query find with hint object', async () => { + const object = new TestObject(); + await object.save(); + + const collection = await config.database.adapter._adaptiveCollection('TestObject'); + let explain = await collection._rawFind({ _id: object.id }, { explain: true }); + expect(explain.queryPlanner.winningPlan.stage).toBe('IDHACK'); + explain = await collection._rawFind({ _id: object.id }, { hint: { _id: 1 }, explain: true }); + expect(explain.queryPlanner.winningPlan.stage).toBe('FETCH'); + expect(explain.queryPlanner.winningPlan.inputStage.keyPattern).toEqual({ + _id: 1, + }); + }); + + it_only_mongodb_version('>=8')('query find with hint object mongodb 8', async () => { + const object = new TestObject(); + await object.save(); + + const collection = await config.database.adapter._adaptiveCollection('TestObject'); + let explain = await collection._rawFind({ _id: object.id }, { explain: true }); + expect(explain.queryPlanner.winningPlan.stage).toBe('EXPRESS_IXSCAN'); + explain = await collection._rawFind({ _id: object.id }, { hint: { _id: 1 }, explain: true }); + expect(explain.queryPlanner.winningPlan.stage).toBe('FETCH'); + expect(explain.queryPlanner.winningPlan.inputStage.keyPattern).toEqual({ + _id: 1, + }); + }); + + it_only_mongodb_version('>=7')('query aggregate with hint string', async () => { + const object = new TestObject({ foo: 'bar' }); + await object.save(); + + const collection = await config.database.adapter._adaptiveCollection('TestObject'); + let result = await collection.aggregate([{ $group: { _id: '$foo' } }], { + explain: true, + }); + let queryPlanner = result[0].queryPlanner; + expect(queryPlanner.winningPlan.queryPlan.stage).toBe('GROUP'); + expect(queryPlanner.winningPlan.queryPlan.inputStage.stage).toBe('COLLSCAN'); + expect(queryPlanner.winningPlan.queryPlan.inputStage.inputStage).toBeUndefined(); + + result = await collection.aggregate([{ $group: { _id: '$foo' } }], { + hint: '_id_', + explain: true, + }); + queryPlanner = result[0].queryPlanner; + expect(queryPlanner.winningPlan.queryPlan.stage).toBe('GROUP'); + expect(queryPlanner.winningPlan.queryPlan.inputStage.stage).toBe('FETCH'); + expect(queryPlanner.winningPlan.queryPlan.inputStage.inputStage.stage).toBe('IXSCAN'); + expect(queryPlanner.winningPlan.queryPlan.inputStage.inputStage.indexName).toBe('_id_'); + }); + + it_only_mongodb_version('>=7')('query aggregate with hint object', async () => { + const object = new TestObject({ foo: 'bar' }); + await object.save(); + + const collection = await config.database.adapter._adaptiveCollection('TestObject'); + let result = await collection.aggregate([{ $group: { _id: '$foo' } }], { + explain: true, + }); + let queryPlanner = result[0].queryPlanner; + expect(queryPlanner.winningPlan.queryPlan.stage).toBe('GROUP'); + expect(queryPlanner.winningPlan.queryPlan.inputStage.stage).toBe('COLLSCAN'); + expect(queryPlanner.winningPlan.queryPlan.inputStage.inputStage).toBeUndefined(); + + result = await collection.aggregate([{ $group: { _id: '$foo' } }], { + hint: { _id: 1 }, + explain: true, + }); + queryPlanner = result[0].queryPlanner; + expect(queryPlanner.winningPlan.queryPlan.stage).toBe('GROUP'); + expect(queryPlanner.winningPlan.queryPlan.inputStage.stage).toBe('FETCH'); + expect(queryPlanner.winningPlan.queryPlan.inputStage.inputStage.stage).toBe('IXSCAN'); + expect(queryPlanner.winningPlan.queryPlan.inputStage.inputStage.indexName).toBe('_id_'); + expect(queryPlanner.winningPlan.queryPlan.inputStage.inputStage.keyPattern).toEqual({ _id: 1 }); + }); + + it_only_mongodb_version('<5.1 || >=6')('query find with hint (rest)', async () => { + const object = new TestObject(); + await object.save(); + let options = Object.assign({}, masterKeyOptions, { + url: Parse.serverURL + '/classes/TestObject', + qs: { + explain: true, + }, + }); + let response = await request(options); + let explain = response.data.results; + expect(explain.queryPlanner.winningPlan.inputStage.stage).toBe('COLLSCAN'); + + options = Object.assign({}, masterKeyOptions, { + url: Parse.serverURL + '/classes/TestObject', + qs: { + explain: true, + hint: '_id_', + }, + }); + response = await request(options); + explain = response.data.results; + expect(explain.queryPlanner.winningPlan.inputStage.inputStage.indexName).toBe('_id_'); + }); + + it_only_mongodb_version('>=7')('query aggregate with hint (rest)', async () => { + const object = new TestObject({ foo: 'bar' }); + await object.save(); + let options = Object.assign({}, masterKeyOptions, { + url: Parse.serverURL + '/aggregate/TestObject', + qs: { + explain: true, + $group: JSON.stringify({ _id: '$foo' }), + }, + }); + let response = await request(options); + let queryPlanner = response.data.results[0].queryPlanner; + expect(queryPlanner.winningPlan.queryPlan.stage).toBe('GROUP'); + expect(queryPlanner.winningPlan.queryPlan.inputStage.stage).toBe('COLLSCAN'); + expect(queryPlanner.winningPlan.queryPlan.inputStage.inputStage).toBeUndefined(); + + options = Object.assign({}, masterKeyOptions, { + url: Parse.serverURL + '/aggregate/TestObject', + qs: { + explain: true, + hint: '_id_', + $group: JSON.stringify({ _id: '$foo' }), + }, + }); + response = await request(options); + queryPlanner = response.data.results[0].queryPlanner; + expect(queryPlanner.winningPlan.queryPlan.stage).toBe('GROUP'); + expect(queryPlanner.winningPlan.queryPlan.inputStage.stage).toBe('FETCH'); + expect(queryPlanner.winningPlan.queryPlan.inputStage.inputStage.stage).toBe('IXSCAN'); + expect(queryPlanner.winningPlan.queryPlan.inputStage.inputStage.indexName).toBe('_id_'); + expect(queryPlanner.winningPlan.queryPlan.inputStage.inputStage.keyPattern).toEqual({ _id: 1 }); + }); +}); diff --git a/spec/ParseQuery.spec.js b/spec/ParseQuery.spec.js index 29a4b655e1..b7c3fe02d7 100644 --- a/spec/ParseQuery.spec.js +++ b/spec/ParseQuery.spec.js @@ -5,2564 +5,5748 @@ 'use strict'; const Parse = require('parse/node'); +const request = require('../lib/request'); +const ParseServerRESTController = require('../lib/ParseServerRESTController').ParseServerRESTController; +const ParseServer = require('../lib/ParseServer').default; +const Deprecator = require('../lib/Deprecator/Deprecator').default; +const Utils = require('../lib/Utils'); + +const masterKeyHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Rest-API-Key': 'test', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', +}; + +const masterKeyOptions = { + headers: masterKeyHeaders, +}; + +const BoxedNumber = Parse.Object.extend({ + className: 'BoxedNumber', +}); describe('Parse.Query testing', () => { - it("basic query", function(done) { - var baz = new TestObject({ foo: 'baz' }); - var qux = new TestObject({ foo: 'qux' }); - Parse.Object.saveAll([baz, qux], function() { - var query = new Parse.Query(TestObject); + it('basic query', function (done) { + const baz = new TestObject({ foo: 'baz' }); + const qux = new TestObject({ foo: 'qux' }); + Parse.Object.saveAll([baz, qux]).then(function () { + const query = new Parse.Query(TestObject); query.equalTo('foo', 'baz'); - query.find({ - success: function(results) { - equal(results.length, 1); - equal(results[0].get('foo'), 'baz'); - done(); - } + query.find().then(function (results) { + equal(results.length, 1); + equal(results[0].get('foo'), 'baz'); + done(); + }); + }); + }); + + it_only_db('mongo')('gracefully handles invalid explain values', async () => { + // Note that anything that is not truthy (like 0) does not cause an exception, as they get swallowed up by ClassesRouter::optionsFromBody + const values = [1, 'yolo', { a: 1 }, [1, 2, 3]]; + for (const value of values) { + try { + await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/_User?explain=${value}`, + json: true, + headers: masterKeyHeaders, + }); + fail('request did not throw'); + } catch (e) { + // Expect that Parse Server did not crash + expect(e.code).not.toEqual('ECONNRESET'); + // Expect that Parse Server validates the explain value and does not crash; + // see https://jira.mongodb.org/browse/NODE-3463 + equal(e.data.code, Parse.Error.INVALID_QUERY); + equal(e.data.error, 'Invalid value for explain'); + } + // get queries (of the form '/classes/:className/:objectId' cannot have the explain key, see ClassesRouter.js) + // so it is enough that we test find queries + } + }); + + it_only_db('mongo')('supports valid explain values', async () => { + const values = [ + false, + true, + 'queryPlanner', + 'executionStats', + 'allPlansExecution', + // 'queryPlannerExtended' is excluded as it only applies to MongoDB Data Lake which is currently not available in our CI environment + ]; + for (const value of values) { + const response = await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/_User?explain=${value}`, + json: true, + headers: masterKeyHeaders, + }); + expect(response.status).toBe(200); + if (value) { + expect(response.data.results.ok).toBe(1); + } + } + }); + + it('searching for null', function (done) { + const baz = new TestObject({ foo: null }); + const qux = new TestObject({ foo: 'qux' }); + const qux2 = new TestObject({}); + Parse.Object.saveAll([baz, qux, qux2]).then(function () { + const query = new Parse.Query(TestObject); + query.equalTo('foo', null); + query.find().then(function (results) { + equal(results.length, 2); + qux.set('foo', null); + qux.save().then(function () { + query.find().then(function (results) { + equal(results.length, 3); + done(); + }); + }); + }); + }); + }); + + it('searching for not null', function (done) { + const baz = new TestObject({ foo: null }); + const qux = new TestObject({ foo: 'qux' }); + const qux2 = new TestObject({}); + Parse.Object.saveAll([baz, qux, qux2]).then(function () { + const query = new Parse.Query(TestObject); + query.notEqualTo('foo', null); + query.find().then(function (results) { + equal(results.length, 1); + qux.set('foo', null); + qux.save().then(function () { + query.find().then(function (results) { + equal(results.length, 0); + done(); + }); + }); }); }); }); - it_exclude_dbs(['postgres'])("notEqualTo with Relation is working", function(done) { - var user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); + it('notEqualTo with Relation is working', function (done) { + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); - var user1 = new Parse.User(); - user1.setPassword("asdf"); - user1.setUsername("qwerty"); + const user1 = new Parse.User(); + user1.setPassword('asdf'); + user1.setUsername('qwerty'); - var user2 = new Parse.User(); - user2.setPassword("asdf"); - user2.setUsername("asdf"); + const user2 = new Parse.User(); + user2.setPassword('asdf'); + user2.setUsername('asdf'); - var Cake = Parse.Object.extend("Cake"); - var cake1 = new Cake(); - var cake2 = new Cake(); - var cake3 = new Cake(); + const Cake = Parse.Object.extend('Cake'); + const cake1 = new Cake(); + const cake2 = new Cake(); + const cake3 = new Cake(); + user + .signUp() + .then(function () { + return user1.signUp(); + }) + .then(function () { + return user2.signUp(); + }) + .then(function () { + const relLike1 = cake1.relation('liker'); + relLike1.add([user, user1]); - user.signUp().then(function(){ - return user1.signUp(); - }).then(function(){ - return user2.signUp(); - }).then(function(){ - var relLike1 = cake1.relation("liker"); - relLike1.add([user, user1]); + const relDislike1 = cake1.relation('hater'); + relDislike1.add(user2); - var relDislike1 = cake1.relation("hater"); - relDislike1.add(user2); - return cake1.save(); - }).then(function(){ - var rellike2 = cake2.relation("liker"); - rellike2.add([user, user1]); + return cake1.save(); + }) + .then(function () { + const rellike2 = cake2.relation('liker'); + rellike2.add([user, user1]); - var relDislike2 = cake2.relation("hater"); - relDislike2.add(user2); + const relDislike2 = cake2.relation('hater'); + relDislike2.add(user2); - return cake2.save(); - }).then(function(){ - var rellike3 = cake3.relation("liker"); - rellike3.add(user); + const relSomething = cake2.relation('something'); + relSomething.add(user); - var relDislike3 = cake3.relation("hater"); - relDislike3.add([user1, user2]); - return cake3.save(); - }).then(function(){ - var query = new Parse.Query(Cake); - // User2 likes nothing so we should receive 0 - query.equalTo("liker", user2); - return query.find().then(function(results){ - equal(results.length, 0); - }); - }).then(function(){ - var query = new Parse.Query(Cake); - // User1 likes two of three cakes - query.equalTo("liker", user1); - return query.find().then(function(results){ - // It should return 2 -> cake 1 and cake 2 - equal(results.length, 2); - }); - }).then(function(){ - var query = new Parse.Query(Cake); - // We want to know which cake the user1 is not appreciating -> cake3 - query.notEqualTo("liker", user1); - return query.find().then(function(results){ - // Should return 1 -> the cake 3 - equal(results.length, 1); - }); - }).then(function(){ - var query = new Parse.Query(Cake); - // User2 is a hater of everything so we should receive 0 - query.notEqualTo("hater", user2); - return query.find().then(function(results){ - equal(results.length, 0); - }); - }).then(function(){ - var query = new Parse.Query(Cake); - // Only cake3 is liked by user - query.notContainedIn("liker", [user1]); - return query.find().then(function(results){ - equal(results.length, 1); - }); - }).then(function(){ - var query = new Parse.Query(Cake); - // All the users - query.containedIn("liker", [user, user1, user2]); - // Exclude user 1 - query.notEqualTo("liker", user1); - // Only cake3 is liked only by user1 - return query.find().then(function(results){ - equal(results.length, 1); - let cake = results[0]; - expect(cake.id).toBe(cake3.id); - }); - }).then(function(){ - done(); - }) - }); - - it("query with limit", function(done) { - var baz = new TestObject({ foo: 'baz' }); - var qux = new TestObject({ foo: 'qux' }); - Parse.Object.saveAll([baz, qux], function() { - var query = new Parse.Query(TestObject); - query.limit(1); - query.find({ - success: function(results) { + return cake2.save(); + }) + .then(function () { + const rellike3 = cake3.relation('liker'); + rellike3.add(user); + + const relDislike3 = cake3.relation('hater'); + relDislike3.add([user1, user2]); + return cake3.save(); + }) + .then(function () { + const query = new Parse.Query(Cake); + // User2 likes nothing so we should receive 0 + query.equalTo('liker', user2); + return query.find().then(function (results) { + equal(results.length, 0); + }); + }) + .then(function () { + const query = new Parse.Query(Cake); + // User1 likes two of three cakes + query.equalTo('liker', user1); + return query.find().then(function (results) { + // It should return 2 -> cake 1 and cake 2 + equal(results.length, 2); + }); + }) + .then(function () { + const query = new Parse.Query(Cake); + // We want to know which cake the user1 is not appreciating -> cake3 + query.notEqualTo('liker', user1); + return query.find().then(function (results) { + // Should return 1 -> the cake 3 equal(results.length, 1); - done(); - } + }); + }) + .then(function () { + const query = new Parse.Query(Cake); + // User2 is a hater of everything so we should receive 0 + query.notEqualTo('hater', user2); + return query.find().then(function (results) { + equal(results.length, 0); + }); + }) + .then(function () { + const query = new Parse.Query(Cake); + // Only cake3 is liked by user + query.notContainedIn('liker', [user1]); + return query.find().then(function (results) { + equal(results.length, 1); + }); + }) + .then(function () { + const query = new Parse.Query(Cake); + // All the users + query.containedIn('liker', [user, user1, user2]); + // Exclude user 1 + query.notEqualTo('liker', user1); + // Only cake3 is liked only by user1 + return query.find().then(function (results) { + equal(results.length, 1); + const cake = results[0]; + expect(cake.id).toBe(cake3.id); + }); + }) + .then(function () { + const query = new Parse.Query(Cake); + // Exclude user1 + query.notEqualTo('liker', user1); + // Only cake1 + query.equalTo('objectId', cake1.id); + // user1 likes cake1 so this should return no results + return query.find().then(function (results) { + equal(results.length, 0); + }); + }) + .then(function () { + const query = new Parse.Query(Cake); + query.notEqualTo('hater', user2); + query.notEqualTo('liker', user2); + // user2 doesn't like any cake so this should be 0 + return query.find().then(function (results) { + equal(results.length, 0); + }); + }) + .then(function () { + const query = new Parse.Query(Cake); + query.equalTo('hater', user); + query.equalTo('liker', user); + // user doesn't hate any cake so this should be 0 + return query.find().then(function (results) { + equal(results.length, 0); + }); + }) + .then(function () { + const query = new Parse.Query(Cake); + query.equalTo('hater', null); + query.equalTo('liker', null); + // user doesn't hate any cake so this should be 0 + return query.find().then(function (results) { + equal(results.length, 0); + }); + }) + .then(function () { + const query = new Parse.Query(Cake); + query.equalTo('something', null); + // user doesn't hate any cake so this should be 0 + return query.find().then(function (results) { + equal(results.length, 0); + }); + }) + .then(function () { + done(); + }) + .catch(err => { + jfail(err); + done(); }); - }); }); - it_exclude_dbs(['postgres'])("containedIn object array queries", function(done) { - var messageList = []; - for (var i = 0; i < 4; ++i) { - var message = new TestObject({}); + it('query notContainedIn on empty array', async () => { + const object = new TestObject(); + object.set('value', 100); + await object.save(); + + const query = new Parse.Query(TestObject); + query.notContainedIn('value', []); + + const results = await query.find(); + equal(results.length, 1); + }); + + it('query containedIn on empty array', async () => { + const object = new TestObject(); + object.set('value', 100); + await object.save(); + + const query = new Parse.Query(TestObject); + query.containedIn('value', []); + + const results = await query.find(); + equal(results.length, 0); + }); + + it('query without limit respects default limit', async () => { + await reconfigureServer({ defaultLimit: 1 }); + const obj1 = new TestObject({ foo: 'baz' }); + const obj2 = new TestObject({ foo: 'qux' }); + await Parse.Object.saveAll([obj1, obj2]); + const query = new Parse.Query(TestObject); + const result = await query.find(); + expect(result.length).toBe(1); + }); + + it('query with limit', async () => { + const obj1 = new TestObject({ foo: 'baz' }); + const obj2 = new TestObject({ foo: 'qux' }); + await Parse.Object.saveAll([obj1, obj2]); + const query = new Parse.Query(TestObject); + query.limit(1); + const result = await query.find(); + expect(result.length).toBe(1); + }); + + it('query with limit overrides default limit', async () => { + await reconfigureServer({ defaultLimit: 2 }); + const obj1 = new TestObject({ foo: 'baz' }); + const obj2 = new TestObject({ foo: 'qux' }); + await Parse.Object.saveAll([obj1, obj2]); + const query = new Parse.Query(TestObject); + query.limit(1); + const result = await query.find(); + expect(result.length).toBe(1); + }); + + it('query with limit equal to maxlimit', async () => { + await reconfigureServer({ maxLimit: 1 }); + const obj1 = new TestObject({ foo: 'baz' }); + const obj2 = new TestObject({ foo: 'qux' }); + await Parse.Object.saveAll([obj1, obj2]); + const query = new Parse.Query(TestObject); + query.limit(1); + const result = await query.find(); + expect(result.length).toBe(1); + }); + + it('query with limit exceeding maxlimit', async () => { + await reconfigureServer({ maxLimit: 1 }); + const obj1 = new TestObject({ foo: 'baz' }); + const obj2 = new TestObject({ foo: 'qux' }); + await Parse.Object.saveAll([obj1, obj2]); + const query = new Parse.Query(TestObject); + query.limit(2); + const result = await query.find(); + expect(result.length).toBe(1); + }); + + it('containedIn object array queries', function (done) { + const messageList = []; + for (let i = 0; i < 4; ++i) { + const message = new TestObject({}); if (i > 0) { message.set('prior', messageList[i - 1]); } messageList.push(message); } - Parse.Object.saveAll(messageList, function() { - equal(messageList.length, 4); + Parse.Object.saveAll(messageList).then( + function () { + equal(messageList.length, 4); - var inList = []; - inList.push(messageList[0]); - inList.push(messageList[2]); + const inList = []; + inList.push(messageList[0]); + inList.push(messageList[2]); - var query = new Parse.Query(TestObject); - query.containedIn('prior', inList); - query.find({ - success: function(results) { - equal(results.length, 2); - done(); - }, - error: function(e) { - fail(e); - done(); - } - }); - }, (e) => { - fail(e); - done(); - }); + const query = new Parse.Query(TestObject); + query.containedIn('prior', inList); + query.find().then( + function (results) { + equal(results.length, 2); + done(); + }, + function (e) { + jfail(e); + done(); + } + ); + }, + e => { + jfail(e); + done(); + } + ); + }); + + it('containedIn null array', done => { + const emails = ['contact@xyz.com', 'contact@zyx.com', null]; + const user = new Parse.User(); + user.setUsername(emails[0]); + user.setPassword('asdf'); + user + .signUp() + .then(() => { + const query = new Parse.Query(Parse.User); + query.containedIn('username', emails); + return query.find({ useMasterKey: true }); + }) + .then(results => { + equal(results.length, 1); + done(); + }, done.fail); }); - it_exclude_dbs(['postgres'])("containsAll number array queries", function(done) { - var NumberSet = Parse.Object.extend({ className: "NumberSet" }); + it('nested equalTo string with single quote', async () => { + const obj = new TestObject({ nested: { foo: "single'quote" } }); + await obj.save(); + const query = new Parse.Query(TestObject); + query.equalTo('nested.foo', "single'quote"); + const result = await query.get(obj.id); + equal(result.get('nested').foo, "single'quote"); + }); - var objectsList = []; - objectsList.push(new NumberSet({ "numbers" : [1, 2, 3, 4, 5] })); - objectsList.push(new NumberSet({ "numbers" : [1, 3, 4, 5] })); + it('nested containedIn string with single quote', async () => { + const obj = new TestObject({ nested: { foo: ["single'quote"] } }); + await obj.save(); + const query = new Parse.Query(TestObject); + query.containedIn('nested.foo', ["single'quote"]); + const result = await query.get(obj.id); + equal(result.get('nested').foo[0], "single'quote"); + }); - Parse.Object.saveAll(objectsList, function() { - var query = new Parse.Query(NumberSet); - query.containsAll("numbers", [1, 2, 3]); - query.find({ - success: function(results) { - equal(results.length, 1); - done(); - }, - error: function(err) { - fail(err); - done(); - }, + it('nested containedIn string', done => { + const sender1 = { group: ['A', 'B'] }; + const sender2 = { group: ['A', 'C'] }; + const sender3 = { group: ['B', 'C'] }; + const obj1 = new TestObject({ sender: sender1 }); + const obj2 = new TestObject({ sender: sender2 }); + const obj3 = new TestObject({ sender: sender3 }); + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const query = new Parse.Query(TestObject); + query.containedIn('sender.group', ['A']); + return query.find(); + }) + .then(results => { + equal(results.length, 2); + done(); + }, done.fail); + }); + + it('nested containedIn number', done => { + const sender1 = { group: [1, 2] }; + const sender2 = { group: [1, 3] }; + const sender3 = { group: [2, 3] }; + const obj1 = new TestObject({ sender: sender1 }); + const obj2 = new TestObject({ sender: sender2 }); + const obj3 = new TestObject({ sender: sender3 }); + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const query = new Parse.Query(TestObject); + query.containedIn('sender.group', [1]); + return query.find(); + }) + .then(results => { + equal(results.length, 2); + done(); + }, done.fail); + }); + + it('containsAll number array queries', function (done) { + const NumberSet = Parse.Object.extend({ className: 'NumberSet' }); + + const objectsList = []; + objectsList.push(new NumberSet({ numbers: [1, 2, 3, 4, 5] })); + objectsList.push(new NumberSet({ numbers: [1, 3, 4, 5] })); + + Parse.Object.saveAll(objectsList) + .then(function () { + const query = new Parse.Query(NumberSet); + query.containsAll('numbers', [1, 2, 3]); + query.find().then( + function (results) { + equal(results.length, 1); + done(); + }, + function (err) { + jfail(err); + done(); + } + ); + }) + .catch(err => { + jfail(err); + done(); }); - }); }); - it_exclude_dbs(['postgres'])("containsAll string array queries", function(done) { - var StringSet = Parse.Object.extend({ className: "StringSet" }); + it('containsAll string array queries', function (done) { + const StringSet = Parse.Object.extend({ className: 'StringSet' }); - var objectsList = []; - objectsList.push(new StringSet({ "strings" : ["a", "b", "c", "d", "e"] })); - objectsList.push(new StringSet({ "strings" : ["a", "c", "d", "e"] })); + const objectsList = []; + objectsList.push(new StringSet({ strings: ['a', 'b', 'c', 'd', 'e'] })); + objectsList.push(new StringSet({ strings: ['a', 'c', 'd', 'e'] })); - Parse.Object.saveAll(objectsList, function() { - var query = new Parse.Query(StringSet); - query.containsAll("strings", ["a", "b", "c"]); - query.find({ - success: function(results) { + Parse.Object.saveAll(objectsList) + .then(function () { + const query = new Parse.Query(StringSet); + query.containsAll('strings', ['a', 'b', 'c']); + query.find().then(function (results) { equal(results.length, 1); done(); - } + }); + }) + .catch(err => { + jfail(err); + done(); }); - }); }); - it_exclude_dbs(['postgres'])("containsAll date array queries", function(done) { - var DateSet = Parse.Object.extend({ className: "DateSet" }); + it('containsAll date array queries', function (done) { + const DateSet = Parse.Object.extend({ className: 'DateSet' }); function parseDate(iso8601) { - var regexp = new RegExp( - '^([0-9]{1,4})-([0-9]{1,2})-([0-9]{1,2})' + 'T' + + const regexp = new RegExp( + '^([0-9]{1,4})-([0-9]{1,2})-([0-9]{1,2})' + + 'T' + '([0-9]{1,2}):([0-9]{1,2}):([0-9]{1,2})' + - '(.([0-9]+))?' + 'Z$'); - var match = regexp.exec(iso8601); + '(.([0-9]+))?' + + 'Z$' + ); + const match = regexp.exec(iso8601); if (!match) { return null; } - var year = match[1] || 0; - var month = (match[2] || 1) - 1; - var day = match[3] || 0; - var hour = match[4] || 0; - var minute = match[5] || 0; - var second = match[6] || 0; - var milli = match[8] || 0; + const year = match[1] || 0; + const month = (match[2] || 1) - 1; + const day = match[3] || 0; + const hour = match[4] || 0; + const minute = match[5] || 0; + const second = match[6] || 0; + const milli = match[8] || 0; return new Date(Date.UTC(year, month, day, hour, minute, second, milli)); } - var makeDates = function(stringArray) { - return stringArray.map(function(dateStr) { - return parseDate(dateStr + "T00:00:00Z"); + const makeDates = function (stringArray) { + return stringArray.map(function (dateStr) { + return parseDate(dateStr + 'T00:00:00Z'); }); }; - var objectsList = []; - objectsList.push(new DateSet({ - "dates" : makeDates(["2013-02-01", "2013-02-02", "2013-02-03", - "2013-02-04"]) - })); - objectsList.push(new DateSet({ - "dates" : makeDates(["2013-02-01", "2013-02-03", "2013-02-04"]) - })); - - Parse.Object.saveAll(objectsList, function() { - var query = new Parse.Query(DateSet); - query.containsAll("dates", makeDates( - ["2013-02-01", "2013-02-02", "2013-02-03"])); - query.find({ - success: function(results) { + const objectsList = []; + objectsList.push( + new DateSet({ + dates: makeDates(['2013-02-01', '2013-02-02', '2013-02-03', '2013-02-04']), + }) + ); + objectsList.push( + new DateSet({ + dates: makeDates(['2013-02-01', '2013-02-03', '2013-02-04']), + }) + ); + + Parse.Object.saveAll(objectsList).then(function () { + const query = new Parse.Query(DateSet); + query.containsAll('dates', makeDates(['2013-02-01', '2013-02-02', '2013-02-03'])); + query.find().then( + function (results) { equal(results.length, 1); done(); }, - error: function(e) { - fail(e); + function (e) { + jfail(e); done(); - }, - }); + } + ); }); }); - it_exclude_dbs(['postgres'])("containsAll object array queries", function(done) { + it_id('25bb35a6-e953-4d6d-a31c-66324d5ae076')(it)('containsAll object array queries', function (done) { + const MessageSet = Parse.Object.extend({ className: 'MessageSet' }); - var MessageSet = Parse.Object.extend({ className: "MessageSet" }); - - var messageList = []; - for (var i = 0; i < 4; ++i) { - messageList.push(new TestObject({ 'i' : i })); + const messageList = []; + for (let i = 0; i < 4; ++i) { + messageList.push(new TestObject({ i: i })); } - Parse.Object.saveAll(messageList, function() { + Parse.Object.saveAll(messageList).then(function () { equal(messageList.length, 4); - var messageSetList = []; - messageSetList.push(new MessageSet({ 'messages' : messageList })); + const messageSetList = []; + messageSetList.push(new MessageSet({ messages: messageList })); - var someList = []; + const someList = []; someList.push(messageList[0]); someList.push(messageList[1]); someList.push(messageList[3]); - messageSetList.push(new MessageSet({ 'messages' : someList })); + messageSetList.push(new MessageSet({ messages: someList })); - Parse.Object.saveAll(messageSetList, function() { - var inList = []; + Parse.Object.saveAll(messageSetList).then(function () { + const inList = []; inList.push(messageList[0]); inList.push(messageList[2]); - var query = new Parse.Query(MessageSet); + const query = new Parse.Query(MessageSet); query.containsAll('messages', inList); - query.find({ - success: function(results) { - equal(results.length, 1); - done(); - } - }); - }); - }); - }); - - var BoxedNumber = Parse.Object.extend({ - className: "BoxedNumber" - }); - - it_exclude_dbs(['postgres'])("equalTo queries", function(done) { - var makeBoxedNumber = function(i) { - return new BoxedNumber({ number: i }); - }; - Parse.Object.saveAll([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.equalTo('number', 3); - query.find({ - success: function(results) { + query.find().then(function (results) { equal(results.length, 1); done(); - } + }); }); }); }); - it_exclude_dbs(['postgres'])("equalTo undefined", function(done) { - var makeBoxedNumber = function(i) { - return new BoxedNumber({ number: i }); - }; - Parse.Object.saveAll([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.equalTo('number', undefined); - query.find(expectSuccess({ - success: function(results) { - equal(results.length, 0); - done(); - } - })); - }); - }); + it('containsAllStartingWith should match all strings that starts with string', done => { + const object = new Parse.Object('Object'); + object.set('strings', ['the', 'brown', 'lazy', 'fox', 'jumps']); + const object2 = new Parse.Object('Object'); + object2.set('strings', ['the', 'brown', 'fox', 'jumps']); + const object3 = new Parse.Object('Object'); + object3.set('strings', ['over', 'the', 'lazy', 'dog']); + + const objectList = [object, object2, object3]; + + Parse.Object.saveAll(objectList).then(results => { + equal(objectList.length, results.length); + + return request({ + url: Parse.serverURL + '/classes/Object', + qs: { + where: JSON.stringify({ + strings: { + $all: [{ $regex: '^\\Qthe\\E' }, { $regex: '^\\Qfox\\E' }, { $regex: '^\\Qlazy\\E' }], + }, + }), + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }) + .then(function (response) { + const results = response.data; + equal(results.results.length, 1); + arrayContains(results.results, object); + + return request({ + url: Parse.serverURL + '/classes/Object', + qs: { + where: JSON.stringify({ + strings: { + $all: [{ $regex: '^\\Qthe\\E' }, { $regex: '^\\Qlazy\\E' }], + }, + }), + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(function (response) { + const results = response.data; + equal(results.results.length, 2); + arrayContains(results.results, object); + arrayContains(results.results, object3); + + return request({ + url: Parse.serverURL + '/classes/Object', + qs: { + where: JSON.stringify({ + strings: { + $all: [{ $regex: '^\\Qhe\\E' }, { $regex: '^\\Qlazy\\E' }], + }, + }), + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(function (response) { + const results = response.data; + equal(results.results.length, 0); - it_exclude_dbs(['postgres'])("lessThan queries", function(done) { - var makeBoxedNumber = function(i) { - return new BoxedNumber({ number: i }); - }; - Parse.Object.saveAll([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.lessThan('number', 7); - query.find({ - success: function(results) { - equal(results.length, 7); done(); - } - }); + }); }); }); - it_exclude_dbs(['postgres'])("lessThanOrEqualTo queries", function(done) { - var makeBoxedNumber = function(i) { - return new BoxedNumber({ number: i }); - }; - Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.lessThanOrEqualTo('number', 7); - query.find({ - success: function(results) { - equal(results.length, 8); - done(); - } + it_id('3ea6ae04-bcc2-453d-8817-4c64d059c2f6')(it)('containsAllStartingWith values must be all of type starting with regex', done => { + const object = new Parse.Object('Object'); + object.set('strings', ['the', 'brown', 'lazy', 'fox', 'jumps']); + + object + .save() + .then(() => { + equal(object.isNew(), false); + + return request({ + url: Parse.serverURL + '/classes/Object', + qs: { + where: JSON.stringify({ + strings: { + $all: [ + { $regex: '^\\Qthe\\E' }, + { $regex: '^\\Qlazy\\E' }, + { $regex: '^\\Qfox\\E' }, + { $unknown: /unknown/ }, + ], + }, + }), + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, }); + }) + .then(done.fail, function () { + done(); }); }); - it_exclude_dbs(['postgres'])("greaterThan queries", function(done) { - var makeBoxedNumber = function(i) { - return new BoxedNumber({ number: i }); - }; - Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.greaterThan('number', 7); - query.find({ - success: function(results) { - equal(results.length, 2); - done(); - } + it('containsAllStartingWith empty array values should return empty results', done => { + const object = new Parse.Object('Object'); + object.set('strings', ['the', 'brown', 'lazy', 'fox', 'jumps']); + + object + .save() + .then(() => { + equal(object.isNew(), false); + + return request({ + url: Parse.serverURL + '/classes/Object', + qs: { + where: JSON.stringify({ + strings: { + $all: [], + }, + }), + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, }); - }); + }) + .then( + function (response) { + const results = response.data; + equal(results.results.length, 0); + done(); + }, + function () {} + ); }); - it_exclude_dbs(['postgres'])("greaterThanOrEqualTo queries", function(done) { - var makeBoxedNumber = function(i) { - return new BoxedNumber({ number: i }); - }; - Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.greaterThanOrEqualTo('number', 7); - query.find({ - success: function(results) { - equal(results.length, 3); - done(); - } + it('containsAllStartingWith single empty value returns empty results', done => { + const object = new Parse.Object('Object'); + object.set('strings', ['the', 'brown', 'lazy', 'fox', 'jumps']); + + object + .save() + .then(() => { + equal(object.isNew(), false); + + return request({ + url: Parse.serverURL + '/classes/Object', + qs: { + where: JSON.stringify({ + strings: { + $all: [{}], + }, + }), + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, }); - }); + }) + .then( + function (response) { + const results = response.data; + equal(results.results.length, 0); + done(); + }, + function () {} + ); }); - it_exclude_dbs(['postgres'])("lessThanOrEqualTo greaterThanOrEqualTo queries", function(done) { - var makeBoxedNumber = function(i) { - return new BoxedNumber({ number: i }); - }; - Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.lessThanOrEqualTo('number', 7); - query.greaterThanOrEqualTo('number', 7); - query.find({ - success: function(results) { - equal(results.length, 1); + it('containsAllStartingWith single regex value should return corresponding matching results', done => { + const object = new Parse.Object('Object'); + object.set('strings', ['the', 'brown', 'lazy', 'fox', 'jumps']); + const object2 = new Parse.Object('Object'); + object2.set('strings', ['the', 'brown', 'fox', 'jumps']); + const object3 = new Parse.Object('Object'); + object3.set('strings', ['over', 'the', 'lazy', 'dog']); + + const objectList = [object, object2, object3]; + + Parse.Object.saveAll(objectList) + .then(results => { + equal(objectList.length, results.length); + + return request({ + url: Parse.serverURL + '/classes/Object', + qs: { + where: JSON.stringify({ + strings: { + $all: [{ $regex: '^\\Qlazy\\E' }], + }, + }), + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then( + function (response) { + const results = response.data; + equal(results.results.length, 2); done(); - } - }); - }); + }, + function () {} + ); }); - it_exclude_dbs(['postgres'])("lessThan greaterThan queries", function(done) { - var makeBoxedNumber = function(i) { - return new BoxedNumber({ number: i }); - }; - Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.lessThan('number', 9); - query.greaterThan('number', 3); - query.find({ - success: function(results) { - equal(results.length, 5); + it('containsAllStartingWith single invalid regex returns empty results', done => { + const object = new Parse.Object('Object'); + object.set('strings', ['the', 'brown', 'lazy', 'fox', 'jumps']); + + object + .save() + .then(() => { + equal(object.isNew(), false); + + return request({ + url: Parse.serverURL + '/classes/Object', + qs: { + where: JSON.stringify({ + strings: { + $all: [{ $unknown: '^\\Qlazy\\E' }], + }, + }), + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + }, + }); + }) + .then( + function (response) { + const results = response.data; + equal(results.results.length, 0); done(); - } + }, + function () {} + ); + }); + + it_id('01a15195-dde2-4368-b996-d746a4ede3a1')(it)('containedBy pointer array', done => { + const objects = Array.from(Array(10).keys()).map(idx => { + const obj = new Parse.Object('Object'); + obj.set('key', idx); + return obj; + }); + + const parent = new Parse.Object('Parent'); + const parent2 = new Parse.Object('Parent'); + const parent3 = new Parse.Object('Parent'); + + Parse.Object.saveAll(objects) + .then(() => { + // [0, 1, 2] + parent.set('objects', objects.slice(0, 3)); + + const shift = objects.shift(); + // [2, 0] + parent2.set('objects', [objects[1], shift]); + + // [1, 2, 3, 4] + parent3.set('objects', objects.slice(1, 4)); + + return Parse.Object.saveAll([parent, parent2, parent3]); + }) + .then(() => { + // [1, 2, 3, 4, 5, 6, 7, 8, 9] + const pointers = objects.map(object => object.toPointer()); + + // Return all Parent where all parent.objects are contained in objects + return request({ + url: Parse.serverURL + '/classes/Parent', + qs: { + where: JSON.stringify({ + objects: { + $containedBy: pointers, + }, + }), + }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then(response => { + const results = response.data; + expect(results.results[0].objectId).not.toBeUndefined(); + expect(results.results[0].objectId).toBe(parent3.id); + expect(results.results.length).toBe(1); + done(); + }); + }); + + it('containedBy number array', done => { + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ + numbers: { $containedBy: [1, 2, 3, 4, 5, 6, 7, 8, 9] }, + }), + }, + }); + const obj1 = new TestObject({ numbers: [0, 1, 2] }); + const obj2 = new TestObject({ numbers: [2, 0] }); + const obj3 = new TestObject({ numbers: [1, 2, 3, 4] }); + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + return request(Object.assign({ url: Parse.serverURL + '/classes/TestObject' }, options)); + }) + .then(response => { + const results = response.data; + expect(results.results[0].objectId).not.toBeUndefined(); + expect(results.results[0].objectId).toBe(obj3.id); + expect(results.results.length).toBe(1); + done(); }); + }); + + it('containedBy empty array', done => { + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ numbers: { $containedBy: [] } }), + }, + }); + const obj1 = new TestObject({ numbers: [0, 1, 2] }); + const obj2 = new TestObject({ numbers: [2, 0] }); + const obj3 = new TestObject({ numbers: [1, 2, 3, 4] }); + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + return request(Object.assign({ url: Parse.serverURL + '/classes/TestObject' }, options)); + }) + .then(response => { + const results = response.data; + expect(results.results.length).toBe(0); + done(); + }); + }); + + it('containedBy invalid query', done => { + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ objects: { $containedBy: 1234 } }), + }, }); + const obj = new TestObject(); + obj + .save() + .then(() => { + return request(Object.assign({ url: Parse.serverURL + '/classes/TestObject' }, options)); + }) + .then(done.fail) + .catch(response => { + equal(response.data.code, Parse.Error.INVALID_JSON); + equal(response.data.error, 'bad $containedBy: should be an array'); + done(); + }); }); - it("notEqualTo queries", function(done) { - var makeBoxedNumber = function(i) { + it('equalTo queries', function (done) { + const makeBoxedNumber = function (i) { return new BoxedNumber({ number: i }); }; - Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.notEqualTo('number', 5); - query.find({ - success: function(results) { - equal(results.length, 9); - done(); - } + Parse.Object.saveAll([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber)).then(function () { + const query = new Parse.Query(BoxedNumber); + query.equalTo('number', 3); + query.find().then(function (results) { + equal(results.length, 1); + done(); }); }); }); - it_exclude_dbs(['postgres'])("containedIn queries", function(done) { - var makeBoxedNumber = function(i) { + it('equalTo undefined', function (done) { + const makeBoxedNumber = function (i) { return new BoxedNumber({ number: i }); }; - Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.containedIn('number', [3,5,7,9,11]); - query.find({ - success: function(results) { - equal(results.length, 4); - done(); - } + Parse.Object.saveAll([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber)).then(function () { + const query = new Parse.Query(BoxedNumber); + query.equalTo('number', undefined); + query.find().then(function (results) { + equal(results.length, 0); + done(); }); }); }); - it_exclude_dbs(['postgres'])("notContainedIn queries", function(done) { - var makeBoxedNumber = function(i) { + it('lessThan queries', function (done) { + const makeBoxedNumber = function (i) { return new BoxedNumber({ number: i }); }; - Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.notContainedIn('number', [3,5,7,9,11]); - query.find({ - success: function(results) { - equal(results.length, 6); - done(); - } + Parse.Object.saveAll([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber)).then(function () { + const query = new Parse.Query(BoxedNumber); + query.lessThan('number', 7); + query.find().then(function (results) { + equal(results.length, 7); + done(); }); }); }); - - it_exclude_dbs(['postgres'])("objectId containedIn queries", function(done) { - var makeBoxedNumber = function(i) { + it('lessThanOrEqualTo queries', function (done) { + const makeBoxedNumber = function (i) { return new BoxedNumber({ number: i }); }; - Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function(list) { - var query = new Parse.Query(BoxedNumber); - query.containedIn('objectId', - [list[2].id, list[3].id, list[0].id, - "NONSENSE"]); - query.ascending('number'); - query.find({ - success: function(results) { - if (results.length != 3) { - fail('expected 3 results'); - } else { - equal(results[0].get('number'), 0); - equal(results[1].get('number'), 2); - equal(results[2].get('number'), 3); - } - done(); - } - }); + Parse.Object.saveAll([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber)).then(function () { + const query = new Parse.Query(BoxedNumber); + query.lessThanOrEqualTo('number', 7); + query.find().then(function (results) { + equal(results.length, 8); + done(); }); + }); }); - it_exclude_dbs(['postgres'])("objectId equalTo queries", function(done) { - var makeBoxedNumber = function(i) { + it('lessThan zero queries', done => { + const makeBoxedNumber = i => { return new BoxedNumber({ number: i }); }; - Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function(list) { - var query = new Parse.Query(BoxedNumber); - query.equalTo('objectId', list[4].id); - query.find({ - success: function(results) { - if (results.length != 1) { - fail('expected 1 result') - done(); - } else { - equal(results[0].get('number'), 4); - } - done(); - } - }); + const numbers = [-3, -2, -1, 0, 1]; + const boxedNumbers = numbers.map(makeBoxedNumber); + Parse.Object.saveAll(boxedNumbers) + .then(() => { + const query = new Parse.Query(BoxedNumber); + query.lessThan('number', 0); + return query.find(); + }) + .then(results => { + equal(results.length, 3); + done(); }); }); - it_exclude_dbs(['postgres'])("find no elements", function(done) { - var makeBoxedNumber = function(i) { + it('lessThanOrEqualTo zero queries', done => { + const makeBoxedNumber = i => { return new BoxedNumber({ number: i }); }; - Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.equalTo('number', 17); - query.find(expectSuccess({ - success: function(results) { - equal(results.length, 0); - done(); - } - })); - }); + const numbers = [-3, -2, -1, 0, 1]; + const boxedNumbers = numbers.map(makeBoxedNumber); + Parse.Object.saveAll(boxedNumbers) + .then(() => { + const query = new Parse.Query(BoxedNumber); + query.lessThanOrEqualTo('number', 0); + return query.find(); + }) + .then(results => { + equal(results.length, 4); + done(); + }); }); - it("find with error", function(done) { - var query = new Parse.Query(BoxedNumber); - query.equalTo('$foo', 'bar'); - query.find(expectError(Parse.Error.INVALID_KEY_NAME, done)); + it('greaterThan queries', function (done) { + const makeBoxedNumber = function (i) { + return new BoxedNumber({ number: i }); + }; + Parse.Object.saveAll([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber)).then(function () { + const query = new Parse.Query(BoxedNumber); + query.greaterThan('number', 7); + query.find().then(function (results) { + equal(results.length, 2); + done(); + }); + }); }); - it("get", function(done) { - Parse.Object.saveAll([new TestObject({foo: 'bar'})], function(items) { - ok(items[0]); - var objectId = items[0].id; - var query = new Parse.Query(TestObject); - query.get(objectId, { - success: function(result) { - ok(result); - equal(result.id, objectId); - equal(result.get('foo'), 'bar'); - ok(result.createdAt instanceof Date); - ok(result.updatedAt instanceof Date); - done(); - } + it('greaterThanOrEqualTo queries', function (done) { + const makeBoxedNumber = function (i) { + return new BoxedNumber({ number: i }); + }; + Parse.Object.saveAll([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber)).then(function () { + const query = new Parse.Query(BoxedNumber); + query.greaterThanOrEqualTo('number', 7); + query.find().then(function (results) { + equal(results.length, 3); + done(); }); }); }); - it("get undefined", function(done) { - Parse.Object.saveAll([new TestObject({foo: 'bar'})], function(items) { - ok(items[0]); - var query = new Parse.Query(TestObject); - query.get(undefined, { - success: fail, - error: done, + it('greaterThan zero queries', done => { + const makeBoxedNumber = i => { + return new BoxedNumber({ number: i }); + }; + const numbers = [-3, -2, -1, 0, 1]; + const boxedNumbers = numbers.map(makeBoxedNumber); + Parse.Object.saveAll(boxedNumbers) + .then(() => { + const query = new Parse.Query(BoxedNumber); + query.greaterThan('number', 0); + return query.find(); + }) + .then(results => { + equal(results.length, 1); + done(); }); - }); }); - it("get error", function(done) { - Parse.Object.saveAll([new TestObject({foo: 'bar'})], function(items) { - ok(items[0]); - var objectId = items[0].id; - var query = new Parse.Query(TestObject); - query.get("InvalidObjectID", { - success: function(result) { - ok(false, "The get should have failed."); - done(); - }, - error: function(object, error) { - equal(error.code, Parse.Error.OBJECT_NOT_FOUND); - done(); - } + it('greaterThanOrEqualTo zero queries', done => { + const makeBoxedNumber = i => { + return new BoxedNumber({ number: i }); + }; + const numbers = [-3, -2, -1, 0, 1]; + const boxedNumbers = numbers.map(makeBoxedNumber); + Parse.Object.saveAll(boxedNumbers) + .then(() => { + const query = new Parse.Query(BoxedNumber); + query.greaterThanOrEqualTo('number', 0); + return query.find(); + }) + .then(results => { + equal(results.length, 2); + done(); }); - }); }); - it("first", function(done) { - Parse.Object.saveAll([new TestObject({foo: 'bar'})], function() { - var query = new Parse.Query(TestObject); - query.equalTo('foo', 'bar'); - query.first({ - success: function(result) { - equal(result.get('foo'), 'bar'); - done(); - } + it('lessThanOrEqualTo greaterThanOrEqualTo queries', function (done) { + const makeBoxedNumber = function (i) { + return new BoxedNumber({ number: i }); + }; + Parse.Object.saveAll([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber)).then(function () { + const query = new Parse.Query(BoxedNumber); + query.lessThanOrEqualTo('number', 7); + query.greaterThanOrEqualTo('number', 7); + query.find().then(function (results) { + equal(results.length, 1); + done(); }); }); }); - it("first no result", function(done) { - Parse.Object.saveAll([new TestObject({foo: 'bar'})], function() { - var query = new Parse.Query(TestObject); - query.equalTo('foo', 'baz'); - query.first({ - success: function(result) { - equal(result, undefined); - done(); - } + it('lessThan greaterThan queries', function (done) { + const makeBoxedNumber = function (i) { + return new BoxedNumber({ number: i }); + }; + Parse.Object.saveAll([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber)).then(function () { + const query = new Parse.Query(BoxedNumber); + query.lessThan('number', 9); + query.greaterThan('number', 3); + query.find().then(function (results) { + equal(results.length, 5); + done(); }); }); }); - it("first with two results", function(done) { - Parse.Object.saveAll([new TestObject({foo: 'bar'}), - new TestObject({foo: 'bar'})], function() { - var query = new Parse.Query(TestObject); - query.equalTo('foo', 'bar'); - query.first({ - success: function(result) { - equal(result.get('foo'), 'bar'); - done(); - } - }); - }); + it('notEqualTo queries', function (done) { + const makeBoxedNumber = function (i) { + return new BoxedNumber({ number: i }); + }; + Parse.Object.saveAll([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber)).then(function () { + const query = new Parse.Query(BoxedNumber); + query.notEqualTo('number', 5); + query.find().then(function (results) { + equal(results.length, 9); + done(); + }); + }); }); - it("first with error", function(done) { - var query = new Parse.Query(BoxedNumber); - query.equalTo('$foo', 'bar'); - query.first(expectError(Parse.Error.INVALID_KEY_NAME, done)); + it('notEqualTo zero queries', done => { + const makeBoxedNumber = i => { + return new BoxedNumber({ number: i }); + }; + const numbers = [-3, -2, -1, 0, 1]; + const boxedNumbers = numbers.map(makeBoxedNumber); + Parse.Object.saveAll(boxedNumbers) + .then(() => { + const query = new Parse.Query(BoxedNumber); + query.notEqualTo('number', 0); + return query.find(); + }) + .then(results => { + equal(results.length, 4); + done(); + }); }); - var Container = Parse.Object.extend({ - className: "Container" + it('equalTo zero queries', done => { + const makeBoxedNumber = i => { + return new BoxedNumber({ number: i }); + }; + const numbers = [-3, -2, -1, 0, 1]; + const boxedNumbers = numbers.map(makeBoxedNumber); + Parse.Object.saveAll(boxedNumbers) + .then(() => { + const query = new Parse.Query(BoxedNumber); + query.equalTo('number', 0); + return query.find(); + }) + .then(results => { + equal(results.length, 1); + done(); + }); }); - it_exclude_dbs(['postgres'])("notEqualTo object", function(done) { - var item1 = new TestObject(); - var item2 = new TestObject(); - var container1 = new Container({item: item1}); - var container2 = new Container({item: item2}); - Parse.Object.saveAll([item1, item2, container1, container2], function() { - var query = new Parse.Query(Container); - query.notEqualTo('item', item1); - query.find({ - success: function(results) { - equal(results.length, 1); - done(); - } + it('number equalTo boolean queries', done => { + const makeBoxedNumber = i => { + return new BoxedNumber({ number: i }); + }; + const numbers = [-3, -2, -1, 0, 1]; + const boxedNumbers = numbers.map(makeBoxedNumber); + Parse.Object.saveAll(boxedNumbers) + .then(() => { + const query = new Parse.Query(BoxedNumber); + query.equalTo('number', false); + return query.find(); + }) + .then(results => { + equal(results.length, 0); + done(); }); - }); }); - it_exclude_dbs(['postgres'])("skip", function(done) { - Parse.Object.saveAll([new TestObject(), new TestObject()], function() { - var query = new Parse.Query(TestObject); - query.skip(1); - query.find({ - success: function(results) { - equal(results.length, 1); - query.skip(3); - query.find({ - success: function(results) { - equal(results.length, 0); - done(); - } - }); - } + it('equalTo false queries', done => { + const obj1 = new TestObject({ field: false }); + const obj2 = new TestObject({ field: true }); + Parse.Object.saveAll([obj1, obj2]) + .then(() => { + const query = new Parse.Query(TestObject); + query.equalTo('field', false); + return query.find(); + }) + .then(results => { + equal(results.length, 1); + done(); }); - }); }); - it_exclude_dbs(['postgres'])("skip doesn't affect count", function(done) { - Parse.Object.saveAll([new TestObject(), new TestObject()], function() { - var query = new Parse.Query(TestObject); - query.count({ - success: function(count) { - equal(count, 2); - query.skip(1); - query.count({ - success: function(count) { - equal(count, 2); - query.skip(3); - query.count({ - success: function(count) { - equal(count, 2); - done(); - } - }); - } - }); + it('where $eq false queries (rest)', done => { + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ field: { $eq: false } }), + }, + }); + const obj1 = new TestObject({ field: false }); + const obj2 = new TestObject({ field: true }); + Parse.Object.saveAll([obj1, obj2]).then(() => { + request(Object.assign({ url: Parse.serverURL + '/classes/TestObject' }, options)).then( + resp => { + equal(resp.data.results.length, 1); + done(); } - }); + ); }); }); - it_exclude_dbs(['postgres'])("count", function(done) { - var makeBoxedNumber = function(i) { - return new BoxedNumber({ number: i }); - }; - Parse.Object.saveAll( - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber), - function() { - var query = new Parse.Query(BoxedNumber); - query.greaterThan("number", 1); - query.count({ - success: function(count) { - equal(count, 8); + it('where $eq null queries (rest)', done => { + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ field: { $eq: null } }), + }, + }); + const obj1 = new TestObject({ field: false }); + const obj2 = new TestObject({ field: null }); + Parse.Object.saveAll([obj1, obj2]).then(() => { + return request(Object.assign({ url: Parse.serverURL + '/classes/TestObject' }, options)).then( + resp => { + equal(resp.data.results.length, 1); done(); } - }); + ); }); }); - it_exclude_dbs(['postgres'])("order by ascending number", function(done) { - var makeBoxedNumber = function(i) { + it('containedIn queries', function (done) { + const makeBoxedNumber = function (i) { return new BoxedNumber({ number: i }); }; - Parse.Object.saveAll([3, 1, 2].map(makeBoxedNumber), function(list) { - var query = new Parse.Query(BoxedNumber); - query.ascending("number"); - query.find(expectSuccess({ - success: function(results) { - equal(results.length, 3); - equal(results[0].get("number"), 1); - equal(results[1].get("number"), 2); - equal(results[2].get("number"), 3); - done(); - } - })); + Parse.Object.saveAll([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber)).then(function () { + const query = new Parse.Query(BoxedNumber); + query.containedIn('number', [3, 5, 7, 9, 11]); + query.find().then(function (results) { + equal(results.length, 4); + done(); + }); }); }); - it_exclude_dbs(['postgres'])("order by descending number", function(done) { - var makeBoxedNumber = function(i) { + it('containedIn false queries', done => { + const makeBoxedNumber = i => { return new BoxedNumber({ number: i }); }; - Parse.Object.saveAll([3, 1, 2].map(makeBoxedNumber), function(list) { - var query = new Parse.Query(BoxedNumber); - query.descending("number"); - query.find(expectSuccess({ - success: function(results) { - equal(results.length, 3); - equal(results[0].get("number"), 3); - equal(results[1].get("number"), 2); - equal(results[2].get("number"), 1); - done(); - } - })); - }); + const numbers = [-3, -2, -1, 0, 1]; + const boxedNumbers = numbers.map(makeBoxedNumber); + Parse.Object.saveAll(boxedNumbers) + .then(() => { + const query = new Parse.Query(BoxedNumber); + query.containedIn('number', false); + return query.find(); + }) + .then(done.fail) + .catch(error => { + equal(error.code, Parse.Error.INVALID_JSON); + equal(error.message, 'bad $in value'); + done(); + }); }); - it_exclude_dbs(['postgres'])("order by ascending number then descending string", function(done) { - var strings = ["a", "b", "c", "d"]; - var makeBoxedNumber = function(num, i) { - return new BoxedNumber({ number: num, string: strings[i] }); + it('notContainedIn false queries', done => { + const makeBoxedNumber = i => { + return new BoxedNumber({ number: i }); }; - Parse.Object.saveAll( - [3, 1, 3, 2].map(makeBoxedNumber), - function(list) { - var query = new Parse.Query(BoxedNumber); - query.ascending("number").addDescending("string"); - query.find(expectSuccess({ - success: function(results) { - equal(results.length, 4); - equal(results[0].get("number"), 1); - equal(results[0].get("string"), "b"); - equal(results[1].get("number"), 2); - equal(results[1].get("string"), "d"); - equal(results[2].get("number"), 3); - equal(results[2].get("string"), "c"); - equal(results[3].get("number"), 3); - equal(results[3].get("string"), "a"); - done(); - } - })); + const numbers = [-3, -2, -1, 0, 1]; + const boxedNumbers = numbers.map(makeBoxedNumber); + Parse.Object.saveAll(boxedNumbers) + .then(() => { + const query = new Parse.Query(BoxedNumber); + query.notContainedIn('number', false); + return query.find(); + }) + .then(done.fail) + .catch(error => { + equal(error.code, Parse.Error.INVALID_JSON); + equal(error.message, 'bad $nin value'); + done(); }); }); - it_exclude_dbs(['postgres'])("order by descending number then ascending string", function(done) { - var strings = ["a", "b", "c", "d"]; - var makeBoxedNumber = function(num, i) { - return new BoxedNumber({ number: num, string: strings[i] }); - }; - Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber), - function(list) { - var query = new Parse.Query(BoxedNumber); - query.descending("number").addAscending("string"); - query.find(expectSuccess({ - success: function(results) { - equal(results.length, 4); - equal(results[0].get("number"), 3); - equal(results[0].get("string"), "a"); - equal(results[1].get("number"), 3); - equal(results[1].get("string"), "c"); - equal(results[2].get("number"), 2); - equal(results[2].get("string"), "d"); - equal(results[3].get("number"), 1); - equal(results[3].get("string"), "b"); - done(); - } - })); - }); - }); - - it_exclude_dbs(['postgres'])("order by descending number and string", function(done) { - var strings = ["a", "b", "c", "d"]; - var makeBoxedNumber = function(num, i) { - return new BoxedNumber({ number: num, string: strings[i] }); - }; - Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber), - function(list) { - var query = new Parse.Query(BoxedNumber); - query.descending("number,string"); - query.find(expectSuccess({ - success: function(results) { - equal(results.length, 4); - equal(results[0].get("number"), 3); - equal(results[0].get("string"), "c"); - equal(results[1].get("number"), 3); - equal(results[1].get("string"), "a"); - equal(results[2].get("number"), 2); - equal(results[2].get("string"), "d"); - equal(results[3].get("number"), 1); - equal(results[3].get("string"), "b"); - done(); - } - })); - }); - }); - - it_exclude_dbs(['postgres'])("order by descending number and string, with space", function(done) { - var strings = ["a", "b", "c", "d"]; - var makeBoxedNumber = function(num, i) { - return new BoxedNumber({ number: num, string: strings[i] }); - }; - Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber), - function(list) { - var query = new Parse.Query(BoxedNumber); - query.descending("number, string"); - query.find(expectSuccess({ - success: function(results) { - equal(results.length, 4); - equal(results[0].get("number"), 3); - equal(results[0].get("string"), "c"); - equal(results[1].get("number"), 3); - equal(results[1].get("string"), "a"); - equal(results[2].get("number"), 2); - equal(results[2].get("string"), "d"); - equal(results[3].get("number"), 1); - equal(results[3].get("string"), "b"); - done(); - } - })); - }); - }); - - it_exclude_dbs(['postgres'])("order by descending number and string, with array arg", function(done) { - var strings = ["a", "b", "c", "d"]; - var makeBoxedNumber = function(num, i) { - return new BoxedNumber({ number: num, string: strings[i] }); - }; - Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber), - function(list) { - var query = new Parse.Query(BoxedNumber); - query.descending(["number", "string"]); - query.find(expectSuccess({ - success: function(results) { - equal(results.length, 4); - equal(results[0].get("number"), 3); - equal(results[0].get("string"), "c"); - equal(results[1].get("number"), 3); - equal(results[1].get("string"), "a"); - equal(results[2].get("number"), 2); - equal(results[2].get("string"), "d"); - equal(results[3].get("number"), 1); - equal(results[3].get("string"), "b"); - done(); - } - })); - }); - }); - - it_exclude_dbs(['postgres'])("order by descending number and string, with multiple args", function(done) { - var strings = ["a", "b", "c", "d"]; - var makeBoxedNumber = function(num, i) { - return new BoxedNumber({ number: num, string: strings[i] }); - }; - Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber), - function(list) { - var query = new Parse.Query(BoxedNumber); - query.descending("number", "string"); - query.find(expectSuccess({ - success: function(results) { - equal(results.length, 4); - equal(results[0].get("number"), 3); - equal(results[0].get("string"), "c"); - equal(results[1].get("number"), 3); - equal(results[1].get("string"), "a"); - equal(results[2].get("number"), 2); - equal(results[2].get("string"), "d"); - equal(results[3].get("number"), 1); - equal(results[3].get("string"), "b"); - done(); - } - })); - }); - }); - - it_exclude_dbs(['postgres'])("can't order by password", function(done) { - var makeBoxedNumber = function(i) { + it('notContainedIn queries', function (done) { + const makeBoxedNumber = function (i) { return new BoxedNumber({ number: i }); }; - Parse.Object.saveAll([3, 1, 2].map(makeBoxedNumber), function(list) { - var query = new Parse.Query(BoxedNumber); - query.ascending("_password"); - query.find(expectError(Parse.Error.INVALID_KEY_NAME, done)); + Parse.Object.saveAll([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber)).then(function () { + const query = new Parse.Query(BoxedNumber); + query.notContainedIn('number', [3, 5, 7, 9, 11]); + query.find().then(function (results) { + equal(results.length, 6); + done(); + }); }); }); - it("order by _created_at", function(done) { - var makeBoxedNumber = function(i) { + it('objectId containedIn queries', function (done) { + const makeBoxedNumber = function (i) { return new BoxedNumber({ number: i }); }; - var numbers = [3, 1, 2].map(makeBoxedNumber); - numbers[0].save().then(() => { - return numbers[1].save(); - }).then(() => { - return numbers[2].save(); - }).then(function() { - var query = new Parse.Query(BoxedNumber); - query.ascending("_created_at"); - query.find({ - success: function(results) { - equal(results.length, 3); - equal(results[0].get("number"), 3); - equal(results[1].get("number"), 1); - equal(results[2].get("number"), 2); - done(); - }, - error: function(e) { - fail(e); - done(); - }, + Parse.Object.saveAll([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber)).then(function (list) { + const query = new Parse.Query(BoxedNumber); + query.containedIn('objectId', [list[2].id, list[3].id, list[0].id, 'NONSENSE']); + query.ascending('number'); + query.find().then(function (results) { + if (results.length != 3) { + fail('expected 3 results'); + } else { + equal(results[0].get('number'), 0); + equal(results[1].get('number'), 2); + equal(results[2].get('number'), 3); + } + done(); }); }); }); - it_exclude_dbs(['postgres'])("order by createdAt", function(done) { - var makeBoxedNumber = function(i) { + it('objectId equalTo queries', function (done) { + const makeBoxedNumber = function (i) { return new BoxedNumber({ number: i }); }; - var numbers = [3, 1, 2].map(makeBoxedNumber); - numbers[0].save().then(() => { - return numbers[1].save(); - }).then(() => { - return numbers[2].save(); - }).then(function() { - var query = new Parse.Query(BoxedNumber); - query.descending("createdAt"); - query.find({ - success: function(results) { - equal(results.length, 3); - equal(results[0].get("number"), 2); - equal(results[1].get("number"), 1); - equal(results[2].get("number"), 3); + Parse.Object.saveAll([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber)).then(function (list) { + const query = new Parse.Query(BoxedNumber); + query.equalTo('objectId', list[4].id); + query.find().then(function (results) { + if (results.length != 1) { + fail('expected 1 result'); done(); + } else { + equal(results[0].get('number'), 4); } + done(); }); }); }); - it_exclude_dbs(['postgres'])("order by _updated_at", function(done) { - var makeBoxedNumber = function(i) { + it('find no elements', function (done) { + const makeBoxedNumber = function (i) { return new BoxedNumber({ number: i }); }; - var numbers = [3, 1, 2].map(makeBoxedNumber); - numbers[0].save().then(() => { - return numbers[1].save(); - }).then(() => { - return numbers[2].save(); - }).then(function() { - numbers[1].set("number", 4); - numbers[1].save(null, { - success: function(model) { - var query = new Parse.Query(BoxedNumber); - query.ascending("_updated_at"); - query.find({ - success: function(results) { - equal(results.length, 3); - equal(results[0].get("number"), 3); - equal(results[1].get("number"), 2); - equal(results[2].get("number"), 4); - done(); - } - }); - } + Parse.Object.saveAll([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber)).then(function () { + const query = new Parse.Query(BoxedNumber); + query.equalTo('number', 17); + query.find().then(function (results) { + equal(results.length, 0); + done(); }); }); }); - it_exclude_dbs(['postgres'])("order by updatedAt", function(done) { - var makeBoxedNumber = function(i) { return new BoxedNumber({ number: i }); }; - var numbers = [3, 1, 2].map(makeBoxedNumber); - numbers[0].save().then(() => { - return numbers[1].save(); - }).then(() => { - return numbers[2].save(); - }).then(function() { - numbers[1].set("number", 4); - numbers[1].save(null, { - success: function(model) { - var query = new Parse.Query(BoxedNumber); - query.descending("_updated_at"); - query.find({ - success: function(results) { - equal(results.length, 3); - equal(results[0].get("number"), 4); - equal(results[1].get("number"), 2); - equal(results[2].get("number"), 3); - done(); - } - }); - } + it('find with error', function (done) { + const query = new Parse.Query(BoxedNumber); + query.equalTo('$foo', 'bar'); + query + .find() + .then(done.fail) + .catch(error => expect(error.code).toBe(Parse.Error.INVALID_KEY_NAME)) + .then(done); + }); + + it('get', function (done) { + Parse.Object.saveAll([new TestObject({ foo: 'bar' })]).then(function (items) { + ok(items[0]); + const objectId = items[0].id; + const query = new Parse.Query(TestObject); + query.get(objectId).then(function (result) { + ok(result); + equal(result.id, objectId); + equal(result.get('foo'), 'bar'); + ok(Utils.isDate(result.createdAt)); + ok(Utils.isDate(result.updatedAt)); + done(); }); }); }); - // Returns a promise - function makeTimeObject(start, i) { - var time = new Date(); - time.setSeconds(start.getSeconds() + i); - var item = new TestObject({name: "item" + i, time: time}); - return item.save(); - } - - // Returns a promise for all the time objects - function makeThreeTimeObjects() { - var start = new Date(); - var one, two, three; - return makeTimeObject(start, 1).then((o1) => { - one = o1; - return makeTimeObject(start, 2); - }).then((o2) => { - two = o2; - return makeTimeObject(start, 3); - }).then((o3) => { - three = o3; - return [one, two, three]; + it('get undefined', function (done) { + Parse.Object.saveAll([new TestObject({ foo: 'bar' })]).then(function (items) { + ok(items[0]); + const query = new Parse.Query(TestObject); + query.get(undefined).then(fail, () => done()); }); - } + }); - it_exclude_dbs(['postgres'])("time equality", function(done) { - makeThreeTimeObjects().then(function(list) { - var query = new Parse.Query(TestObject); - query.equalTo("time", list[1].get("time")); - query.find({ - success: function(results) { - equal(results.length, 1); - equal(results[0].get("name"), "item2"); + it('get error', function (done) { + Parse.Object.saveAll([new TestObject({ foo: 'bar' })]).then(function (items) { + ok(items[0]); + const query = new Parse.Query(TestObject); + query.get('InvalidObjectID').then( + function () { + ok(false, 'The get should have failed.'); done(); - } + }, + function (error) { + equal(error.code, Parse.Error.OBJECT_NOT_FOUND); + done(); + } + ); + }); + }); + + it('first', function (done) { + Parse.Object.saveAll([new TestObject({ foo: 'bar' })]).then(function () { + const query = new Parse.Query(TestObject); + query.equalTo('foo', 'bar'); + query.first().then(function (result) { + equal(result.get('foo'), 'bar'); + done(); + }); + }); + }); + + it('first no result', function (done) { + Parse.Object.saveAll([new TestObject({ foo: 'bar' })]).then(function () { + const query = new Parse.Query(TestObject); + query.equalTo('foo', 'baz'); + query.first().then(function (result) { + equal(result, undefined); + done(); + }); + }); + }); + + it('first with two results', function (done) { + Parse.Object.saveAll([new TestObject({ foo: 'bar' }), new TestObject({ foo: 'bar' })]).then( + function () { + const query = new Parse.Query(TestObject); + query.equalTo('foo', 'bar'); + query.first().then(function (result) { + equal(result.get('foo'), 'bar'); + done(); + }); + } + ); + }); + + it('first with error', function (done) { + const query = new Parse.Query(BoxedNumber); + query.equalTo('$foo', 'bar'); + query + .first() + .then(done.fail) + .catch(e => expect(e.code).toBe(Parse.Error.INVALID_KEY_NAME)) + .then(done); + }); + + const Container = Parse.Object.extend({ + className: 'Container', + }); + + it('notEqualTo object', function (done) { + const item1 = new TestObject(); + const item2 = new TestObject(); + const container1 = new Container({ item: item1 }); + const container2 = new Container({ item: item2 }); + Parse.Object.saveAll([item1, item2, container1, container2]).then(function () { + const query = new Parse.Query(Container); + query.notEqualTo('item', item1); + query.find().then(function (results) { + equal(results.length, 1); + done(); + }); + }); + }); + + it('skip', function (done) { + Parse.Object.saveAll([new TestObject(), new TestObject()]).then(function () { + const query = new Parse.Query(TestObject); + query.skip(1); + query.find().then(function (results) { + equal(results.length, 1); + query.skip(3); + query.find().then(function (results) { + equal(results.length, 0); + done(); + }); + }); + }); + }); + + it("skip doesn't affect count", function (done) { + Parse.Object.saveAll([new TestObject(), new TestObject()]).then(function () { + const query = new Parse.Query(TestObject); + query.count().then(function (count) { + equal(count, 2); + query.skip(1); + query.count().then(function (count) { + equal(count, 2); + query.skip(3); + query.count().then(function (count) { + equal(count, 2); + done(); + }); + }); + }); + }); + }); + + it('count', function (done) { + const makeBoxedNumber = function (i) { + return new BoxedNumber({ number: i }); + }; + Parse.Object.saveAll([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(makeBoxedNumber)).then(function () { + const query = new Parse.Query(BoxedNumber); + query.greaterThan('number', 1); + query.count().then(function (count) { + equal(count, 8); + done(); + }); + }); + }); + + it('order by ascending number', function (done) { + const makeBoxedNumber = function (i) { + return new BoxedNumber({ number: i }); + }; + Parse.Object.saveAll([3, 1, 2].map(makeBoxedNumber)).then(function () { + const query = new Parse.Query(BoxedNumber); + query.ascending('number'); + query.find().then(function (results) { + equal(results.length, 3); + equal(results[0].get('number'), 1); + equal(results[1].get('number'), 2); + equal(results[2].get('number'), 3); + done(); + }); + }); + }); + + it('order by descending number', function (done) { + const makeBoxedNumber = function (i) { + return new BoxedNumber({ number: i }); + }; + Parse.Object.saveAll([3, 1, 2].map(makeBoxedNumber)).then(function () { + const query = new Parse.Query(BoxedNumber); + query.descending('number'); + query.find().then(function (results) { + equal(results.length, 3); + equal(results[0].get('number'), 3); + equal(results[1].get('number'), 2); + equal(results[2].get('number'), 1); + done(); + }); + }); + }); + + it('can order on an object string field', function (done) { + const testSet = [ + { sortField: { value: 'Z' } }, + { sortField: { value: 'A' } }, + { sortField: { value: 'M' } }, + ]; + + const objects = testSet.map(e => new Parse.Object('Test', e)); + Parse.Object.saveAll(objects) + .then(() => new Parse.Query('Test').addDescending('sortField.value').first()) + .then(result => { + expect(result.get('sortField').value).toBe('Z'); + return new Parse.Query('Test').addAscending('sortField.value').first(); + }) + .then(result => { + expect(result.get('sortField').value).toBe('A'); + done(); + }) + .catch(done.fail); + }); + + it('can order on an object string field (level 2)', function (done) { + const testSet = [ + { sortField: { value: { field: 'Z' } } }, + { sortField: { value: { field: 'A' } } }, + { sortField: { value: { field: 'M' } } }, + ]; + + const objects = testSet.map(e => new Parse.Object('Test', e)); + Parse.Object.saveAll(objects) + .then(() => new Parse.Query('Test').addDescending('sortField.value.field').first()) + .then(result => { + expect(result.get('sortField').value.field).toBe('Z'); + return new Parse.Query('Test').addAscending('sortField.value.field').first(); + }) + .then(result => { + expect(result.get('sortField').value.field).toBe('A'); + done(); + }) + .catch(done.fail); + }); + + it_id('65c8238d-cf02-49d0-a919-8a17f5a58280')(it)('can order on an object number field', function (done) { + const testSet = [ + { sortField: { value: 10 } }, + { sortField: { value: 1 } }, + { sortField: { value: 5 } }, + ]; + + const objects = testSet.map(e => new Parse.Object('Test', e)); + Parse.Object.saveAll(objects) + .then(() => new Parse.Query('Test').addDescending('sortField.value').first()) + .then(result => { + expect(result.get('sortField').value).toBe(10); + return new Parse.Query('Test').addAscending('sortField.value').first(); + }) + .then(result => { + expect(result.get('sortField').value).toBe(1); + done(); + }) + .catch(done.fail); + }); + + it_id('d8f0bead-b931-4d66-8b0c-28c5705e463c')(it)('can order on an object number field (level 2)', function (done) { + const testSet = [ + { sortField: { value: { field: 10 } } }, + { sortField: { value: { field: 1 } } }, + { sortField: { value: { field: 5 } } }, + ]; + + const objects = testSet.map(e => new Parse.Object('Test', e)); + Parse.Object.saveAll(objects) + .then(() => new Parse.Query('Test').addDescending('sortField.value.field').first()) + .then(result => { + expect(result.get('sortField').value.field).toBe(10); + return new Parse.Query('Test').addAscending('sortField.value.field').first(); + }) + .then(result => { + expect(result.get('sortField').value.field).toBe(1); + done(); + }) + .catch(done.fail); + }); + + it('order by ascending number then descending string', function (done) { + const strings = ['a', 'b', 'c', 'd']; + const makeBoxedNumber = function (num, i) { + return new BoxedNumber({ number: num, string: strings[i] }); + }; + Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber)).then(function () { + const query = new Parse.Query(BoxedNumber); + query.ascending('number').addDescending('string'); + query.find().then(function (results) { + equal(results.length, 4); + equal(results[0].get('number'), 1); + equal(results[0].get('string'), 'b'); + equal(results[1].get('number'), 2); + equal(results[1].get('string'), 'd'); + equal(results[2].get('number'), 3); + equal(results[2].get('string'), 'c'); + equal(results[3].get('number'), 3); + equal(results[3].get('string'), 'a'); + done(); + }); + }); + }); + + it('order by non-existing string', async () => { + const strings = ['a', 'b', 'c', 'd']; + const makeBoxedNumber = function (num, i) { + return new BoxedNumber({ number: num, string: strings[i] }); + }; + await Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber)); + const results = await new Parse.Query(BoxedNumber).ascending('foo').find(); + expect(results.length).toBe(4); + }); + + it('order by descending number then ascending string', function (done) { + const strings = ['a', 'b', 'c', 'd']; + const makeBoxedNumber = function (num, i) { + return new BoxedNumber({ number: num, string: strings[i] }); + }; + + const objects = [3, 1, 3, 2].map(makeBoxedNumber); + Parse.Object.saveAll(objects) + .then(() => { + const query = new Parse.Query(BoxedNumber); + query.descending('number').addAscending('string'); + return query.find(); + }) + .then( + results => { + equal(results.length, 4); + equal(results[0].get('number'), 3); + equal(results[0].get('string'), 'a'); + equal(results[1].get('number'), 3); + equal(results[1].get('string'), 'c'); + equal(results[2].get('number'), 2); + equal(results[2].get('string'), 'd'); + equal(results[3].get('number'), 1); + equal(results[3].get('string'), 'b'); + done(); + }, + err => { + jfail(err); + done(); + } + ); + }); + + it('order by descending number and string', function (done) { + const strings = ['a', 'b', 'c', 'd']; + const makeBoxedNumber = function (num, i) { + return new BoxedNumber({ number: num, string: strings[i] }); + }; + Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber)).then(function () { + const query = new Parse.Query(BoxedNumber); + query.descending('number,string'); + query.find().then(function (results) { + equal(results.length, 4); + equal(results[0].get('number'), 3); + equal(results[0].get('string'), 'c'); + equal(results[1].get('number'), 3); + equal(results[1].get('string'), 'a'); + equal(results[2].get('number'), 2); + equal(results[2].get('string'), 'd'); + equal(results[3].get('number'), 1); + equal(results[3].get('string'), 'b'); + done(); + }); + }); + }); + + it('order by descending number and string, with space', function (done) { + const strings = ['a', 'b', 'c', 'd']; + const makeBoxedNumber = function (num, i) { + return new BoxedNumber({ number: num, string: strings[i] }); + }; + Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber)).then( + function () { + const query = new Parse.Query(BoxedNumber); + query.descending('number, string'); + query.find().then(function (results) { + equal(results.length, 4); + equal(results[0].get('number'), 3); + equal(results[0].get('string'), 'c'); + equal(results[1].get('number'), 3); + equal(results[1].get('string'), 'a'); + equal(results[2].get('number'), 2); + equal(results[2].get('string'), 'd'); + equal(results[3].get('number'), 1); + equal(results[3].get('string'), 'b'); + done(); + }); + }, + err => { + jfail(err); + done(); + } + ); + }); + + it('order by descending number and string, with array arg', function (done) { + const strings = ['a', 'b', 'c', 'd']; + const makeBoxedNumber = function (num, i) { + return new BoxedNumber({ number: num, string: strings[i] }); + }; + Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber)).then(function () { + const query = new Parse.Query(BoxedNumber); + query.descending(['number', 'string']); + query.find().then(function (results) { + equal(results.length, 4); + equal(results[0].get('number'), 3); + equal(results[0].get('string'), 'c'); + equal(results[1].get('number'), 3); + equal(results[1].get('string'), 'a'); + equal(results[2].get('number'), 2); + equal(results[2].get('string'), 'd'); + equal(results[3].get('number'), 1); + equal(results[3].get('string'), 'b'); + done(); + }); + }); + }); + + it('order by descending number and string, with multiple args', function (done) { + const strings = ['a', 'b', 'c', 'd']; + const makeBoxedNumber = function (num, i) { + return new BoxedNumber({ number: num, string: strings[i] }); + }; + Parse.Object.saveAll([3, 1, 3, 2].map(makeBoxedNumber)).then(function () { + const query = new Parse.Query(BoxedNumber); + query.descending('number', 'string'); + query.find().then(function (results) { + equal(results.length, 4); + equal(results[0].get('number'), 3); + equal(results[0].get('string'), 'c'); + equal(results[1].get('number'), 3); + equal(results[1].get('string'), 'a'); + equal(results[2].get('number'), 2); + equal(results[2].get('string'), 'd'); + equal(results[3].get('number'), 1); + equal(results[3].get('string'), 'b'); + done(); + }); + }); + }); + + it("can't order by password", function (done) { + const makeBoxedNumber = function (i) { + return new BoxedNumber({ number: i }); + }; + Parse.Object.saveAll([3, 1, 2].map(makeBoxedNumber)).then(function () { + const query = new Parse.Query(BoxedNumber); + query.ascending('_password'); + query + .find() + .then(done.fail) + .catch(e => expect(e.code).toBe(Parse.Error.INVALID_KEY_NAME)) + .then(done); + }); + }); + + it('order by _created_at', function (done) { + const makeBoxedNumber = function (i) { + return new BoxedNumber({ number: i }); + }; + const numbers = [3, 1, 2].map(makeBoxedNumber); + numbers[0] + .save() + .then(() => { + return numbers[1].save(); + }) + .then(() => { + return numbers[2].save(); + }) + .then(function () { + const query = new Parse.Query(BoxedNumber); + query.ascending('_created_at'); + query.find().then(function (results) { + equal(results.length, 3); + equal(results[0].get('number'), 3); + equal(results[1].get('number'), 1); + equal(results[2].get('number'), 2); + done(); + }, done.fail); + }); + }); + + it('order by createdAt', function (done) { + const makeBoxedNumber = function (i) { + return new BoxedNumber({ number: i }); + }; + const numbers = [3, 1, 2].map(makeBoxedNumber); + numbers[0] + .save() + .then(() => { + return numbers[1].save(); + }) + .then(() => { + return numbers[2].save(); + }) + .then(function () { + const query = new Parse.Query(BoxedNumber); + query.descending('createdAt'); + query.find().then(function (results) { + equal(results.length, 3); + equal(results[0].get('number'), 2); + equal(results[1].get('number'), 1); + equal(results[2].get('number'), 3); + done(); + }); + }); + }); + + it('order by _updated_at', function (done) { + const makeBoxedNumber = function (i) { + return new BoxedNumber({ number: i }); + }; + const numbers = [3, 1, 2].map(makeBoxedNumber); + numbers[0] + .save() + .then(() => { + return numbers[1].save(); + }) + .then(() => { + return numbers[2].save(); + }) + .then(function () { + numbers[1].set('number', 4); + numbers[1].save().then(function () { + const query = new Parse.Query(BoxedNumber); + query.ascending('_updated_at'); + query.find().then(function (results) { + equal(results.length, 3); + equal(results[0].get('number'), 3); + equal(results[1].get('number'), 2); + equal(results[2].get('number'), 4); + done(); + }); + }); + }); + }); + + it('order by updatedAt', function (done) { + const makeBoxedNumber = function (i) { + return new BoxedNumber({ number: i }); + }; + const numbers = [3, 1, 2].map(makeBoxedNumber); + numbers[0] + .save() + .then(() => { + return numbers[1].save(); + }) + .then(() => { + return numbers[2].save(); + }) + .then(function () { + numbers[1].set('number', 4); + numbers[1].save().then(function () { + const query = new Parse.Query(BoxedNumber); + query.descending('_updated_at'); + query.find().then(function (results) { + equal(results.length, 3); + equal(results[0].get('number'), 4); + equal(results[1].get('number'), 2); + equal(results[2].get('number'), 3); + done(); + }); + }); + }); + }); + + // Returns a promise + function makeTimeObject(start, i) { + const time = new Date(); + time.setSeconds(start.getSeconds() + i); + const item = new TestObject({ name: 'item' + i, time: time }); + return item.save(); + } + + // Returns a promise for all the time objects + function makeThreeTimeObjects() { + const start = new Date(); + let one, two, three; + return makeTimeObject(start, 1) + .then(o1 => { + one = o1; + return makeTimeObject(start, 2); + }) + .then(o2 => { + two = o2; + return makeTimeObject(start, 3); + }) + .then(o3 => { + three = o3; + return [one, two, three]; + }); + } + + it('time equality', function (done) { + makeThreeTimeObjects().then(function (list) { + const query = new Parse.Query(TestObject); + query.equalTo('time', list[1].get('time')); + query.find().then(function (results) { + equal(results.length, 1); + equal(results[0].get('name'), 'item2'); + done(); + }); + }); + }); + + it('time lessThan', function (done) { + makeThreeTimeObjects().then(function (list) { + const query = new Parse.Query(TestObject); + query.lessThan('time', list[2].get('time')); + query.find().then(function (results) { + equal(results.length, 2); + done(); + }); + }); + }); + + // This test requires Date objects to be consistently stored as a Date. + it('time createdAt', function (done) { + makeThreeTimeObjects().then(function (list) { + const query = new Parse.Query(TestObject); + query.greaterThanOrEqualTo('createdAt', list[0].createdAt); + query.find().then(function (results) { + equal(results.length, 3); + done(); + }); + }); + }); + + it('matches string', function (done) { + const thing1 = new TestObject(); + thing1.set('myString', 'football'); + const thing2 = new TestObject(); + thing2.set('myString', 'soccer'); + Parse.Object.saveAll([thing1, thing2]).then(function () { + const query = new Parse.Query(TestObject); + query.matches('myString', '^fo*\\wb[^o]l+$'); + query.find().then(function (results) { + equal(results.length, 1); + done(); + }); + }); + }); + + it('matches regex', function (done) { + const thing1 = new TestObject(); + thing1.set('myString', 'football'); + const thing2 = new TestObject(); + thing2.set('myString', 'soccer'); + Parse.Object.saveAll([thing1, thing2]).then(function () { + const query = new Parse.Query(TestObject); + query.matches('myString', /^fo*\wb[^o]l+$/); + query.find().then(function (results) { + equal(results.length, 1); + done(); + }); + }); + }); + + it('case insensitive regex success', function (done) { + const thing = new TestObject(); + thing.set('myString', 'football'); + Parse.Object.saveAll([thing]).then(function () { + const query = new Parse.Query(TestObject); + query.matches('myString', 'FootBall', 'i'); + query.find().then(done); + }); + }); + + it('regexes with invalid options fail', function (done) { + const query = new Parse.Query(TestObject); + query.matches('myString', 'FootBall', 'some invalid option'); + query + .find() + .then(done.fail) + .catch(e => expect(e.code).toBe(Parse.Error.INVALID_QUERY)) + .then(done); + }); + + it_id('351f57a8-e00a-4da2-887d-6e25c9e359fc')(it)('regex with unicode option', async function () { + const thing = new TestObject(); + thing.set('myString', 'hello ä¸–į•Œ'); + await Parse.Object.saveAll([thing]); + const query = new Parse.Query(TestObject); + query.matches('myString', 'ä¸–į•Œ', 'u'); + const results = await query.find(); + equal(results.length, 1); + }); + + it_id('823852f6-1de5-45ba-a2b9-ed952fcc6012')(it)('Use a regex that requires all modifiers', function (done) { + const thing = new TestObject(); + thing.set('myString', 'PArSe\nCom'); + Parse.Object.saveAll([thing]).then(function () { + const query = new Parse.Query(TestObject); + query.matches( + 'myString', + "parse # First fragment. We'll write this in one case but match insensitively\n" + + '.com # Second fragment. This can be separated by any character, including newline;' + + 'however, this comment must end with a newline to recognize it as a comment\n', + 'mixs' + ); + query.find().then( + function (results) { + equal(results.length, 1); + done(); + }, + function (err) { + jfail(err); + done(); + } + ); + }); + }); + + it('Regular expression constructor includes modifiers inline', function (done) { + const thing = new TestObject(); + thing.set('myString', '\n\nbuffer\n\nparse.COM'); + Parse.Object.saveAll([thing]).then(function () { + const query = new Parse.Query(TestObject); + query.matches('myString', /parse\.com/im); + query.find().then(function (results) { + equal(results.length, 1); + done(); + }); + }); + }); + + const someAscii = + "\\E' !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTU" + + "VWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~'"; + + it('contains', function (done) { + Parse.Object.saveAll([ + new TestObject({ myString: 'zax' + someAscii + 'qub' }), + new TestObject({ myString: 'start' + someAscii }), + new TestObject({ myString: someAscii + 'end' }), + new TestObject({ myString: someAscii }), + ]).then(function () { + const query = new Parse.Query(TestObject); + query.contains('myString', someAscii); + query.find().then(function (results) { + equal(results.length, 4); + done(); + }); + }); + }); + + it('nested contains', done => { + const sender1 = { group: ['A', 'B'] }; + const sender2 = { group: ['A', 'C'] }; + const sender3 = { group: ['B', 'C'] }; + const obj1 = new TestObject({ sender: sender1 }); + const obj2 = new TestObject({ sender: sender2 }); + const obj3 = new TestObject({ sender: sender3 }); + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const query = new Parse.Query(TestObject); + query.contains('sender.group', 'A'); + return query.find(); + }) + .then(results => { + equal(results.length, 2); + done(); + }, done.fail); + }); + + it('startsWith', function (done) { + Parse.Object.saveAll([ + new TestObject({ myString: 'zax' + someAscii + 'qub' }), + new TestObject({ myString: 'start' + someAscii }), + new TestObject({ myString: someAscii + 'end' }), + new TestObject({ myString: someAscii }), + ]).then(function () { + const query = new Parse.Query(TestObject); + query.startsWith('myString', someAscii); + query.find().then(function (results) { + equal(results.length, 2); + done(); + }); + }); + }); + + it('endsWith', function (done) { + Parse.Object.saveAll([ + new TestObject({ myString: 'zax' + someAscii + 'qub' }), + new TestObject({ myString: 'start' + someAscii }), + new TestObject({ myString: someAscii + 'end' }), + new TestObject({ myString: someAscii }), + ]).then(function () { + const query = new Parse.Query(TestObject); + query.endsWith('myString', someAscii); + query.find().then(function (results) { + equal(results.length, 2); + done(); + }); + }); + }); + + it('exists', function (done) { + const objects = []; + for (const i of [0, 1, 2, 3, 4, 5, 6, 7, 8]) { + const item = new TestObject(); + if (i % 2 === 0) { + item.set('x', i + 1); + } else { + item.set('y', i + 1); + } + objects.push(item); + } + Parse.Object.saveAll(objects).then(function () { + const query = new Parse.Query(TestObject); + query.exists('x'); + query.find().then(function (results) { + equal(results.length, 5); + for (const result of results) { + ok(result.get('x')); + } + done(); + }); + }); + }); + + it('doesNotExist', function (done) { + const objects = []; + for (const i of [0, 1, 2, 3, 4, 5, 6, 7, 8]) { + const item = new TestObject(); + if (i % 2 === 0) { + item.set('x', i + 1); + } else { + item.set('y', i + 1); + } + objects.push(item); + } + Parse.Object.saveAll(objects).then(function () { + const query = new Parse.Query(TestObject); + query.doesNotExist('x'); + query.find().then(function (results) { + equal(results.length, 4); + for (const result of results) { + ok(result.get('y')); + } + done(); + }); + }); + }); + + it('exists relation', function (done) { + const objects = []; + for (const i of [0, 1, 2, 3, 4, 5, 6, 7, 8]) { + const container = new Container(); + if (i % 2 === 0) { + const item = new TestObject(); + item.set('x', i); + container.set('x', item); + objects.push(item); + } else { + container.set('y', i); + } + objects.push(container); + } + Parse.Object.saveAll(objects).then(function () { + const query = new Parse.Query(Container); + query.exists('x'); + query.find().then(function (results) { + equal(results.length, 5); + for (const result of results) { + ok(result.get('x')); + } + done(); + }); + }); + }); + + it('doesNotExist relation', function (done) { + const objects = []; + for (const i of [0, 1, 2, 3, 4, 5, 6, 7]) { + const container = new Container(); + if (i % 2 === 0) { + const item = new TestObject(); + item.set('x', i); + container.set('x', item); + objects.push(item); + } else { + container.set('y', i); + } + objects.push(container); + } + Parse.Object.saveAll(objects).then(function () { + const query = new Parse.Query(Container); + query.doesNotExist('x'); + query.find().then(function (results) { + equal(results.length, 4); + for (const result of results) { + ok(result.get('y')); + } + done(); + }); + }); + }); + + it("don't include by default", function (done) { + const child = new TestObject(); + const parent = new Container(); + child.set('foo', 'bar'); + parent.set('child', child); + Parse.Object.saveAll([child, parent]).then(function () { + child._clearServerData(); + const query = new Parse.Query(Container); + query.find().then(function (results) { + equal(results.length, 1); + const parentAgain = results[0]; + const goodURL = Parse.serverURL; + Parse.serverURL = 'YAAAAAAAAARRRRRGGGGGGGGG'; + const childAgain = parentAgain.get('child'); + ok(childAgain); + equal(childAgain.get('foo'), undefined); + Parse.serverURL = goodURL; + done(); + }); + }); + }); + + it('include relation', function (done) { + const child = new TestObject(); + const parent = new Container(); + child.set('foo', 'bar'); + parent.set('child', child); + Parse.Object.saveAll([child, parent]).then(function () { + const query = new Parse.Query(Container); + query.include('child'); + query.find().then(function (results) { + equal(results.length, 1); + const parentAgain = results[0]; + const goodURL = Parse.serverURL; + Parse.serverURL = 'YAAAAAAAAARRRRRGGGGGGGGG'; + const childAgain = parentAgain.get('child'); + ok(childAgain); + equal(childAgain.get('foo'), 'bar'); + Parse.serverURL = goodURL; + done(); + }); + }); + }); + + it('include relation array', function (done) { + const child = new TestObject(); + const parent = new Container(); + child.set('foo', 'bar'); + parent.set('child', child); + Parse.Object.saveAll([child, parent]).then(function () { + const query = new Parse.Query(Container); + query.include(['child']); + query.find().then(function (results) { + equal(results.length, 1); + const parentAgain = results[0]; + const goodURL = Parse.serverURL; + Parse.serverURL = 'YAAAAAAAAARRRRRGGGGGGGGG'; + const childAgain = parentAgain.get('child'); + ok(childAgain); + equal(childAgain.get('foo'), 'bar'); + Parse.serverURL = goodURL; + done(); + }); + }); + }); + + it('nested include', function (done) { + const Child = Parse.Object.extend('Child'); + const Parent = Parse.Object.extend('Parent'); + const Grandparent = Parse.Object.extend('Grandparent'); + const objects = []; + for (let i = 0; i < 5; ++i) { + const grandparent = new Grandparent({ + z: i, + parent: new Parent({ + y: i, + child: new Child({ + x: i, + }), + }), + }); + objects.push(grandparent); + } + + Parse.Object.saveAll(objects).then(function () { + const query = new Parse.Query(Grandparent); + query.include(['parent.child']); + query.find().then(function (results) { + equal(results.length, 5); + for (const object of results) { + equal(object.get('z'), object.get('parent').get('y')); + equal(object.get('z'), object.get('parent').get('child').get('x')); + } + done(); + }); + }); + }); + + it("include doesn't make dirty wrong", function (done) { + const Parent = Parse.Object.extend('ParentObject'); + const Child = Parse.Object.extend('ChildObject'); + const parent = new Parent(); + const child = new Child(); + child.set('foo', 'bar'); + parent.set('child', child); + + Parse.Object.saveAll([child, parent]).then(function () { + const query = new Parse.Query(Parent); + query.include('child'); + query.find().then(function (results) { + equal(results.length, 1); + const parentAgain = results[0]; + const childAgain = parentAgain.get('child'); + equal(childAgain.id, child.id); + equal(parentAgain.id, parent.id); + equal(childAgain.get('foo'), 'bar'); + equal(false, parentAgain.dirty()); + equal(false, childAgain.dirty()); + done(); + }); + }); + }); + + it('properly includes array', done => { + const objects = []; + let total = 0; + while (objects.length != 5) { + const object = new Parse.Object('AnObject'); + object.set('key', objects.length); + total += objects.length; + objects.push(object); + } + Parse.Object.saveAll(objects) + .then(() => { + const object = new Parse.Object('AContainer'); + object.set('objects', objects); + return object.save(); + }) + .then(() => { + const query = new Parse.Query('AContainer'); + query.include('objects'); + return query.find(); + }) + .then( + results => { + expect(results.length).toBe(1); + const res = results[0]; + const objects = res.get('objects'); + expect(objects.length).toBe(5); + objects.forEach(object => { + total -= object.get('key'); + }); + expect(total).toBe(0); + done(); + }, + () => { + fail('should not fail'); + done(); + } + ); + }); + + it('properly includes array of mixed objects', done => { + const objects = []; + let total = 0; + while (objects.length != 5) { + const object = new Parse.Object('AnObject'); + object.set('key', objects.length); + total += objects.length; + objects.push(object); + } + while (objects.length != 10) { + const object = new Parse.Object('AnotherObject'); + object.set('key', objects.length); + total += objects.length; + objects.push(object); + } + Parse.Object.saveAll(objects) + .then(() => { + const object = new Parse.Object('AContainer'); + object.set('objects', objects); + return object.save(); + }) + .then(() => { + const query = new Parse.Query('AContainer'); + query.include('objects'); + return query.find(); + }) + .then( + results => { + expect(results.length).toBe(1); + const res = results[0]; + const objects = res.get('objects'); + expect(objects.length).toBe(10); + objects.forEach(object => { + total -= object.get('key'); + }); + expect(total).toBe(0); + done(); + }, + e => { + fail('should not fail'); + fail(JSON.stringify(e)); + done(); + } + ); + }); + + it('properly nested array of mixed objects with bad ids', done => { + const objects = []; + let total = 0; + while (objects.length != 5) { + const object = new Parse.Object('AnObject'); + object.set('key', objects.length); + objects.push(object); + } + while (objects.length != 10) { + const object = new Parse.Object('AnotherObject'); + object.set('key', objects.length); + objects.push(object); + } + Parse.Object.saveAll(objects) + .then(() => { + const object = new Parse.Object('AContainer'); + for (let i = 0; i < objects.length; i++) { + if (i % 2 == 0) { + objects[i].id = 'randomThing'; + } else { + total += objects[i].get('key'); + } + } + object.set('objects', objects); + return object.save(); + }) + .then(() => { + const query = new Parse.Query('AContainer'); + query.include('objects'); + return query.find(); + }) + .then( + results => { + expect(results.length).toBe(1); + const res = results[0]; + const objects = res.get('objects'); + expect(objects.length).toBe(5); + objects.forEach(object => { + total -= object.get('key'); + }); + expect(total).toBe(0); + done(); + }, + err => { + jfail(err); + fail('should not fail'); + done(); + } + ); + }); + + it('properly fetches nested pointers', done => { + const color = new Parse.Object('Color'); + color.set('hex', '#133733'); + const circle = new Parse.Object('Circle'); + circle.set('radius', 1337); + + Parse.Object.saveAll([color, circle]) + .then(() => { + circle.set('color', color); + const badCircle = new Parse.Object('Circle'); + badCircle.id = 'badId'; + const complexFigure = new Parse.Object('ComplexFigure'); + complexFigure.set('consistsOf', [circle, badCircle]); + return complexFigure.save(); + }) + .then(() => { + const q = new Parse.Query('ComplexFigure'); + q.include('consistsOf.color'); + return q.find(); + }) + .then( + results => { + expect(results.length).toBe(1); + const figure = results[0]; + expect(figure.get('consistsOf').length).toBe(1); + expect(figure.get('consistsOf')[0].get('color').get('hex')).toBe('#133733'); + done(); + }, + () => { + fail('should not fail'); + done(); + } + ); + }); + + it('result object creation uses current extension', function (done) { + const ParentObject = Parse.Object.extend({ className: 'ParentObject' }); + // Add a foo() method to ChildObject. + let ChildObject = Parse.Object.extend('ChildObject', { + foo: function () { + return 'foo'; + }, + }); + + const parent = new ParentObject(); + const child = new ChildObject(); + parent.set('child', child); + Parse.Object.saveAll([child, parent]).then(function () { + // Add a bar() method to ChildObject. + ChildObject = Parse.Object.extend('ChildObject', { + bar: function () { + return 'bar'; + }, + }); + + const query = new Parse.Query(ParentObject); + query.include('child'); + query.find().then(function (results) { + equal(results.length, 1); + const parentAgain = results[0]; + const childAgain = parentAgain.get('child'); + equal(childAgain.foo(), 'foo'); + equal(childAgain.bar(), 'bar'); + done(); + }); + }); + }); + + it('matches query', function (done) { + const ParentObject = Parse.Object.extend('ParentObject'); + const ChildObject = Parse.Object.extend('ChildObject'); + const objects = []; + for (let i = 0; i < 10; ++i) { + objects.push( + new ParentObject({ + child: new ChildObject({ x: i }), + x: 10 + i, + }) + ); + } + Parse.Object.saveAll(objects).then(function () { + const subQuery = new Parse.Query(ChildObject); + subQuery.greaterThan('x', 5); + const query = new Parse.Query(ParentObject); + query.matchesQuery('child', subQuery); + query.find().then(function (results) { + equal(results.length, 4); + for (const object of results) { + ok(object.get('x') > 15); + } + const query = new Parse.Query(ParentObject); + query.doesNotMatchQuery('child', subQuery); + query.find().then(function (results) { + equal(results.length, 6); + for (const object of results) { + ok(object.get('x') >= 10); + ok(object.get('x') <= 15); + done(); + } + }); + }); + }); + }); + + it('select query', function (done) { + const RestaurantObject = Parse.Object.extend('Restaurant'); + const PersonObject = Parse.Object.extend('Person'); + const objects = [ + new RestaurantObject({ ratings: 5, location: 'Djibouti' }), + new RestaurantObject({ ratings: 3, location: 'Ouagadougou' }), + new PersonObject({ name: 'Bob', hometown: 'Djibouti' }), + new PersonObject({ name: 'Tom', hometown: 'Ouagadougou' }), + new PersonObject({ name: 'Billy', hometown: 'Detroit' }), + ]; + + Parse.Object.saveAll(objects).then(function () { + const query = new Parse.Query(RestaurantObject); + query.greaterThan('ratings', 4); + const mainQuery = new Parse.Query(PersonObject); + mainQuery.matchesKeyInQuery('hometown', 'location', query); + mainQuery.find().then(function (results) { + equal(results.length, 1); + equal(results[0].get('name'), 'Bob'); + done(); + }); + }); + }); + + it('$select inside $or', done => { + const Restaurant = Parse.Object.extend('Restaurant'); + const Person = Parse.Object.extend('Person'); + const objects = [ + new Restaurant({ ratings: 5, location: 'Djibouti' }), + new Restaurant({ ratings: 3, location: 'Ouagadougou' }), + new Person({ name: 'Bob', hometown: 'Djibouti' }), + new Person({ name: 'Tom', hometown: 'Ouagadougou' }), + new Person({ name: 'Billy', hometown: 'Detroit' }), + ]; + + Parse.Object.saveAll(objects) + .then(() => { + const subquery = new Parse.Query(Restaurant); + subquery.greaterThan('ratings', 4); + const query1 = new Parse.Query(Person); + query1.matchesKeyInQuery('hometown', 'location', subquery); + const query2 = new Parse.Query(Person); + query2.equalTo('name', 'Tom'); + const query = Parse.Query.or(query1, query2); + return query.find(); + }) + .then( + results => { + expect(results.length).toEqual(2); + done(); + }, + error => { + jfail(error); + done(); + } + ); + }); + + it('$nor valid query', done => { + const objects = Array.from(Array(10).keys()).map(rating => { + return new TestObject({ rating: rating }); + }); + + const highValue = 5; + const lowValue = 3; + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ + $nor: [{ rating: { $gt: highValue } }, { rating: { $lte: lowValue } }], + }), + }, + }); + + Parse.Object.saveAll(objects) + .then(() => { + return request(Object.assign({ url: Parse.serverURL + '/classes/TestObject' }, options)); + }) + .then(response => { + const results = response.data; + expect(results.results.length).toBe(highValue - lowValue); + expect(results.results.every(res => res.rating > lowValue && res.rating <= highValue)).toBe( + true + ); + done(); + }); + }); + + it('$nor invalid query - empty array', done => { + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ $nor: [] }), + }, + }); + const obj = new TestObject(); + obj + .save() + .then(() => { + return request(Object.assign({ url: Parse.serverURL + '/classes/TestObject' }, options)); + }) + .then(done.fail) + .catch(response => { + equal(response.data.code, Parse.Error.INVALID_QUERY); + done(); + }); + }); + + it('$nor invalid query - wrong type', done => { + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ $nor: 1337 }), + }, + }); + const obj = new TestObject(); + obj + .save() + .then(() => { + return request(Object.assign({ url: Parse.serverURL + '/classes/TestObject' }, options)); + }) + .then(done.fail) + .catch(response => { + equal(response.data.code, Parse.Error.INVALID_QUERY); + done(); + }); + }); + + it('dontSelect query', function (done) { + const RestaurantObject = Parse.Object.extend('Restaurant'); + const PersonObject = Parse.Object.extend('Person'); + const objects = [ + new RestaurantObject({ ratings: 5, location: 'Djibouti' }), + new RestaurantObject({ ratings: 3, location: 'Ouagadougou' }), + new PersonObject({ name: 'Bob', hometown: 'Djibouti' }), + new PersonObject({ name: 'Tom', hometown: 'Ouagadougou' }), + new PersonObject({ name: 'Billy', hometown: 'Djibouti' }), + ]; + + Parse.Object.saveAll(objects).then(function () { + const query = new Parse.Query(RestaurantObject); + query.greaterThan('ratings', 4); + const mainQuery = new Parse.Query(PersonObject); + mainQuery.doesNotMatchKeyInQuery('hometown', 'location', query); + mainQuery.find().then(function (results) { + equal(results.length, 1); + equal(results[0].get('name'), 'Tom'); + done(); + }); + }); + }); + + it('dontSelect query without conditions', function (done) { + const RestaurantObject = Parse.Object.extend('Restaurant'); + const PersonObject = Parse.Object.extend('Person'); + const objects = [ + new RestaurantObject({ location: 'Djibouti' }), + new RestaurantObject({ location: 'Ouagadougou' }), + new PersonObject({ name: 'Bob', hometown: 'Djibouti' }), + new PersonObject({ name: 'Tom', hometown: 'Yoloblahblahblah' }), + new PersonObject({ name: 'Billy', hometown: 'Ouagadougou' }), + ]; + + Parse.Object.saveAll(objects).then(function () { + const query = new Parse.Query(RestaurantObject); + const mainQuery = new Parse.Query(PersonObject); + mainQuery.doesNotMatchKeyInQuery('hometown', 'location', query); + mainQuery.find().then(results => { + equal(results.length, 1); + equal(results[0].get('name'), 'Tom'); + done(); + }); + }); + }); + + it('equalTo on same column as $dontSelect should not break $dontSelect functionality (#3678)', function (done) { + const AuthorObject = Parse.Object.extend('Author'); + const BlockedObject = Parse.Object.extend('Blocked'); + const PostObject = Parse.Object.extend('Post'); + + let postAuthor = null; + let requestUser = null; + + return new AuthorObject({ name: 'Julius' }) + .save() + .then(user => { + postAuthor = user; + return new AuthorObject({ name: 'Bob' }).save(); + }) + .then(user => { + requestUser = user; + const objects = [ + new PostObject({ author: postAuthor, title: 'Lorem ipsum' }), + new PostObject({ author: requestUser, title: 'Kafka' }), + new PostObject({ author: requestUser, title: 'Brown fox' }), + new BlockedObject({ + blockedBy: postAuthor, + blockedUser: requestUser, + }), + ]; + return Parse.Object.saveAll(objects); + }) + .then(() => { + const banListQuery = new Parse.Query(BlockedObject); + banListQuery.equalTo('blockedUser', requestUser); + + return new Parse.Query(PostObject) + .equalTo('author', postAuthor) + .doesNotMatchKeyInQuery('author', 'blockedBy', banListQuery) + .find() + .then(r => { + expect(r.length).toEqual(0); + done(); + }, done.fail); + }); + }); + + it('multiple dontSelect query', function (done) { + const RestaurantObject = Parse.Object.extend('Restaurant'); + const PersonObject = Parse.Object.extend('Person'); + const objects = [ + new RestaurantObject({ ratings: 7, location: 'Djibouti2' }), + new RestaurantObject({ ratings: 5, location: 'Djibouti' }), + new RestaurantObject({ ratings: 3, location: 'Ouagadougou' }), + new PersonObject({ name: 'Bob2', hometown: 'Djibouti2' }), + new PersonObject({ name: 'Bob', hometown: 'Djibouti' }), + new PersonObject({ name: 'Tom', hometown: 'Ouagadougou' }), + ]; + + Parse.Object.saveAll(objects).then(function () { + const query = new Parse.Query(RestaurantObject); + query.greaterThan('ratings', 6); + const query2 = new Parse.Query(RestaurantObject); + query2.lessThan('ratings', 4); + const subQuery = new Parse.Query(PersonObject); + subQuery.matchesKeyInQuery('hometown', 'location', query); + const subQuery2 = new Parse.Query(PersonObject); + subQuery2.matchesKeyInQuery('hometown', 'location', query2); + const mainQuery = new Parse.Query(PersonObject); + mainQuery.doesNotMatchKeyInQuery('objectId', 'objectId', Parse.Query.or(subQuery, subQuery2)); + mainQuery.find().then(function (results) { + equal(results.length, 1); + equal(results[0].get('name'), 'Bob'); + done(); + }); + }); + }); + + it('include user', function (done) { + Parse.User.signUp('bob', 'password', { age: 21 }).then(function (user) { + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj + .save({ + owner: user, + }) + .then(function (obj) { + const query = new Parse.Query(TestObject); + query.include('owner'); + query.get(obj.id).then(function (objAgain) { + equal(objAgain.id, obj.id); + ok(objAgain.get('owner') instanceof Parse.User); + equal(objAgain.get('owner').get('age'), 21); + done(); + }, done.fail); + }, done.fail); + }, done.fail); + }); + + it('or queries', function (done) { + const objects = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(function (x) { + const object = new Parse.Object('BoxedNumber'); + object.set('x', x); + return object; + }); + Parse.Object.saveAll(objects).then(function () { + const query1 = new Parse.Query('BoxedNumber'); + query1.lessThan('x', 2); + const query2 = new Parse.Query('BoxedNumber'); + query2.greaterThan('x', 5); + const orQuery = Parse.Query.or(query1, query2); + orQuery.find().then(function (results) { + equal(results.length, 6); + for (const number of results) { + ok(number.get('x') < 2 || number.get('x') > 5); + } + done(); + }); + }); + }); + + // This relies on matchesQuery aka the $inQuery operator + it('or complex queries', function (done) { + const objects = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(function (x) { + const child = new Parse.Object('Child'); + child.set('x', x); + const parent = new Parse.Object('Parent'); + parent.set('child', child); + parent.set('y', x); + return parent; + }); + + Parse.Object.saveAll(objects).then(function () { + const subQuery = new Parse.Query('Child'); + subQuery.equalTo('x', 4); + const query1 = new Parse.Query('Parent'); + query1.matchesQuery('child', subQuery); + const query2 = new Parse.Query('Parent'); + query2.lessThan('y', 2); + const orQuery = Parse.Query.or(query1, query2); + orQuery.find().then(function (results) { + equal(results.length, 3); + done(); + }); + }); + }); + + it('async methods', function (done) { + const saves = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(function (x) { + const obj = new Parse.Object('TestObject'); + obj.set('x', x + 1); + return obj; + }); + + Parse.Object.saveAll(saves) + .then(function () { + const query = new Parse.Query('TestObject'); + query.ascending('x'); + return query.first(); + }) + .then(function (obj) { + equal(obj.get('x'), 1); + const query = new Parse.Query('TestObject'); + query.descending('x'); + return query.find(); + }) + .then(function (results) { + equal(results.length, 10); + const query = new Parse.Query('TestObject'); + return query.get(results[0].id); + }) + .then(function (obj1) { + equal(obj1.get('x'), 10); + const query = new Parse.Query('TestObject'); + return query.count(); + }) + .then(function (count) { + equal(count, 10); + }) + .then(function () { + done(); + }); + }); + + it('query.each', function (done) { + const TOTAL = 50; + const COUNT = 25; + + const items = range(TOTAL).map(function (x) { + const obj = new TestObject(); + obj.set('x', x); + return obj; + }); + + Parse.Object.saveAll(items).then(function () { + const query = new Parse.Query(TestObject); + query.lessThan('x', COUNT); + + const seen = []; + query + .each( + function (obj) { + seen[obj.get('x')] = (seen[obj.get('x')] || 0) + 1; + }, + { + batchSize: 10, + } + ) + .then(function () { + equal(seen.length, COUNT); + for (let i = 0; i < COUNT; i++) { + equal(seen[i], 1, 'Should have seen object number ' + i); + } + done(); + }, done.fail); + }); + }); + + it('query.each async', function (done) { + const TOTAL = 50; + const COUNT = 25; + + expect(COUNT + 1); + + const items = range(TOTAL).map(function (x) { + const obj = new TestObject(); + obj.set('x', x); + return obj; + }); + + const seen = []; + + Parse.Object.saveAll(items) + .then(function () { + const query = new Parse.Query(TestObject); + query.lessThan('x', COUNT); + return query.each( + function (obj) { + return new Promise(resolve => { + process.nextTick(function () { + seen[obj.get('x')] = (seen[obj.get('x')] || 0) + 1; + resolve(); + }); + }); + }, + { + batchSize: 10, + } + ); + }) + .then(function () { + equal(seen.length, COUNT); + for (let i = 0; i < COUNT; i++) { + equal(seen[i], 1, 'Should have seen object number ' + i); + } + done(); }); + }); + + it('query.each fails with order', function (done) { + const TOTAL = 50; + const COUNT = 25; + + const items = range(TOTAL).map(function (x) { + const obj = new TestObject(); + obj.set('x', x); + return obj; + }); + + const seen = []; + + Parse.Object.saveAll(items) + .then(function () { + const query = new Parse.Query(TestObject); + query.lessThan('x', COUNT); + query.ascending('x'); + return query.each(function (obj) { + seen[obj.get('x')] = (seen[obj.get('x')] || 0) + 1; + }); + }) + .then( + function () { + ok(false, 'This should have failed.'); + done(); + }, + function () { + done(); + } + ); + }); + + it('query.each fails with skip', function (done) { + const TOTAL = 50; + const COUNT = 25; + + const items = range(TOTAL).map(function (x) { + const obj = new TestObject(); + obj.set('x', x); + return obj; + }); + + const seen = []; + + Parse.Object.saveAll(items) + .then(function () { + const query = new Parse.Query(TestObject); + query.lessThan('x', COUNT); + query.skip(5); + return query.each(function (obj) { + seen[obj.get('x')] = (seen[obj.get('x')] || 0) + 1; + }); + }) + .then( + function () { + ok(false, 'This should have failed.'); + done(); + }, + function () { + done(); + } + ); + }); + + it('query.each fails with limit', function (done) { + const TOTAL = 50; + const COUNT = 25; + + expect(0); + + const items = range(TOTAL).map(function (x) { + const obj = new TestObject(); + obj.set('x', x); + return obj; + }); + + const seen = []; + + Parse.Object.saveAll(items) + .then(function () { + const query = new Parse.Query(TestObject); + query.lessThan('x', COUNT); + query.limit(5); + return query.each(function (obj) { + seen[obj.get('x')] = (seen[obj.get('x')] || 0) + 1; + }); + }) + .then( + function () { + ok(false, 'This should have failed.'); + done(); + }, + function () { + done(); + } + ); + }); + + it('select keys query JS SDK', async () => { + const obj = new TestObject({ foo: 'baz', bar: 1, qux: 2 }); + await obj.save(); + obj._clearServerData(); + const query1 = new Parse.Query(TestObject); + query1.select('foo'); + const result1 = await query1.first(); + ok(result1.id, 'expected object id to be set'); + ok(result1.createdAt, 'expected object createdAt to be set'); + ok(result1.updatedAt, 'expected object updatedAt to be set'); + ok(!result1.dirty(), 'expected result not to be dirty'); + strictEqual(result1.get('foo'), 'baz'); + strictEqual(result1.get('bar'), undefined, "expected 'bar' field to be unset"); + strictEqual(result1.get('qux'), undefined, "expected 'qux' field to be unset"); + + const result2 = await result1.fetch(); + strictEqual(result2.get('foo'), 'baz'); + strictEqual(result2.get('bar'), 1); + strictEqual(result2.get('qux'), 2); + + obj._clearServerData(); + const query2 = new Parse.Query(TestObject); + query2.select(); + const result3 = await query2.first(); + ok(result3.id, 'expected object id to be set'); + ok(result3.createdAt, 'expected object createdAt to be set'); + ok(result3.updatedAt, 'expected object updatedAt to be set'); + ok(!result3.dirty(), 'expected result not to be dirty'); + strictEqual(result3.get('foo'), undefined, "expected 'foo' field to be unset"); + strictEqual(result3.get('bar'), undefined, "expected 'bar' field to be unset"); + strictEqual(result3.get('qux'), undefined, "expected 'qux' field to be unset"); + + obj._clearServerData(); + const query3 = new Parse.Query(TestObject); + query3.select([]); + const result4 = await query3.first(); + ok(result4.id, 'expected object id to be set'); + ok(result4.createdAt, 'expected object createdAt to be set'); + ok(result4.updatedAt, 'expected object updatedAt to be set'); + ok(!result4.dirty(), 'expected result not to be dirty'); + strictEqual(result4.get('foo'), undefined, "expected 'foo' field to be unset"); + strictEqual(result4.get('bar'), undefined, "expected 'bar' field to be unset"); + strictEqual(result4.get('qux'), undefined, "expected 'qux' field to be unset"); + + obj._clearServerData(); + const query4 = new Parse.Query(TestObject); + query4.select(['foo']); + const result5 = await query4.first(); + ok(result5.id, 'expected object id to be set'); + ok(result5.createdAt, 'expected object createdAt to be set'); + ok(result5.updatedAt, 'expected object updatedAt to be set'); + ok(!result5.dirty(), 'expected result not to be dirty'); + strictEqual(result5.get('foo'), 'baz'); + strictEqual(result5.get('bar'), undefined, "expected 'bar' field to be unset"); + strictEqual(result5.get('qux'), undefined, "expected 'qux' field to be unset"); + + obj._clearServerData(); + const query5 = new Parse.Query(TestObject); + query5.select(['foo', 'bar']); + const result6 = await query5.first(); + ok(result6.id, 'expected object id to be set'); + ok(!result6.dirty(), 'expected result not to be dirty'); + strictEqual(result6.get('foo'), 'baz'); + strictEqual(result6.get('bar'), 1); + strictEqual(result6.get('qux'), undefined, "expected 'qux' field to be unset"); + + obj._clearServerData(); + const query6 = new Parse.Query(TestObject); + query6.select(['foo', 'bar', 'qux']); + const result7 = await query6.first(); + ok(result7.id, 'expected object id to be set'); + ok(!result7.dirty(), 'expected result not to be dirty'); + strictEqual(result7.get('foo'), 'baz'); + strictEqual(result7.get('bar'), 1); + strictEqual(result7.get('qux'), 2); + + obj._clearServerData(); + const query7 = new Parse.Query(TestObject); + query7.select('foo', 'bar'); + const result8 = await query7.first(); + ok(result8.id, 'expected object id to be set'); + ok(!result8.dirty(), 'expected result not to be dirty'); + strictEqual(result8.get('foo'), 'baz'); + strictEqual(result8.get('bar'), 1); + strictEqual(result8.get('qux'), undefined, "expected 'qux' field to be unset"); + + obj._clearServerData(); + const query8 = new Parse.Query(TestObject); + query8.select('foo', 'bar', 'qux'); + const result9 = await query8.first(); + ok(result9.id, 'expected object id to be set'); + ok(!result9.dirty(), 'expected result not to be dirty'); + strictEqual(result9.get('foo'), 'baz'); + strictEqual(result9.get('bar'), 1); + strictEqual(result9.get('qux'), 2); + }); + + it('select keys (arrays)', async () => { + const obj = new TestObject({ foo: 'baz', bar: 1, hello: 'world' }); + await obj.save(); + + const response = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + keys: 'hello', + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, + }); + expect(response.data.results[0].foo).toBeUndefined(); + expect(response.data.results[0].bar).toBeUndefined(); + expect(response.data.results[0].hello).toBe('world'); + + const response2 = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + keys: ['foo', 'hello'], + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, + }); + expect(response2.data.results[0].foo).toBe('baz'); + expect(response2.data.results[0].bar).toBeUndefined(); + expect(response2.data.results[0].hello).toBe('world'); + + const response3 = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + keys: ['foo', 'bar', 'hello'], + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, + }); + expect(response3.data.results[0].foo).toBe('baz'); + expect(response3.data.results[0].bar).toBe(1); + expect(response3.data.results[0].hello).toBe('world'); + + const response4 = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + keys: [''], + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, + }); + ok(response4.data.results[0].objectId, 'expected objectId to be set'); + ok(response4.data.results[0].createdAt, 'expected object createdAt to be set'); + ok(response4.data.results[0].updatedAt, 'expected object updatedAt to be set'); + expect(response4.data.results[0].foo).toBeUndefined(); + expect(response4.data.results[0].bar).toBeUndefined(); + expect(response4.data.results[0].hello).toBeUndefined(); + + const response5 = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + keys: [], + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, + }); + ok(response5.data.results[0].objectId, 'expected objectId to be set'); + ok(response5.data.results[0].createdAt, 'expected object createdAt to be set'); + ok(response5.data.results[0].updatedAt, 'expected object updatedAt to be set'); + expect(response5.data.results[0].foo).toBe('baz'); + expect(response5.data.results[0].bar).toBe(1); + expect(response5.data.results[0].hello).toBe('world'); + }); + + it('select keys (strings)', async () => { + const obj = new TestObject({ foo: 'baz', bar: 1, hello: 'world' }); + await obj.save(); + + const response = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + keys: '', + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, + }); + ok(response.data.results[0].objectId, 'expected objectId to be set'); + ok(response.data.results[0].createdAt, 'expected object createdAt to be set'); + ok(response.data.results[0].updatedAt, 'expected object updatedAt to be set'); + expect(response.data.results[0].foo).toBeUndefined(); + expect(response.data.results[0].bar).toBeUndefined(); + expect(response.data.results[0].hello).toBeUndefined(); + + const response2 = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + keys: '["foo", "hello"]', + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, + }); + ok(response2.data.results[0].objectId, 'expected objectId to be set'); + ok(response2.data.results[0].createdAt, 'expected object createdAt to be set'); + ok(response2.data.results[0].updatedAt, 'expected object updatedAt to be set'); + expect(response2.data.results[0].foo).toBe('baz'); + expect(response2.data.results[0].bar).toBeUndefined(); + expect(response2.data.results[0].hello).toBe('world'); + + const response3 = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + keys: '["foo", "bar", "hello"]', + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, }); + ok(response3.data.results[0].objectId, 'expected objectId to be set'); + ok(response3.data.results[0].createdAt, 'expected object createdAt to be set'); + ok(response3.data.results[0].updatedAt, 'expected object updatedAt to be set'); + expect(response3.data.results[0].foo).toBe('baz'); + expect(response3.data.results[0].bar).toBe(1); + expect(response3.data.results[0].hello).toBe('world'); + }); + + it('exclude keys query JS SDK', async () => { + const obj = new TestObject({ foo: 'baz', bar: 1, qux: 2 }); + + await obj.save(); + obj._clearServerData(); + const query1 = new Parse.Query(TestObject); + query1.exclude('foo'); + const result1 = await query1.first(); + ok(result1.id, 'expected object id to be set'); + ok(result1.createdAt, 'expected object createdAt to be set'); + ok(result1.updatedAt, 'expected object updatedAt to be set'); + ok(!result1.dirty(), 'expected result not to be dirty'); + strictEqual(result1.get('foo'), undefined, "expected 'bar' field to be unset"); + strictEqual(result1.get('bar'), 1); + strictEqual(result1.get('qux'), 2); + + const result2 = await result1.fetch(); + strictEqual(result2.get('foo'), 'baz'); + strictEqual(result2.get('bar'), 1); + strictEqual(result2.get('qux'), 2); + + obj._clearServerData(); + const query2 = new Parse.Query(TestObject); + query2.exclude(); + const result3 = await query2.first(); + ok(result3.id, 'expected object id to be set'); + ok(result3.createdAt, 'expected object createdAt to be set'); + ok(result3.updatedAt, 'expected object updatedAt to be set'); + ok(!result3.dirty(), 'expected result not to be dirty'); + strictEqual(result3.get('foo'), 'baz'); + strictEqual(result3.get('bar'), 1); + strictEqual(result3.get('qux'), 2); + + obj._clearServerData(); + const query3 = new Parse.Query(TestObject); + query3.exclude([]); + const result4 = await query3.first(); + ok(result4.id, 'expected object id to be set'); + ok(result4.createdAt, 'expected object createdAt to be set'); + ok(result4.updatedAt, 'expected object updatedAt to be set'); + ok(!result4.dirty(), 'expected result not to be dirty'); + strictEqual(result4.get('foo'), 'baz'); + strictEqual(result4.get('bar'), 1); + strictEqual(result4.get('qux'), 2); + + obj._clearServerData(); + const query4 = new Parse.Query(TestObject); + query4.exclude(['foo']); + const result5 = await query4.first(); + ok(result5.id, 'expected object id to be set'); + ok(result5.createdAt, 'expected object createdAt to be set'); + ok(result5.updatedAt, 'expected object updatedAt to be set'); + ok(!result5.dirty(), 'expected result not to be dirty'); + strictEqual(result5.get('foo'), undefined, "expected 'bar' field to be unset"); + strictEqual(result5.get('bar'), 1); + strictEqual(result5.get('qux'), 2); + + obj._clearServerData(); + const query5 = new Parse.Query(TestObject); + query5.exclude(['foo', 'bar']); + const result6 = await query5.first(); + ok(result6.id, 'expected object id to be set'); + ok(!result6.dirty(), 'expected result not to be dirty'); + strictEqual(result6.get('foo'), undefined, "expected 'bar' field to be unset"); + strictEqual(result6.get('bar'), undefined, "expected 'bar' field to be unset"); + strictEqual(result6.get('qux'), 2); + + obj._clearServerData(); + const query6 = new Parse.Query(TestObject); + query6.exclude(['foo', 'bar', 'qux']); + const result7 = await query6.first(); + ok(result7.id, 'expected object id to be set'); + ok(!result7.dirty(), 'expected result not to be dirty'); + strictEqual(result7.get('foo'), undefined, "expected 'bar' field to be unset"); + strictEqual(result7.get('bar'), undefined, "expected 'bar' field to be unset"); + strictEqual(result7.get('qux'), undefined, "expected 'bar' field to be unset"); + + obj._clearServerData(); + const query7 = new Parse.Query(TestObject); + query7.exclude('foo'); + const result8 = await query7.first(); + ok(result8.id, 'expected object id to be set'); + ok(!result8.dirty(), 'expected result not to be dirty'); + strictEqual(result8.get('foo'), undefined, "expected 'bar' field to be unset"); + strictEqual(result8.get('bar'), 1); + strictEqual(result8.get('qux'), 2); + + obj._clearServerData(); + const query8 = new Parse.Query(TestObject); + query8.exclude('foo', 'bar'); + const result9 = await query8.first(); + ok(result9.id, 'expected object id to be set'); + ok(!result9.dirty(), 'expected result not to be dirty'); + strictEqual(result9.get('foo'), undefined, "expected 'bar' field to be unset"); + strictEqual(result9.get('bar'), undefined, "expected 'bar' field to be unset"); + strictEqual(result9.get('qux'), 2); + + obj._clearServerData(); + const query9 = new Parse.Query(TestObject); + query9.exclude('foo', 'bar', 'qux'); + const result10 = await query9.first(); + ok(result10.id, 'expected object id to be set'); + ok(!result10.dirty(), 'expected result not to be dirty'); + strictEqual(result10.get('foo'), undefined, "expected 'bar' field to be unset"); + strictEqual(result10.get('bar'), undefined, "expected 'bar' field to be unset"); + strictEqual(result10.get('qux'), undefined, "expected 'bar' field to be unset"); + }); + + it('exclude keys (arrays)', async () => { + const obj = new TestObject({ foo: 'baz', hello: 'world' }); + await obj.save(); + + const response = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + excludeKeys: ['foo'], + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, + }); + ok(response.data.results[0].objectId, 'expected objectId to be set'); + ok(response.data.results[0].createdAt, 'expected object createdAt to be set'); + ok(response.data.results[0].updatedAt, 'expected object updatedAt to be set'); + expect(response.data.results[0].foo).toBeUndefined(); + expect(response.data.results[0].hello).toBe('world'); + + const response2 = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + excludeKeys: ['foo', 'hello'], + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, + }); + ok(response2.data.results[0].objectId, 'expected objectId to be set'); + ok(response2.data.results[0].createdAt, 'expected object createdAt to be set'); + ok(response2.data.results[0].updatedAt, 'expected object updatedAt to be set'); + expect(response2.data.results[0].foo).toBeUndefined(); + expect(response2.data.results[0].hello).toBeUndefined(); + + const response3 = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + excludeKeys: [], + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, + }); + ok(response3.data.results[0].objectId, 'expected objectId to be set'); + ok(response3.data.results[0].createdAt, 'expected object createdAt to be set'); + ok(response3.data.results[0].updatedAt, 'expected object updatedAt to be set'); + expect(response3.data.results[0].foo).toBe('baz'); + expect(response3.data.results[0].hello).toBe('world'); + + const response4 = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + excludeKeys: [''], + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, + }); + ok(response4.data.results[0].objectId, 'expected objectId to be set'); + ok(response4.data.results[0].createdAt, 'expected object createdAt to be set'); + ok(response4.data.results[0].updatedAt, 'expected object updatedAt to be set'); + expect(response4.data.results[0].foo).toBe('baz'); + expect(response4.data.results[0].hello).toBe('world'); + }); + + it('exclude keys (strings)', async () => { + const obj = new TestObject({ foo: 'baz', hello: 'world' }); + await obj.save(); + + const response = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + excludeKeys: 'foo', + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, + }); + ok(response.data.results[0].objectId, 'expected objectId to be set'); + ok(response.data.results[0].createdAt, 'expected object createdAt to be set'); + ok(response.data.results[0].updatedAt, 'expected object updatedAt to be set'); + expect(response.data.results[0].foo).toBeUndefined(); + expect(response.data.results[0].hello).toBe('world'); + + const response2 = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + excludeKeys: '', + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, + }); + ok(response2.data.results[0].objectId, 'expected objectId to be set'); + ok(response2.data.results[0].createdAt, 'expected object createdAt to be set'); + ok(response2.data.results[0].updatedAt, 'expected object updatedAt to be set'); + expect(response2.data.results[0].foo).toBe('baz'); + expect(response2.data.results[0].hello).toBe('world'); + + const response3 = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + excludeKeys: '["hello"]', + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, + }); + ok(response3.data.results[0].objectId, 'expected objectId to be set'); + ok(response3.data.results[0].createdAt, 'expected object createdAt to be set'); + ok(response3.data.results[0].updatedAt, 'expected object updatedAt to be set'); + expect(response3.data.results[0].foo).toBe('baz'); + expect(response3.data.results[0].hello).toBeUndefined(); + + const response4 = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + excludeKeys: '["foo", "hello"]', + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, + }); + ok(response4.data.results[0].objectId, 'expected objectId to be set'); + ok(response4.data.results[0].createdAt, 'expected object createdAt to be set'); + ok(response4.data.results[0].updatedAt, 'expected object updatedAt to be set'); + expect(response4.data.results[0].foo).toBeUndefined(); + expect(response4.data.results[0].hello).toBeUndefined(); + }); + + it('exclude keys with select same key', async () => { + const obj = new TestObject({ foo: 'baz', hello: 'world' }); + await obj.save(); + + const response = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + keys: 'foo', + excludeKeys: 'foo', + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, + }); + expect(response.data.results[0].foo).toBeUndefined(); + expect(response.data.results[0].hello).toBeUndefined(); + }); + + it('exclude keys with select different key', async () => { + const obj = new TestObject({ foo: 'baz', hello: 'world' }); + await obj.save(); + + const response = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + keys: 'foo,hello', + excludeKeys: 'foo', + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, + }); + expect(response.data.results[0].foo).toBeUndefined(); + expect(response.data.results[0].hello).toBe('world'); + }); + + it('exclude keys with include same key', async () => { + const pointer = new TestObject(); + await pointer.save(); + const obj = new TestObject({ child: pointer, hello: 'world' }); + await obj.save(); + + const response = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + include: 'child', + excludeKeys: 'child', + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, + }); + expect(response.data.results[0].child).toBeUndefined(); + expect(response.data.results[0].hello).toBe('world'); + }); + + it('exclude keys with include different key', async () => { + const pointer = new TestObject(); + await pointer.save(); + const obj = new TestObject({ + child1: pointer, + child2: pointer, + hello: 'world', + }); + await obj.save(); + + const response = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + include: 'child1,child2', + excludeKeys: 'child1', + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, + }); + expect(response.data.results[0].child1).toBeUndefined(); + expect(response.data.results[0].child2.objectId).toEqual(pointer.id); + expect(response.data.results[0].hello).toBe('world'); + }); + + it('exclude keys with includeAll', async () => { + const pointer = new TestObject(); + await pointer.save(); + const obj = new TestObject({ + child1: pointer, + child2: pointer, + hello: 'world', + }); + await obj.save(); + + const response = await request({ + url: Parse.serverURL + '/classes/TestObject', + qs: { + includeAll: true, + excludeKeys: 'child1', + where: JSON.stringify({ objectId: obj.id }), + }, + headers: masterKeyHeaders, + }); + expect(response.data.results[0].child).toBeUndefined(); + expect(response.data.results[0].child2.objectId).toEqual(pointer.id); + expect(response.data.results[0].hello).toBe('world'); + }); + + it('select keys with each query', function (done) { + const obj = new TestObject({ foo: 'baz', bar: 1 }); + + obj.save().then(function () { + obj._clearServerData(); + const query = new Parse.Query(TestObject); + query.select('foo'); + query + .each(function (result) { + ok(result.id, 'expected object id to be set'); + ok(result.createdAt, 'expected object createdAt to be set'); + ok(result.updatedAt, 'expected object updatedAt to be set'); + ok(!result.dirty(), 'expected result not to be dirty'); + strictEqual(result.get('foo'), 'baz'); + strictEqual(result.get('bar'), undefined, 'expected "bar" field to be unset'); + }) + .then( + function () { + done(); + }, + function (err) { + jfail(err); + done(); + } + ); + }); + }); + + it_id('56b09b92-c756-4bae-8c32-1c32b5b4c397')(it)('notEqual with array of pointers', done => { + const children = []; + const parents = []; + const promises = []; + for (let i = 0; i < 2; i++) { + const proc = iter => { + const child = new Parse.Object('Child'); + children.push(child); + const parent = new Parse.Object('Parent'); + parents.push(parent); + promises.push( + child.save().then(() => { + parents[iter].set('child', [children[iter]]); + return parents[iter].save(); + }) + ); + }; + proc(i); + } + Promise.all(promises) + .then(() => { + const query = new Parse.Query('Parent'); + query.notEqualTo('child', children[0]); + return query.find(); + }) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].id).toEqual(parents[1].id); + done(); + }) + .catch(error => { + console.log(error); + }); + }); + + // PG don't support creating a null column + it_exclude_dbs(['postgres'])('querying for null value', done => { + const obj = new Parse.Object('TestObject'); + obj.set('aNull', null); + obj + .save() + .then(() => { + const query = new Parse.Query('TestObject'); + query.equalTo('aNull', null); + return query.find(); + }) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0].get('aNull')).toEqual(null); + done(); + }); + }); + + it('query within dictionary', done => { + const promises = []; + for (let i = 0; i < 2; i++) { + const proc = iter => { + const obj = new Parse.Object('TestObject'); + obj.set('aDict', { x: iter + 1, y: iter + 2 }); + promises.push(obj.save()); + }; + proc(i); + } + Promise.all(promises) + .then(() => { + const query = new Parse.Query('TestObject'); + query.equalTo('aDict.x', 1); + return query.find(); + }) + .then( + results => { + expect(results.length).toEqual(1); + done(); + }, + error => { + console.log(error); + } + ); + }); + + it('supports include on the wrong key type (#2262)', function (done) { + const childObject = new Parse.Object('TestChildObject'); + childObject.set('hello', 'world'); + childObject + .save() + .then(() => { + const obj = new Parse.Object('TestObject'); + obj.set('foo', 'bar'); + obj.set('child', childObject); + return obj.save(); + }) + .then(() => { + const q = new Parse.Query('TestObject'); + q.include('child'); + q.include('child.parent'); + q.include('createdAt'); + q.include('createdAt.createdAt'); + return q.find(); + }) + .then( + objs => { + expect(objs.length).toBe(1); + expect(objs[0].get('child').get('hello')).toEqual('world'); + expect(Utils.isDate(objs[0].createdAt)).toBe(true); + done(); + }, + () => { + fail('should not fail'); + done(); + } + ); + }); + + it('query match on array with single object', done => { + const target = { + __type: 'Pointer', + className: 'TestObject', + objectId: 'abc123', + }; + const obj = new Parse.Object('TestObject'); + obj.set('someObjs', [target]); + obj + .save() + .then(() => { + const query = new Parse.Query('TestObject'); + query.equalTo('someObjs', target); + return query.find(); + }) + .then( + results => { + expect(results.length).toEqual(1); + done(); + }, + error => { + console.log(error); + } + ); + }); + + it('query match on array with multiple objects', done => { + const target1 = { + __type: 'Pointer', + className: 'TestObject', + objectId: 'abc', + }; + const target2 = { + __type: 'Pointer', + className: 'TestObject', + objectId: '123', + }; + const obj = new Parse.Object('TestObject'); + obj.set('someObjs', [target1, target2]); + obj + .save() + .then(() => { + const query = new Parse.Query('TestObject'); + query.equalTo('someObjs', target1); + return query.find(); + }) + .then( + results => { + expect(results.length).toEqual(1); + done(); + }, + error => { + console.log(error); + } + ); + }); + + it('query should not match on array when searching for null', done => { + const target = { + __type: 'Pointer', + className: 'TestObject', + objectId: '123', + }; + const obj = new Parse.Object('TestObject'); + obj.set('someKey', 'someValue'); + obj.set('someObjs', [target]); + obj + .save() + .then(() => { + const query = new Parse.Query('TestObject'); + query.equalTo('someKey', 'someValue'); + query.equalTo('someObjs', null); + return query.find(); + }) + .then( + results => { + expect(results.length).toEqual(0); + done(); + }, + error => { + console.log(error); + } + ); + }); + + // #371 + it('should properly interpret a query v1', done => { + const query = new Parse.Query('C1'); + const auxQuery = new Parse.Query('C1'); + query.matchesKeyInQuery('A1', 'A2', auxQuery); + query.include('A3'); + query.include('A2'); + query.find().then( + () => { + done(); + }, + err => { + jfail(err); + fail('should not failt'); + done(); + } + ); + }); + + it_id('7079f0ef-47b3-4a1e-aac0-32654dadaa27')(it)('should properly interpret a query v2', done => { + const user = new Parse.User(); + user.set('username', 'foo'); + user.set('password', 'bar'); + return user + .save() + .then(user => { + const objIdQuery = new Parse.Query('_User').equalTo('objectId', user.id); + const blockedUserQuery = user.relation('blockedUsers').query(); + + const aResponseQuery = new Parse.Query('MatchRelationshipActivityResponse'); + aResponseQuery.equalTo('userA', user); + aResponseQuery.equalTo('userAResponse', 1); + + const bResponseQuery = new Parse.Query('MatchRelationshipActivityResponse'); + bResponseQuery.equalTo('userB', user); + bResponseQuery.equalTo('userBResponse', 1); + + const matchOr = Parse.Query.or(aResponseQuery, bResponseQuery); + const matchRelationshipA = new Parse.Query('_User'); + matchRelationshipA.matchesKeyInQuery('objectId', 'userAObjectId', matchOr); + const matchRelationshipB = new Parse.Query('_User'); + matchRelationshipB.matchesKeyInQuery('objectId', 'userBObjectId', matchOr); + + const orQuery = Parse.Query.or( + objIdQuery, + blockedUserQuery, + matchRelationshipA, + matchRelationshipB + ); + const query = new Parse.Query('_User'); + query.doesNotMatchQuery('objectId', orQuery); + return query.find(); + }) + .then( + () => { + done(); + }, + err => { + jfail(err); + fail('should not fail'); + done(); + } + ); + }); + + it('should match a key in an array (#3195)', function (done) { + const AuthorObject = Parse.Object.extend('Author'); + const GroupObject = Parse.Object.extend('Group'); + const PostObject = Parse.Object.extend('Post'); + + return new AuthorObject() + .save() + .then(user => { + const post = new PostObject({ + author: user, + }); + + const group = new GroupObject({ + members: [user], + }); + + return Promise.all([post.save(), group.save()]); + }) + .then(results => { + const p = results[0]; + return new Parse.Query(PostObject) + .matchesKeyInQuery('author', 'members', new Parse.Query(GroupObject)) + .find() + .then(r => { + expect(r.length).toEqual(1); + if (r.length > 0) { + expect(r[0].id).toEqual(p.id); + } + done(); + }, done.fail); + }); + }); + + it_id('d95818c0-9e3c-41e6-be20-e7bafb59eefb')(it)('should find objects with array of pointers', done => { + const objects = []; + while (objects.length != 5) { + const object = new Parse.Object('ContainedObject'); + object.set('index', objects.length); + objects.push(object); + } + + Parse.Object.saveAll(objects) + .then(objects => { + const container = new Parse.Object('Container'); + const pointers = objects.map(obj => { + return { + __type: 'Pointer', + className: 'ContainedObject', + objectId: obj.id, + }; + }); + container.set('objects', pointers); + const container2 = new Parse.Object('Container'); + container2.set('objects', pointers.slice(2, 3)); + return Parse.Object.saveAll([container, container2]); + }) + .then(() => { + const inQuery = new Parse.Query('ContainedObject'); + inQuery.greaterThanOrEqualTo('index', 1); + const query = new Parse.Query('Container'); + query.matchesQuery('objects', inQuery); + return query.find(); + }) + .then(results => { + if (results) { + expect(results.length).toBe(2); + } + done(); + }) + .catch(err => { + jfail(err); + fail('should not fail'); + done(); + }); + }); + + it('query with two OR subqueries (regression test #1259)', done => { + const relatedObject = new Parse.Object('Class2'); + relatedObject + .save() + .then(relatedObject => { + const anObject = new Parse.Object('Class1'); + const relation = anObject.relation('relation'); + relation.add(relatedObject); + return anObject.save(); + }) + .then(anObject => { + const q1 = anObject.relation('relation').query(); + q1.doesNotExist('nonExistantKey1'); + const q2 = anObject.relation('relation').query(); + q2.doesNotExist('nonExistantKey2'); + Parse.Query.or(q1, q2) + .find() + .then(results => { + expect(results.length).toEqual(1); + if (results.length == 1) { + expect(results[0].objectId).toEqual(q1.objectId); + } + done(); + }); + }); + }); + + it('objectId containedIn with multiple large array', done => { + const obj = new Parse.Object('MyClass'); + obj + .save() + .then(obj => { + const longListOfStrings = []; + for (let i = 0; i < 130; i++) { + longListOfStrings.push(i.toString()); + } + longListOfStrings.push(obj.id); + const q = new Parse.Query('MyClass'); + q.containedIn('objectId', longListOfStrings); + q.containedIn('objectId', longListOfStrings); + return q.find(); + }) + .then(results => { + expect(results.length).toEqual(1); + done(); + }); + }); + + it('containedIn with pointers should work with string array', done => { + const obj = new Parse.Object('MyClass'); + const child = new Parse.Object('Child'); + child + .save() + .then(() => { + obj.set('child', child); + return obj.save(); + }) + .then(() => { + const objs = []; + for (let i = 0; i < 10; i++) { + objs.push(new Parse.Object('MyClass')); + } + return Parse.Object.saveAll(objs); + }) + .then(() => { + const query = new Parse.Query('MyClass'); + query.containedIn('child', [child.id]); + return query.find(); + }) + .then(results => { + expect(results.length).toBe(1); + }) + .then(done) + .catch(done.fail); }); - it_exclude_dbs(['postgres'])("time lessThan", function(done) { - makeThreeTimeObjects().then(function(list) { - var query = new Parse.Query(TestObject); - query.lessThan("time", list[2].get("time")); - query.find({ - success: function(results) { - equal(results.length, 2); - done(); - } - }); - }); + it('containedIn with pointers should work with string array, with many objects', done => { + const objs = []; + const children = []; + for (let i = 0; i < 10; i++) { + const obj = new Parse.Object('MyClass'); + const child = new Parse.Object('Child'); + objs.push(obj); + children.push(child); + } + Parse.Object.saveAll(children) + .then(() => { + return Parse.Object.saveAll( + objs.map((obj, i) => { + obj.set('child', children[i]); + return obj; + }) + ); + }) + .then(() => { + const query = new Parse.Query('MyClass'); + const subset = children.slice(0, 5).map(child => { + return child.id; + }); + query.containedIn('child', subset); + return query.find(); + }) + .then(results => { + expect(results.length).toBe(5); + }) + .then(done) + .catch(done.fail); }); - // This test requires Date objects to be consistently stored as a Date. - it_exclude_dbs(['postgres'])("time createdAt", function(done) { - makeThreeTimeObjects().then(function(list) { - var query = new Parse.Query(TestObject); - query.greaterThanOrEqualTo("createdAt", list[0].createdAt); - query.find({ - success: function(results) { - equal(results.length, 3); - done(); - } + it('include for specific object', function (done) { + const child = new Parse.Object('Child'); + const parent = new Parse.Object('Parent'); + child.set('foo', 'bar'); + parent.set('child', child); + Parse.Object.saveAll([child, parent]).then(function (response) { + const savedParent = response[1]; + const parentQuery = new Parse.Query('Parent'); + parentQuery.include('child'); + parentQuery.get(savedParent.id).then(function (parentObj) { + const childPointer = parentObj.get('child'); + ok(childPointer); + equal(childPointer.get('foo'), 'bar'); + done(); }); }); }); - it_exclude_dbs(['postgres'])("matches string", function(done) { - var thing1 = new TestObject(); - thing1.set("myString", "football"); - var thing2 = new TestObject(); - thing2.set("myString", "soccer"); - Parse.Object.saveAll([thing1, thing2], function() { - var query = new Parse.Query(TestObject); - query.matches("myString", "^fo*\\wb[^o]l+$"); - query.find({ - success: function(results) { - equal(results.length, 1); - done(); - } + it('select keys for specific object', function (done) { + const Foobar = new Parse.Object('Foobar'); + Foobar.set('foo', 'bar'); + Foobar.set('fizz', 'buzz'); + Foobar.save().then(function (savedFoobar) { + const foobarQuery = new Parse.Query('Foobar'); + foobarQuery.select('fizz'); + foobarQuery.get(savedFoobar.id).then(function (foobarObj) { + equal(foobarObj.get('fizz'), 'buzz'); + equal(foobarObj.get('foo'), undefined); + done(); }); }); }); - it_exclude_dbs(['postgres'])("matches regex", function(done) { - var thing1 = new TestObject(); - thing1.set("myString", "football"); - var thing2 = new TestObject(); - thing2.set("myString", "soccer"); - Parse.Object.saveAll([thing1, thing2], function() { - var query = new Parse.Query(TestObject); - query.matches("myString", /^fo*\wb[^o]l+$/); - query.find({ - success: function(results) { - equal(results.length, 1); + it('select nested keys (issue #1567)', function (done) { + const Foobar = new Parse.Object('Foobar'); + const BarBaz = new Parse.Object('Barbaz'); + BarBaz.set('key', 'value'); + BarBaz.set('otherKey', 'value'); + BarBaz.save() + .then(() => { + Foobar.set('foo', 'bar'); + Foobar.set('fizz', 'buzz'); + Foobar.set('barBaz', BarBaz); + return Foobar.save(); + }) + .then(function (savedFoobar) { + const foobarQuery = new Parse.Query('Foobar'); + foobarQuery.select(['fizz', 'barBaz.key']); + foobarQuery.get(savedFoobar.id).then(function (foobarObj) { + equal(foobarObj.get('fizz'), 'buzz'); + equal(foobarObj.get('foo'), undefined); + if (foobarObj.has('barBaz')) { + equal(foobarObj.get('barBaz').get('key'), 'value'); + equal(foobarObj.get('barBaz').get('otherKey'), undefined); + } else { + fail('barBaz should be set'); + } done(); - } + }); }); - }); }); - it_exclude_dbs(['postgres'])("case insensitive regex success", function(done) { - var thing = new TestObject(); - thing.set("myString", "football"); - Parse.Object.saveAll([thing], function() { - var query = new Parse.Query(TestObject); - query.matches("myString", "FootBall", "i"); - query.find({ - success: function(results) { + it('select nested keys 2 level (issue #1567)', function (done) { + const Foobar = new Parse.Object('Foobar'); + const BarBaz = new Parse.Object('Barbaz'); + const Bazoo = new Parse.Object('Bazoo'); + + Bazoo.set('some', 'thing'); + Bazoo.set('otherSome', 'value'); + Bazoo.save() + .then(() => { + BarBaz.set('key', 'value'); + BarBaz.set('otherKey', 'value'); + BarBaz.set('bazoo', Bazoo); + return BarBaz.save(); + }) + .then(() => { + Foobar.set('foo', 'bar'); + Foobar.set('fizz', 'buzz'); + Foobar.set('barBaz', BarBaz); + return Foobar.save(); + }) + .then(function (savedFoobar) { + const foobarQuery = new Parse.Query('Foobar'); + foobarQuery.select(['fizz', 'barBaz.key', 'barBaz.bazoo.some']); + foobarQuery.get(savedFoobar.id).then(function (foobarObj) { + equal(foobarObj.get('fizz'), 'buzz'); + equal(foobarObj.get('foo'), undefined); + if (foobarObj.has('barBaz')) { + equal(foobarObj.get('barBaz').get('key'), 'value'); + equal(foobarObj.get('barBaz').get('otherKey'), undefined); + equal(foobarObj.get('barBaz').get('bazoo').get('some'), 'thing'); + equal(foobarObj.get('barBaz').get('bazoo').get('otherSome'), undefined); + } else { + fail('barBaz should be set'); + } done(); - } + }); }); - }); }); - it_exclude_dbs(['postgres'])("regexes with invalid options fail", function(done) { - var query = new Parse.Query(TestObject); - query.matches("myString", "FootBall", "some invalid option"); - query.find(expectError(Parse.Error.INVALID_QUERY, done)); + it('exclude nested keys', async () => { + const Foobar = new Parse.Object('Foobar'); + const BarBaz = new Parse.Object('Barbaz'); + BarBaz.set('key', 'value'); + BarBaz.set('otherKey', 'value'); + await BarBaz.save(); + + Foobar.set('foo', 'bar'); + Foobar.set('fizz', 'buzz'); + Foobar.set('barBaz', BarBaz); + const savedFoobar = await Foobar.save(); + + const foobarQuery = new Parse.Query('Foobar'); + foobarQuery.exclude(['foo', 'barBaz.otherKey']); + const foobarObj = await foobarQuery.get(savedFoobar.id); + equal(foobarObj.get('fizz'), 'buzz'); + equal(foobarObj.get('foo'), undefined); + if (foobarObj.has('barBaz')) { + equal(foobarObj.get('barBaz').get('key'), 'value'); + equal(foobarObj.get('barBaz').get('otherKey'), undefined); + } else { + fail('barBaz should be set'); + } }); - it_exclude_dbs(['postgres'])("Use a regex that requires all modifiers", function(done) { - var thing = new TestObject(); - thing.set("myString", "PArSe\nCom"); - Parse.Object.saveAll([thing], function() { - var query = new Parse.Query(TestObject); - query.matches( - "myString", - "parse # First fragment. We'll write this in one case but match " + - "insensitively\n.com # Second fragment. This can be separated by any " + - "character, including newline", - "mixs"); - query.find({ - success: function(results) { - equal(results.length, 1); - done(); - } - }); - }); + it('exclude nested keys 2 level', async () => { + const Foobar = new Parse.Object('Foobar'); + const BarBaz = new Parse.Object('Barbaz'); + const Bazoo = new Parse.Object('Bazoo'); + + Bazoo.set('some', 'thing'); + Bazoo.set('otherSome', 'value'); + await Bazoo.save(); + + BarBaz.set('key', 'value'); + BarBaz.set('otherKey', 'value'); + BarBaz.set('bazoo', Bazoo); + await BarBaz.save(); + + Foobar.set('foo', 'bar'); + Foobar.set('fizz', 'buzz'); + Foobar.set('barBaz', BarBaz); + const savedFoobar = await Foobar.save(); + + const foobarQuery = new Parse.Query('Foobar'); + foobarQuery.exclude(['foo', 'barBaz.otherKey', 'barBaz.bazoo.otherSome']); + const foobarObj = await foobarQuery.get(savedFoobar.id); + equal(foobarObj.get('fizz'), 'buzz'); + equal(foobarObj.get('foo'), undefined); + if (foobarObj.has('barBaz')) { + equal(foobarObj.get('barBaz').get('key'), 'value'); + equal(foobarObj.get('barBaz').get('otherKey'), undefined); + equal(foobarObj.get('barBaz').get('bazoo').get('some'), 'thing'); + equal(foobarObj.get('barBaz').get('bazoo').get('otherSome'), undefined); + } else { + fail('barBaz should be set'); + } }); - it_exclude_dbs(['postgres'])("Regular expression constructor includes modifiers inline", function(done) { - var thing = new TestObject(); - thing.set("myString", "\n\nbuffer\n\nparse.COM"); - Parse.Object.saveAll([thing], function() { - var query = new Parse.Query(TestObject); - query.matches("myString", /parse\.com/mi); - query.find({ - success: function(results) { - equal(results.length, 1); - done(); - } - }); + it('include with *', async () => { + const child1 = new TestObject({ foo: 'bar', name: 'ac' }); + const child2 = new TestObject({ foo: 'baz', name: 'flo' }); + const child3 = new TestObject({ foo: 'bad', name: 'mo' }); + const parent = new Container({ child1, child2, child3 }); + await Parse.Object.saveAll([parent, child1, child2, child3]); + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ objectId: parent.id }), + include: '*', + }, }); + const resp = await request( + Object.assign({ url: Parse.serverURL + '/classes/Container' }, options) + ); + const result = resp.data.results[0]; + equal(result.child1.foo, 'bar'); + equal(result.child2.foo, 'baz'); + equal(result.child3.foo, 'bad'); + equal(result.child1.name, 'ac'); + equal(result.child2.name, 'flo'); + equal(result.child3.name, 'mo'); }); - var someAscii = "\\E' !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTU" + - "VWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~'"; - - it_exclude_dbs(['postgres'])("contains", function(done) { - Parse.Object.saveAll([new TestObject({myString: "zax" + someAscii + "qub"}), - new TestObject({myString: "start" + someAscii}), - new TestObject({myString: someAscii + "end"}), - new TestObject({myString: someAscii})], function() { - var query = new Parse.Query(TestObject); - query.contains("myString", someAscii); - query.find({ - success: function(results, foo) { - equal(results.length, 4); - done(); - } - }); - }); - }); - - it_exclude_dbs(['postgres'])("startsWith", function(done) { - Parse.Object.saveAll([new TestObject({myString: "zax" + someAscii + "qub"}), - new TestObject({myString: "start" + someAscii}), - new TestObject({myString: someAscii + "end"}), - new TestObject({myString: someAscii})], function() { - var query = new Parse.Query(TestObject); - query.startsWith("myString", someAscii); - query.find({ - success: function(results, foo) { - equal(results.length, 2); - done(); - } - }); - }); - }); - - it_exclude_dbs(['postgres'])("endsWith", function(done) { - Parse.Object.saveAll([new TestObject({myString: "zax" + someAscii + "qub"}), - new TestObject({myString: "start" + someAscii}), - new TestObject({myString: someAscii + "end"}), - new TestObject({myString: someAscii})], function() { - var query = new Parse.Query(TestObject); - query.startsWith("myString", someAscii); - query.find({ - success: function(results, foo) { - equal(results.length, 2); - done(); - } - }); - }); - }); - - it_exclude_dbs(['postgres'])("exists", function(done) { - var objects = []; - for (var i of [0, 1, 2, 3, 4, 5, 6, 7, 8]) { - var item = new TestObject(); - if (i % 2 === 0) { - item.set('x', i + 1); - } else { - item.set('y', i + 1); - } - objects.push(item); - } - Parse.Object.saveAll(objects, function() { - var query = new Parse.Query(TestObject); - query.exists("x"); - query.find({ - success: function(results) { - equal(results.length, 5); - for (var result of results) { - ok(result.get("x")); - }; - done(); - } - }); + it('include with ["*"]', async () => { + const child1 = new TestObject({ foo: 'bar', name: 'ac' }); + const child2 = new TestObject({ foo: 'baz', name: 'flo' }); + const child3 = new TestObject({ foo: 'bad', name: 'mo' }); + const parent = new Container({ child1, child2, child3 }); + await Parse.Object.saveAll([parent, child1, child2, child3]); + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ objectId: parent.id }), + include: '["*"]', + }, }); + const resp = await request( + Object.assign({ url: Parse.serverURL + '/classes/Container' }, options) + ); + const result = resp.data.results[0]; + equal(result.child1.foo, 'bar'); + equal(result.child2.foo, 'baz'); + equal(result.child3.foo, 'bad'); + equal(result.child1.name, 'ac'); + equal(result.child2.name, 'flo'); + equal(result.child3.name, 'mo'); }); - it_exclude_dbs(['postgres'])("doesNotExist", function(done) { - var objects = []; - for (var i of [0, 1, 2, 3, 4, 5, 6, 7, 8]) { - var item = new TestObject(); - if (i % 2 === 0) { - item.set('x', i + 1); - } else { - item.set('y', i + 1); - } - objects.push(item); - }; - Parse.Object.saveAll(objects, function() { - var query = new Parse.Query(TestObject); - query.doesNotExist("x"); - query.find({ - success: function(results) { - equal(results.length, 4); - for (var result of results) { - ok(result.get("y")); - } - done(); - } - }); + it('include with * overrides', async () => { + const child1 = new TestObject({ foo: 'bar', name: 'ac' }); + const child2 = new TestObject({ foo: 'baz', name: 'flo' }); + const child3 = new TestObject({ foo: 'bad', name: 'mo' }); + const parent = new Container({ child1, child2, child3 }); + await Parse.Object.saveAll([parent, child1, child2, child3]); + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ objectId: parent.id }), + include: 'child2,*', + }, }); + const resp = await request( + Object.assign({ url: Parse.serverURL + '/classes/Container' }, options) + ); + const result = resp.data.results[0]; + equal(result.child1.foo, 'bar'); + equal(result.child2.foo, 'baz'); + equal(result.child3.foo, 'bad'); + equal(result.child1.name, 'ac'); + equal(result.child2.name, 'flo'); + equal(result.child3.name, 'mo'); }); - it_exclude_dbs(['postgres'])("exists relation", function(done) { - var objects = []; - for (var i of [0, 1, 2, 3, 4, 5, 6, 7, 8]) { - var container = new Container(); - if (i % 2 === 0) { - var item = new TestObject(); - item.set('x', i); - container.set('x', item); - objects.push(item); - } else { - container.set('y', i); - } - objects.push(container); - }; - Parse.Object.saveAll(objects, function() { - var query = new Parse.Query(Container); - query.exists("x"); - query.find({ - success: function(results) { - equal(results.length, 5); - for (var result of results) { - ok(result.get("x")); - }; - done(); - } - }); + it('include with ["*"] overrides', async () => { + const child1 = new TestObject({ foo: 'bar', name: 'ac' }); + const child2 = new TestObject({ foo: 'baz', name: 'flo' }); + const child3 = new TestObject({ foo: 'bad', name: 'mo' }); + const parent = new Container({ child1, child2, child3 }); + await Parse.Object.saveAll([parent, child1, child2, child3]); + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ objectId: parent.id }), + include: '["child2","*"]', + }, }); + const resp = await request( + Object.assign({ url: Parse.serverURL + '/classes/Container' }, options) + ); + const result = resp.data.results[0]; + equal(result.child1.foo, 'bar'); + equal(result.child2.foo, 'baz'); + equal(result.child3.foo, 'bad'); + equal(result.child1.name, 'ac'); + equal(result.child2.name, 'flo'); + equal(result.child3.name, 'mo'); }); - it_exclude_dbs(['postgres'])("doesNotExist relation", function(done) { - var objects = []; - for (var i of [0, 1, 2, 3, 4, 5, 6, 7]) { - var container = new Container(); - if (i % 2 === 0) { - var item = new TestObject(); - item.set('x', i); - container.set('x', item); - objects.push(item); - } else { - container.set('y', i); - } - objects.push(container); - } - Parse.Object.saveAll(objects, function() { - var query = new Parse.Query(Container); - query.doesNotExist("x"); - query.find({ - success: function(results) { - equal(results.length, 4); - for (var result of results) { - ok(result.get("y")); - }; - done(); - } + it('includeAll', done => { + const child1 = new TestObject({ foo: 'bar', name: 'ac' }); + const child2 = new TestObject({ foo: 'baz', name: 'flo' }); + const child3 = new TestObject({ foo: 'bad', name: 'mo' }); + const parent = new Container({ child1, child2, child3 }); + Parse.Object.saveAll([parent, child1, child2, child3]) + .then(() => { + const options = Object.assign({}, masterKeyOptions, { + qs: { + where: JSON.stringify({ objectId: parent.id }), + includeAll: true, + }, + }); + return request(Object.assign({ url: Parse.serverURL + '/classes/Container' }, options)); + }) + .then(resp => { + const result = resp.data.results[0]; + equal(result.child1.foo, 'bar'); + equal(result.child2.foo, 'baz'); + equal(result.child3.foo, 'bad'); + equal(result.child1.name, 'ac'); + equal(result.child2.name, 'flo'); + equal(result.child3.name, 'mo'); + done(); }); - }); }); - it_exclude_dbs(['postgres'])("don't include by default", function(done) { - var child = new TestObject(); - var parent = new Container(); - child.set("foo", "bar"); - parent.set("child", child); - Parse.Object.saveAll([child, parent], function() { - child._clearServerData(); - var query = new Parse.Query(Container); - query.find({ - success: function(results) { + it('include pointer and pointer array', function (done) { + const child = new TestObject(); + const child2 = new TestObject(); + child.set('foo', 'bar'); + child2.set('hello', 'world'); + Parse.Object.saveAll([child, child2]).then(function () { + const parent = new Container(); + parent.set('child', child.toPointer()); + parent.set('child2', [child2.toPointer()]); + parent.save().then(function () { + const query = new Parse.Query(Container); + query.include(['child', 'child2']); + query.find().then(function (results) { equal(results.length, 1); - var parentAgain = results[0]; - var goodURL = Parse.serverURL; - Parse.serverURL = "YAAAAAAAAARRRRRGGGGGGGGG"; - var childAgain = parentAgain.get("child"); + const parentAgain = results[0]; + const childAgain = parentAgain.get('child'); ok(childAgain); - equal(childAgain.get("foo"), undefined); - Parse.serverURL = goodURL; + equal(childAgain.get('foo'), 'bar'); + const child2Again = parentAgain.get('child2'); + equal(child2Again.length, 1); + ok(child2Again); + equal(child2Again[0].get('hello'), 'world'); done(); - } + }); }); }); }); - it("include relation", function(done) { - var child = new TestObject(); - var parent = new Container(); - child.set("foo", "bar"); - parent.set("child", child); - Parse.Object.saveAll([child, parent], function() { - var query = new Parse.Query(Container); - query.include("child"); - query.find({ - success: function(results) { + it('include pointer and pointer array (keys switched)', function (done) { + const child = new TestObject(); + const child2 = new TestObject(); + child.set('foo', 'bar'); + child2.set('hello', 'world'); + Parse.Object.saveAll([child, child2]).then(function () { + const parent = new Container(); + parent.set('child', child.toPointer()); + parent.set('child2', [child2.toPointer()]); + parent.save().then(function () { + const query = new Parse.Query(Container); + query.include(['child2', 'child']); + query.find().then(function (results) { equal(results.length, 1); - var parentAgain = results[0]; - var goodURL = Parse.serverURL; - Parse.serverURL = "YAAAAAAAAARRRRRGGGGGGGGG"; - var childAgain = parentAgain.get("child"); + const parentAgain = results[0]; + const childAgain = parentAgain.get('child'); ok(childAgain); - equal(childAgain.get("foo"), "bar"); - Parse.serverURL = goodURL; + equal(childAgain.get('foo'), 'bar'); + const child2Again = parentAgain.get('child2'); + equal(child2Again.length, 1); + ok(child2Again); + equal(child2Again[0].get('hello'), 'world'); done(); - } + }); }); }); }); - it("include relation array", function(done) { - var child = new TestObject(); - var parent = new Container(); - child.set("foo", "bar"); - parent.set("child", child); - Parse.Object.saveAll([child, parent], function() { - var query = new Parse.Query(Container); - query.include(["child"]); - query.find({ - success: function(results) { + it('includeAll pointer and pointer array', function (done) { + const child = new TestObject(); + const child2 = new TestObject(); + child.set('foo', 'bar'); + child2.set('hello', 'world'); + Parse.Object.saveAll([child, child2]).then(function () { + const parent = new Container(); + parent.set('child', child.toPointer()); + parent.set('child2', [child2.toPointer()]); + parent.save().then(function () { + const query = new Parse.Query(Container); + query.includeAll(); + query.find().then(function (results) { equal(results.length, 1); - var parentAgain = results[0]; - var goodURL = Parse.serverURL; - Parse.serverURL = "YAAAAAAAAARRRRRGGGGGGGGG"; - var childAgain = parentAgain.get("child"); + const parentAgain = results[0]; + const childAgain = parentAgain.get('child'); ok(childAgain); - equal(childAgain.get("foo"), "bar"); - Parse.serverURL = goodURL; + equal(childAgain.get('foo'), 'bar'); + const child2Again = parentAgain.get('child2'); + equal(child2Again.length, 1); + ok(child2Again); + equal(child2Again[0].get('hello'), 'world'); done(); - } + }); }); }); }); - it("nested include", function(done) { - var Child = Parse.Object.extend("Child"); - var Parent = Parse.Object.extend("Parent"); - var Grandparent = Parse.Object.extend("Grandparent"); - var objects = []; - for (var i = 0; i < 5; ++i) { - var grandparent = new Grandparent({ - z:i, - parent: new Parent({ - y:i, - child: new Child({ - x:i - }) - }) - }); - objects.push(grandparent); - } + it('select nested keys 2 level includeAll', done => { + const Foobar = new Parse.Object('Foobar'); + const BarBaz = new Parse.Object('Barbaz'); + const Bazoo = new Parse.Object('Bazoo'); + const Tang = new Parse.Object('Tang'); + + Bazoo.set('some', 'thing'); + Bazoo.set('otherSome', 'value'); + Bazoo.save() + .then(() => { + BarBaz.set('key', 'value'); + BarBaz.set('otherKey', 'value'); + BarBaz.set('bazoo', Bazoo); + return BarBaz.save(); + }) + .then(() => { + Tang.set('clan', 'wu'); + return Tang.save(); + }) + .then(() => { + Foobar.set('foo', 'bar'); + Foobar.set('fizz', 'buzz'); + Foobar.set('barBaz', BarBaz); + Foobar.set('group', Tang); + return Foobar.save(); + }) + .then(savedFoobar => { + const options = Object.assign( + { + url: Parse.serverURL + '/classes/Foobar', + }, + masterKeyOptions, + { + qs: { + where: JSON.stringify({ objectId: savedFoobar.id }), + includeAll: true, + keys: 'fizz,barBaz.key,barBaz.bazoo.some', + }, + } + ); + return request(options); + }) + .then(resp => { + const result = resp.data.results[0]; + equal(result.group.clan, 'wu'); + equal(result.foo, undefined); + equal(result.fizz, 'buzz'); + equal(result.barBaz.key, 'value'); + equal(result.barBaz.otherKey, undefined); + equal(result.barBaz.bazoo.some, 'thing'); + equal(result.barBaz.bazoo.otherSome, undefined); + done(); + }) + .catch(done.fail); + }); + + it('includeAll handles circular pointer references', async () => { + // Create two objects that reference each other + const objA = new TestObject(); + const objB = new TestObject(); + + objA.set('name', 'Object A'); + objB.set('name', 'Object B'); + + // Save them first + await Parse.Object.saveAll([objA, objB]); + + // Create circular references: A -> B -> A + objA.set('ref', objB); + objB.set('ref', objA); + + await Parse.Object.saveAll([objA, objB]); + + // Query with includeAll + const query = new Parse.Query('TestObject'); + query.equalTo('objectId', objA.id); + query.includeAll(); + + const results = await query.find(); + + // Verify the object is returned + expect(results.length).toBe(1); + const resultA = results[0]; + expect(resultA.get('name')).toBe('Object A'); + + // Verify the immediate reference is included (1 level deep) + const refB = resultA.get('ref'); + expect(refB).toBeDefined(); + expect(refB.get('name')).toBe('Object B'); + + // Verify that includeAll only includes 1 level deep + // B's pointer back to A should exist as an object but without full data + const refBackToA = refB.get('ref'); + expect(refBackToA).toBeDefined(); + expect(refBackToA.id).toBe(objA.id); + + // The circular reference exists but is NOT fully populated + // (name field is undefined because it's not included at this depth) + expect(refBackToA.get('name')).toBeUndefined(); + + // Verify using toJSON that it's stored as a pointer + const refBackToAJSON = refB.toJSON().ref; + expect(refBackToAJSON).toBeDefined(); + expect(refBackToAJSON.__type).toBe('Pointer'); + expect(refBackToAJSON.className).toBe('TestObject'); + expect(refBackToAJSON.objectId).toBe(objA.id); + }); - Parse.Object.saveAll(objects, function() { - var query = new Parse.Query(Grandparent); - query.include(["parent.child"]); - query.find({ - success: function(results) { - equal(results.length, 5); - for (var object of results) { - equal(object.get("z"), object.get("parent").get("y")); - equal(object.get("z"), object.get("parent").get("child").get("x")); + it('includeAll handles self-referencing pointer', async () => { + // Create an object that points to itself + const selfRef = new TestObject(); + selfRef.set('name', 'Self-Referencing'); + + await selfRef.save(); + + // Make it point to itself + selfRef.set('ref', selfRef); + await selfRef.save(); + + // Query with includeAll + const query = new Parse.Query('TestObject'); + query.equalTo('objectId', selfRef.id); + query.includeAll(); + + const results = await query.find(); + + // Verify the object is returned + expect(results.length).toBe(1); + const result = results[0]; + expect(result.get('name')).toBe('Self-Referencing'); + + // Verify the self-reference is included (since it's at the first level) + const ref = result.get('ref'); + expect(ref).toBeDefined(); + expect(ref.id).toBe(selfRef.id); + + // The self-reference should be fully populated at the first level + // because includeAll includes all pointer fields at the immediate level + expect(ref.get('name')).toBe('Self-Referencing'); + }); + + it('select nested keys 2 level without include (issue #3185)', function (done) { + const Foobar = new Parse.Object('Foobar'); + const BarBaz = new Parse.Object('Barbaz'); + const Bazoo = new Parse.Object('Bazoo'); + + Bazoo.set('some', 'thing'); + Bazoo.set('otherSome', 'value'); + Bazoo.save() + .then(() => { + BarBaz.set('key', 'value'); + BarBaz.set('otherKey', 'value'); + BarBaz.set('bazoo', Bazoo); + return BarBaz.save(); + }) + .then(() => { + Foobar.set('foo', 'bar'); + Foobar.set('fizz', 'buzz'); + Foobar.set('barBaz', BarBaz); + return Foobar.save(); + }) + .then(function (savedFoobar) { + const foobarQuery = new Parse.Query('Foobar'); + foobarQuery.select(['fizz', 'barBaz.key', 'barBaz.bazoo.some']); + return foobarQuery.get(savedFoobar.id); + }) + .then(foobarObj => { + equal(foobarObj.get('fizz'), 'buzz'); + equal(foobarObj.get('foo'), undefined); + if (foobarObj.has('barBaz')) { + equal(foobarObj.get('barBaz').get('key'), 'value'); + equal(foobarObj.get('barBaz').get('otherKey'), undefined); + if (foobarObj.get('barBaz').has('bazoo')) { + equal(foobarObj.get('barBaz').get('bazoo').get('some'), 'thing'); + equal(foobarObj.get('barBaz').get('bazoo').get('otherSome'), undefined); + } else { + fail('bazoo should be set'); } - done(); + } else { + fail('barBaz should be set'); } + done(); }); - }); }); - it("include doesn't make dirty wrong", function(done) { - var Parent = Parse.Object.extend("ParentObject"); - var Child = Parse.Object.extend("ChildObject"); - var parent = new Parent(); - var child = new Child(); - child.set("foo", "bar"); - parent.set("child", child); + it('properly handles nested ors', function (done) { + const objects = []; + while (objects.length != 4) { + const obj = new Parse.Object('Object'); + obj.set('x', objects.length); + objects.push(obj); + } + Parse.Object.saveAll(objects) + .then(() => { + const q0 = new Parse.Query('Object'); + q0.equalTo('x', 0); + const q1 = new Parse.Query('Object'); + q1.equalTo('x', 1); + const q2 = new Parse.Query('Object'); + q2.equalTo('x', 2); + const or01 = Parse.Query.or(q0, q1); + return Parse.Query.or(or01, q2).find(); + }) + .then(results => { + expect(results.length).toBe(3); + done(); + }) + .catch(error => { + fail('should not fail'); + jfail(error); + done(); + }); + }); - Parse.Object.saveAll([child, parent], function() { - var query = new Parse.Query(Parent); - query.include("child"); - query.find({ - success: function(results) { - equal(results.length, 1); - var parentAgain = results[0]; - var childAgain = parentAgain.get("child"); - equal(childAgain.id, child.id); - equal(parentAgain.id, parent.id); - equal(childAgain.get("foo"), "bar"); - equal(false, parentAgain.dirty()); - equal(false, childAgain.dirty()); + it('should not depend on parameter order #3169', function (done) { + const score1 = new Parse.Object('Score', { scoreId: '1' }); + const score2 = new Parse.Object('Score', { scoreId: '2' }); + const game1 = new Parse.Object('Game', { gameId: '1' }); + const game2 = new Parse.Object('Game', { gameId: '2' }); + Parse.Object.saveAll([score1, score2, game1, game2]) + .then(() => { + game1.set('score', [score1]); + game2.set('score', [score2]); + return Parse.Object.saveAll([game1, game2]); + }) + .then(() => { + const where = { + score: { + objectId: score1.id, + className: 'Score', + __type: 'Pointer', + }, + }; + return request({ + method: 'POST', + url: Parse.serverURL + '/classes/Game', + body: { where, _method: 'GET' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'Content-Type': 'application/json', + }, + }); + }) + .then( + response => { + const results = response.data; + expect(results.results.length).toBe(1); done(); - } - }); + }, + res => done.fail(res.data) + ); + }); + + it('should not interfere with has when using select on field with undefined value #3999', done => { + const obj1 = new Parse.Object('TestObject'); + const obj2 = new Parse.Object('OtherObject'); + obj2.set('otherField', 1); + obj1.set('testPointerField', obj2); + obj1.set('shouldBe', true); + const obj3 = new Parse.Object('TestObject'); + obj3.set('shouldBe', false); + Parse.Object.saveAll([obj1, obj3]) + .then(() => { + const query = new Parse.Query('TestObject'); + query.include('testPointerField'); + query.select(['testPointerField', 'testPointerField.otherField', 'shouldBe']); + return query.find(); + }) + .then(results => { + results.forEach(result => { + equal(result.has('testPointerField'), result.get('shouldBe')); + }); + done(); + }) + .catch(done.fail); + }); + + it('should handle relative times correctly', async () => { + const now = Date.now(); + const obj1 = new Parse.Object('MyCustomObject', { + name: 'obj1', + ttl: new Date(now + 2 * 24 * 60 * 60 * 1000), // 2 days from now + }); + const obj2 = new Parse.Object('MyCustomObject', { + name: 'obj2', + ttl: new Date(now - 2 * 24 * 60 * 60 * 1000), // 2 days ago }); + + await Parse.Object.saveAll([obj1, obj2]); + const q1 = new Parse.Query('MyCustomObject'); + q1.greaterThan('ttl', { $relativeTime: 'in 1 day' }); + const results1 = await q1.find({ useMasterKey: true }); + expect(results1.length).toBe(1); + + const q2 = new Parse.Query('MyCustomObject'); + q2.greaterThan('ttl', { $relativeTime: '1 day ago' }); + const results2 = await q2.find({ useMasterKey: true }); + expect(results2.length).toBe(1); + + const q3 = new Parse.Query('MyCustomObject'); + q3.lessThan('ttl', { $relativeTime: '5 days ago' }); + const results3 = await q3.find({ useMasterKey: true }); + expect(results3.length).toBe(0); + + const q4 = new Parse.Query('MyCustomObject'); + q4.greaterThan('ttl', { $relativeTime: '3 days ago' }); + const results4 = await q4.find({ useMasterKey: true }); + expect(results4.length).toBe(2); + + const q5 = new Parse.Query('MyCustomObject'); + q5.greaterThan('ttl', { $relativeTime: 'now' }); + const results5 = await q5.find({ useMasterKey: true }); + expect(results5.length).toBe(1); + + const q6 = new Parse.Query('MyCustomObject'); + q6.greaterThan('ttl', { $relativeTime: 'now' }); + q6.lessThan('ttl', { $relativeTime: 'in 1 day' }); + const results6 = await q6.find({ useMasterKey: true }); + expect(results6.length).toBe(0); + + const q7 = new Parse.Query('MyCustomObject'); + q7.greaterThan('ttl', { $relativeTime: '1 year 3 weeks ago' }); + const results7 = await q7.find({ useMasterKey: true }); + expect(results7.length).toBe(2); }); - it('properly includes array', (done) => { - let objects = []; - let total = 0; - while(objects.length != 5) { - let object = new Parse.Object('AnObject'); - object.set('key', objects.length); - total += objects.length; - objects.push(object); - } - Parse.Object.saveAll(objects).then(() => { - let object = new Parse.Object("AContainer"); - object.set('objects', objects); - return object.save(); - }).then(() => { - let query = new Parse.Query('AContainer'); - query.include('objects'); - return query.find() - }).then((results) => { - expect(results.length).toBe(1); - let res = results[0]; - let objects = res.get('objects'); - expect(objects.length).toBe(5); - objects.forEach((object) => { - total -= object.get('key'); - }); - expect(total).toBe(0); - done() - }, () => { - fail('should not fail'); - done(); - }) - }); - - it('properly includes array of mixed objects', (done) => { - let objects = []; - let total = 0; - while(objects.length != 5) { - let object = new Parse.Object('AnObject'); - object.set('key', objects.length); - total += objects.length; - objects.push(object); - } - while(objects.length != 10) { - let object = new Parse.Object('AnotherObject'); - object.set('key', objects.length); - total += objects.length; - objects.push(object); - } - Parse.Object.saveAll(objects).then(() => { - let object = new Parse.Object("AContainer"); - object.set('objects', objects); - return object.save(); - }).then(() => { - let query = new Parse.Query('AContainer'); - query.include('objects'); - return query.find() - }).then((results) => { - expect(results.length).toBe(1); - let res = results[0]; - let objects = res.get('objects'); - expect(objects.length).toBe(10); - objects.forEach((object) => { - total -= object.get('key'); - }); - expect(total).toBe(0); - done() - }, (err) => { - fail('should not fail'); - done(); - }) - }); - - it('properly nested array of mixed objects with bad ids', (done) => { - let objects = []; - let total = 0; - while(objects.length != 5) { - let object = new Parse.Object('AnObject'); - object.set('key', objects.length); - objects.push(object); + it('should error on invalid relative time', async () => { + const obj1 = new Parse.Object('MyCustomObject', { + name: 'obj1', + ttl: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), // 2 days from now + }); + await obj1.save({ useMasterKey: true }); + const q = new Parse.Query('MyCustomObject'); + q.greaterThan('ttl', { $relativeTime: '-12 bananas ago' }); + try { + await q.find({ useMasterKey: true }); + fail('Should have thrown error'); + } catch (error) { + expect(error.code).toBe(Parse.Error.INVALID_JSON); } - while(objects.length != 10) { - let object = new Parse.Object('AnotherObject'); - object.set('key', objects.length); - objects.push(object); + }); + + it('should error when using $relativeTime on non-Date field', async () => { + const obj1 = new Parse.Object('MyCustomObject', { + name: 'obj1', + nonDateField: 'abcd', + ttl: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), // 2 days from now + }); + await obj1.save({ useMasterKey: true }); + const q = new Parse.Query('MyCustomObject'); + q.greaterThan('nonDateField', { $relativeTime: '1 day ago' }); + try { + await q.find({ useMasterKey: true }); + fail('Should have thrown error'); + } catch (error) { + expect(error.code).toBe(Parse.Error.INVALID_JSON); } - Parse.Object.saveAll(objects).then(() => { - let object = new Parse.Object("AContainer"); - for (var i=0; i { - let query = new Parse.Query('AContainer'); - query.include('objects'); - return query.find() - }).then((results) => { - expect(results.length).toBe(1); - let res = results[0]; - let objects = res.get('objects'); - expect(objects.length).toBe(5); - objects.forEach((object) => { - total -= object.get('key'); - }); - expect(total).toBe(0); - done() - }, (err) => { - console.error(err); - fail('should not fail'); - done(); - }) - }); - - it_exclude_dbs(['postgres'])('properly fetches nested pointers', (done) =>  { - let color = new Parse.Object('Color'); - color.set('hex','#133733'); - let circle = new Parse.Object('Circle'); - circle.set('radius', 1337); + }); - Parse.Object.saveAll([color, circle]).then(() => { - circle.set('color', color); - let badCircle = new Parse.Object('Circle'); - badCircle.id = 'badId'; - let complexFigure = new Parse.Object('ComplexFigure'); - complexFigure.set('consistsOf', [circle, badCircle]); - return complexFigure.save(); - }).then(() => { - let q = new Parse.Query('ComplexFigure'); - q.include('consistsOf.color'); - return q.find() - }).then((results) => { - expect(results.length).toBe(1); - let figure = results[0]; - expect(figure.get('consistsOf').length).toBe(1); - expect(figure.get('consistsOf')[0].get('color').get('hex')).toBe('#133733'); - done(); - }, (err) => { - fail('should not fail'); - done(); - }) - - }); - - it("result object creation uses current extension", function(done) { - var ParentObject = Parse.Object.extend({ className: "ParentObject" }); - // Add a foo() method to ChildObject. - var ChildObject = Parse.Object.extend("ChildObject", { - foo: function() { - return "foo"; - } + it('should match complex structure with dot notation when using matchesKeyInQuery', function (done) { + const group1 = new Parse.Object('Group', { + name: 'Group #1', }); - var parent = new ParentObject(); - var child = new ChildObject(); - parent.set("child", child); - Parse.Object.saveAll([child, parent], function() { - // Add a bar() method to ChildObject. - ChildObject = Parse.Object.extend("ChildObject", { - bar: function() { - return "bar"; - } - }); + const group2 = new Parse.Object('Group', { + name: 'Group #2', + }); + + Parse.Object.saveAll([group1, group2]) + .then(() => { + const role1 = new Parse.Object('Role', { + name: 'Role #1', + type: 'x', + belongsTo: group1, + }); + + const role2 = new Parse.Object('Role', { + name: 'Role #2', + type: 'y', + belongsTo: group1, + }); + + return Parse.Object.saveAll([role1, role2]); + }) + .then(() => { + const rolesOfTypeX = new Parse.Query('Role'); + rolesOfTypeX.equalTo('type', 'x'); + + const groupsWithRoleX = new Parse.Query('Group'); + groupsWithRoleX.matchesKeyInQuery('objectId', 'belongsTo.objectId', rolesOfTypeX); - var query = new Parse.Query(ParentObject); - query.include("child"); - query.find({ - success: function(results) { + groupsWithRoleX.find().then(function (results) { equal(results.length, 1); - var parentAgain = results[0]; - var childAgain = parentAgain.get("child"); - equal(childAgain.foo(), "foo"); - equal(childAgain.bar(), "bar"); + equal(results[0].get('name'), group1.get('name')); done(); - } + }); }); - }); }); - it_exclude_dbs(['postgres'])("matches query", function(done) { - var ParentObject = Parse.Object.extend("ParentObject"); - var ChildObject = Parse.Object.extend("ChildObject"); - var objects = []; - for (var i = 0; i < 10; ++i) { - objects.push( - new ParentObject({ - child: new ChildObject({x: i}), - x: 10 + i - })); - } - Parse.Object.saveAll(objects, function() { - var subQuery = new Parse.Query(ChildObject); - subQuery.greaterThan("x", 5); - var query = new Parse.Query(ParentObject); - query.matchesQuery("child", subQuery); - query.find({ - success: function(results) { - equal(results.length, 4); - for (var object of results) { - ok(object.get("x") > 15); - } - var query = new Parse.Query(ParentObject); - query.doesNotMatchQuery("child", subQuery); - query.find({ - success: function (results) { - equal(results.length, 6); - for (var object of results) { - ok(object.get("x") >= 10); - ok(object.get("x") <= 15); - done(); - } - } - }); - } - }); + it('should match complex structure with dot notation when using doesNotMatchKeyInQuery', function (done) { + const group1 = new Parse.Object('Group', { + name: 'Group #1', }); - }); - it_exclude_dbs(['postgres'])("select query", function(done) { - var RestaurantObject = Parse.Object.extend("Restaurant"); - var PersonObject = Parse.Object.extend("Person"); - var objects = [ - new RestaurantObject({ ratings: 5, location: "Djibouti" }), - new RestaurantObject({ ratings: 3, location: "Ouagadougou" }), - new PersonObject({ name: "Bob", hometown: "Djibouti" }), - new PersonObject({ name: "Tom", hometown: "Ouagadougou" }), - new PersonObject({ name: "Billy", hometown: "Detroit" }) - ]; + const group2 = new Parse.Object('Group', { + name: 'Group #2', + }); + + Parse.Object.saveAll([group1, group2]) + .then(() => { + const role1 = new Parse.Object('Role', { + name: 'Role #1', + type: 'x', + belongsTo: group1, + }); + + const role2 = new Parse.Object('Role', { + name: 'Role #2', + type: 'y', + belongsTo: group1, + }); + + return Parse.Object.saveAll([role1, role2]); + }) + .then(() => { + const rolesOfTypeX = new Parse.Query('Role'); + rolesOfTypeX.equalTo('type', 'x'); + + const groupsWithRoleX = new Parse.Query('Group'); + groupsWithRoleX.doesNotMatchKeyInQuery('objectId', 'belongsTo.objectId', rolesOfTypeX); - Parse.Object.saveAll(objects, function() { - var query = new Parse.Query(RestaurantObject); - query.greaterThan("ratings", 4); - var mainQuery = new Parse.Query(PersonObject); - mainQuery.matchesKeyInQuery("hometown", "location", query); - mainQuery.find(expectSuccess({ - success: function(results) { + groupsWithRoleX.find().then(function (results) { equal(results.length, 1); - equal(results[0].get('name'), 'Bob'); + equal(results[0].get('name'), group2.get('name')); done(); - } - })); - }); + }); + }); }); - it_exclude_dbs(['postgres'])('$select inside $or', (done) => { - var Restaurant = Parse.Object.extend('Restaurant'); - var Person = Parse.Object.extend('Person'); - var objects = [ - new Restaurant({ ratings: 5, location: "Djibouti" }), - new Restaurant({ ratings: 3, location: "Ouagadougou" }), - new Person({ name: "Bob", hometown: "Djibouti" }), - new Person({ name: "Tom", hometown: "Ouagadougou" }), - new Person({ name: "Billy", hometown: "Detroit" }) - ]; + it('should not throw error with undefined dot notation when using matchesKeyInQuery', async () => { + const group = new Parse.Object('Group', { name: 'Group #1' }); + await group.save(); - Parse.Object.saveAll(objects).then(() => { - var subquery = new Parse.Query(Restaurant); - subquery.greaterThan('ratings', 4); - var query1 = new Parse.Query(Person); - query1.matchesKeyInQuery('hometown', 'location', subquery); - var query2 = new Parse.Query(Person); - query2.equalTo('name', 'Tom'); - var query = Parse.Query.or(query1, query2); - return query.find(); - }).then((results) => { - expect(results.length).toEqual(2); - done(); - }, (error) => { - fail(error); - done(); - }); - }); - - it_exclude_dbs(['postgres'])("dontSelect query", function(done) { - var RestaurantObject = Parse.Object.extend("Restaurant"); - var PersonObject = Parse.Object.extend("Person"); - var objects = [ - new RestaurantObject({ ratings: 5, location: "Djibouti" }), - new RestaurantObject({ ratings: 3, location: "Ouagadougou" }), - new PersonObject({ name: "Bob", hometown: "Djibouti" }), - new PersonObject({ name: "Tom", hometown: "Ouagadougou" }), - new PersonObject({ name: "Billy", hometown: "Djibouti" }) - ]; + const role1 = new Parse.Object('Role', { + name: 'Role #1', + type: 'x', + belongsTo: group, + }); - Parse.Object.saveAll(objects, function() { - var query = new Parse.Query(RestaurantObject); - query.greaterThan("ratings", 4); - var mainQuery = new Parse.Query(PersonObject); - mainQuery.doesNotMatchKeyInQuery("hometown", "location", query); - mainQuery.find(expectSuccess({ - success: function(results) { - equal(results.length, 1); - equal(results[0].get('name'), 'Tom'); - done(); - } - })); + const role2 = new Parse.Object('Role', { + name: 'Role #2', + type: 'y', + belongsTo: undefined, }); + await Parse.Object.saveAll([role1, role2]); + + const rolesOfTypeX = new Parse.Query('Role'); + rolesOfTypeX.equalTo('type', 'x'); + + const groupsWithRoleX = new Parse.Query('Group'); + groupsWithRoleX.matchesKeyInQuery('objectId', 'belongsTo.objectId', rolesOfTypeX); + + const results = await groupsWithRoleX.find(); + equal(results.length, 1); + equal(results[0].get('name'), group.get('name')); }); - it_exclude_dbs(['postgres'])("dontSelect query without conditions", function(done) { - const RestaurantObject = Parse.Object.extend("Restaurant"); - const PersonObject = Parse.Object.extend("Person"); - const objects = [ - new RestaurantObject({ location: "Djibouti" }), - new RestaurantObject({ location: "Ouagadougou" }), - new PersonObject({ name: "Bob", hometown: "Djibouti" }), - new PersonObject({ name: "Tom", hometown: "Yoloblahblahblah" }), - new PersonObject({ name: "Billy", hometown: "Ouagadougou" }) - ]; + it('should not throw error with undefined dot notation when using doesNotMatchKeyInQuery', async () => { + const group1 = new Parse.Object('Group', { name: 'Group #1' }); + const group2 = new Parse.Object('Group', { name: 'Group #2' }); + await Parse.Object.saveAll([group1, group2]); - Parse.Object.saveAll(objects, function() { - const query = new Parse.Query(RestaurantObject); - const mainQuery = new Parse.Query(PersonObject); - mainQuery.doesNotMatchKeyInQuery("hometown", "location", query); - mainQuery.find().then(results => { - equal(results.length, 1); - equal(results[0].get('name'), 'Tom'); - done(); - }); + const role1 = new Parse.Object('Role', { + name: 'Role #1', + type: 'x', + belongsTo: group1, }); + + const role2 = new Parse.Object('Role', { + name: 'Role #2', + type: 'y', + belongsTo: undefined, + }); + await Parse.Object.saveAll([role1, role2]); + + const rolesOfTypeX = new Parse.Query('Role'); + rolesOfTypeX.equalTo('type', 'x'); + + const groupsWithRoleX = new Parse.Query('Group'); + groupsWithRoleX.doesNotMatchKeyInQuery('objectId', 'belongsTo.objectId', rolesOfTypeX); + + const results = await groupsWithRoleX.find(); + equal(results.length, 1); + equal(results[0].get('name'), group2.get('name')); }); - it("object with length", function(done) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.set("length", 5); - equal(obj.get("length"), 5); - obj.save(null, { - success: function(obj) { - var query = new Parse.Query(TestObject); - query.find({ - success: function(results) { - equal(results.length, 1); - equal(results[0].get("length"), 5); - done(); + it_id('8886b994-fbb8-487d-a863-43bbd2b24b73')(it)('withJSON supports geoWithin.centerSphere', done => { + const inbound = new Parse.GeoPoint(1.5, 1.5); + const onbound = new Parse.GeoPoint(10, 10); + const outbound = new Parse.GeoPoint(20, 20); + const obj1 = new Parse.Object('TestObject', { location: inbound }); + const obj2 = new Parse.Object('TestObject', { location: onbound }); + const obj3 = new Parse.Object('TestObject', { location: outbound }); + const center = new Parse.GeoPoint(0, 0); + const distanceInKilometers = 1569 + 1; // 1569km is the approximate distance between {0, 0} and {10, 10}. + Parse.Object.saveAll([obj1, obj2, obj3]) + .then(() => { + const q = new Parse.Query(TestObject); + const jsonQ = q.toJSON(); + jsonQ.where.location = { + $geoWithin: { + $centerSphere: [center, distanceInKilometers / 6371.0], }, - error: function(error) { - ok(false, error.message); - done(); - } - }); - }, - error: function(error) { - ok(false, error.message); + }; + q.withJSON(jsonQ); + return q.find(); + }) + .then(results => { + equal(results.length, 2); + const q = new Parse.Query(TestObject); + const jsonQ = q.toJSON(); + jsonQ.where.location = { + $geoWithin: { + $centerSphere: [[0, 0], distanceInKilometers / 6371.0], + }, + }; + q.withJSON(jsonQ); + return q.find(); + }) + .then(results => { + equal(results.length, 2); done(); - } - }); + }) + .catch(error => { + fail(error); + done(); + }); }); - it("include user", function(done) { - Parse.User.signUp("bob", "password", { age: 21 }, { - success: function(user) { - var TestObject = Parse.Object.extend("TestObject"); - var obj = new TestObject(); - obj.save({ - owner: user - }, { - success: function(obj) { - var query = new Parse.Query(TestObject); - query.include("owner"); - query.get(obj.id, { - success: function(objAgain) { - equal(objAgain.id, obj.id); - ok(objAgain.get("owner") instanceof Parse.User); - equal(objAgain.get("owner").get("age"), 21); - done(); - }, - error: function(objAgain, error) { - ok(false, error.message); - done(); - } - }); - }, - error: function(obj, error) { - ok(false, error.message); - done(); - } - }); + it('withJSON with geoWithin.centerSphere fails without parameters', done => { + const q = new Parse.Query(TestObject); + const jsonQ = q.toJSON(); + jsonQ.where.location = { + $geoWithin: { + $centerSphere: [], }, - error: function(user, error) { - ok(false, error.message); - done(); - } - }); + }; + q.withJSON(jsonQ); + q.find() + .then(done.fail) + .catch(e => expect(e.code).toBe(Parse.Error.INVALID_JSON)) + .then(done); }); - it_exclude_dbs(['postgres'])("or queries", function(done) { - var objects = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(function(x) { - var object = new Parse.Object('BoxedNumber'); - object.set('x', x); - return object; - }); - Parse.Object.saveAll(objects, expectSuccess({ - success: function() { - var query1 = new Parse.Query('BoxedNumber'); - query1.lessThan('x', 2); - var query2 = new Parse.Query('BoxedNumber'); - query2.greaterThan('x', 5); - var orQuery = Parse.Query.or(query1, query2); - orQuery.find(expectSuccess({ - success: function(results) { - equal(results.length, 6); - for (var number of results) { - ok(number.get('x') < 2 || number.get('x') > 5); - } - done(); - } - })); - } - })); + it('withJSON with geoWithin.centerSphere fails with invalid distance', done => { + const q = new Parse.Query(TestObject); + const jsonQ = q.toJSON(); + jsonQ.where.location = { + $geoWithin: { + $centerSphere: [[0, 0], 'invalid_distance'], + }, + }; + q.withJSON(jsonQ); + q.find() + .then(done.fail) + .catch(e => expect(e.code).toBe(Parse.Error.INVALID_JSON)) + .then(done); }); - // This relies on matchesQuery aka the $inQuery operator - it_exclude_dbs(['postgres'])("or complex queries", function(done) { - var objects = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(function(x) { - var child = new Parse.Object('Child'); - child.set('x', x); - var parent = new Parse.Object('Parent'); - parent.set('child', child); - parent.set('y', x); - return parent; - }); + it('withJSON with geoWithin.centerSphere fails with invalid coordinate', done => { + const q = new Parse.Query(TestObject); + const jsonQ = q.toJSON(); + jsonQ.where.location = { + $geoWithin: { + $centerSphere: [[-190, -190], 1], + }, + }; + q.withJSON(jsonQ); + q.find() + .then(done.fail) + .catch(() => done()); + }); - Parse.Object.saveAll(objects, expectSuccess({ - success: function() { - var subQuery = new Parse.Query('Child'); - subQuery.equalTo('x', 4); - var query1 = new Parse.Query('Parent'); - query1.matchesQuery('child', subQuery); - var query2 = new Parse.Query('Parent'); - query2.lessThan('y', 2); - var orQuery = Parse.Query.or(query1, query2); - orQuery.find(expectSuccess({ - success: function(results) { - equal(results.length, 3); - done(); - } - })); - } - })); + it('withJSON with geoWithin.centerSphere fails with invalid geo point', done => { + const q = new Parse.Query(TestObject); + const jsonQ = q.toJSON(); + jsonQ.where.location = { + $geoWithin: { + $centerSphere: [{ longitude: 0, dummytude: 0 }, 1], + }, + }; + q.withJSON(jsonQ); + q.find() + .then(done.fail) + .catch(() => done()); }); - it_exclude_dbs(['postgres'])("async methods", function(done) { - var saves = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(function(x) { - var obj = new Parse.Object("TestObject"); - obj.set("x", x + 1); - return obj.save(); + it_id('02d4e7e6-859a-4ab6-878d-135ccc77040e')(it)('can add new config to existing config', async () => { + await request({ + method: 'PUT', + url: 'http://localhost:8378/1/config', + json: true, + body: { + params: { + files: [{ __type: 'File', name: 'name', url: 'http://url' }], + }, + }, + headers: masterKeyHeaders, }); - Parse.Promise.when(saves).then(function() { - var query = new Parse.Query("TestObject"); - query.ascending("x"); - return query.first(); - - }).then(function(obj) { - equal(obj.get("x"), 1); - var query = new Parse.Query("TestObject"); - query.descending("x"); - return query.find(); + await request({ + method: 'PUT', + url: 'http://localhost:8378/1/config', + json: true, + body: { + params: { newConfig: 'good' }, + }, + headers: masterKeyHeaders, + }); - }).then(function(results) { - equal(results.length, 10); - var query = new Parse.Query("TestObject"); - return query.get(results[0].id); + const result = await Parse.Config.get(); + equal(result.get('files')[0].toJSON(), { + __type: 'File', + name: 'name', + url: 'http://url', + }); + equal(result.get('newConfig'), 'good'); + }); - }).then(function(obj1) { - equal(obj1.get("x"), 10); - var query = new Parse.Query("TestObject"); - return query.count(); + it('can set object type key', async () => { + const data = { bar: true, baz: 100 }; + const object = new TestObject(); + object.set('objectField', data); + await object.save(); - }).then(function(count) { - equal(count, 10); + const query = new Parse.Query(TestObject); + let result = await query.get(object.id); + equal(result.get('objectField'), data); - }).then(function() { - done(); + object.set('objectField.baz', 50, { ignoreValidation: true }); + await object.save(); - }); + result = await query.get(object.id); + equal(result.get('objectField'), { bar: true, baz: 50 }); }); - it_exclude_dbs(['postgres'])("query.each", function(done) { - var TOTAL = 50; - var COUNT = 25; + it('can update numeric array', async () => { + const data1 = [0, 1.1, 1, -2, 3]; + const data2 = [0, 1.1, 1, -2, 3, 4]; + const obj1 = new TestObject(); + obj1.set('array', data1); + await obj1.save(); + equal(obj1.get('array'), data1); - var items = range(TOTAL).map(function(x) { - var obj = new TestObject(); - obj.set("x", x); - return obj; - }); + const query = new Parse.Query(TestObject); + query.equalTo('objectId', obj1.id); - Parse.Object.saveAll(items).then(function() { - var query = new Parse.Query(TestObject); - query.lessThan("x", COUNT); + const result = await query.first(); + equal(result.get('array'), data1); - var seen = []; - query.each(function(obj) { - seen[obj.get("x")] = (seen[obj.get("x")] || 0) + 1; + result.set('array', data2); + equal(result.get('array'), data2); + await result.save(); + equal(result.get('array'), data2); - }, { - batchSize: 10, - success: function() { - equal(seen.length, COUNT); - for (var i = 0; i < COUNT; i++) { - equal(seen[i], 1, "Should have seen object number " + i); - }; - done(); - }, - error: function(error) { - ok(false, error); - done(); - } - }); - }); + const results = await query.find(); + equal(results[0].get('array'), data2); }); - it_exclude_dbs(['postgres'])("query.each async", function(done) { - var TOTAL = 50; - var COUNT = 25; + it('can update mixed array', async () => { + const data1 = [0, 1.1, 'hello world', { foo: 'bar' }]; + const data2 = [0, 1, { foo: 'bar' }, [], [1, 2, 'bar']]; + const obj1 = new TestObject(); + obj1.set('array', data1); + await obj1.save(); + equal(obj1.get('array'), data1); - expect(COUNT + 1); + const query = new Parse.Query(TestObject); + query.equalTo('objectId', obj1.id); - var items = range(TOTAL).map(function(x) { - var obj = new TestObject(); - obj.set("x", x); - return obj; - }); + const result = await query.first(); + equal(result.get('array'), data1); - var seen = []; + result.set('array', data2); + equal(result.get('array'), data2); - Parse.Object.saveAll(items).then(function() { - var query = new Parse.Query(TestObject); - query.lessThan("x", COUNT); - return query.each(function(obj) { - var promise = new Parse.Promise(); - process.nextTick(function() { - seen[obj.get("x")] = (seen[obj.get("x")] || 0) + 1; - promise.resolve(); - }); - return promise; - }, { - batchSize: 10 - }); + await result.save(); + equal(result.get('array'), data2); - }).then(function() { - equal(seen.length, COUNT); - for (var i = 0; i < COUNT; i++) { - equal(seen[i], 1, "Should have seen object number " + i); - }; - done(); - }); + const results = await query.find(); + equal(results[0].get('array'), data2); }); - it("query.each fails with order", function(done) { - var TOTAL = 50; - var COUNT = 25; + it('can query regex with unicode', async () => { + const object = new TestObject(); + object.set('field', 'autoÃļo'); + await object.save(); - var items = range(TOTAL).map(function(x) { - var obj = new TestObject(); - obj.set("x", x); - return obj; - }); + const query = new Parse.Query(TestObject); + query.contains('field', 'autoÃļo'); + const results = await query.find(); - var seen = []; + expect(results.length).toBe(1); + expect(results[0].get('field')).toBe('autoÃļo'); + }); - Parse.Object.saveAll(items).then(function() { - var query = new Parse.Query(TestObject); - query.lessThan("x", COUNT); - query.ascending("x"); - return query.each(function(obj) { - seen[obj.get("x")] = (seen[obj.get("x")] || 0) + 1; - }); + it('can update mixed array more than 100 elements', async () => { + const array = [0, 1.1, 'hello world', { foo: 'bar' }, null]; + const obj = new TestObject({ array }); + await obj.save(); - }).then(function() { - ok(false, "This should have failed."); - done(); - }, function(error) { - done(); - }); + const query = new Parse.Query(TestObject); + const result = await query.get(obj.id); + equal(result.get('array').length, 5); + + for (let i = 0; i < 100; i += 1) { + array.push(i); + } + obj.set('array', array); + await obj.save(); + + const results = await query.find(); + equal(results[0].get('array').length, 105); }); - it("query.each fails with skip", function(done) { - var TOTAL = 50; - var COUNT = 25; + xit('todo: exclude keys with select key (sdk query get)', async done => { + // there is some problem with js sdk caching - var items = range(TOTAL).map(function(x) { - var obj = new TestObject(); - obj.set("x", x); - return obj; + const obj = new TestObject({ foo: 'baz', hello: 'world' }); + await obj.save(); + + const query = new Parse.Query('TestObject'); + + query.withJSON({ + keys: 'hello', + excludeKeys: 'hello', }); - var seen = []; + const object = await query.get(obj.id); + expect(object.get('foo')).toBeUndefined(); + expect(object.get('hello')).toBeUndefined(); + done(); + }); - Parse.Object.saveAll(items).then(function() { - var query = new Parse.Query(TestObject); - query.lessThan("x", COUNT); - query.skip(5); - return query.each(function(obj) { - seen[obj.get("x")] = (seen[obj.get("x")] || 0) + 1; - }); + it_only_db('mongo')('can use explain on User class', async () => { + // Create user + const user = new Parse.User(); + user.set('username', 'foo'); + user.set('password', 'bar'); + await user.save(); + // Query for user with explain + const query = new Parse.Query('_User'); + query.equalTo('objectId', user.id); + query.explain(); + const result = await query.find({ useMasterKey: true }); + // Validate + expect(result.executionStats).not.toBeUndefined(); + }); - }).then(function() { - ok(false, "This should have failed."); - done(); - }, function(error) { - done(); + it('should query with distinct within eachBatch and direct access enabled', async () => { + await reconfigureServer({ + directAccess: true, }); + + Parse.CoreManager.setRESTController( + ParseServerRESTController(Parse.applicationId, ParseServer.promiseRouter({ appId: Parse.applicationId })) + ); + + const user = new Parse.User(); + user.set('username', 'foo'); + user.set('password', 'bar'); + await user.save(); + + const score = new Parse.Object('Score'); + score.set('player', user); + score.set('score', 1); + await score.save(); + + await new Parse.Query('_User') + .equalTo('objectId', user.id) + .eachBatch(async ([user]) => { + const score = await new Parse.Query('Score') + .equalTo('player', user) + .distinct('score', { useMasterKey: true }); + expect(score).toEqual([1]); + }, { useMasterKey: true }); }); - it("query.each fails with limit", function(done) { - var TOTAL = 50; - var COUNT = 25; + describe_only_db('mongo')('query nested keys', () => { + it('queries nested key using equalTo', async () => { + const child = new Parse.Object('Child'); + child.set('key', 'value'); + await child.save(); + + const parent = new Parse.Object('Parent'); + parent.set('some', { + nested: { + key: { + child, + }, + }, + }); + await parent.save(); - expect(0); + const query1 = await new Parse.Query('Parent') + .equalTo('some.nested.key.child', child) + .find(); - var items = range(TOTAL).map(function(x) { - var obj = new TestObject(); - obj.set("x", x); - return obj; + expect(query1.length).toEqual(1); }); - var seen = []; + it('queries nested key using containedIn', async () => { + const child = new Parse.Object('Child'); + child.set('key', 'value'); + await child.save(); - Parse.Object.saveAll(items).then(function() { - var query = new Parse.Query(TestObject); - query.lessThan("x", COUNT); - query.limit(5); - return query.each(function(obj) { - seen[obj.get("x")] = (seen[obj.get("x")] || 0) + 1; + const parent = new Parse.Object('Parent'); + parent.set('some', { + nested: { + key: { + child, + }, + }, }); + await parent.save(); - }).then(function() { - ok(false, "This should have failed."); - done(); - }, function(error) { - done(); + const query1 = await new Parse.Query('Parent') + .containedIn('some.nested.key.child', [child]) + .find(); + + expect(query1.length).toEqual(1); }); - }); - it("select keys query", function(done) { - var obj = new TestObject({ foo: 'baz', bar: 1 }); + it('queries nested key using matchesQuery', async () => { + const child = new Parse.Object('Child'); + child.set('key', 'value'); + await child.save(); - obj.save().then(function () { - obj._clearServerData(); - var query = new Parse.Query(TestObject); - query.select('foo'); - return query.first(); - }).then(function(result) { - ok(result.id, "expected object id to be set"); - ok(result.createdAt, "expected object createdAt to be set"); - ok(result.updatedAt, "expected object updatedAt to be set"); - ok(!result.dirty(), "expected result not to be dirty"); - strictEqual(result.get('foo'), 'baz'); - strictEqual(result.get('bar'), undefined, - "expected 'bar' field to be unset"); - return result.fetch(); - }).then(function(result) { - strictEqual(result.get('foo'), 'baz'); - strictEqual(result.get('bar'), 1); - }).then(function() { - obj._clearServerData(); - var query = new Parse.Query(TestObject); - query.select([]); - return query.first(); - }).then(function(result) { - ok(result.id, "expected object id to be set"); - ok(!result.dirty(), "expected result not to be dirty"); - strictEqual(result.get('foo'), undefined, - "expected 'foo' field to be unset"); - strictEqual(result.get('bar'), undefined, - "expected 'bar' field to be unset"); - }).then(function() { - obj._clearServerData(); - var query = new Parse.Query(TestObject); - query.select(['foo','bar']); - return query.first(); - }).then(function(result) { - ok(result.id, "expected object id to be set"); - ok(!result.dirty(), "expected result not to be dirty"); - strictEqual(result.get('foo'), 'baz'); - strictEqual(result.get('bar'), 1); - }).then(function() { - obj._clearServerData(); - var query = new Parse.Query(TestObject); - query.select('foo', 'bar'); - return query.first(); - }).then(function(result) { - ok(result.id, "expected object id to be set"); - ok(!result.dirty(), "expected result not to be dirty"); - strictEqual(result.get('foo'), 'baz'); - strictEqual(result.get('bar'), 1); - }).then(function() { - done(); - }, function (err) { - ok(false, "other error: " + JSON.stringify(err)); - done(); - }); - }); - - it('select keys with each query', function(done) { - var obj = new TestObject({ foo: 'baz', bar: 1 }); - - obj.save().then(function() { - obj._clearServerData(); - var query = new Parse.Query(TestObject); - query.select('foo'); - query.each(function(result) { - ok(result.id, 'expected object id to be set'); - ok(result.createdAt, 'expected object createdAt to be set'); - ok(result.updatedAt, 'expected object updatedAt to be set'); - ok(!result.dirty(), 'expected result not to be dirty'); - strictEqual(result.get('foo'), 'baz'); - strictEqual(result.get('bar'), undefined, - 'expected "bar" field to be unset'); - }).then(function() { - done(); - }, function(err) { - ok(false, JSON.stringify(err)); - done(); + const parent = new Parse.Object('Parent'); + parent.set('some', { + nested: { + key: { + child, + }, + }, }); + await parent.save(); + + const query1 = await new Parse.Query('Parent') + .matchesQuery('some.nested.key.child', new Parse.Query('Child').equalTo('key', 'value')) + .find(); + + expect(query1.length).toEqual(1); }); }); - it_exclude_dbs(['postgres'])('notEqual with array of pointers', (done) => { - var children = []; - var parents = []; - var promises = []; - for (var i = 0; i < 2; i++) { - var proc = (iter) => { - var child = new Parse.Object('Child'); - children.push(child); - var parent = new Parse.Object('Parent'); - parents.push(parent); - promises.push( - child.save().then(() => { - parents[iter].set('child', [children[iter]]); - return parents[iter].save(); - }) + describe('allowPublicExplain', () => { + it_id('a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d')(it_only_db('mongo'))( + 'explain works with and without master key when allowPublicExplain is true', + async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI: 'mongodb://localhost:27017/parse', + databaseOptions: { + allowPublicExplain: true, + }, + }); + + const obj = new TestObject({ foo: 'bar' }); + await obj.save(); + + // Without master key + const query = new Parse.Query(TestObject); + query.explain(); + const resultWithoutMasterKey = await query.find(); + expect(resultWithoutMasterKey).toBeDefined(); + + // With master key + const queryWithMasterKey = new Parse.Query(TestObject); + queryWithMasterKey.explain(); + const resultWithMasterKey = await queryWithMasterKey.find({ useMasterKey: true }); + expect(resultWithMasterKey).toBeDefined(); + } + ); + + it_id('b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e')(it_only_db('mongo'))( + 'explain requires master key when allowPublicExplain is false', + async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI: 'mongodb://localhost:27017/parse', + databaseOptions: { + allowPublicExplain: false, + }, + }); + + const obj = new TestObject({ foo: 'bar' }); + await obj.save(); + + // Without master key + const query = new Parse.Query(TestObject); + query.explain(); + await expectAsync(query.find()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_QUERY, + 'Using the explain query parameter requires the master key' + ) ); - }; - proc(i); - } - Promise.all(promises).then(() => { - var query = new Parse.Query('Parent'); - query.notEqualTo('child', children[0]); - return query.find(); - }).then((results) => { - expect(results.length).toEqual(1); - expect(results[0].id).toEqual(parents[1].id); - done(); - }).catch((error) => { console.log(error); }); - }); - - it_exclude_dbs(['postgres'])('querying for null value', (done) => { - var obj = new Parse.Object('TestObject'); - obj.set('aNull', null); - obj.save().then(() => { - var query = new Parse.Query('TestObject'); - query.equalTo('aNull', null); - return query.find(); - }).then((results) => { - expect(results.length).toEqual(1); - expect(results[0].get('aNull')).toEqual(null); - done(); - }) - }); - - it_exclude_dbs(['postgres'])('query within dictionary', (done) => { - var objs = []; - var promises = []; - for (var i = 0; i < 2; i++) { - var proc = (iter) => { - var obj = new Parse.Object('TestObject'); - obj.set('aDict', { x: iter + 1, y: iter + 2 }); - promises.push(obj.save()); - }; - proc(i); - } - Promise.all(promises).then(() => { - var query = new Parse.Query('TestObject'); - query.equalTo('aDict.x', 1); - return query.find(); - }).then((results) => { - expect(results.length).toEqual(1); - done(); - }, (error) => { - console.log(error); - }); - }); - it_exclude_dbs(['postgres'])('supports include on the wrong key type (#2262)', function(done) { - let childObject = new Parse.Object('TestChildObject'); - childObject.set('hello', 'world'); - childObject.save().then(() => { - let obj = new Parse.Object('TestObject'); - obj.set('foo', 'bar'); - obj.set('child', childObject); - return obj.save(); - }).then(() => { - let q = new Parse.Query('TestObject'); - q.include('child'); - q.include('child.parent'); - q.include('createdAt'); - q.include('createdAt.createdAt'); - return q.find(); - }).then((objs) => { - expect(objs.length).toBe(1); - expect(objs[0].get('child').get('hello')).toEqual('world'); - expect(objs[0].createdAt instanceof Date).toBe(true); - done(); - }, (err) => { - fail('should not fail'); - done(); - }); - }); - - it_exclude_dbs(['postgres'])('query match on array with single object', (done) => { - var target = {__type: 'Pointer', className: 'TestObject', objectId: 'abc123'}; - var obj = new Parse.Object('TestObject'); - obj.set('someObjs', [target]); - obj.save().then(() => { - var query = new Parse.Query('TestObject'); - query.equalTo('someObjs', target); - return query.find(); - }).then((results) => { - expect(results.length).toEqual(1); - done(); - }, (error) => { - console.log(error); - }); - }); - - it_exclude_dbs(['postgres'])('query match on array with multiple objects', (done) => { - var target1 = {__type: 'Pointer', className: 'TestObject', objectId: 'abc'}; - var target2 = {__type: 'Pointer', className: 'TestObject', objectId: '123'}; - var obj= new Parse.Object('TestObject'); - obj.set('someObjs', [target1, target2]); - obj.save().then(() => { - var query = new Parse.Query('TestObject'); - query.equalTo('someObjs', target1); - return query.find(); - }).then((results) => { - expect(results.length).toEqual(1); - done(); - }, (error) => { - console.log(error); - }); - }); + // With master key + const queryWithMasterKey = new Parse.Query(TestObject); + queryWithMasterKey.explain(); + const result = await queryWithMasterKey.find({ useMasterKey: true }); + expect(result).toBeDefined(); + } + ); + + it_id('c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f')(it_only_db('mongo'))( + 'explain requires master key by default', + async () => { + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI: 'mongodb://localhost:27017/parse', + databaseOptions: { + allowPublicExplain: undefined, + }, + }); - // #371 - it_exclude_dbs(['postgres'])('should properly interpret a query v1', (done) => { - var query = new Parse.Query("C1"); - var auxQuery = new Parse.Query("C1"); - query.matchesKeyInQuery("A1", "A2", auxQuery); - query.include("A3"); - query.include("A2"); - query.find().then((result) => { - done(); - }, (err) => { - console.error(err); - fail("should not failt"); - done(); - }) - }); - - it_exclude_dbs(['postgres'])('should properly interpret a query v2', (done) => { - var user = new Parse.User(); - user.set("username", "foo"); - user.set("password", "bar"); - return user.save().then( (user) => { - var objIdQuery = new Parse.Query("_User").equalTo("objectId", user.id); - var blockedUserQuery = user.relation("blockedUsers").query(); - - var aResponseQuery = new Parse.Query("MatchRelationshipActivityResponse"); - aResponseQuery.equalTo("userA", user); - aResponseQuery.equalTo("userAResponse", 1); - - var bResponseQuery = new Parse.Query("MatchRelationshipActivityResponse"); - bResponseQuery.equalTo("userB", user); - bResponseQuery.equalTo("userBResponse", 1); - - var matchOr = Parse.Query.or(aResponseQuery, bResponseQuery); - var matchRelationshipA = new Parse.Query("_User"); - matchRelationshipA.matchesKeyInQuery("objectId", "userAObjectId", matchOr); - var matchRelationshipB = new Parse.Query("_User"); - matchRelationshipB.matchesKeyInQuery("objectId", "userBObjectId", matchOr); - - - var orQuery = Parse.Query.or(objIdQuery, blockedUserQuery, matchRelationshipA, matchRelationshipB); - var query = new Parse.Query("_User"); - query.doesNotMatchQuery("objectId", orQuery); - return query.find(); - }).then((res) => { - done(); - done(); - }, (err) => { - console.error(err); - fail("should not fail"); - done(); - }); - }); - - it_exclude_dbs(['postgres'])('should find objects with array of pointers', (done) => { - var objects = []; - while(objects.length != 5) { - var object = new Parse.Object('ContainedObject'); - object.set('index', objects.length); - objects.push(object); - } + const obj = new TestObject({ foo: 'bar' }); + await obj.save(); + + // Without master key - should fail + const query = new Parse.Query(TestObject); + query.explain(); + await expectAsync(query.find()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_QUERY, + 'Using the explain query parameter requires the master key' + ) + ); - Parse.Object.saveAll(objects).then((objects) => { - var container = new Parse.Object('Container'); - var pointers = objects.map((obj) => { - return { - __type: 'Pointer', - className: 'ContainedObject', - objectId: obj.id - } - }) - container.set('objects', pointers); - let container2 = new Parse.Object('Container'); - container2.set('objects', pointers.slice(2, 3)); - return Parse.Object.saveAll([container, container2]); - }).then(() => { - let inQuery = new Parse.Query('ContainedObject'); - inQuery.greaterThanOrEqualTo('index', 1); - let query = new Parse.Query('Container'); - query.matchesQuery('objects', inQuery); - return query.find(); - }).then((results) => { - if (results) { - expect(results.length).toBe(2); + // With master key - should succeed + const queryWithMasterKey = new Parse.Query(TestObject); + queryWithMasterKey.explain(); + const resultWithMasterKey = await queryWithMasterKey.find({ useMasterKey: true }); + expect(resultWithMasterKey).toBeDefined(); } - done(); - }).fail((err) => { - console.error(err); - fail('should not fail'); - done(); - }) - }) - - it_exclude_dbs(['postgres'])('query with two OR subqueries (regression test #1259)', done => { - let relatedObject = new Parse.Object('Class2'); - relatedObject.save().then(relatedObject => { - let anObject = new Parse.Object('Class1'); - let relation = anObject.relation('relation'); - relation.add(relatedObject); - return anObject.save(); - }).then(anObject => { - let q1 = anObject.relation('relation').query(); - q1.doesNotExist('nonExistantKey1'); - let q2 = anObject.relation('relation').query(); - q2.doesNotExist('nonExistantKey2'); - let orQuery = Parse.Query.or(q1, q2).find().then(results => { - expect(results.length).toEqual(1); - expect(results[0].objectId).toEqual(q1.objectId); - done(); - }); - }); + ); }); - it('objectId containedIn with multiple large array', done => { - let obj = new Parse.Object('MyClass'); - obj.save().then(obj => { - let longListOfStrings = []; - for (let i = 0; i < 130; i++) { - longListOfStrings.push(i.toString()); - } - longListOfStrings.push(obj.id); - let q = new Parse.Query('MyClass'); - q.containedIn('objectId', longListOfStrings); - q.containedIn('objectId', longListOfStrings); - return q.find(); - }).then(results => { - expect(results.length).toEqual(1); - done(); + describe('query input type validation', () => { + const restHeaders = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + describe('malformed where parameter', () => { + it('rejects invalid JSON in where parameter with proper error instead of 500', async () => { + await expectAsync( + request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/TestClass?where=%7Bbad-json', + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('rejects invalid JSON in where parameter for roles endpoint', async () => { + await expectAsync( + request({ + method: 'GET', + url: 'http://localhost:8378/1/roles?where=%7Bbad-json', + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('rejects invalid JSON in where parameter for users endpoint', async () => { + await expectAsync( + request({ + method: 'GET', + url: 'http://localhost:8378/1/users?where=%7Bbad-json', + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('rejects invalid JSON in where parameter for sessions endpoint', async () => { + await expectAsync( + request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions?where=%7Bbad-json', + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('still accepts valid JSON in where parameter', async () => { + const result = await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(JSON.stringify({}))}`, + headers: restHeaders, + }); + expect(result.data.results).toEqual([]); + }); }); - }); - it('include for specific object', function(done){ - var child = new Parse.Object('Child'); - var parent = new Parse.Object('Parent'); - child.set('foo', 'bar'); - parent.set('child', child); - Parse.Object.saveAll([child, parent], function(response){ - var savedParent = response[1]; - var parentQuery = new Parse.Query('Parent'); - parentQuery.include('child'); - parentQuery.get(savedParent.id, { - success: function(parentObj) { - var childPointer = parentObj.get('child'); - ok(childPointer); - equal(childPointer.get('foo'), 'bar'); - done(); - } + describe('$regex type validation', () => { + it('rejects object $regex in query', async () => { + const where = JSON.stringify({ field: { $regex: { a: 1 } } }); + await expectAsync( + request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('rejects numeric $regex in query', async () => { + const where = JSON.stringify({ field: { $regex: 123 } }); + await expectAsync( + request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('rejects array $regex in query', async () => { + const where = JSON.stringify({ field: { $regex: ['test'] } }); + await expectAsync( + request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('still accepts valid string $regex in query', async () => { + const obj = new Parse.Object('TestClass'); + obj.set('field', 'hello'); + await obj.save(); + const where = JSON.stringify({ field: { $regex: '^hello$' } }); + const result = await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }); + expect(result.data.results.length).toBe(1); }); }); - }); - it('select keys for specific object', function(done){ - var Foobar = new Parse.Object('Foobar'); - Foobar.set('foo', 'bar'); - Foobar.set('fizz', 'buzz'); - Foobar.save({ - success: function(savedFoobar){ - var foobarQuery = new Parse.Query('Foobar'); - foobarQuery.select('fizz'); - foobarQuery.get(savedFoobar.id,{ - success: function(foobarObj){ - equal(foobarObj.get('fizz'), 'buzz'); - equal(foobarObj.get('foo'), undefined); - done(); - } + describe('$options type validation', () => { + it('rejects numeric $options in query', async () => { + const where = JSON.stringify({ field: { $regex: 'test', $options: 123 } }); + await expectAsync( + request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('rejects object $options in query', async () => { + const where = JSON.stringify({ field: { $regex: 'test', $options: { a: 1 } } }); + await expectAsync( + request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('rejects boolean $options in query', async () => { + const where = JSON.stringify({ field: { $regex: 'test', $options: true } }); + await expectAsync( + request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('still accepts valid string $options in query', async () => { + const obj = new Parse.Object('TestClass'); + obj.set('field', 'hello'); + await obj.save(); + const where = JSON.stringify({ field: { $regex: 'hello', $options: 'i' } }); + const result = await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, }); - } - }) - }); + expect(result.data.results.length).toBe(1); + }); + }); - it_exclude_dbs(['postgres'])('properly handles nested ors', function(done) { - var objects = []; - while(objects.length != 4) { - var obj = new Parse.Object('Object'); - obj.set('x', objects.length); - objects.push(obj) - } - Parse.Object.saveAll(objects).then(() => { - let q0 = new Parse.Query('Object'); - q0.equalTo('x', 0); - let q1 = new Parse.Query('Object'); - q1.equalTo('x', 1); - let q2 = new Parse.Query('Object'); - q2.equalTo('x', 2); - let or01 = Parse.Query.or(q0,q1); - return Parse.Query.or(or01, q2).find(); - }).then((results) => { - expect(results.length).toBe(3); - done(); - }).catch((error) => { - fail('should not fail'); - console.error(error); - done(); - }) + describe('$in type validation on text fields', () => { + it_only_db('postgres')('rejects non-matching type in $in for text field with proper error instead of 500', async () => { + const obj = new Parse.Object('TestClass'); + obj.set('textField', 'hello'); + await obj.save(); + const where = JSON.stringify({ textField: { $in: [1, 2, 3] } }); + await expectAsync( + request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('still accepts matching type in $in for text field', async () => { + const obj = new Parse.Object('TestClass'); + obj.set('textField', 'hello'); + await obj.save(); + const where = JSON.stringify({ textField: { $in: ['hello', 'world'] } }); + const result = await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }); + expect(result.data.results.length).toBe(1); + }); + }); }); }); diff --git a/spec/ParseRelation.spec.js b/spec/ParseRelation.spec.js index e8df9ae3ac..98b4938433 100644 --- a/spec/ParseRelation.spec.js +++ b/spec/ParseRelation.spec.js @@ -2,677 +2,748 @@ // This is a port of the test suite: // hungry/js/test/parse_relation_test.js -var ChildObject = Parse.Object.extend({className: "ChildObject"}); -var ParentObject = Parse.Object.extend({className: "ParentObject"}); +const ChildObject = Parse.Object.extend({ className: 'ChildObject' }); +const ParentObject = Parse.Object.extend({ className: 'ParentObject' }); describe('Parse.Relation testing', () => { - it_exclude_dbs(['postgres'])("simple add and remove relation", (done) => { - var child = new ChildObject(); - child.set("x", 2); - var parent = new ParentObject(); - parent.set("x", 4); - var relation = parent.relation("child"); - - child.save().then(() => { - relation.add(child); - return parent.save(); - }, (e) => { - fail(e); - }).then(() => { - return relation.query().find(); - }).then((list) => { - equal(list.length, 1, - "Should have gotten one element back"); - equal(list[0].id, child.id, - "Should have gotten the right value"); - ok(!parent.dirty("child"), - "The relation should not be dirty"); - - relation.remove(child); - return parent.save(); - }).then(() => { - return relation.query().find(); - }).then((list) => { - equal(list.length, 0, - "Delete should have worked"); - ok(!parent.dirty("child"), - "The relation should not be dirty"); - done(); - }); - }); + it('simple add and remove relation', done => { + const child = new ChildObject(); + child.set('x', 2); + const parent = new ParentObject(); + parent.set('x', 4); + const relation = parent.relation('child'); + + child + .save() + .then( + () => { + relation.add(child); + return parent.save(); + }, + e => { + fail(e); + } + ) + .then(() => { + return relation.query().find(); + }) + .then(list => { + equal(list.length, 1, 'Should have gotten one element back'); + equal(list[0].id, child.id, 'Should have gotten the right value'); + ok(!parent.dirty('child'), 'The relation should not be dirty'); - it_exclude_dbs(['postgres'])("query relation without schema", (done) => { - var ChildObject = Parse.Object.extend("ChildObject"); - var childObjects = []; - for (var i = 0; i < 10; i++) { - childObjects.push(new ChildObject({x:i})); - }; - - Parse.Object.saveAll(childObjects, expectSuccess({ - success: function(list) { - var ParentObject = Parse.Object.extend("ParentObject"); - var parent = new ParentObject(); - parent.set("x", 4); - var relation = parent.relation("child"); - relation.add(childObjects[0]); - parent.save(null, expectSuccess({ - success: function() { - var parentAgain = new ParentObject(); - parentAgain.id = parent.id; - var relation = parentAgain.relation("child"); - relation.query().find(expectSuccess({ - success: function(list) { - equal(list.length, 1, - "Should have gotten one element back"); - equal(list[0].id, childObjects[0].id, - "Should have gotten the right value"); - done(); - } - })); - } - })); - } - })); + relation.remove(child); + return parent.save(); + }) + .then(() => { + return relation.query().find(); + }) + .then(list => { + equal(list.length, 0, 'Delete should have worked'); + ok(!parent.dirty('child'), 'The relation should not be dirty'); + done(); + }); }); - it_exclude_dbs(['postgres'])("relations are constructed right from query", (done) => { - - var ChildObject = Parse.Object.extend("ChildObject"); - var childObjects = []; - for (var i = 0; i < 10; i++) { - childObjects.push(new ChildObject({x: i})); + it('query relation without schema', async () => { + const ChildObject = Parse.Object.extend('ChildObject'); + const childObjects = []; + for (let i = 0; i < 10; i++) { + childObjects.push(new ChildObject({ x: i })); } - Parse.Object.saveAll(childObjects, { - success: function(list) { - var ParentObject = Parse.Object.extend("ParentObject"); - var parent = new ParentObject(); - parent.set("x", 4); - var relation = parent.relation("child"); - relation.add(childObjects[0]); - parent.save(null, { - success: function() { - var query = new Parse.Query(ParentObject); - query.get(parent.id, { - success: function(object) { - var relationAgain = object.relation("child"); - relationAgain.query().find({ - success: function(list) { - equal(list.length, 1, - "Should have gotten one element back"); - equal(list[0].id, childObjects[0].id, - "Should have gotten the right value"); - ok(!parent.dirty("child"), - "The relation should not be dirty"); - done(); - }, - error: function(list) { - ok(false, "This shouldn't have failed"); - done(); - } - }); + await Parse.Object.saveAll(childObjects); + const ParentObject = Parse.Object.extend('ParentObject'); + const parent = new ParentObject(); + parent.set('x', 4); + let relation = parent.relation('child'); + relation.add(childObjects[0]); + await parent.save(); + const parentAgain = new ParentObject(); + parentAgain.id = parent.id; + relation = parentAgain.relation('child'); + const list = await relation.query().find(); + equal(list.length, 1, 'Should have gotten one element back'); + equal(list[0].id, childObjects[0].id, 'Should have gotten the right value'); + }); - } - }); - } - }); - } - }); + it('relations are constructed right from query', async () => { + const ChildObject = Parse.Object.extend('ChildObject'); + const childObjects = []; + for (let i = 0; i < 10; i++) { + childObjects.push(new ChildObject({ x: i })); + } + await Parse.Object.saveAll(childObjects); + const ParentObject = Parse.Object.extend('ParentObject'); + const parent = new ParentObject(); + parent.set('x', 4); + const relation = parent.relation('child'); + relation.add(childObjects[0]); + await parent.save(); + const query = new Parse.Query(ParentObject); + const object = await query.get(parent.id); + const relationAgain = object.relation('child'); + const list = await relationAgain.query().find(); + equal(list.length, 1, 'Should have gotten one element back'); + equal(list[0].id, childObjects[0].id, 'Should have gotten the right value'); + ok(!parent.dirty('child'), 'The relation should not be dirty'); }); - it_exclude_dbs(['postgres'])("compound add and remove relation", (done) => { - var ChildObject = Parse.Object.extend("ChildObject"); - var childObjects = []; - for (var i = 0; i < 10; i++) { - childObjects.push(new ChildObject({x: i})); + it('compound add and remove relation', done => { + const ChildObject = Parse.Object.extend('ChildObject'); + const childObjects = []; + for (let i = 0; i < 10; i++) { + childObjects.push(new ChildObject({ x: i })); } - var parent; - var relation; + let parent; + let relation; - Parse.Object.saveAll(childObjects).then(function(list) { - var ParentObject = Parse.Object.extend('ParentObject'); - parent = new ParentObject(); - parent.set('x', 4); - relation = parent.relation('child'); - relation.add(childObjects[0]); - relation.add(childObjects[1]); - relation.remove(childObjects[0]); - relation.add(childObjects[2]); - return parent.save(); - }).then(function() { - return relation.query().find(); - }).then(function(list) { - equal(list.length, 2, 'Should have gotten two elements back'); - ok(!parent.dirty('child'), 'The relation should not be dirty'); - relation.remove(childObjects[1]); - relation.remove(childObjects[2]); - relation.add(childObjects[1]); - relation.add(childObjects[0]); - return parent.save(); - }).then(function() { - return relation.query().find(); - }).then(function(list) { - equal(list.length, 2, 'Deletes and then adds should have worked'); - ok(!parent.dirty('child'), 'The relation should not be dirty'); - done(); - }, function(err) { - ok(false, err.message); - done(); - }); + Parse.Object.saveAll(childObjects) + .then(function () { + const ParentObject = Parse.Object.extend('ParentObject'); + parent = new ParentObject(); + parent.set('x', 4); + relation = parent.relation('child'); + relation.add(childObjects[0]); + relation.add(childObjects[1]); + relation.remove(childObjects[0]); + relation.add(childObjects[2]); + return parent.save(); + }) + .then(function () { + return relation.query().find(); + }) + .then(function (list) { + equal(list.length, 2, 'Should have gotten two elements back'); + ok(!parent.dirty('child'), 'The relation should not be dirty'); + relation.remove(childObjects[1]); + relation.remove(childObjects[2]); + relation.add(childObjects[1]); + relation.add(childObjects[0]); + return parent.save(); + }) + .then(function () { + return relation.query().find(); + }) + .then( + function (list) { + equal(list.length, 2, 'Deletes and then adds should have worked'); + ok(!parent.dirty('child'), 'The relation should not be dirty'); + done(); + }, + function (err) { + ok(false, err.message); + done(); + } + ); }); + it('related at ordering optimizations', done => { + const ChildObject = Parse.Object.extend('ChildObject'); + const childObjects = []; + for (let i = 0; i < 10; i++) { + childObjects.push(new ChildObject({ x: i })); + } - it_exclude_dbs(['postgres'])("queries with relations", (done) => { + let parent; + let relation; + + Parse.Object.saveAll(childObjects) + .then(function () { + const ParentObject = Parse.Object.extend('ParentObject'); + parent = new ParentObject(); + parent.set('x', 4); + relation = parent.relation('child'); + relation.add(childObjects); + return parent.save(); + }) + .then(function () { + const query = relation.query(); + query.descending('createdAt'); + query.skip(1); + query.limit(3); + return query.find(); + }) + .then(function (list) { + expect(list.length).toBe(3); + }) + .then(done, done.fail); + }); - var ChildObject = Parse.Object.extend("ChildObject"); - var childObjects = []; - for (var i = 0; i < 10; i++) { - childObjects.push(new ChildObject({x: i})); + it('queries with relations', async () => { + const ChildObject = Parse.Object.extend('ChildObject'); + const childObjects = []; + for (let i = 0; i < 10; i++) { + childObjects.push(new ChildObject({ x: i })); } - Parse.Object.saveAll(childObjects, { - success: function() { - var ParentObject = Parse.Object.extend("ParentObject"); - var parent = new ParentObject(); - parent.set("x", 4); - var relation = parent.relation("child"); - relation.add(childObjects[0]); - relation.add(childObjects[1]); - relation.add(childObjects[2]); - parent.save(null, { - success: function() { - var query = relation.query(); - query.equalTo("x", 2); - query.find({ - success: function(list) { - equal(list.length, 1, - "There should only be one element"); - ok(list[0] instanceof ChildObject, - "Should be of type ChildObject"); - equal(list[0].id, childObjects[2].id, - "We should have gotten back the right result"); - done(); - } - }); - } - }); - } - }); + await Parse.Object.saveAll(childObjects); + const ParentObject = Parse.Object.extend('ParentObject'); + const parent = new ParentObject(); + parent.set('x', 4); + const relation = parent.relation('child'); + relation.add(childObjects[0]); + relation.add(childObjects[1]); + relation.add(childObjects[2]); + await parent.save(); + const query = relation.query(); + query.equalTo('x', 2); + const list = await query.find(); + equal(list.length, 1, 'There should only be one element'); + ok(list[0] instanceof ChildObject, 'Should be of type ChildObject'); + equal(list[0].id, childObjects[2].id, 'We should have gotten back the right result'); }); - it_exclude_dbs(['postgres'])("queries on relation fields", (done) => { - var ChildObject = Parse.Object.extend("ChildObject"); - var childObjects = []; - for (var i = 0; i < 10; i++) { - childObjects.push(new ChildObject({x: i})); + it('queries on relation fields', async () => { + const ChildObject = Parse.Object.extend('ChildObject'); + const childObjects = []; + for (let i = 0; i < 10; i++) { + childObjects.push(new ChildObject({ x: i })); } - Parse.Object.saveAll(childObjects, { - success: function() { - var ParentObject = Parse.Object.extend("ParentObject"); - var parent = new ParentObject(); - parent.set("x", 4); - var relation = parent.relation("child"); - relation.add(childObjects[0]); - relation.add(childObjects[1]); - relation.add(childObjects[2]); - var parent2 = new ParentObject(); - parent2.set("x", 3); - var relation2 = parent2.relation("child"); - relation2.add(childObjects[4]); - relation2.add(childObjects[5]); - relation2.add(childObjects[6]); - var parents = []; - parents.push(parent); - parents.push(parent2); - Parse.Object.saveAll(parents, { - success: function() { - var query = new Parse.Query(ParentObject); - var objects = []; - objects.push(childObjects[4]); - objects.push(childObjects[9]); - query.containedIn("child", objects); - query.find({ - success: function(list) { - equal(list.length, 1, "There should be only one result"); - equal(list[0].id, parent2.id, - "Should have gotten back the right result"); - done(); - } - }); - } - }); - } - }); + await Parse.Object.saveAll(childObjects); + const ParentObject = Parse.Object.extend('ParentObject'); + const parent = new ParentObject(); + parent.set('x', 4); + const relation = parent.relation('child'); + relation.add(childObjects[0]); + relation.add(childObjects[1]); + relation.add(childObjects[2]); + const parent2 = new ParentObject(); + parent2.set('x', 3); + const relation2 = parent2.relation('child'); + relation2.add(childObjects[4]); + relation2.add(childObjects[5]); + relation2.add(childObjects[6]); + const parents = []; + parents.push(parent); + parents.push(parent2); + await Parse.Object.saveAll(parents); + const query = new Parse.Query(ParentObject); + const objects = []; + objects.push(childObjects[4]); + objects.push(childObjects[9]); + const list = await query.containedIn('child', objects).find(); + equal(list.length, 1, 'There should be only one result'); + equal(list[0].id, parent2.id, 'Should have gotten back the right result'); }); - it_exclude_dbs(['postgres'])("queries on relation fields with multiple containedIn (regression test for #1271)", (done) => { - let ChildObject = Parse.Object.extend("ChildObject"); - let childObjects = []; + it('queries on relation fields with multiple containedIn (regression test for #1271)', done => { + const ChildObject = Parse.Object.extend('ChildObject'); + const childObjects = []; for (let i = 0; i < 10; i++) { - childObjects.push(new ChildObject({x: i})); + childObjects.push(new ChildObject({ x: i })); } - Parse.Object.saveAll(childObjects).then(() => { - let ParentObject = Parse.Object.extend("ParentObject"); - let parent = new ParentObject(); - parent.set("x", 4); - let parent1Children = parent.relation("child"); - parent1Children.add(childObjects[0]); - parent1Children.add(childObjects[1]); - parent1Children.add(childObjects[2]); - let parent2 = new ParentObject(); - parent2.set("x", 3); - let parent2Children = parent2.relation("child"); - parent2Children.add(childObjects[4]); - parent2Children.add(childObjects[5]); - parent2Children.add(childObjects[6]); - - let parent2OtherChildren = parent2.relation("otherChild"); - parent2OtherChildren.add(childObjects[0]); - parent2OtherChildren.add(childObjects[1]); - parent2OtherChildren.add(childObjects[2]); - - return Parse.Object.saveAll([parent, parent2]); - }).then(() => { - let objectsWithChild0InBothChildren = new Parse.Query(ParentObject); - objectsWithChild0InBothChildren.containedIn("child", [childObjects[0]]); - objectsWithChild0InBothChildren.containedIn("otherChild", [childObjects[0]]); - return objectsWithChild0InBothChildren.find(); - }).then(objectsWithChild0InBothChildren => { - //No parent has child 0 in both it's "child" and "otherChild" field; - expect(objectsWithChild0InBothChildren.length).toEqual(0); - }).then(() => { - let objectsWithChild4andOtherChild1 = new Parse.Query(ParentObject); - objectsWithChild4andOtherChild1.containedIn("child", [childObjects[4]]); - objectsWithChild4andOtherChild1.containedIn("otherChild", [childObjects[1]]); - return objectsWithChild4andOtherChild1.find(); - }).then(objects => { - // parent2 has child 4 and otherChild 1 - expect(objects.length).toEqual(1); - done(); - }); + Parse.Object.saveAll(childObjects) + .then(() => { + const ParentObject = Parse.Object.extend('ParentObject'); + const parent = new ParentObject(); + parent.set('x', 4); + const parent1Children = parent.relation('child'); + parent1Children.add(childObjects[0]); + parent1Children.add(childObjects[1]); + parent1Children.add(childObjects[2]); + const parent2 = new ParentObject(); + parent2.set('x', 3); + const parent2Children = parent2.relation('child'); + parent2Children.add(childObjects[4]); + parent2Children.add(childObjects[5]); + parent2Children.add(childObjects[6]); + + const parent2OtherChildren = parent2.relation('otherChild'); + parent2OtherChildren.add(childObjects[0]); + parent2OtherChildren.add(childObjects[1]); + parent2OtherChildren.add(childObjects[2]); + + return Parse.Object.saveAll([parent, parent2]); + }) + .then(() => { + const objectsWithChild0InBothChildren = new Parse.Query(ParentObject); + objectsWithChild0InBothChildren.containedIn('child', [childObjects[0]]); + objectsWithChild0InBothChildren.containedIn('otherChild', [childObjects[0]]); + return objectsWithChild0InBothChildren.find(); + }) + .then(objectsWithChild0InBothChildren => { + //No parent has child 0 in both it's "child" and "otherChild" field; + expect(objectsWithChild0InBothChildren.length).toEqual(0); + }) + .then(() => { + const objectsWithChild4andOtherChild1 = new Parse.Query(ParentObject); + objectsWithChild4andOtherChild1.containedIn('child', [childObjects[4]]); + objectsWithChild4andOtherChild1.containedIn('otherChild', [childObjects[1]]); + return objectsWithChild4andOtherChild1.find(); + }) + .then(objects => { + // parent2 has child 4 and otherChild 1 + expect(objects.length).toEqual(1); + done(); + }); }); - it_exclude_dbs(['postgres'])("query on pointer and relation fields with equal", (done) => { - var ChildObject = Parse.Object.extend("ChildObject"); - var childObjects = []; - for (var i = 0; i < 10; i++) { - childObjects.push(new ChildObject({x: i})); + it('query on pointer and relation fields with equal', done => { + const ChildObject = Parse.Object.extend('ChildObject'); + const childObjects = []; + for (let i = 0; i < 10; i++) { + childObjects.push(new ChildObject({ x: i })); } - Parse.Object.saveAll(childObjects).then(() => { - var ParentObject = Parse.Object.extend("ParentObject"); - var parent = new ParentObject(); - parent.set("x", 4); - var relation = parent.relation("toChilds"); + Parse.Object.saveAll(childObjects) + .then(() => { + const ParentObject = Parse.Object.extend('ParentObject'); + const parent = new ParentObject(); + parent.set('x', 4); + const relation = parent.relation('toChilds'); relation.add(childObjects[0]); relation.add(childObjects[1]); relation.add(childObjects[2]); - var parent2 = new ParentObject(); - parent2.set("x", 3); - parent2.set("toChild", childObjects[2]); + const parent2 = new ParentObject(); + parent2.set('x', 3); + parent2.set('toChild', childObjects[2]); - var parents = []; + const parents = []; parents.push(parent); parents.push(parent2); parents.push(new ParentObject()); - return Parse.Object.saveAll(parents).then(() => { - var query = new Parse.Query(ParentObject); - query.equalTo("objectId", parent.id); - query.equalTo("toChilds", childObjects[2]); + return Parse.Object.saveAll(parents).then(() => { + const query = new Parse.Query(ParentObject); + query.equalTo('objectId', parent.id); + query.equalTo('toChilds', childObjects[2]); - return query.find().then((list) => { - equal(list.length, 1, "There should be 1 result"); + return query.find().then(list => { + equal(list.length, 1, 'There should be 1 result'); done(); }); }); - }); + }) + .catch(err => { + jfail(err); + done(); + }); }); - it_exclude_dbs(['postgres'])("query on pointer and relation fields with equal bis", (done) => { - var ChildObject = Parse.Object.extend("ChildObject"); - var childObjects = []; - for (var i = 0; i < 10; i++) { - childObjects.push(new ChildObject({x: i})); + it('query on pointer and relation fields with equal bis', done => { + const ChildObject = Parse.Object.extend('ChildObject'); + const childObjects = []; + for (let i = 0; i < 10; i++) { + childObjects.push(new ChildObject({ x: i })); } Parse.Object.saveAll(childObjects).then(() => { - var ParentObject = Parse.Object.extend("ParentObject"); - var parent = new ParentObject(); - parent.set("x", 4); - var relation = parent.relation("toChilds"); - relation.add(childObjects[0]); - relation.add(childObjects[1]); - relation.add(childObjects[2]); + const ParentObject = Parse.Object.extend('ParentObject'); + const parent = new ParentObject(); + parent.set('x', 4); + const relation = parent.relation('toChilds'); + relation.add(childObjects[0]); + relation.add(childObjects[1]); + relation.add(childObjects[2]); - var parent2 = new ParentObject(); - parent2.set("x", 3); - parent2.relation("toChilds").add(childObjects[2]); + const parent2 = new ParentObject(); + parent2.set('x', 3); + parent2.relation('toChilds').add(childObjects[2]); - var parents = []; - parents.push(parent); - parents.push(parent2); - parents.push(new ParentObject()); + const parents = []; + parents.push(parent); + parents.push(parent2); + parents.push(new ParentObject()); - return Parse.Object.saveAll(parents).then(() => { - var query = new Parse.Query(ParentObject); - query.equalTo("objectId", parent2.id); - // childObjects[2] is in 2 relations - // before the fix, that woul yield 2 results - query.equalTo("toChilds", childObjects[2]); + return Parse.Object.saveAll(parents).then(() => { + const query = new Parse.Query(ParentObject); + query.equalTo('objectId', parent2.id); + // childObjects[2] is in 2 relations + // before the fix, that woul yield 2 results + query.equalTo('toChilds', childObjects[2]); - return query.find().then((list) => { - equal(list.length, 1, "There should be 1 result"); - done(); - }); + return query.find().then(list => { + equal(list.length, 1, 'There should be 1 result'); + done(); }); + }); }); }); - it_exclude_dbs(['postgres'])("or queries on pointer and relation fields", (done) => { - var ChildObject = Parse.Object.extend("ChildObject"); - var childObjects = []; - for (var i = 0; i < 10; i++) { - childObjects.push(new ChildObject({x: i})); + it('or queries on pointer and relation fields', done => { + const ChildObject = Parse.Object.extend('ChildObject'); + const childObjects = []; + for (let i = 0; i < 10; i++) { + childObjects.push(new ChildObject({ x: i })); } Parse.Object.saveAll(childObjects).then(() => { - var ParentObject = Parse.Object.extend("ParentObject"); - var parent = new ParentObject(); - parent.set("x", 4); - var relation = parent.relation("toChilds"); - relation.add(childObjects[0]); - relation.add(childObjects[1]); - relation.add(childObjects[2]); - - var parent2 = new ParentObject(); - parent2.set("x", 3); - parent2.set("toChild", childObjects[2]); - - var parents = []; - parents.push(parent); - parents.push(parent2); - parents.push(new ParentObject()); + const ParentObject = Parse.Object.extend('ParentObject'); + const parent = new ParentObject(); + parent.set('x', 4); + const relation = parent.relation('toChilds'); + relation.add(childObjects[0]); + relation.add(childObjects[1]); + relation.add(childObjects[2]); - return Parse.Object.saveAll(parents).then(() => { - var query1 = new Parse.Query(ParentObject); - query1.containedIn("toChilds", [childObjects[2]]); - var query2 = new Parse.Query(ParentObject); - query2.equalTo("toChild", childObjects[2]); - var query = Parse.Query.or(query1, query2); - return query.find().then((list) => { - var objectIds = list.map(function(item){ - return item.id; - }); - expect(objectIds.indexOf(parent.id)).not.toBe(-1); - expect(objectIds.indexOf(parent2.id)).not.toBe(-1); - equal(list.length, 2, "There should be 2 results"); - done(); + const parent2 = new ParentObject(); + parent2.set('x', 3); + parent2.set('toChild', childObjects[2]); + + const parents = []; + parents.push(parent); + parents.push(parent2); + parents.push(new ParentObject()); + + return Parse.Object.saveAll(parents).then(() => { + const query1 = new Parse.Query(ParentObject); + query1.containedIn('toChilds', [childObjects[2]]); + const query2 = new Parse.Query(ParentObject); + query2.equalTo('toChild', childObjects[2]); + const query = Parse.Query.or(query1, query2); + return query.find().then(list => { + const objectIds = list.map(function (item) { + return item.id; }); + expect(objectIds.indexOf(parent.id)).not.toBe(-1); + expect(objectIds.indexOf(parent2.id)).not.toBe(-1); + equal(list.length, 2, 'There should be 2 results'); + done(); }); + }); }); }); + it('or queries with base constraint on relation field', async () => { + const ChildObject = Parse.Object.extend('ChildObject'); + const childObjects = []; + for (let i = 0; i < 10; i++) { + childObjects.push(new ChildObject({ x: i })); + } + await Parse.Object.saveAll(childObjects); + const ParentObject = Parse.Object.extend('ParentObject'); + const parent = new ParentObject(); + parent.set('x', 4); + const relation = parent.relation('toChilds'); + relation.add(childObjects[0]); + relation.add(childObjects[1]); + relation.add(childObjects[2]); + + const parent2 = new ParentObject(); + parent2.set('x', 3); + const relation2 = parent2.relation('toChilds'); + relation2.add(childObjects[0]); + relation2.add(childObjects[1]); + relation2.add(childObjects[2]); + + const parents = []; + parents.push(parent); + parents.push(parent2); + parents.push(new ParentObject()); + + await Parse.Object.saveAll(parents); + const query1 = new Parse.Query(ParentObject); + query1.equalTo('x', 4); + const query2 = new Parse.Query(ParentObject); + query2.equalTo('x', 3); + + const query = Parse.Query.or(query1, query2); + query.equalTo('toChilds', childObjects[2]); + + const list = await query.find(); + const objectIds = list.map(item => item.id); + expect(objectIds.indexOf(parent.id)).not.toBe(-1); + expect(objectIds.indexOf(parent2.id)).not.toBe(-1); + equal(list.length, 2, 'There should be 2 results'); + }); - it_exclude_dbs(['postgres'])("Get query on relation using un-fetched parent object", (done) => { + it('Get query on relation using un-fetched parent object', done => { // Setup data model - var Wheel = Parse.Object.extend('Wheel'); - var Car = Parse.Object.extend('Car'); - var origWheel = new Wheel(); - origWheel.save().then(function() { - var car = new Car(); - var relation = car.relation('wheels'); - relation.add(origWheel); - return car.save(); - }).then(function(car) { - // Test starts here. - // Create an un-fetched shell car object - var unfetchedCar = new Car(); - unfetchedCar.id = car.id; - var relation = unfetchedCar.relation('wheels'); - var query = relation.query(); - - // Parent object is un-fetched, so this will call /1/classes/Car instead - // of /1/classes/Wheel and pass { "redirectClassNameForKey":"wheels" }. - return query.get(origWheel.id); - }).then(function(wheel) { - // Make sure this is Wheel and not Car. - strictEqual(wheel.className, 'Wheel'); - strictEqual(wheel.id, origWheel.id); - }).then(function() { - done(); - },function(err) { - ok(false, 'unexpected error: ' + JSON.stringify(err)); - done(); - }); + const Wheel = Parse.Object.extend('Wheel'); + const Car = Parse.Object.extend('Car'); + const origWheel = new Wheel(); + origWheel + .save() + .then(function () { + const car = new Car(); + const relation = car.relation('wheels'); + relation.add(origWheel); + return car.save(); + }) + .then(function (car) { + // Test starts here. + // Create an un-fetched shell car object + const unfetchedCar = new Car(); + unfetchedCar.id = car.id; + const relation = unfetchedCar.relation('wheels'); + const query = relation.query(); + + // Parent object is un-fetched, so this will call /1/classes/Car instead + // of /1/classes/Wheel and pass { "redirectClassNameForKey":"wheels" }. + return query.get(origWheel.id); + }) + .then(function (wheel) { + // Make sure this is Wheel and not Car. + strictEqual(wheel.className, 'Wheel'); + strictEqual(wheel.id, origWheel.id); + }) + .then( + function () { + done(); + }, + function (err) { + ok(false, 'unexpected error: ' + JSON.stringify(err)); + done(); + } + ); }); - it_exclude_dbs(['postgres'])("Find query on relation using un-fetched parent object", (done) => { + it('Find query on relation using un-fetched parent object', done => { // Setup data model - var Wheel = Parse.Object.extend('Wheel'); - var Car = Parse.Object.extend('Car'); - var origWheel = new Wheel(); - origWheel.save().then(function() { - var car = new Car(); - var relation = car.relation('wheels'); - relation.add(origWheel); - return car.save(); - }).then(function(car) { - // Test starts here. - // Create an un-fetched shell car object - var unfetchedCar = new Car(); - unfetchedCar.id = car.id; - var relation = unfetchedCar.relation('wheels'); - var query = relation.query(); - - // Parent object is un-fetched, so this will call /1/classes/Car instead - // of /1/classes/Wheel and pass { "redirectClassNameForKey":"wheels" }. - return query.find(origWheel.id); - }).then(function(results) { - // Make sure this is Wheel and not Car. - var wheel = results[0]; - strictEqual(wheel.className, 'Wheel'); - strictEqual(wheel.id, origWheel.id); - }).then(function() { - done(); - },function(err) { - ok(false, 'unexpected error: ' + JSON.stringify(err)); - done(); - }); + const Wheel = Parse.Object.extend('Wheel'); + const Car = Parse.Object.extend('Car'); + const origWheel = new Wheel(); + origWheel + .save() + .then(function () { + const car = new Car(); + const relation = car.relation('wheels'); + relation.add(origWheel); + return car.save(); + }) + .then(function (car) { + // Test starts here. + // Create an un-fetched shell car object + const unfetchedCar = new Car(); + unfetchedCar.id = car.id; + const relation = unfetchedCar.relation('wheels'); + const query = relation.query(); + + // Parent object is un-fetched, so this will call /1/classes/Car instead + // of /1/classes/Wheel and pass { "redirectClassNameForKey":"wheels" }. + return query.find(); + }) + .then(function (results) { + // Make sure this is Wheel and not Car. + const wheel = results[0]; + strictEqual(wheel.className, 'Wheel'); + strictEqual(wheel.id, origWheel.id); + }) + .then( + function () { + done(); + }, + function (err) { + ok(false, 'unexpected error: ' + JSON.stringify(err)); + done(); + } + ); }); - it_exclude_dbs(['postgres'])('Find objects with a related object using equalTo', (done) => { + it('Find objects with a related object using equalTo', done => { // Setup the objects - var Card = Parse.Object.extend('Card'); - var House = Parse.Object.extend('House'); - var card = new Card(); - card.save().then(() => { - var house = new House(); - var relation = house.relation('cards'); - relation.add(card); - return house.save(); - }).then(() => { - var query = new Parse.Query('House'); - query.equalTo('cards', card); - return query.find(); - }).then((results) => { - expect(results.length).toEqual(1); - done(); - }); + const Card = Parse.Object.extend('Card'); + const House = Parse.Object.extend('House'); + const card = new Card(); + card + .save() + .then(() => { + const house = new House(); + const relation = house.relation('cards'); + relation.add(card); + return house.save(); + }) + .then(() => { + const query = new Parse.Query('House'); + query.equalTo('cards', card); + return query.find(); + }) + .then(results => { + expect(results.length).toEqual(1); + done(); + }); }); - it_exclude_dbs(['postgres'])('should properly get related objects with unfetched queries', (done) => { - let objects = []; - let owners = []; - let allObjects = []; + it('should properly get related objects with unfetched queries', done => { + const objects = []; + const owners = []; + const allObjects = []; // Build 10 Objects and 10 owners while (objects.length != 10) { - let object = new Parse.Object('AnObject'); + const object = new Parse.Object('AnObject'); object.set({ index: objects.length, - even: objects.length % 2 == 0 + even: objects.length % 2 == 0, }); objects.push(object); - let owner = new Parse.Object('AnOwner'); + const owner = new Parse.Object('AnOwner'); owners.push(owner); allObjects.push(object); allObjects.push(owner); } - let anotherOwner = new Parse.Object('AnotherOwner'); + const anotherOwner = new Parse.Object('AnotherOwner'); - return Parse.Object.saveAll(allObjects.concat([anotherOwner])).then(() => { - // put all the AnObject into the anotherOwner relationKey - anotherOwner.relation('relationKey').add(objects); - // Set each object[i] into owner[i]; - owners.forEach((owner,i) => { - owner.set('key', objects[i]); - }); - return Parse.Object.saveAll(owners.concat([anotherOwner])); - }).then(() => { - // Query on the relation of another owner - let object = new Parse.Object('AnotherOwner'); - object.id = anotherOwner.id; - let relationQuery = object.relation('relationKey').query(); - // Just get the even ones - relationQuery.equalTo('even', true); - // Make the query on anOwner - let query = new Parse.Query('AnOwner'); - // where key match the relation query. - query.matchesQuery('key', relationQuery); - query.include('key'); - return query.find(); - }).then((results) => { - expect(results.length).toBe(5); - results.forEach((result) => { - expect(result.get('key').get('even')).toBe(true); - }); - return Promise.resolve(); - }).then(() => { - // Query on the relation of another owner - let object = new Parse.Object('AnotherOwner'); - object.id = anotherOwner.id; - let relationQuery = object.relation('relationKey').query(); - // Just get the even ones - relationQuery.equalTo('even', true); - // Make the query on anOwner - let query = new Parse.Query('AnOwner'); - // where key match the relation query. - query.doesNotMatchQuery('key', relationQuery); - query.include('key'); - return query.find(); - }).then((results) => { - expect(results.length).toBe(5); - results.forEach((result) => { - expect(result.get('key').get('even')).toBe(false); - }); - done(); - }) + return Parse.Object.saveAll(allObjects.concat([anotherOwner])) + .then(() => { + // put all the AnObject into the anotherOwner relationKey + anotherOwner.relation('relationKey').add(objects); + // Set each object[i] into owner[i]; + owners.forEach((owner, i) => { + owner.set('key', objects[i]); + }); + return Parse.Object.saveAll(owners.concat([anotherOwner])); + }) + .then(() => { + // Query on the relation of another owner + const object = new Parse.Object('AnotherOwner'); + object.id = anotherOwner.id; + const relationQuery = object.relation('relationKey').query(); + // Just get the even ones + relationQuery.equalTo('even', true); + // Make the query on anOwner + const query = new Parse.Query('AnOwner'); + // where key match the relation query. + query.matchesQuery('key', relationQuery); + query.include('key'); + return query.find(); + }) + .then(results => { + expect(results.length).toBe(5); + results.forEach(result => { + expect(result.get('key').get('even')).toBe(true); + }); + return Promise.resolve(); + }) + .then(() => { + // Query on the relation of another owner + const object = new Parse.Object('AnotherOwner'); + object.id = anotherOwner.id; + const relationQuery = object.relation('relationKey').query(); + // Just get the even ones + relationQuery.equalTo('even', true); + // Make the query on anOwner + const query = new Parse.Query('AnOwner'); + // where key match the relation query. + query.doesNotMatchQuery('key', relationQuery); + query.include('key'); + return query.find(); + }) + .then( + results => { + expect(results.length).toBe(5); + results.forEach(result => { + expect(result.get('key').get('even')).toBe(false); + }); + done(); + }, + e => { + fail(JSON.stringify(e)); + done(); + } + ); }); - it_exclude_dbs(['postgres'])("select query", function(done) { - var RestaurantObject = Parse.Object.extend("Restaurant"); - var PersonObject = Parse.Object.extend("Person"); - var OwnerObject = Parse.Object.extend('Owner'); - var restaurants = [ - new RestaurantObject({ ratings: 5, location: "Djibouti" }), - new RestaurantObject({ ratings: 3, location: "Ouagadougou" }), + it('select query', function (done) { + const RestaurantObject = Parse.Object.extend('Restaurant'); + const PersonObject = Parse.Object.extend('Person'); + const OwnerObject = Parse.Object.extend('Owner'); + const restaurants = [ + new RestaurantObject({ ratings: 5, location: 'Djibouti' }), + new RestaurantObject({ ratings: 3, location: 'Ouagadougou' }), ]; - let persons = [ - new PersonObject({ name: "Bob", hometown: "Djibouti" }), - new PersonObject({ name: "Tom", hometown: "Ouagadougou" }), - new PersonObject({ name: "Billy", hometown: "Detroit" }), + const persons = [ + new PersonObject({ name: 'Bob', hometown: 'Djibouti' }), + new PersonObject({ name: 'Tom', hometown: 'Ouagadougou' }), + new PersonObject({ name: 'Billy', hometown: 'Detroit' }), ]; - let owner = new OwnerObject({name: 'Joe'}); - let ownerId; - let allObjects = [owner].concat(restaurants).concat(persons); + const owner = new OwnerObject({ name: 'Joe' }); + const allObjects = [owner].concat(restaurants).concat(persons); expect(allObjects.length).toEqual(6); - Parse.Object.saveAll([owner].concat(restaurants).concat(persons)).then(function() { - ownerId = owner.id; - owner.relation('restaurants').add(restaurants); - return owner.save() - }).then(() => { - let unfetchedOwner = new OwnerObject(); - unfetchedOwner.id = owner.id; - var query = unfetchedOwner.relation('restaurants').query(); - query.greaterThan("ratings", 4); - var mainQuery = new Parse.Query(PersonObject); - mainQuery.matchesKeyInQuery("hometown", "location", query); - mainQuery.find(expectSuccess({ - success: function(results) { + Parse.Object.saveAll([owner].concat(restaurants).concat(persons)) + .then(function () { + owner.relation('restaurants').add(restaurants); + return owner.save(); + }) + .then( + async () => { + const unfetchedOwner = new OwnerObject(); + unfetchedOwner.id = owner.id; + const query = unfetchedOwner.relation('restaurants').query(); + query.greaterThan('ratings', 4); + const mainQuery = new Parse.Query(PersonObject); + mainQuery.matchesKeyInQuery('hometown', 'location', query); + const results = await mainQuery.find(); equal(results.length, 1); if (results.length > 0) { equal(results[0].get('name'), 'Bob'); } done(); + }, + e => { + fail(JSON.stringify(e)); + done(); } - })); - }); + ); }); - it_exclude_dbs(['postgres'])("dontSelect query", function(done) { - var RestaurantObject = Parse.Object.extend("Restaurant"); - var PersonObject = Parse.Object.extend("Person"); - var OwnerObject = Parse.Object.extend('Owner'); - var restaurants = [ - new RestaurantObject({ ratings: 5, location: "Djibouti" }), - new RestaurantObject({ ratings: 3, location: "Ouagadougou" }), + it('dontSelect query', function (done) { + const RestaurantObject = Parse.Object.extend('Restaurant'); + const PersonObject = Parse.Object.extend('Person'); + const OwnerObject = Parse.Object.extend('Owner'); + const restaurants = [ + new RestaurantObject({ ratings: 5, location: 'Djibouti' }), + new RestaurantObject({ ratings: 3, location: 'Ouagadougou' }), ]; - let persons = [ - new PersonObject({ name: "Bob", hometown: "Djibouti" }), - new PersonObject({ name: "Tom", hometown: "Ouagadougou" }), - new PersonObject({ name: "Billy", hometown: "Detroit" }), + const persons = [ + new PersonObject({ name: 'Bob', hometown: 'Djibouti' }), + new PersonObject({ name: 'Tom', hometown: 'Ouagadougou' }), + new PersonObject({ name: 'Billy', hometown: 'Detroit' }), ]; - let owner = new OwnerObject({name: 'Joe'}); - let ownerId; - let allObjects = [owner].concat(restaurants).concat(persons); + const owner = new OwnerObject({ name: 'Joe' }); + const allObjects = [owner].concat(restaurants).concat(persons); expect(allObjects.length).toEqual(6); - Parse.Object.saveAll([owner].concat(restaurants).concat(persons)).then(function() { - ownerId = owner.id; - owner.relation('restaurants').add(restaurants); - return owner.save() - }).then(() => { - let unfetchedOwner = new OwnerObject(); - unfetchedOwner.id = owner.id; - var query = unfetchedOwner.relation('restaurants').query(); - query.greaterThan("ratings", 4); - var mainQuery = new Parse.Query(PersonObject); - mainQuery.doesNotMatchKeyInQuery("hometown", "location", query); - mainQuery.ascending('name'); - mainQuery.find(expectSuccess({ - success: function(results) { + Parse.Object.saveAll([owner].concat(restaurants).concat(persons)) + .then(function () { + owner.relation('restaurants').add(restaurants); + return owner.save(); + }) + .then( + async () => { + const unfetchedOwner = new OwnerObject(); + unfetchedOwner.id = owner.id; + const query = unfetchedOwner.relation('restaurants').query(); + query.greaterThan('ratings', 4); + const mainQuery = new Parse.Query(PersonObject); + mainQuery.doesNotMatchKeyInQuery('hometown', 'location', query); + mainQuery.ascending('name'); + const results = await mainQuery.find(); equal(results.length, 2); if (results.length > 0) { equal(results[0].get('name'), 'Billy'); equal(results[1].get('name'), 'Tom'); } done(); + }, + e => { + fail(JSON.stringify(e)); + done(); } - })); - }); + ); }); - it_exclude_dbs(['postgres'])('relations are not bidirectional (regression test for #871)', done => { - let PersonObject = Parse.Object.extend("Person"); - let p1 = new PersonObject(); - let p2 = new PersonObject(); + it('relations are not bidirectional (regression test for #871)', done => { + const PersonObject = Parse.Object.extend('Person'); + const p1 = new PersonObject(); + const p2 = new PersonObject(); Parse.Object.saveAll([p1, p2]).then(results => { - let p1 = results[0]; - let p2 = results[1]; - let relation = p1.relation('relation'); + const p1 = results[0]; + const p2 = results[1]; + const relation = p1.relation('relation'); relation.add(p2); p1.save().then(() => { - let query = new Parse.Query(PersonObject); + const query = new Parse.Query(PersonObject); query.equalTo('relation', p1); query.find().then(results => { expect(results.length).toEqual(0); - let query = new Parse.Query(PersonObject); + const query = new Parse.Query(PersonObject); query.equalTo('relation', p2); query.find().then(results => { expect(results.length).toEqual(1); @@ -680,62 +751,147 @@ describe('Parse.Relation testing', () => { done(); }); }); - }) + }); }); }); - it_exclude_dbs(['postgres'])('can query roles in Cloud Code (regession test #1489)', done => { - Parse.Cloud.define('isAdmin', (request, response) => { - let query = new Parse.Query(Parse.Role); + it('can query roles in Cloud Code (regession test #1489)', done => { + Parse.Cloud.define('isAdmin', request => { + const query = new Parse.Query(Parse.Role); query.equalTo('name', 'admin'); - query.first({ useMasterKey: true }) - .then(role => { - let relation = new Parse.Relation(role, 'users'); - let admins = relation.query(); - admins.equalTo('username', request.user.get('username')); - admins.first({ useMasterKey: true }) - .then(user => { - if (user) { - response.success(user); + return query.first({ useMasterKey: true }).then( + role => { + const relation = new Parse.Relation(role, 'users'); + const admins = relation.query(); + admins.equalTo('username', request.user.get('username')); + admins.first({ useMasterKey: true }).then( + user => { + if (user) { + done(); + } else { + fail('Should have found admin user, found nothing instead'); + done(); + } + }, + () => { + fail('User not admin'); + done(); + } + ); + }, + error => { + fail('Should have found admin user, errored instead'); + fail(error); + done(); + } + ); + }); + + const adminUser = new Parse.User(); + adminUser.set('username', 'name'); + adminUser.set('password', 'pass'); + adminUser.signUp().then( + adminUser => { + const adminACL = new Parse.ACL(); + adminACL.setPublicReadAccess(true); + + // Create admin role + const adminRole = new Parse.Role('admin', adminACL); + adminRole.getUsers().add(adminUser); + adminRole.save().then( + () => { + Parse.Cloud.run('isAdmin'); + }, + error => { + fail('failed to save role'); + fail(error); done(); - } else { - fail('Should have found admin user, found nothing instead'); + } + ); + }, + error => { + fail('failed to sign up'); + fail(error); + done(); + } + ); + }); + + it('can be saved without error', done => { + const obj1 = new Parse.Object('PPAP'); + obj1.save().then( + () => { + const newRelation = obj1.relation('aRelation'); + newRelation.add(obj1); + obj1.save().then( + () => { + const relation = obj1.get('aRelation'); + obj1.set('aRelation', relation); + obj1.save().then( + () => { + done(); + }, + error => { + fail('failed to save ParseRelation object'); + fail(error); + done(); + } + ); + }, + error => { + fail('failed to create relation field'); + fail(error); done(); } - }, error => { - fail('User not admin'); - done(); - }) - }, error => { - fail('Should have found admin user, errored instead'); + ); + }, + error => { + fail('failed to save obj'); fail(error); done(); - }); - }); + } + ); + }); - let adminUser = new Parse.User(); - adminUser.set('username', 'name'); - adminUser.set('password', 'pass'); - adminUser.signUp() - .then(adminUser => { - let adminACL = new Parse.ACL(); - adminACL.setPublicReadAccess(true); - - // Create admin role - let adminRole = new Parse.Role('admin', adminACL); - adminRole.getUsers().add(adminUser); - adminRole.save() + it('ensures beforeFind on relation doesnt side effect', done => { + const parent = new Parse.Object('Parent'); + const child = new Parse.Object('Child'); + child + .save() .then(() => { - Parse.Cloud.run('isAdmin'); - }, error => { - fail('failed to save role'); - fail(error); - done() - }); - }, error => { - fail('failed to sign up'); - fail(error); - done(); - }); + parent.relation('children').add(child); + return parent.save(); + }) + .then(() => { + // We need to use a new reference otherwise the JS SDK remembers the className for a relation + // After saves or finds + const otherParent = new Parse.Object('Parent'); + otherParent.id = parent.id; + return otherParent.relation('children').query().find(); + }) + .then(children => { + // Without an after find all is good, all results have been redirected with proper className + children.forEach(child => expect(child.className).toBe('Child')); + // Setup the afterFind + Parse.Cloud.afterFind('Child', req => { + return Promise.resolve( + req.objects.map(child => { + child.set('afterFound', true); + return child; + }) + ); + }); + const otherParent = new Parse.Object('Parent'); + otherParent.id = parent.id; + return otherParent.relation('children').query().find(); + }) + .then(children => { + children.forEach(child => { + expect(child.className).toBe('Child'); + expect(child.get('afterFound')).toBe(true); + }); + }) + .then(done) + .catch(done.fail); }); }); diff --git a/spec/ParseRole.spec.js b/spec/ParseRole.spec.js index 142fc929ae..95e6189a6a 100644 --- a/spec/ParseRole.spec.js +++ b/spec/ParseRole.spec.js @@ -1,350 +1,678 @@ -"use strict"; +'use strict'; // Roles are not accessible without the master key, so they are not intended // for use by clients. We can manually test them using the master key. -var RestQuery = require("../src/RestQuery"); -var Auth = require("../src/Auth").Auth; -var Config = require("../src/Config"); +const RestQuery = require('../lib/RestQuery'); +const Auth = require('../lib/Auth').Auth; +const Config = require('../lib/Config'); + +function testLoadRoles(config, done) { + const rolesNames = ['FooRole', 'BarRole', 'BazRole']; + const roleIds = {}; + createTestUser() + .then(user => { + // Put the user on the 1st role + return createRole(rolesNames[0], null, user) + .then(aRole => { + roleIds[aRole.get('name')] = aRole.id; + // set the 1st role as a sibling of the second + // user will should have 2 role now + return createRole(rolesNames[1], aRole, null); + }) + .then(anotherRole => { + roleIds[anotherRole.get('name')] = anotherRole.id; + // set this role as a sibling of the last + // the user should now have 3 roles + return createRole(rolesNames[2], anotherRole, null); + }) + .then(lastRole => { + roleIds[lastRole.get('name')] = lastRole.id; + const auth = new Auth({ config, isMaster: true, user: user }); + return auth._loadRoles(); + }); + }) + .then( + roles => { + expect(roles.length).toEqual(3); + rolesNames.forEach(name => { + expect(roles.indexOf('role:' + name)).not.toBe(-1); + }); + done(); + }, + function () { + fail('should succeed'); + done(); + } + ); +} + +const createRole = function (name, sibling, user) { + const role = new Parse.Role(name, new Parse.ACL()); + if (user) { + const users = role.relation('users'); + users.add(user); + } + if (sibling) { + role.relation('roles').add(sibling); + } + return role.save({}, { useMasterKey: true }); +}; describe('Parse Role testing', () => { - it_exclude_dbs(['postgres'])('Do a bunch of basic role testing', done => { - var user; - var role; - - createTestUser().then((x) => { - user = x; - let acl = new Parse.ACL(); - acl.setPublicReadAccess(true); - acl.setPublicWriteAccess(false); - role = new Parse.Object('_Role'); - role.set('name', 'Foos'); - role.setACL(acl); - var users = role.relation('users'); - users.add(user); - return role.save({}, { useMasterKey: true }); - }).then((x) => { - var query = new Parse.Query('_Role'); - return query.find({ useMasterKey: true }); - }).then((x) => { - expect(x.length).toEqual(1); - var relation = x[0].relation('users').query(); - return relation.first({ useMasterKey: true }); - }).then((x) => { - expect(x.id).toEqual(user.id); - // Here we've got a valid role and a user assigned. - // Lets create an object only the role can read/write and test - // the different scenarios. - var obj = new Parse.Object('TestObject'); - var acl = new Parse.ACL(); - acl.setPublicReadAccess(false); - acl.setPublicWriteAccess(false); - acl.setRoleReadAccess('Foos', true); - acl.setRoleWriteAccess('Foos', true); - obj.setACL(acl); - return obj.save(); - }).then((x) => { - var query = new Parse.Query('TestObject'); - return query.find({ sessionToken: user.getSessionToken() }); - }).then((x) => { - expect(x.length).toEqual(1); - var objAgain = x[0]; - objAgain.set('foo', 'bar'); - // This should succeed: - return objAgain.save({}, {sessionToken: user.getSessionToken()}); - }).then((x) => { - x.set('foo', 'baz'); - // This should fail: - return x.save({},{sessionToken: ""}); - }).then((x) => { - fail('Should not have been able to save.'); - }, (e) => { - expect(e.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - done(); - }); + it('Do a bunch of basic role testing', done => { + let user; + let role; + createTestUser() + .then(x => { + user = x; + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + acl.setPublicWriteAccess(false); + role = new Parse.Object('_Role'); + role.set('name', 'Foos'); + role.setACL(acl); + const users = role.relation('users'); + users.add(user); + return role.save({}, { useMasterKey: true }); + }) + .then(() => { + const query = new Parse.Query('_Role'); + return query.find({ useMasterKey: true }); + }) + .then(x => { + expect(x.length).toEqual(1); + const relation = x[0].relation('users').query(); + return relation.first({ useMasterKey: true }); + }) + .then(x => { + expect(x.id).toEqual(user.id); + // Here we've got a valid role and a user assigned. + // Lets create an object only the role can read/write and test + // the different scenarios. + const obj = new Parse.Object('TestObject'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(false); + acl.setPublicWriteAccess(false); + acl.setRoleReadAccess('Foos', true); + acl.setRoleWriteAccess('Foos', true); + obj.setACL(acl); + return obj.save(); + }) + .then(() => { + const query = new Parse.Query('TestObject'); + return query.find({ sessionToken: user.getSessionToken() }); + }) + .then(x => { + expect(x.length).toEqual(1); + const objAgain = x[0]; + objAgain.set('foo', 'bar'); + // This should succeed: + return objAgain.save({}, { sessionToken: user.getSessionToken() }); + }) + .then(x => { + x.set('foo', 'baz'); + // This should fail: + return x.save({}, { sessionToken: '' }); + }) + .then( + () => { + fail('Should not have been able to save.'); + }, + e => { + expect(e.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + done(); + } + ); }); - var createRole = function(name, sibling, user) { - var role = new Parse.Role(name, new Parse.ACL()); - if (user) { - var users = role.relation('users'); - users.add(user); - } - if (sibling) { - role.relation('roles').add(sibling); - } - return role.save({}, { useMasterKey: true }); - }; - - it_exclude_dbs(['postgres'])("should not recursively load the same role multiple times", (done) => { - var rootRole = "RootRole"; - var roleNames = ["FooRole", "BarRole", "BazRole"]; - var allRoles = [rootRole].concat(roleNames); - - var roleObjs = {}; - var createAllRoles = function(user) { - var promises = allRoles.map(function(roleName) { - return createRole(roleName, null, user) - .then(function(roleObj) { - roleObjs[roleName] = roleObj; - return roleObj; - }); + it_id('b03abe32-e8e4-4666-9b81-9c804aa53400')(it)('should not recursively load the same role multiple times', done => { + const rootRole = 'RootRole'; + const roleNames = ['FooRole', 'BarRole', 'BazRole']; + const allRoles = [rootRole].concat(roleNames); + + const roleObjs = {}; + const createAllRoles = function (user) { + const promises = allRoles.map(function (roleName) { + return createRole(roleName, null, user).then(function (roleObj) { + roleObjs[roleName] = roleObj; + return roleObj; + }); }); return Promise.all(promises); }; - var restExecute = spyOn(RestQuery.prototype, "execute").and.callThrough(); - - var user, - auth, - getAllRolesSpy; - createTestUser().then( (newUser) => { - user = newUser; - return createAllRoles(user); - }).then ( (roles) => { - var rootRoleObj = roleObjs[rootRole]; - roles.forEach(function(role, i) { - // Add all roles to the RootRole - if (role.id !== rootRoleObj.id) { - role.relation("roles").add(rootRoleObj); - } - // Add all "roleNames" roles to the previous role - if (i > 0) { - role.relation("roles").add(roles[i - 1]); - } - }); + const restExecute = spyOn(RestQuery._UnsafeRestQuery.prototype, 'execute').and.callThrough(); - return Parse.Object.saveAll(roles, { useMasterKey: true }); - }).then( () => { - auth = new Auth({config: new Config("test"), isMaster: true, user: user}); - getAllRolesSpy = spyOn(auth, "_getAllRolesNamesForRoleIds").and.callThrough(); + let user, auth, getAllRolesSpy; + createTestUser() + .then(newUser => { + user = newUser; + return createAllRoles(user); + }) + .then(roles => { + const rootRoleObj = roleObjs[rootRole]; + roles.forEach(function (role, i) { + // Add all roles to the RootRole + if (role.id !== rootRoleObj.id) { + role.relation('roles').add(rootRoleObj); + } + // Add all "roleNames" roles to the previous role + if (i > 0) { + role.relation('roles').add(roles[i - 1]); + } + }); - return auth._loadRoles(); - }).then ( (roles) => { - expect(roles.length).toEqual(4); + return Parse.Object.saveAll(roles, { useMasterKey: true }); + }) + .then(() => { + auth = new Auth({ + config: Config.get('test'), + isMaster: true, + user: user, + }); + getAllRolesSpy = spyOn(auth, '_getAllRolesNamesForRoleIds').and.callThrough(); - allRoles.forEach(function(name) { - expect(roles.indexOf("role:"+name)).not.toBe(-1); - }); + return auth._loadRoles(); + }) + .then(roles => { + expect(roles.length).toEqual(4); - // 1 Query for the initial setup - // 1 query for the parent roles - expect(restExecute.calls.count()).toEqual(2); - - // 1 call for the 1st layer of roles - // 1 call for the 2nd layer - expect(getAllRolesSpy.calls.count()).toEqual(2); - done() - }).catch( (err) => { - fail("should succeed"); - done(); - }); + allRoles.forEach(function (name) { + expect(roles.indexOf('role:' + name)).not.toBe(-1); + }); + // 1 Query for the initial setup + // 1 query for the parent roles + expect(restExecute.calls.count()).toEqual(2); + + // 1 call for the 1st layer of roles + // 1 call for the 2nd layer + expect(getAllRolesSpy.calls.count()).toEqual(2); + done(); + }) + .catch(() => { + fail('should succeed'); + done(); + }); }); - it_exclude_dbs(['postgres'])("should recursively load roles", (done) => { - var rolesNames = ["FooRole", "BarRole", "BazRole"]; - var roleIds = {}; - createTestUser().then( (user) => { - // Put the user on the 1st role - return createRole(rolesNames[0], null, user).then( (aRole) => { - roleIds[aRole.get("name")] = aRole.id; - // set the 1st role as a sibling of the second - // user will should have 2 role now - return createRole(rolesNames[1], aRole, null); - }).then( (anotherRole) => { - roleIds[anotherRole.get("name")] = anotherRole.id; - // set this role as a sibling of the last - // the user should now have 3 roles - return createRole(rolesNames[2], anotherRole, null); - }).then( (lastRole) => { - roleIds[lastRole.get("name")] = lastRole.id; - var auth = new Auth({ config: new Config("test"), isMaster: true, user: user }); - return auth._loadRoles(); - }) - }).then( (roles) => { - expect(roles.length).toEqual(3); - rolesNames.forEach( (name) => { - expect(roles.indexOf('role:'+name)).not.toBe(-1); - }); - done(); - }, function(err){ - fail("should succeed") - done(); - }); + it('should recursively load roles', done => { + testLoadRoles(Config.get('test'), done); }); - it_exclude_dbs(['postgres'])("_Role object should not save without name.", (done) => { - var role = new Parse.Role(); - role.save(null,{useMasterKey:true}) - .then((r) => { - fail("_Role object should not save without name."); - }, (error) => { - expect(error.code).toEqual(111); - role.set('name','testRole'); - role.save(null,{useMasterKey:true}) - .then((r2)=>{ - fail("_Role object should not save without ACL."); - }, (error2) =>{ - expect(error2.code).toEqual(111); - done(); - }); - }); + it('should recursively load roles without config', done => { + testLoadRoles(undefined, done); }); - it("Should properly resolve roles", (done) => { - let admin = new Parse.Role("Admin", new Parse.ACL()); - let moderator = new Parse.Role("Moderator", new Parse.ACL()); - let superModerator = new Parse.Role("SuperModerator", new Parse.ACL()); - let contentManager = new Parse.Role('ContentManager', new Parse.ACL()); - let superContentManager = new Parse.Role('SuperContentManager', new Parse.ACL()); - Parse.Object.saveAll([admin, moderator, contentManager, superModerator, superContentManager], {useMasterKey: true}).then(() => { - contentManager.getRoles().add([moderator, superContentManager]); - moderator.getRoles().add([admin, superModerator]); - superContentManager.getRoles().add(superModerator); - return Parse.Object.saveAll([admin, moderator, contentManager, superModerator, superContentManager], {useMasterKey: true}); - }).then(() => { - var auth = new Auth({ config: new Config("test"), isMaster: true }); - // For each role, fetch their sibling, what they inherit - // return with result and roleId for later comparison - let promises = [admin, moderator, contentManager, superModerator].map((role) => { - return auth._getAllRolesNamesForRoleIds([role.id]).then((result) => { - return Parse.Promise.as({ - id: role.id, - name: role.get('name'), - roleNames: result - }); - }) - }); + it('_Role object should not save without name.', done => { + const role = new Parse.Role(); + role.save(null, { useMasterKey: true }).then( + () => { + fail('_Role object should not save without name.'); + }, + error => { + expect(error.code).toEqual(111); + role.set('name', 'testRole'); + role.save(null, { useMasterKey: true }).then( + () => { + fail('_Role object should not save without ACL.'); + }, + error2 => { + expect(error2.code).toEqual(111); + done(); + } + ); + } + ); + }); - return Parse.Promise.when(promises); - }).then((results) => { - results.forEach((result) => { - let id = result.id; - let roleNames = result.roleNames; - if (id == admin.id) { - expect(roleNames.length).toBe(2); - expect(roleNames.indexOf("Moderator")).not.toBe(-1); - expect(roleNames.indexOf("ContentManager")).not.toBe(-1); - } else if (id == moderator.id) { - expect(roleNames.length).toBe(1); - expect(roleNames.indexOf("ContentManager")).toBe(0); - } else if (id == contentManager.id) { - expect(roleNames.length).toBe(0); - } else if (id == superModerator.id) { - expect(roleNames.length).toBe(3); - expect(roleNames.indexOf("Moderator")).not.toBe(-1); - expect(roleNames.indexOf("ContentManager")).not.toBe(-1); - expect(roleNames.indexOf("SuperContentManager")).not.toBe(-1); + it('Different _Role objects cannot have the same name.', async done => { + await reconfigureServer(); + const roleName = 'MyRole'; + let aUser; + createTestUser() + .then(user => { + aUser = user; + return createRole(roleName, null, aUser); + }) + .then(firstRole => { + expect(firstRole.getName()).toEqual(roleName); + return createRole(roleName, null, aUser); + }) + .then( + () => { + fail('_Role cannot have the same name as another role'); + done(); + }, + error => { + expect(error.code).toEqual(137); + done(); } - }); - done(); - }).fail((err) => { - done(); + ); + }); + + it('Should properly resolve roles', done => { + const admin = new Parse.Role('Admin', new Parse.ACL()); + const moderator = new Parse.Role('Moderator', new Parse.ACL()); + const superModerator = new Parse.Role('SuperModerator', new Parse.ACL()); + const contentManager = new Parse.Role('ContentManager', new Parse.ACL()); + const superContentManager = new Parse.Role('SuperContentManager', new Parse.ACL()); + Parse.Object.saveAll([admin, moderator, contentManager, superModerator, superContentManager], { + useMasterKey: true, }) + .then(() => { + contentManager.getRoles().add([moderator, superContentManager]); + moderator.getRoles().add([admin, superModerator]); + superContentManager.getRoles().add(superModerator); + return Parse.Object.saveAll( + [admin, moderator, contentManager, superModerator, superContentManager], + { useMasterKey: true } + ); + }) + .then(() => { + const auth = new Auth({ config: Config.get('test'), isMaster: true }); + // For each role, fetch their sibling, what they inherit + // return with result and roleId for later comparison + const promises = [admin, moderator, contentManager, superModerator].map(role => { + return auth._getAllRolesNamesForRoleIds([role.id]).then(result => { + return Promise.resolve({ + id: role.id, + name: role.get('name'), + roleNames: result, + }); + }); + }); + return Promise.all(promises); + }) + .then(results => { + results.forEach(result => { + const id = result.id; + const roleNames = result.roleNames; + if (id == admin.id) { + expect(roleNames.length).toBe(2); + expect(roleNames.indexOf('Moderator')).not.toBe(-1); + expect(roleNames.indexOf('ContentManager')).not.toBe(-1); + } else if (id == moderator.id) { + expect(roleNames.length).toBe(1); + expect(roleNames.indexOf('ContentManager')).toBe(0); + } else if (id == contentManager.id) { + expect(roleNames.length).toBe(0); + } else if (id == superModerator.id) { + expect(roleNames.length).toBe(3); + expect(roleNames.indexOf('Moderator')).not.toBe(-1); + expect(roleNames.indexOf('ContentManager')).not.toBe(-1); + expect(roleNames.indexOf('SuperContentManager')).not.toBe(-1); + } + }); + done(); + }) + .catch(() => { + done(); + }); }); - it_exclude_dbs(['postgres'])('can create role and query empty users', (done)=> { - var roleACL = new Parse.ACL(); + it('can create role and query empty users', done => { + const roleACL = new Parse.ACL(); roleACL.setPublicReadAccess(true); - var role = new Parse.Role('subscribers', roleACL); - role.save({}, {useMasterKey : true}) - .then((x)=>{ - var query = role.relation('users').query(); - query.find({useMasterKey : true}) - .then((users)=>{ + const role = new Parse.Role('subscribers', roleACL); + role.save({}, { useMasterKey: true }).then( + () => { + const query = role.relation('users').query(); + query.find({ useMasterKey: true }).then( + () => { done(); - }, (e)=>{ + }, + () => { fail('should not have errors'); done(); - }); - }, (e) => { + } + ); + }, + () => { fail('should not have errored'); - }); + } + ); }); // Based on various scenarios described in issues #827 and #683, - it_exclude_dbs(['postgres'])('should properly handle role permissions on objects', (done) => { - var user, user2, user3; - var role, role2, role3; - var obj, obj2; + it('should properly handle role permissions on objects', done => { + let user, user2, user3; + let role, role2, role3; + let obj, obj2; - var prACL = new Parse.ACL(); + const prACL = new Parse.ACL(); prACL.setPublicReadAccess(true); - var adminACL, superACL, customerACL; - - createTestUser().then((x) => { - user = x; - user2 = new Parse.User(); - return user2.save({ username: 'user2', password: 'omgbbq' }); - }).then((x) => { - user3 = new Parse.User(); - return user3.save({ username: 'user3', password: 'omgbbq' }); - }).then((x) => { - role = new Parse.Role('Admin', prACL); - role.getUsers().add(user); - return role.save({}, { useMasterKey: true }); - }).then(() => { - adminACL = new Parse.ACL(); - adminACL.setRoleReadAccess("Admin", true); - adminACL.setRoleWriteAccess("Admin", true); - - role2 = new Parse.Role('Super', prACL); - role2.getUsers().add(user2); - return role2.save({}, { useMasterKey: true }); - }).then(() => { - superACL = new Parse.ACL(); - superACL.setRoleReadAccess("Super", true); - superACL.setRoleWriteAccess("Super", true); - - role.getRoles().add(role2); - return role.save({}, { useMasterKey: true }); - }).then(() => { - role3 = new Parse.Role('Customer', prACL); - role3.getUsers().add(user3); - role3.getRoles().add(role); - return role3.save({}, { useMasterKey: true }); - }).then(() => { - customerACL = new Parse.ACL(); - customerACL.setRoleReadAccess("Customer", true); - customerACL.setRoleWriteAccess("Customer", true); - - var query = new Parse.Query('_Role'); - return query.find({ useMasterKey: true }); - }).then((x) => { - expect(x.length).toEqual(3); - - obj = new Parse.Object('TestObjectRoles'); - obj.set('ACL', customerACL); - return obj.save(null, { useMasterKey: true }); - }).then(() => { - // Above, the Admin role was added to the Customer role. - // An object secured by the Customer ACL should be able to be edited by the Admin user. - obj.set('changedByAdmin', true); - return obj.save(null, { sessionToken: user.getSessionToken() }); - }).then(() => { - obj2 = new Parse.Object('TestObjectRoles'); - obj2.set('ACL', adminACL); - return obj2.save(null, { useMasterKey: true }); - }, (e) => { - fail('Admin user should have been able to save.'); - done(); - }).then(() => { - // An object secured by the Admin ACL should not be able to be edited by a Customer role user. - obj2.set('changedByCustomer', true); - return obj2.save(null, { sessionToken: user3.getSessionToken() }); - }).then(() => { - fail('Customer user should not have been able to save.'); - done(); - }, (e) => { - if (e) { - expect(e.code).toEqual(101); - } else { - fail('should return an error'); - } - done(); - }) + let adminACL, superACL, customerACL; + + createTestUser() + .then(x => { + user = x; + user2 = new Parse.User(); + return user2.save({ username: 'user2', password: 'omgbbq' }); + }) + .then(() => { + user3 = new Parse.User(); + return user3.save({ username: 'user3', password: 'omgbbq' }); + }) + .then(() => { + role = new Parse.Role('Admin', prACL); + role.getUsers().add(user); + return role.save({}, { useMasterKey: true }); + }) + .then(() => { + adminACL = new Parse.ACL(); + adminACL.setRoleReadAccess('Admin', true); + adminACL.setRoleWriteAccess('Admin', true); + + role2 = new Parse.Role('Super', prACL); + role2.getUsers().add(user2); + return role2.save({}, { useMasterKey: true }); + }) + .then(() => { + superACL = new Parse.ACL(); + superACL.setRoleReadAccess('Super', true); + superACL.setRoleWriteAccess('Super', true); + + role.getRoles().add(role2); + return role.save({}, { useMasterKey: true }); + }) + .then(() => { + role3 = new Parse.Role('Customer', prACL); + role3.getUsers().add(user3); + role3.getRoles().add(role); + return role3.save({}, { useMasterKey: true }); + }) + .then(() => { + customerACL = new Parse.ACL(); + customerACL.setRoleReadAccess('Customer', true); + customerACL.setRoleWriteAccess('Customer', true); + + const query = new Parse.Query('_Role'); + return query.find({ useMasterKey: true }); + }) + .then(x => { + expect(x.length).toEqual(3); + + obj = new Parse.Object('TestObjectRoles'); + obj.set('ACL', customerACL); + return obj.save(null, { useMasterKey: true }); + }) + .then(() => { + // Above, the Admin role was added to the Customer role. + // An object secured by the Customer ACL should be able to be edited by the Admin user. + obj.set('changedByAdmin', true); + return obj.save(null, { sessionToken: user.getSessionToken() }); + }) + .then( + () => { + obj2 = new Parse.Object('TestObjectRoles'); + obj2.set('ACL', adminACL); + return obj2.save(null, { useMasterKey: true }); + }, + () => { + fail('Admin user should have been able to save.'); + done(); + } + ) + .then(() => { + // An object secured by the Admin ACL should not be able to be edited by a Customer role user. + obj2.set('changedByCustomer', true); + return obj2.save(null, { sessionToken: user3.getSessionToken() }); + }) + .then( + () => { + fail('Customer user should not have been able to save.'); + done(); + }, + e => { + if (e) { + expect(e.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + } else { + fail('should return an error'); + } + done(); + } + ); + }); + + it('should add multiple users to a role and remove users', done => { + let user, user2, user3; + let role; + let obj; + + const prACL = new Parse.ACL(); + prACL.setPublicReadAccess(true); + prACL.setPublicWriteAccess(true); + + createTestUser() + .then(x => { + user = x; + user2 = new Parse.User(); + return user2.save({ username: 'user2', password: 'omgbbq' }); + }) + .then(() => { + user3 = new Parse.User(); + return user3.save({ username: 'user3', password: 'omgbbq' }); + }) + .then(() => { + role = new Parse.Role('sharedRole', prACL); + const users = role.relation('users'); + users.add(user); + users.add(user2); + users.add(user3); + return role.save({}, { useMasterKey: true }); + }) + .then(() => { + // query for saved role and get 3 users + const query = new Parse.Query('_Role'); + query.equalTo('name', 'sharedRole'); + return query.find({ useMasterKey: true }); + }) + .then(role => { + expect(role.length).toEqual(1); + const users = role[0].relation('users').query(); + return users.find({ useMasterKey: true }); + }) + .then(users => { + expect(users.length).toEqual(3); + obj = new Parse.Object('TestObjectRoles'); + obj.set('ACL', prACL); + return obj.save(null, { useMasterKey: true }); + }) + .then(() => { + // Above, the Admin role was added to the Customer role. + // An object secured by the Customer ACL should be able to be edited by the Admin user. + obj.set('changedByUsers', true); + return obj.save(null, { sessionToken: user.getSessionToken() }); + }) + .then(() => { + // query for saved role and get 3 users + const query = new Parse.Query('_Role'); + query.equalTo('name', 'sharedRole'); + return query.find({ useMasterKey: true }); + }) + .then(role => { + expect(role.length).toEqual(1); + const users = role[0].relation('users'); + users.remove(user); + users.remove(user3); + return role[0].save({}, { useMasterKey: true }); + }) + .then(role => { + const users = role.relation('users').query(); + return users.find({ useMasterKey: true }); + }) + .then(users => { + expect(users.length).toEqual(1); + expect(users[0].get('username')).toEqual('user2'); + done(); + }); + }); + + it('should be secure (#3835)', done => { + const acl = new Parse.ACL(); + acl.getPublicReadAccess(true); + const role = new Parse.Role('admin', acl); + role + .save() + .then(() => { + const user = new Parse.User(); + return user.signUp({ username: 'hello', password: 'world' }); + }) + .then(user => { + role.getUsers().add(user); + return role.save(); + }) + .then(done.fail, () => { + const query = role.getUsers().query(); + return query.find({ useMasterKey: true }); + }) + .then(results => { + expect(results.length).toBe(0); + done(); + }) + .catch(done.fail); + }); + + it('should match when matching in users relation', done => { + const user = new Parse.User(); + user.save({ username: 'admin', password: 'admin' }).then(user => { + const aCL = new Parse.ACL(); + aCL.setPublicReadAccess(true); + aCL.setPublicWriteAccess(true); + const role = new Parse.Role('admin', aCL); + const users = role.relation('users'); + users.add(user); + role.save({}, { useMasterKey: true }).then(() => { + const query = new Parse.Query(Parse.Role); + query.equalTo('name', 'admin'); + query.equalTo('users', user); + query.find().then(function (roles) { + expect(roles.length).toEqual(1); + done(); + }); + }); + }); + }); + + it('should not match any entry when not matching in users relation', done => { + const user = new Parse.User(); + user.save({ username: 'admin', password: 'admin' }).then(user => { + const aCL = new Parse.ACL(); + aCL.setPublicReadAccess(true); + aCL.setPublicWriteAccess(true); + const role = new Parse.Role('admin', aCL); + const users = role.relation('users'); + users.add(user); + role.save({}, { useMasterKey: true }).then(() => { + const otherUser = new Parse.User(); + otherUser.save({ username: 'otherUser', password: 'otherUser' }).then(otherUser => { + const query = new Parse.Query(Parse.Role); + query.equalTo('name', 'admin'); + query.equalTo('users', otherUser); + query.find().then(function (roles) { + expect(roles.length).toEqual(0); + done(); + }); + }); + }); + }); + }); + + it('should not match any entry when searching for null in users relation', done => { + const user = new Parse.User(); + user.save({ username: 'admin', password: 'admin' }).then(user => { + const aCL = new Parse.ACL(); + aCL.setPublicReadAccess(true); + aCL.setPublicWriteAccess(true); + const role = new Parse.Role('admin', aCL); + const users = role.relation('users'); + users.add(user); + role.save({}, { useMasterKey: true }).then(() => { + const query = new Parse.Query(Parse.Role); + query.equalTo('name', 'admin'); + query.equalTo('users', null); + query.find().then(function (roles) { + expect(roles.length).toEqual(0); + done(); + }); + }); + }); + }); + + it('should trigger afterSave hook when using Parse.Role', async () => { + const afterSavePromise = new Promise(resolve => { + Parse.Cloud.afterSave(Parse.Role, req => { + expect(req.object).toBeDefined(); + expect(req.object.get('name')).toBe('AnotherTestRole'); + resolve(); + }); + }); + + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + const role = new Parse.Role('AnotherTestRole', acl); + + const savedRole = await role.save({}, { useMasterKey: true }); + expect(savedRole.id).toBeDefined(); + + await afterSavePromise; + }); + + it('should trigger beforeSave hook and allow modifying role in beforeSave', async () => { + Parse.Cloud.beforeSave(Parse.Role, req => { + // Add a custom field in beforeSave + req.object.set('customField', 'addedInBeforeSave'); + }); + + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + const role = new Parse.Role('ModifiedRole', acl); + + const savedRole = await role.save({}, { useMasterKey: true }); + expect(savedRole.id).toBeDefined(); + expect(savedRole.get('customField')).toBe('addedInBeforeSave'); + }); + + it('should trigger beforeSave hook using Parse.Role', async () => { + let beforeSaveCalled = false; + + Parse.Cloud.beforeSave(Parse.Role, req => { + beforeSaveCalled = true; + expect(req.object).toBeDefined(); + expect(req.object.get('name')).toBe('BeforeSaveWithClassRef'); + }); + + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + const role = new Parse.Role('BeforeSaveWithClassRef', acl); + + const savedRole = await role.save({}, { useMasterKey: true }); + expect(savedRole.id).toBeDefined(); + expect(beforeSaveCalled).toBe(true); }); + it('should allow modifying role name in beforeSave hook', async () => { + Parse.Cloud.beforeSave(Parse.Role, req => { + // Modify the role name in beforeSave + if (req.object.get('name') === 'OriginalName') { + req.object.set('name', 'ModifiedName'); + } + }); + + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + const role = new Parse.Role('OriginalName', acl); + + const savedRole = await role.save({}, { useMasterKey: true }); + expect(savedRole.id).toBeDefined(); + expect(savedRole.get('name')).toBe('ModifiedName'); + + // Verify the name was actually saved to the database + const query = new Parse.Query(Parse.Role); + const fetchedRole = await query.get(savedRole.id, { useMasterKey: true }); + expect(fetchedRole.get('name')).toBe('ModifiedName'); + }); }); diff --git a/spec/ParseServer.spec.js b/spec/ParseServer.spec.js new file mode 100644 index 0000000000..1450522579 --- /dev/null +++ b/spec/ParseServer.spec.js @@ -0,0 +1,63 @@ +'use strict'; +/* Tests for ParseServer.js */ +const express = require('express'); +const ParseServer = require('../lib/ParseServer').default; +const path = require('path'); +const { spawn } = require('child_process'); + +describe('Server Url Checks', () => { + let server; + beforeEach(done => { + if (!server) { + const app = express(); + app.get('/health', function (req, res) { + res.json({ + status: 'ok', + }); + }); + server = app.listen(13376, undefined, done); + } else { + done(); + } + }); + + afterAll(done => { + Parse.serverURL = 'http://localhost:8378/1'; + server.close(done); + }); + + it('validate good server url', async () => { + Parse.serverURL = 'http://localhost:13376'; + const response = await ParseServer.verifyServerUrl(); + expect(response).toBeTrue(); + }); + + it('mark bad server url', async () => { + spyOn(console, 'warn').and.callFake(() => {}); + Parse.serverURL = 'notavalidurl'; + const response = await ParseServer.verifyServerUrl(); + expect(response).not.toBeTrue(); + expect(console.warn).toHaveBeenCalledWith( + `\nWARNING, Unable to connect to 'notavalidurl' as the URL is invalid. Cloud code and push notifications may be unavailable!\n` + ); + }); + + it('does not have unhandled promise rejection in the case of load error', done => { + const parseServerProcess = spawn(path.resolve(__dirname, './support/FailingServer.js')); + let stdout; + let stderr; + parseServerProcess.stdout.on('data', data => { + stdout = data.toString(); + }); + parseServerProcess.stderr.on('data', data => { + stderr = data.toString(); + }); + parseServerProcess.on('close', async code => { + expect(code).toEqual(1); + expect(stdout).not.toContain('UnhandledPromiseRejectionWarning'); + expect(stderr).toContain('Database error'); + await reconfigureServer(); + done(); + }); + }); +}); diff --git a/spec/ParseServerRESTController.spec.js b/spec/ParseServerRESTController.spec.js new file mode 100644 index 0000000000..061f2fcf28 --- /dev/null +++ b/spec/ParseServerRESTController.spec.js @@ -0,0 +1,769 @@ +const ParseServerRESTController = require('../lib/ParseServerRESTController') + .ParseServerRESTController; +const ParseServer = require('../lib/ParseServer').default; +const Parse = require('parse/node').Parse; + +let RESTController; + +describe('ParseServerRESTController', () => { + let createSpy; + beforeEach(() => { + RESTController = ParseServerRESTController( + Parse.applicationId, + ParseServer.promiseRouter({ appId: Parse.applicationId }) + ); + createSpy = spyOn(databaseAdapter, 'createObject').and.callThrough(); + }); + + it('should handle a get request', async () => { + const res = await RESTController.request('GET', '/classes/MyObject'); + expect(res.results.length).toBe(0); + }); + + it('should handle a get request with full serverURL mount path', async () => { + const res = await RESTController.request('GET', '/1/classes/MyObject'); + expect(res.results.length).toBe(0); + }); + + it('should handle a POST batch without transaction', async () => { + const res = await RESTController.request('POST', 'batch', { + requests: [ + { + method: 'GET', + path: '/classes/MyObject', + }, + { + method: 'POST', + path: '/classes/MyObject', + body: { key: 'value' }, + }, + { + method: 'GET', + path: '/classes/MyObject', + }, + ], + }); + expect(res.length).toBe(3); + }); + + it('should handle a POST batch with transaction=false', async () => { + const res = await RESTController.request('POST', 'batch', { + requests: [ + { + method: 'GET', + path: '/classes/MyObject', + }, + { + method: 'POST', + path: '/classes/MyObject', + body: { key: 'value' }, + }, + { + method: 'GET', + path: '/classes/MyObject', + }, + ], + transaction: false, + }); + expect(res.length).toBe(3); + }); + + it('should handle response status', async () => { + const router = ParseServer.promiseRouter({ appId: Parse.applicationId }); + spyOn(router, 'tryRouteRequest').and.callThrough(); + RESTController = ParseServerRESTController(Parse.applicationId, router); + const resp = await RESTController.request('POST', '/classes/MyObject'); + const { status, response, location } = await router.tryRouteRequest.calls.all()[0].returnValue; + + expect(status).toBe(201); + expect(response).toEqual(resp); + expect(location).toBe(`http://localhost:8378/1/classes/MyObject/${resp.objectId}`); + }); + + it('should handle response status in batch', async () => { + const router = ParseServer.promiseRouter({ appId: Parse.applicationId }); + spyOn(router, 'tryRouteRequest').and.callThrough(); + RESTController = ParseServerRESTController(Parse.applicationId, router); + const resp = await RESTController.request( + 'POST', + 'batch', + { + requests: [ + { + method: 'POST', + path: '/classes/MyObject', + }, + { + method: 'POST', + path: '/classes/MyObject', + }, + ], + }, + { + returnStatus: true, + } + ); + expect(resp.length).toBe(2); + expect(resp[0]._status).toBe(201); + expect(resp[1]._status).toBe(201); + expect(resp[0].success).toBeDefined(); + expect(resp[1].success).toBeDefined(); + expect(router.tryRouteRequest.calls.all().length).toBe(2); + }); + + it('properly handle existed', async done => { + const restController = Parse.CoreManager.getRESTController(); + Parse.CoreManager.setRESTController(RESTController); + Parse.Cloud.define('handleStatus', async () => { + const obj = new Parse.Object('TestObject'); + expect(obj.existed()).toBe(false); + await obj.save(); + expect(obj.existed()).toBe(false); + + const query = new Parse.Query('TestObject'); + const result = await query.get(obj.id); + expect(result.existed()).toBe(true); + Parse.CoreManager.setRESTController(restController); + done(); + }); + await Parse.Cloud.run('handleStatus'); + }); + + if ( + process.env.MONGODB_TOPOLOGY === 'replicaset' || + process.env.PARSE_SERVER_TEST_DB === 'postgres' + ) { + describe('transactions', () => { + it('should handle a batch request with transaction = true', async () => { + const myObject = new Parse.Object('MyObject'); // This is important because transaction only works on pre-existing collections + await myObject.save(); + await myObject.destroy(); + createSpy.calls.reset(); + const response = await RESTController.request('POST', 'batch', { + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value2' }, + }, + ], + transaction: true, + }); + expect(response.length).toEqual(2); + expect(response[0].success.objectId).toBeDefined(); + expect(response[0].success.createdAt).toBeDefined(); + expect(response[1].success.objectId).toBeDefined(); + expect(response[1].success.createdAt).toBeDefined(); + const query = new Parse.Query('MyObject'); + const results = await query.find(); + expect(createSpy.calls.count()).toBe(2); + for (let i = 0; i + 1 < createSpy.calls.length; i = i + 2) { + expect(createSpy.calls.argsFor(i)[3]).toBe( + createSpy.calls.argsFor(i + 1)[3] + ); + } + expect(results.map(result => result.get('key')).sort()).toEqual(['value1', 'value2']); + }); + + it('should not save anything when one operation fails in a transaction', async () => { + const myObject = new Parse.Object('MyObject'); // This is important because transaction only works on pre-existing collections + await myObject.save({ key: 'stringField' }); + await myObject.destroy(); + createSpy.calls.reset(); + try { + // Saving a number to a string field should fail + await RESTController.request('POST', 'batch', { + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + ], + transaction: true, + }); + fail(); + } catch (error) { + expect(error).toBeDefined(); + const query = new Parse.Query('MyObject'); + const results = await query.find(); + expect(results.length).toBe(0); + } + }); + + it('should generate separate session for each call', async () => { + const myObject = new Parse.Object('MyObject'); // This is important because transaction only works on pre-existing collections + await myObject.save({ key: 'stringField' }); + await myObject.destroy(); + + const myObject2 = new Parse.Object('MyObject2'); // This is important because transaction only works on pre-existing collections + await myObject2.save({ key: 'stringField' }); + await myObject2.destroy(); + + createSpy.calls.reset(); + + let myObjectCalls = 0; + Parse.Cloud.beforeSave('MyObject', async () => { + myObjectCalls++; + if (myObjectCalls === 2) { + try { + // Saving a number to a string field should fail + await RESTController.request('POST', 'batch', { + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + ], + transaction: true, + }); + fail('should fail'); + } catch (e) { + expect(e).toBeDefined(); + } + } + }); + + const response = await RESTController.request('POST', 'batch', { + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value2' }, + }, + ], + transaction: true, + }); + + expect(response.length).toEqual(2); + expect(response[0].success.objectId).toBeDefined(); + expect(response[0].success.createdAt).toBeDefined(); + expect(response[1].success.objectId).toBeDefined(); + expect(response[1].success.createdAt).toBeDefined(); + + await RESTController.request('POST', 'batch', { + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject3', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject3', + body: { key: 'value2' }, + }, + ], + }); + + const query = new Parse.Query('MyObject'); + const results = await query.find(); + expect(results.map(result => result.get('key')).sort()).toEqual(['value1', 'value2']); + + const query2 = new Parse.Query('MyObject2'); + const results2 = await query2.find(); + expect(results2.length).toEqual(0); + + const query3 = new Parse.Query('MyObject3'); + const results3 = await query3.find(); + expect(results3.map(result => result.get('key')).sort()).toEqual(['value1', 'value2']); + + expect(createSpy.calls.count() >= 13).toEqual(true); + let transactionalSession; + let transactionalSession2; + let myObjectDBCalls = 0; + let myObject2DBCalls = 0; + let myObject3DBCalls = 0; + for (let i = 0; i < createSpy.calls.count(); i++) { + const args = createSpy.calls.argsFor(i); + switch (args[0]) { + case 'MyObject': + myObjectDBCalls++; + if (!transactionalSession || (myObjectDBCalls - 1) % 2 === 0) { + transactionalSession = args[3]; + } else { + expect(transactionalSession).toBe(args[3]); + } + if (transactionalSession2) { + expect(transactionalSession2).not.toBe(args[3]); + } + break; + case 'MyObject2': + myObject2DBCalls++; + if (!transactionalSession2 || (myObject2DBCalls - 1) % 9 === 0) { + transactionalSession2 = args[3]; + } else { + expect(transactionalSession2).toBe(args[3]); + } + if (transactionalSession) { + expect(transactionalSession).not.toBe(args[3]); + } + break; + case 'MyObject3': + myObject3DBCalls++; + expect(args[3]).toEqual(null); + break; + } + } + expect(myObjectDBCalls % 2).toEqual(0); + expect(myObjectDBCalls > 0).toEqual(true); + expect(myObject2DBCalls % 9).toEqual(0); + expect(myObject2DBCalls > 0).toEqual(true); + expect(myObject3DBCalls % 2).toEqual(0); + expect(myObject3DBCalls > 0).toEqual(true); + }); + }); + } + + it('should handle a POST request', async () => { + await RESTController.request('POST', '/classes/MyObject', { key: 'value' }); + const res = await RESTController.request('GET', '/classes/MyObject'); + expect(res.results.length).toBe(1); + expect(res.results[0].key).toEqual('value'); + }); + + it('should handle a POST request with context', async () => { + Parse.Cloud.beforeSave('MyObject', req => { + expect(req.context.a).toEqual('a'); + }); + Parse.Cloud.afterSave('MyObject', req => { + expect(req.context.a).toEqual('a'); + }); + + await RESTController.request( + 'POST', + '/classes/MyObject', + { key: 'value' }, + { context: { a: 'a' } } + ); + }); + + it('should deep copy context so mutations in beforeSave do not leak across requests', async () => { + const sharedContext = { counter: 0, nested: { value: 'original' } }; + + Parse.Cloud.beforeSave('ContextTestObject', req => { + // Mutate the context in beforeSave + req.context.counter = (req.context.counter || 0) + 1; + req.context.nested.value = 'mutated'; + req.context.addedByHook = true; + }); + + // First save — this should not affect the original sharedContext + await RESTController.request( + 'POST', + '/classes/ContextTestObject', + { key: 'value1' }, + { context: sharedContext } + ); + + // The original context object must remain unchanged + expect(sharedContext.counter).toEqual(0); + expect(sharedContext.nested.value).toEqual('original'); + expect(sharedContext.addedByHook).toBeUndefined(); + + // Second save with the same context — should also start with the original values + await RESTController.request( + 'POST', + '/classes/ContextTestObject', + { key: 'value2' }, + { context: sharedContext } + ); + + // The original context object must still remain unchanged + expect(sharedContext.counter).toEqual(0); + expect(sharedContext.nested.value).toEqual('original'); + expect(sharedContext.addedByHook).toBeUndefined(); + }); + + it('should isolate context between concurrent requests', async () => { + const contexts = []; + + Parse.Cloud.beforeSave('ConcurrentContextObject', req => { + // Each request should see its own context, not a shared one + req.context.requestId = req.object.get('requestId'); + contexts.push({ ...req.context }); + }); + + const sharedContext = { shared: true }; + + await Promise.all([ + RESTController.request( + 'POST', + '/classes/ConcurrentContextObject', + { requestId: 'req1' }, + { context: sharedContext } + ), + RESTController.request( + 'POST', + '/classes/ConcurrentContextObject', + { requestId: 'req2' }, + { context: sharedContext } + ), + ]); + + // Each hook should have seen its own requestId, not the other's + const req1Context = contexts.find(c => c.requestId === 'req1'); + const req2Context = contexts.find(c => c.requestId === 'req2'); + expect(req1Context).toBeDefined(); + expect(req2Context).toBeDefined(); + expect(req1Context.requestId).toEqual('req1'); + expect(req2Context.requestId).toEqual('req2'); + // Original context must remain unchanged + expect(sharedContext.requestId).toBeUndefined(); + }); + + it('should reject with an error when context contains non-cloneable values', async () => { + const nonCloneableContext = { fn: () => {} }; + try { + await RESTController.request( + 'POST', + '/classes/MyObject', + { key: 'value' }, + { context: nonCloneableContext } + ); + fail('should have rejected for non-cloneable context'); + } catch (error) { + expect(error).toBeDefined(); + expect(error.code).toEqual(Parse.Error.INVALID_VALUE); + expect(error.message).toContain('Context contains non-cloneable values'); + } + }); + + it('ensures sessionTokens are properly handled', async () => { + const user = await Parse.User.signUp('user', 'pass'); + const sessionToken = user.getSessionToken(); + const res = await RESTController.request('GET', '/users/me', undefined, { + sessionToken, + }); + // Result is in JSON format + expect(res.objectId).toEqual(user.id); + }); + + it('ensures masterKey is properly handled', async () => { + const user = await Parse.User.signUp('user', 'pass'); + const userId = user.id; + await Parse.User.logOut(); + const res = await RESTController.request('GET', '/classes/_User', undefined, { + useMasterKey: true, + }); + expect(res.results.length).toBe(1); + expect(res.results[0].objectId).toEqual(userId); + }); + + it('ensures no user is created when passing an empty username', async () => { + try { + await RESTController.request('POST', '/classes/_User', { + username: '', + password: 'world', + }); + fail('Success callback should not be called when passing an empty username.'); + } catch (err) { + expect(err.code).toBe(Parse.Error.USERNAME_MISSING); + expect(err.message).toBe('bad or missing username'); + } + }); + + it('ensures no user is created when passing an empty password', async () => { + try { + await RESTController.request('POST', '/classes/_User', { + username: 'hello', + password: '', + }); + fail('Success callback should not be called when passing an empty password.'); + } catch (err) { + expect(err.code).toBe(Parse.Error.PASSWORD_MISSING); + expect(err.message).toBe('password is required'); + } + }); + + it('ensures no session token is created on creating users', async () => { + const user = await RESTController.request('POST', '/classes/_User', { + username: 'hello', + password: 'world', + }); + expect(user.sessionToken).toBeUndefined(); + const query = new Parse.Query('_Session'); + const sessions = await query.find({ useMasterKey: true }); + expect(sessions.length).toBe(0); + }); + + it('ensures a session token is created when passing installationId != cloud', async () => { + const user = await RESTController.request( + 'POST', + '/classes/_User', + { username: 'hello', password: 'world' }, + { installationId: 'my-installation' } + ); + expect(user.sessionToken).not.toBeUndefined(); + const query = new Parse.Query('_Session'); + const sessions = await query.find({ useMasterKey: true }); + expect(sessions.length).toBe(1); + expect(sessions[0].get('installationId')).toBe('my-installation'); + }); + + it('ensures logIn is saved with installationId', async () => { + const installationId = 'installation123'; + const user = await RESTController.request( + 'POST', + '/classes/_User', + { username: 'hello', password: 'world' }, + { installationId } + ); + expect(user.sessionToken).not.toBeUndefined(); + const query = new Parse.Query('_Session'); + let sessions = await query.find({ useMasterKey: true }); + + expect(sessions.length).toBe(1); + expect(sessions[0].get('installationId')).toBe(installationId); + expect(sessions[0].get('sessionToken')).toBe(user.sessionToken); + + const loggedUser = await RESTController.request( + 'POST', + '/login', + { username: 'hello', password: 'world' }, + { installationId } + ); + expect(loggedUser.sessionToken).not.toBeUndefined(); + sessions = await query.find({ useMasterKey: true }); + + // Should clean up old sessions with this installationId + expect(sessions.length).toBe(1); + expect(sessions[0].get('installationId')).toBe(installationId); + expect(sessions[0].get('sessionToken')).toBe(loggedUser.sessionToken); + }); + + it('returns a statusId when running jobs', async () => { + Parse.Cloud.job('CloudJob', () => { + return 'Cloud job completed'; + }); + const res = await RESTController.request( + 'POST', + '/jobs/CloudJob', + {}, + { useMasterKey: true, returnStatus: true } + ); + const jobStatusId = res._headers['X-Parse-Job-Status-Id']; + expect(jobStatusId).toBeDefined(); + const result = await Parse.Cloud.getJobStatus(jobStatusId); + expect(result.id).toBe(jobStatusId); + }); + + it('returns a statusId when running push notifications', async () => { + const payload = { + data: { alert: 'We return status!' }, + where: { deviceType: 'ios' }, + }; + const res = await RESTController.request('POST', '/push', payload, { + useMasterKey: true, + returnStatus: true, + }); + const pushStatusId = res._headers['X-Parse-Push-Status-Id']; + expect(pushStatusId).toBeDefined(); + + const result = await Parse.Push.getPushStatus(pushStatusId); + expect(result.id).toBe(pushStatusId); + }); + + it('returns a statusId when running batch push notifications', async () => { + const payload = { + data: { alert: 'We return status!' }, + where: { deviceType: 'ios' }, + }; + const res = await RESTController.request('POST', 'batch', { + requests: [{ + method: 'POST', + path: '/push', + body: payload, + }], + }, { + useMasterKey: true, + returnStatus: true, + }); + const pushStatusId = res[0]._headers['X-Parse-Push-Status-Id']; + expect(pushStatusId).toBeDefined(); + + const result = await Parse.Push.getPushStatus(pushStatusId); + expect(result.id).toBe(pushStatusId); + }); +}); diff --git a/spec/ParseSession.spec.js b/spec/ParseSession.spec.js new file mode 100644 index 0000000000..b622bb04c0 --- /dev/null +++ b/spec/ParseSession.spec.js @@ -0,0 +1,697 @@ +// +// Tests behavior of Parse Sessions +// + +'use strict'; +const request = require('../lib/request'); + +function setupTestUsers() { + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + const user1 = new Parse.User(); + const user2 = new Parse.User(); + const user3 = new Parse.User(); + + user1.set('username', 'testuser_1'); + user2.set('username', 'testuser_2'); + user3.set('username', 'testuser_3'); + + user1.set('password', 'password'); + user2.set('password', 'password'); + user3.set('password', 'password'); + + user1.setACL(acl); + user2.setACL(acl); + user3.setACL(acl); + + return user1 + .signUp() + .then(() => { + return user2.signUp(); + }) + .then(() => { + return user3.signUp(); + }); +} + +describe('Parse.Session', () => { + // multiple sessions with masterKey + sessionToken + it('should retain original sessionTokens with masterKey & sessionToken set', done => { + setupTestUsers() + .then(user => { + const query = new Parse.Query(Parse.Session); + return query.find({ + useMasterKey: true, + sessionToken: user.get('sessionToken'), + }); + }) + .then(results => { + const foundKeys = []; + expect(results.length).toBe(3); + for (const key in results) { + const sessionToken = results[key].get('sessionToken'); + if (foundKeys[sessionToken]) { + fail('Duplicate session token present in response'); + break; + } + foundKeys[sessionToken] = 1; + } + done(); + }) + .catch(err => { + fail(err); + }); + }); + + // single session returned, with just one sessionToken + it('should retain original sessionTokens with just sessionToken set', done => { + let knownSessionToken; + setupTestUsers() + .then(user => { + knownSessionToken = user.get('sessionToken'); + const query = new Parse.Query(Parse.Session); + return query.find({ + sessionToken: knownSessionToken, + }); + }) + .then(results => { + expect(results.length).toBe(1); + const sessionToken = results[0].get('sessionToken'); + expect(sessionToken).toBe(knownSessionToken); + done(); + }) + .catch(err => { + fail(err); + }); + }); + + // multiple users with masterKey + sessionToken + it('token on users should retain original sessionTokens with masterKey & sessionToken set', done => { + setupTestUsers() + .then(user => { + const query = new Parse.Query(Parse.User); + return query.find({ + useMasterKey: true, + sessionToken: user.get('sessionToken'), + }); + }) + .then(results => { + const foundKeys = []; + expect(results.length).toBe(3); + for (const key in results) { + const sessionToken = results[key].get('sessionToken'); + if (foundKeys[sessionToken] && sessionToken !== undefined) { + fail('Duplicate session token present in response'); + break; + } + foundKeys[sessionToken] = 1; + } + done(); + }) + .catch(err => { + fail(err); + }); + }); + + // multiple users with just sessionToken + it('token on users should retain original sessionTokens with just sessionToken set', done => { + let knownSessionToken; + setupTestUsers() + .then(user => { + knownSessionToken = user.get('sessionToken'); + const query = new Parse.Query(Parse.User); + return query.find({ + sessionToken: knownSessionToken, + }); + }) + .then(results => { + const foundKeys = []; + expect(results.length).toBe(3); + for (const key in results) { + const sessionToken = results[key].get('sessionToken'); + if (foundKeys[sessionToken] && sessionToken !== undefined) { + fail('Duplicate session token present in response'); + break; + } + foundKeys[sessionToken] = 1; + } + + done(); + }) + .catch(err => { + fail(err); + }); + }); + + it('cannot edit session with known ID', async () => { + await setupTestUsers(); + const [first, second] = await new Parse.Query(Parse.Session).find({ useMasterKey: true }); + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Rest-API-Key': 'rest', + 'X-Parse-Session-Token': second.get('sessionToken'), + 'Content-Type': 'application/json', + }; + const firstUser = first.get('user').id; + const secondUser = second.get('user').id; + const e = await request({ + method: 'PUT', + headers, + url: `http://localhost:8378/1/sessions/${first.id}`, + body: JSON.stringify({ + foo: 'bar', + user: { __type: 'Pointer', className: '_User', objectId: secondUser }, + }), + }).catch(e => e.data); + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + expect(e.error).toBe('Object not found.'); + await Parse.Object.fetchAll([first, second], { useMasterKey: true }); + expect(first.get('user').id).toBe(firstUser); + expect(second.get('user').id).toBe(secondUser); + }); + + it('should ignore sessionToken when creating a session via POST /classes/_Session', async () => { + const user = await Parse.User.signUp('sessionuser', 'password'); + const sessionToken = user.getSessionToken(); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/_Session', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + sessionToken: 'r:ATTACKER_CONTROLLED_TOKEN', + }, + }); + + // The returned session should have a server-generated token, not the attacker's + expect(response.data.sessionToken).not.toBe('r:ATTACKER_CONTROLLED_TOKEN'); + expect(response.data.sessionToken).toMatch(/^r:/); + }); + + it('should ignore expiresAt when creating a session via POST /classes/_Session', async () => { + const user = await Parse.User.signUp('sessionuser2', 'password'); + const sessionToken = user.getSessionToken(); + const farFuture = new Date('2099-12-31T23:59:59.000Z'); + + await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/_Session', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + expiresAt: { __type: 'Date', iso: farFuture.toISOString() }, + }, + }); + + // Fetch the newly created session and verify expiresAt is server-generated, not 2099 + const sessions = await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/_Session', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }); + const newSession = sessions.data.results.find(s => s.sessionToken !== sessionToken); + const expiresAt = new Date(newSession.expiresAt.iso); + expect(expiresAt.getFullYear()).not.toBe(2099); + }); + + it('should ignore createdWith when creating a session via POST /classes/_Session', async () => { + const user = await Parse.User.signUp('sessionuser3', 'password'); + const sessionToken = user.getSessionToken(); + + await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/_Session', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + createdWith: { action: 'attacker', authProvider: 'evil' }, + }, + }); + + const sessions = await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/_Session', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }); + const newSession = sessions.data.results.find(s => s.sessionToken !== sessionToken); + expect(newSession.createdWith.action).toBe('create'); + expect(newSession.createdWith.authProvider).toBeUndefined(); + }); + + it('should reject expiresAt when updating a session via PUT', async () => { + const user = await Parse.User.signUp('sessionupdateuser1', 'password'); + const sessionToken = user.getSessionToken(); + + // Get the session objectId + const sessionRes = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + const sessionId = sessionRes.data.objectId; + const originalExpiresAt = sessionRes.data.expiresAt; + + // Attempt to overwrite expiresAt via PUT + const updateRes = await request({ + method: 'PUT', + url: `http://localhost:8378/1/sessions/${sessionId}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + expiresAt: { __type: 'Date', iso: '2099-12-31T23:59:59.000Z' }, + }, + }).catch(e => e); + + expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + + // Verify expiresAt was not changed + const verifyRes = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + expect(verifyRes.data.expiresAt).toEqual(originalExpiresAt); + }); + + it('should reject createdWith when updating a session via PUT', async () => { + const user = await Parse.User.signUp('sessionupdateuser2', 'password'); + const sessionToken = user.getSessionToken(); + + // Get the session objectId + const sessionRes = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + const sessionId = sessionRes.data.objectId; + const originalCreatedWith = sessionRes.data.createdWith; + + // Attempt to overwrite createdWith via PUT + const updateRes = await request({ + method: 'PUT', + url: `http://localhost:8378/1/sessions/${sessionId}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + createdWith: { action: 'attacker', authProvider: 'evil' }, + }, + }).catch(e => e); + + expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + + // Verify createdWith was not changed + const verifyRes = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + expect(verifyRes.data.createdWith).toEqual(originalCreatedWith); + }); + + it('should allow master key to update expiresAt on a session', async () => { + const user = await Parse.User.signUp('sessionupdateuser3', 'password'); + const sessionToken = user.getSessionToken(); + + // Get the session objectId + const sessionRes = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + const sessionId = sessionRes.data.objectId; + const farFuture = '2099-12-31T23:59:59.000Z'; + + // Master key should be able to update expiresAt + await request({ + method: 'PUT', + url: `http://localhost:8378/1/sessions/${sessionId}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', + }, + body: { + expiresAt: { __type: 'Date', iso: farFuture }, + }, + }); + + // Verify expiresAt was changed + const verifyRes = await request({ + method: 'GET', + url: `http://localhost:8378/1/sessions/${sessionId}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }); + expect(verifyRes.data.expiresAt.iso).toBe(farFuture); + }); + + it('should reject null expiresAt when updating a session via PUT', async () => { + const user = await Parse.User.signUp('sessionupdatenull1', 'password'); + const sessionToken = user.getSessionToken(); + + const sessionRes = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + const sessionId = sessionRes.data.objectId; + const originalExpiresAt = sessionRes.data.expiresAt; + + const updateRes = await request({ + method: 'PUT', + url: `http://localhost:8378/1/sessions/${sessionId}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + expiresAt: null, + }, + }).catch(e => e); + + expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + + const verifyRes = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + expect(verifyRes.data.expiresAt).toEqual(originalExpiresAt); + }); + + it('should reject null createdWith when updating a session via PUT', async () => { + const user = await Parse.User.signUp('sessionupdatenull2', 'password'); + const sessionToken = user.getSessionToken(); + + const sessionRes = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + const sessionId = sessionRes.data.objectId; + const originalCreatedWith = sessionRes.data.createdWith; + + const updateRes = await request({ + method: 'PUT', + url: `http://localhost:8378/1/sessions/${sessionId}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + createdWith: null, + }, + }).catch(e => e); + + expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + + const verifyRes = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + expect(verifyRes.data.createdWith).toEqual(originalCreatedWith); + }); + + it('should reject null installationId when updating a session via PUT', async () => { + const user = await Parse.User.signUp('sessionupdatenull3', 'password'); + const sessionToken = user.getSessionToken(); + + const sessionRes = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + const sessionId = sessionRes.data.objectId; + + const updateRes = await request({ + method: 'PUT', + url: `http://localhost:8378/1/sessions/${sessionId}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + installationId: null, + }, + }).catch(e => e); + + expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it('should reject null sessionToken when updating a session via PUT', async () => { + const user = await Parse.User.signUp('sessionupdatenull4', 'password'); + const sessionToken = user.getSessionToken(); + + const sessionRes = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + const sessionId = sessionRes.data.objectId; + + const updateRes = await request({ + method: 'PUT', + url: `http://localhost:8378/1/sessions/${sessionId}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + sessionToken: null, + }, + }).catch(e => e); + + expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it('should reject null ACL when updating a session via PUT', async () => { + const user = await Parse.User.signUp('sessionupdatenull5', 'password'); + const sessionToken = user.getSessionToken(); + + const sessionRes = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + const sessionId = sessionRes.data.objectId; + + const updateRes = await request({ + method: 'PUT', + url: `http://localhost:8378/1/sessions/${sessionId}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + ACL: null, + }, + }).catch(e => e); + + expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it('should reject null ACL when creating a session via POST', async () => { + const user = await Parse.User.signUp('sessioncreatenull1', 'password'); + const sessionToken = user.getSessionToken(); + + const createRes = await request({ + method: 'POST', + url: 'http://localhost:8378/1/sessions', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + ACL: null, + }, + }).catch(e => e); + + expect(createRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it('should reject null user when updating a session via PUT', async () => { + const user = await Parse.User.signUp('sessionupdatenull6', 'password'); + const sessionToken = user.getSessionToken(); + + const sessionRes = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + const sessionId = sessionRes.data.objectId; + + const updateRes = await request({ + method: 'PUT', + url: `http://localhost:8378/1/sessions/${sessionId}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + user: null, + }, + }).catch(e => e); + + expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + describe('PUT /sessions/me', () => { + it('should return error with invalid session token', async () => { + const response = await request({ + method: 'PUT', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': 'r:invalid-session-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).not.toBe(500); + expect(response.data.code).toBe(Parse.Error.INVALID_SESSION_TOKEN); + }); + + it('should return error without session token', async () => { + const response = await request({ + method: 'PUT', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBeGreaterThanOrEqual(400); + expect(response.status).toBeLessThan(500); + expect(response.data?.code).toBeDefined(); + }); + }); + + describe('DELETE /sessions/me', () => { + it('should return error with invalid session token', async () => { + const response = await request({ + method: 'DELETE', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': 'r:invalid-session-token', + }, + }).catch(e => e); + expect(response.status).not.toBe(500); + expect(response.data.code).toBe(Parse.Error.INVALID_SESSION_TOKEN); + }); + + it('should return error without session token', async () => { + const response = await request({ + method: 'DELETE', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }).catch(e => e); + expect(response.status).toBeGreaterThanOrEqual(400); + expect(response.status).toBeLessThan(500); + expect(response.data?.code).toBeDefined(); + }); + }); +}); diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 8a4d918649..8e31097b81 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -5,909 +5,1282 @@ // Tests that involve revocable sessions. // Tests that involve sending password reset emails. -"use strict"; - -var request = require('request'); -var passwordCrypto = require('../src/password'); -var Config = require('../src/Config'); -const rp = require('request-promise'); - -function verifyACL(user) { - const ACL = user.getACL(); - expect(ACL.getReadAccess(user)).toBe(true); - expect(ACL.getWriteAccess(user)).toBe(true); - expect(ACL.getPublicReadAccess()).toBe(true); - expect(ACL.getPublicWriteAccess()).toBe(false); - const perms = ACL.permissionsById; - expect(Object.keys(perms).length).toBe(2); - expect(perms[user.id].read).toBe(true); - expect(perms[user.id].write).toBe(true); - expect(perms['*'].read).toBe(true); - expect(perms['*'].write).not.toBe(true); -} +'use strict'; -describe('Parse.User testing', () => { - it("user sign up class method", (done) => { - Parse.User.signUp("asdf", "zxcv", null, { - success: function(user) { - ok(user.getSessionToken()); - done(); - } - }); +const MongoStorageAdapter = require('../lib/Adapters/Storage/Mongo/MongoStorageAdapter').default; +const request = require('../lib/request'); +const passwordCrypto = require('../lib/password'); +const Config = require('../lib/Config'); +const cryptoUtils = require('../lib/cryptoUtils'); +const Utils = require('../lib/Utils'); + + +describe('allowExpiredAuthDataToken option', () => { + it('should accept true value', async () => { + await reconfigureServer({ allowExpiredAuthDataToken: true }); + expect(Config.get(Parse.applicationId).allowExpiredAuthDataToken).toBe(true); }); - it("user sign up instance method", (done) => { - var user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.signUp(null, { - success: function(user) { - ok(user.getSessionToken()); - done(); - }, - error: function(userAgain, error) { - ok(undefined, error); - } - }); + it('should accept false value', async () => { + await reconfigureServer({ allowExpiredAuthDataToken: false }); + expect(Config.get(Parse.applicationId).allowExpiredAuthDataToken).toBe(false); }); - it("user login wrong username", (done) => { - Parse.User.signUp("asdf", "zxcv", null, { - success: function(user) { - Parse.User.logIn("non_existent_user", "asdf3", - expectError(Parse.Error.OBJECT_NOT_FOUND, done)); - }, - error: function(err) { - console.error(err); - fail("Shit should not fail"); - done(); - } - }); + it('should default false', async () => { + await reconfigureServer({}); + expect(Config.get(Parse.applicationId).allowExpiredAuthDataToken).toBe(false); }); - it("user login wrong password", (done) => { - Parse.User.signUp("asdf", "zxcv", null, { - success: function(user) { - Parse.User.logIn("asdf", "asdfWrong", - expectError(Parse.Error.OBJECT_NOT_FOUND, done)); - } - }); + it('should enforce boolean values', async () => { + const options = [[], 'a', '', 0, 1, {}, 'true', 'false']; + for (const option of options) { + await expectAsync(reconfigureServer({ allowExpiredAuthDataToken: option })).toBeRejected(); + } }); +}); - it("user login", (done) => { - Parse.User.signUp("asdf", "zxcv", null, { - success: function(user) { - Parse.User.logIn("asdf", "zxcv", { - success: function(user) { - equal(user.get("username"), "asdf"); - verifyACL(user); - done(); - } - }); - } - }); +describe('Parse.User testing', () => { + let loggerErrorSpy; + beforeEach(() => { + const logger = require('../lib/logger').default; + loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); }); - it_exclude_dbs(['postgres'])('should respect ACL without locking user out', (done) => { - let user = new Parse.User(); - let ACL = new Parse.ACL(); - ACL.setPublicReadAccess(false); - ACL.setPublicWriteAccess(false); - user.setUsername('asdf'); - user.setPassword('zxcv'); - user.setACL(ACL); - user.signUp().then((user) => { - return Parse.User.logIn("asdf", "zxcv"); - }).then((user) => { - equal(user.get("username"), "asdf"); - const ACL = user.getACL(); - expect(ACL.getReadAccess(user)).toBe(true); - expect(ACL.getWriteAccess(user)).toBe(true); - expect(ACL.getPublicReadAccess()).toBe(false); - expect(ACL.getPublicWriteAccess()).toBe(false); - const perms = ACL.permissionsById; - expect(Object.keys(perms).length).toBe(1); - expect(perms[user.id].read).toBe(true); - expect(perms[user.id].write).toBe(true); - expect(perms['*']).toBeUndefined(); - // Try to lock out user - let newACL = new Parse.ACL(); - newACL.setReadAccess(user.id, false); - newACL.setWriteAccess(user.id, false); - user.setACL(newACL); - return user.save(); - }).then((user) => { - return Parse.User.logIn("asdf", "zxcv"); - }).then((user) => { - equal(user.get("username"), "asdf"); - const ACL = user.getACL(); - expect(ACL.getReadAccess(user)).toBe(true); - expect(ACL.getWriteAccess(user)).toBe(true); - expect(ACL.getPublicReadAccess()).toBe(false); - expect(ACL.getPublicWriteAccess()).toBe(false); - const perms = ACL.permissionsById; - expect(Object.keys(perms).length).toBe(1); - expect(perms[user.id].read).toBe(true); - expect(perms[user.id].write).toBe(true); - expect(perms['*']).toBeUndefined(); - done(); - }).catch((err) => { - fail("Should not fail"); - done(); - }) + it('user sign up class method', async done => { + const user = await Parse.User.signUp('asdf', 'zxcv'); + ok(user.getSessionToken()); + done(); }); - it_exclude_dbs(['postgres'])("user login with files", (done) => { - let file = new Parse.File("yolo.txt", [1,2,3], "text/plain"); - file.save().then((file) => { - return Parse.User.signUp("asdf", "zxcv", { "file" : file }); - }).then(() => { - return Parse.User.logIn("asdf", "zxcv"); - }).then((user) => { - let fileAgain = user.get('file'); - ok(fileAgain.name()); - ok(fileAgain.url()); - done(); - }); + it('user sign up instance method', async () => { + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + await user.signUp(); + ok(user.getSessionToken()); }); - it_exclude_dbs(['postgres'])('become sends token back', done => { - let user = null; - var sessionToken = null; - - Parse.User.signUp('Jason', 'Parse', { 'code': 'red' }).then(newUser => { - user = newUser; - expect(user.get('code'), 'red'); - - sessionToken = newUser.getSessionToken(); - expect(sessionToken).toBeDefined(); - - return Parse.User.become(sessionToken); - }).then(newUser => { - expect(newUser.id).toEqual(user.id); - expect(newUser.get('username'), 'Jason'); - expect(newUser.get('code'), 'red'); - expect(newUser.getSessionToken()).toEqual(sessionToken); - }).then(() => { + it('user login wrong username', async done => { + await Parse.User.signUp('asdf', 'zxcv'); + try { + await Parse.User.logIn('non_existent_user', 'asdf3'); + done.fail(); + } catch (e) { + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); done(); - }, error => { - fail(error); - done(); - }); + } }); - it("become", (done) => { - var user = null; - var sessionToken = null; - - Parse.Promise.as().then(function() { - return Parse.User.signUp("Jason", "Parse", { "code": "red" }); - - }).then(function(newUser) { - equal(Parse.User.current(), newUser); - - user = newUser; - sessionToken = newUser.getSessionToken(); - ok(sessionToken); - - return Parse.User.logOut(); - }).then(() => { - ok(!Parse.User.current()); + it('user login wrong password', async done => { + await Parse.User.signUp('asdf', 'zxcv'); + try { + await Parse.User.logIn('asdf', 'asdfWrong'); + done.fail(); + } catch (e) { + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + done(); + } + }); - return Parse.User.become(sessionToken); + it('normalizes login response time for non-existent and existing users', async () => { + const passwordCrypto = require('../lib/password'); + const compareSpy = spyOn(passwordCrypto, 'compare').and.callThrough(); + await Parse.User.signUp('existinguser', 'password123'); + compareSpy.calls.reset(); + + // Login with non-existent user — should use dummy hash + await expectAsync( + Parse.User.logIn('nonexistentuser', 'wrongpassword') + ).toBeRejected(); + expect(compareSpy).toHaveBeenCalledTimes(1); + expect(compareSpy).toHaveBeenCalledWith('wrongpassword', passwordCrypto.dummyHash); + compareSpy.calls.reset(); + + // Login with existing user but wrong password — should use real hash + await expectAsync( + Parse.User.logIn('existinguser', 'wrongpassword') + ).toBeRejected(); + expect(compareSpy).toHaveBeenCalledTimes(1); + expect(compareSpy.calls.mostRecent().args[0]).toBe('wrongpassword'); + expect(compareSpy.calls.mostRecent().args[1]).not.toBe(passwordCrypto.dummyHash); + }); - }).then(function(newUser) { - equal(Parse.User.current(), newUser); + it('logs username taken with configured log level', async () => { + await reconfigureServer({ logLevels: { signupUsernameTaken: 'warn' } }); + const logger = require('../lib/logger').default; + loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + const loggerWarnSpy = spyOn(logger, 'warn').and.callThrough(); - ok(newUser); - equal(newUser.id, user.id); - equal(newUser.get("username"), "Jason"); - equal(newUser.get("code"), "red"); + const user = new Parse.User(); + user.setUsername('dupUser'); + user.setPassword('pass'); + await user.signUp(); - return Parse.User.logOut(); - }).then(() => { - ok(!Parse.User.current()); + const user2 = new Parse.User(); + user2.setUsername('dupUser'); + user2.setPassword('pass2'); - return Parse.User.become("somegarbage"); + expect(loggerWarnSpy).not.toHaveBeenCalled(); - }).then(function() { - // This should have failed actually. - ok(false, "Shouldn't have been able to log in with garbage session token."); - }, function(error) { - ok(error); - // Handle the error. - return Parse.Promise.as(); + try { + await user2.signUp(); + fail('should have thrown'); + } catch (e) { + expect(e.code).toBe(Parse.Error.USERNAME_TAKEN); + } - }).then(function() { - done(); - }, function(error) { - ok(false, error); - done(); - }); + expect(loggerWarnSpy).toHaveBeenCalledTimes(1); + expect(loggerErrorSpy.calls.count()).toBe(0); }); - it("cannot save non-authed user", (done) => { - var user = new Parse.User(); - user.set({ - "password": "asdf", - "email": "asdf@example.com", - "username": "zxcv" - }); - user.signUp(null, { - success: function(userAgain) { - equal(userAgain, user); - var query = new Parse.Query(Parse.User); - query.get(user.id, { - success: function(userNotAuthed) { - user = new Parse.User(); - user.set({ - "username": "hacker", - "password": "password" - }); - user.signUp(null, { - success: function(userAgain) { - equal(userAgain, user); - userNotAuthed.set("username", "changed"); - userNotAuthed.save().then(fail, (err) => { - expect(err.code).toEqual(Parse.Error.SESSION_MISSING); - done(); - }); - }, - error: function(model, error) { - ok(undefined, error); - } - }); - }, - error: function(model, error) { - ok(undefined, error); - } - }); - } - }); - }); + it('can silence username taken log event', async () => { + await reconfigureServer({ logLevels: { signupUsernameTaken: 'silent' } }); + const logger = require('../lib/logger').default; + loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + const loggerWarnSpy = spyOn(logger, 'warn').and.callThrough(); - it("cannot delete non-authed user", (done) => { - var user = new Parse.User(); - user.signUp({ - "password": "asdf", - "email": "asdf@example.com", - "username": "zxcv" - }, { - success: function() { - var query = new Parse.Query(Parse.User); - query.get(user.id, { - success: function(userNotAuthed) { - user = new Parse.User(); - user.signUp({ - "username": "hacker", - "password": "password" - }, { - success: function(userAgain) { - equal(userAgain, user); - userNotAuthed.set("username", "changed"); - userNotAuthed.destroy(expectError( - Parse.Error.SESSION_MISSING, done)); - } - }); - } - }); - } - }); - }); + const user = new Parse.User(); + user.setUsername('dupUser'); + user.setPassword('pass'); + await user.signUp(); + + const user2 = new Parse.User(); + user2.setUsername('dupUser'); + user2.setPassword('pass2'); + try { + await user2.signUp(); + fail('should have thrown'); + } catch (e) { + expect(e.code).toBe(Parse.Error.USERNAME_TAKEN); + } - it_exclude_dbs(['postgres'])("cannot saveAll with non-authed user", (done) => { - var user = new Parse.User(); - user.signUp({ - "password": "asdf", - "email": "asdf@example.com", - "username": "zxcv" - }, { - success: function() { - var query = new Parse.Query(Parse.User); - query.get(user.id, { - success: function(userNotAuthed) { - user = new Parse.User(); - user.signUp({ - username: "hacker", - password: "password" - }, { - success: function() { - query.get(user.id, { - success: function(userNotAuthedNotChanged) { - userNotAuthed.set("username", "changed"); - var object = new TestObject(); - object.save({ - user: userNotAuthedNotChanged - }, { - success: function(object) { - var item1 = new TestObject(); - item1.save({ - number: 0 - }, { - success: function(item1) { - item1.set("number", 1); - var item2 = new TestObject(); - item2.set("number", 2); - Parse.Object.saveAll( - [item1, item2, userNotAuthed], - expectError(Parse.Error.SESSION_MISSING, done)); - } - }); - } - }); - } - }); - } - }); - } - }); - } - }); + expect(loggerWarnSpy).not.toHaveBeenCalled(); + expect(loggerErrorSpy.calls.count()).toBe(0); }); - it("current user", (done) => { - var user = new Parse.User(); - user.set("password", "asdf"); - user.set("email", "asdf@example.com"); - user.set("username", "zxcv"); - user.signUp().then(() => { - var currentUser = Parse.User.current(); - equal(user.id, currentUser.id); - ok(user.getSessionToken()); - - var currentUserAgain = Parse.User.current(); - // should be the same object - equal(currentUser, currentUserAgain); - - // test logging out the current user - return Parse.User.logOut(); - }).then(() => { - equal(Parse.User.current(), null); - done(); + it('user login with context', async () => { + let hit = 0; + const context = { foo: 'bar' }; + Parse.Cloud.beforeLogin(req => { + expect(req.context).toEqual(context); + hit++; }); - }); - - it("user.isCurrent", (done) => { - var user1 = new Parse.User(); - var user2 = new Parse.User(); - var user3 = new Parse.User(); - - user1.set("username", "a"); - user2.set("username", "b"); - user3.set("username", "c"); - - user1.set("password", "password"); - user2.set("password", "password"); - user3.set("password", "password"); - - user1.signUp().then(() => { - equal(user1.isCurrent(), true); - equal(user2.isCurrent(), false); - equal(user3.isCurrent(), false); - return user2.signUp(); - }).then(() => { - equal(user1.isCurrent(), false); - equal(user2.isCurrent(), true); - equal(user3.isCurrent(), false); - return user3.signUp(); - }).then(() => { - equal(user1.isCurrent(), false); - equal(user2.isCurrent(), false); - equal(user3.isCurrent(), true); - return Parse.User.logIn("a", "password"); - }).then(() => { - equal(user1.isCurrent(), true); - equal(user2.isCurrent(), false); - equal(user3.isCurrent(), false); - return Parse.User.logIn("b", "password"); - }).then(() => { - equal(user1.isCurrent(), false); - equal(user2.isCurrent(), true); - equal(user3.isCurrent(), false); - return Parse.User.logIn("b", "password"); - }).then(() => { - equal(user1.isCurrent(), false); - equal(user2.isCurrent(), true); - equal(user3.isCurrent(), false); - return Parse.User.logOut(); - }).then(() => { - equal(user2.isCurrent(), false); - done(); + Parse.Cloud.afterLogin(req => { + expect(req.context).toEqual(context); + hit++; }); - }); - - it("user associations", (done) => { - var child = new TestObject(); - child.save(null, { - success: function() { - var user = new Parse.User(); - user.set("password", "asdf"); - user.set("email", "asdf@example.com"); - user.set("username", "zxcv"); - user.set("child", child); - user.signUp(null, { - success: function() { - var object = new TestObject(); - object.set("user", user); - object.save(null, { - success: function() { - var query = new Parse.Query(TestObject); - query.get(object.id, { - success: function(objectAgain) { - var userAgain = objectAgain.get("user"); - userAgain.fetch({ - success: function() { - equal(user.id, userAgain.id); - equal(userAgain.get("child").id, child.id); - done(); - } - }); - } - }); - } - }); - } - }); - } + await Parse.User.signUp('asdf', 'zxcv'); + await request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Cloud-Context': JSON.stringify(context), + 'Content-Type': 'application/json', + }, + body: { + _method: 'GET', + username: 'asdf', + password: 'zxcv', + }, }); + expect(hit).toBe(2); }); - it("user queries", (done) => { - var user = new Parse.User(); - user.set("password", "asdf"); - user.set("email", "asdf@example.com"); - user.set("username", "zxcv"); - user.signUp(null, { - success: function() { - var query = new Parse.Query(Parse.User); - query.get(user.id, { - success: function(userAgain) { - equal(userAgain.id, user.id); - query.find({ - success: function(users) { - equal(users.length, 1); - equal(users[0].id, user.id); - ok(userAgain.get("email"), "asdf@example.com"); - done(); - } - }); - } - }); - } - }); + it('user login with non-string username with REST API', async done => { + await Parse.User.signUp('asdf', 'zxcv'); + request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + _method: 'GET', + username: { $regex: '^asd' }, + password: 'zxcv', + }, + }) + .then(res => { + fail(`no request should succeed: ${JSON.stringify(res)}`); + done(); + }) + .catch(err => { + expect(err.status).toBe(404); + expect(err.text).toMatch( + `{"code":${Parse.Error.OBJECT_NOT_FOUND},"error":"Invalid username/password."}` + ); + done(); + }); }); - function signUpAll(list, optionsOrCallback) { - var promise = Parse.Promise.as(); - list.forEach((user) => { - promise = promise.then(function() { - return user.signUp(); + it('user login with non-string username with REST API (again)', async done => { + await Parse.User.signUp('asdf', 'zxcv'); + request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + _method: 'GET', + username: 'asdf', + password: { $regex: '^zx' }, + }, + }) + .then(res => { + fail(`no request should succeed: ${JSON.stringify(res)}`); + done(); + }) + .catch(err => { + expect(err.status).toBe(404); + expect(err.text).toMatch( + `{"code":${Parse.Error.OBJECT_NOT_FOUND},"error":"Invalid username/password."}` + ); + done(); }); - }); - promise = promise.then(function() { return list; }); - return promise._thenRunCallbacks(optionsOrCallback); - } - - it_exclude_dbs(['postgres'])("contained in user array queries", (done) => { - var USERS = 4; - var MESSAGES = 5; - - // Make a list of users. - var userList = range(USERS).map(function(i) { - var user = new Parse.User(); - user.set("password", "user_num_" + i); - user.set("email", "user_num_" + i + "@example.com"); - user.set("username", "xinglblog_num_" + i); - return user; - }); + }); - signUpAll(userList, function(users) { - // Make a list of messages. - var messageList = range(MESSAGES).map(function(i) { - var message = new TestObject(); - message.set("to", users[(i + 1) % USERS]); - message.set("from", users[i % USERS]); - return message; + it('user login using POST with REST API', async done => { + await Parse.User.signUp('some_user', 'some_password'); + request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + body: { + username: 'some_user', + password: 'some_password', + }, + }) + .then(res => { + expect(res.data.username).toBe('some_user'); + done(); + }) + .catch(err => { + fail(`no request should fail: ${JSON.stringify(err)}`); + done(); }); + }); - // Save all the messages. - Parse.Object.saveAll(messageList, function(messages) { - - // Assemble an "in" list. - var inList = [users[0], users[3], users[3]]; // Intentional dupe - var query = new Parse.Query(TestObject); - query.containedIn("from", inList); - query.find({ - success: function(results) { - equal(results.length, 3); - done(); - } - }); + it('user login', async done => { + await Parse.User.signUp('asdf', 'zxcv'); + const user = await Parse.User.logIn('asdf', 'zxcv'); + equal(user.get('username'), 'asdf'); + const ACL = user.getACL(); + expect(ACL.getReadAccess(user)).toBe(true); + expect(ACL.getWriteAccess(user)).toBe(true); + expect(ACL.getPublicReadAccess()).toBe(false); + expect(ACL.getPublicWriteAccess()).toBe(false); + const perms = ACL.permissionsById; + expect(Object.keys(perms).length).toBe(1); + expect(perms[user.id].read).toBe(true); + expect(perms[user.id].write).toBe(true); + expect(perms['*']).toBeUndefined(); + done(); + }); + it('should respect ACL without locking user out', done => { + const user = new Parse.User(); + const ACL = new Parse.ACL(); + ACL.setPublicReadAccess(false); + ACL.setPublicWriteAccess(false); + user.setUsername('asdf'); + user.setPassword('zxcv'); + user.setACL(ACL); + user + .signUp() + .then(() => { + return Parse.User.logIn('asdf', 'zxcv'); + }) + .then(user => { + equal(user.get('username'), 'asdf'); + const ACL = user.getACL(); + expect(ACL.getReadAccess(user)).toBe(true); + expect(ACL.getWriteAccess(user)).toBe(true); + expect(ACL.getPublicReadAccess()).toBe(false); + expect(ACL.getPublicWriteAccess()).toBe(false); + const perms = ACL.permissionsById; + expect(Object.keys(perms).length).toBe(1); + expect(perms[user.id].read).toBe(true); + expect(perms[user.id].write).toBe(true); + expect(perms['*']).toBeUndefined(); + // Try to lock out user + const newACL = new Parse.ACL(); + newACL.setReadAccess(user.id, false); + newACL.setWriteAccess(user.id, false); + user.setACL(newACL); + return user.save(); + }) + .then(() => { + return Parse.User.logIn('asdf', 'zxcv'); + }) + .then(user => { + equal(user.get('username'), 'asdf'); + const ACL = user.getACL(); + expect(ACL.getReadAccess(user)).toBe(true); + expect(ACL.getWriteAccess(user)).toBe(true); + expect(ACL.getPublicReadAccess()).toBe(false); + expect(ACL.getPublicWriteAccess()).toBe(false); + const perms = ACL.permissionsById; + expect(Object.keys(perms).length).toBe(1); + expect(perms[user.id].read).toBe(true); + expect(perms[user.id].write).toBe(true); + expect(perms['*']).toBeUndefined(); + done(); + }) + .catch(() => { + fail('Should not fail'); + done(); }); - }); }); - it("saving a user signs them up but doesn't log them in", (done) => { - var user = new Parse.User(); - user.save({ - password: "asdf", - email: "asdf@example.com", - username: "zxcv" - }, { - success: function() { - equal(Parse.User.current(), null); + it('should let masterKey lockout user', done => { + const user = new Parse.User(); + const ACL = new Parse.ACL(); + ACL.setPublicReadAccess(false); + ACL.setPublicWriteAccess(false); + user.setUsername('asdf'); + user.setPassword('zxcv'); + user.setACL(ACL); + user + .signUp() + .then(() => { + return Parse.User.logIn('asdf', 'zxcv'); + }) + .then(user => { + equal(user.get('username'), 'asdf'); + // Lock the user down + const ACL = new Parse.ACL(); + user.setACL(ACL); + return user.save(null, { useMasterKey: true }); + }) + .then(() => { + expect(user.getACL().getPublicReadAccess()).toBe(false); + return Parse.User.logIn('asdf', 'zxcv'); + }) + .then(done.fail) + .catch(err => { + expect(err.message).toBe('Invalid username/password.'); + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); done(); - } - }); + }); }); - it("user updates", (done) => { - var user = new Parse.User(); - user.signUp({ - password: "asdf", - email: "asdf@example.com", - username: "zxcv" - }, { - success: function(user) { - user.set("username", "test"); - user.save(null, { - success: function() { - equal(Object.keys(user.attributes).length, 6); - ok(user.attributes["username"]); - ok(user.attributes["email"]); - user.destroy({ - success: function() { - var query = new Parse.Query(Parse.User); - query.get(user.id, { - error: function(model, error) { - // The user should no longer exist. - equal(error.code, Parse.Error.OBJECT_NOT_FOUND); - done(); - } - }); - }, - error: function(model, error) { - ok(undefined, error); - } - }); - }, - error: function(model, error) { - ok(undefined, error); - } - }); - }, - error: function(model, error) { - ok(undefined, error); - } + it_only_db('mongo')('should let legacy users without ACL login', async () => { + await reconfigureServer(); + const databaseURI = 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase'; + const adapter = new MongoStorageAdapter({ + collectionPrefix: 'test_', + uri: databaseURI, }); - }); + await adapter.connect(); - it_exclude_dbs(['postgres'])("count users", (done) => { - var james = new Parse.User(); - james.set("username", "james"); - james.set("password", "mypass"); - james.signUp(null, { - success: function() { - var kevin = new Parse.User(); - kevin.set("username", "kevin"); - kevin.set("password", "mypass"); - kevin.signUp(null, { - success: function() { - var query = new Parse.Query(Parse.User); - query.count({ - success: function(count) { - equal(count, 2); - done(); - } - }); - } - }); - } + const user = new Parse.User(); + await user.signUp({ + username: 'newUser', + password: 'password', }); - }); - it("user sign up with container class", (done) => { - Parse.User.signUp("ilya", "mypass", { "array": ["hello"] }, { - success: function() { - done(); - } + const collection = await adapter._adaptiveCollection('_User'); + await collection.insertOne({ + // the hashed password is 'password' hashed + _hashed_password: '$2b$10$mJ2ca2UbCM9hlojYHZxkQe8pyEXe5YMg0nMdvP4AJBeqlTEZJ6/Uu', + _session_token: 'xxx', + email: 'xxx@a.b', + username: 'oldUser', + emailVerified: true, + _email_verify_token: 'yyy', }); + + // get the 2 users + const users = await collection.find(); + expect(users.length).toBe(2); + + const aUser = await Parse.User.logIn('oldUser', 'password'); + expect(aUser).not.toBeUndefined(); + + const newUser = await Parse.User.logIn('newUser', 'password'); + expect(newUser).not.toBeUndefined(); }); - it("user modified while saving", (done) => { - Parse.Object.disableSingleInstance(); - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "password"); - user.signUp(null, { - success: function(userAgain) { - equal(userAgain.get("username"), "bob"); - ok(userAgain.dirty("username")); - var query = new Parse.Query(Parse.User); - query.get(user.id, { - success: function(freshUser) { - equal(freshUser.id, user.id); - equal(freshUser.get("username"), "alice"); - Parse.Object.enableSingleInstance(); - done(); - } - }); - } + it_only_db('mongo')('should reject duplicate authData when masterKey locks user out (mongo)', async () => { + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/_User', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + key: 'value', + authData: { anonymous: { id: '00000000-0000-0000-0000-000000000001' } }, + }, }); - ok(user.set("username", "bob")); + const body = response.data; + const objectId = body.objectId; + expect(body.sessionToken).toBeDefined(); + expect(objectId).toBeDefined(); + const user = new Parse.User(); + user.id = objectId; + const ACL = new Parse.ACL(); + user.setACL(ACL); + await user.save(null, { useMasterKey: true }); + const options = { + method: 'POST', + url: `http://localhost:8378/1/classes/_User/`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + key: 'otherValue', + authData: { + anonymous: { id: '00000000-0000-0000-0000-000000000001' }, + }, + }, + }; + try { + await request(options); + fail('should have thrown'); + } catch (err) { + expect(err.data.code).toBe(208); + expect(err.data.error).toBe('this auth is already used'); + } }); - it("user modified while saving with unsaved child", (done) => { - Parse.Object.disableSingleInstance(); - var user = new Parse.User(); - user.set("username", "alice"); - user.set("password", "password"); - user.set("child", new TestObject()); - user.signUp(null, { - success: function(userAgain) { - equal(userAgain.get("username"), "bob"); - // Should be dirty, but it depends on batch support. - // ok(userAgain.dirty("username")); - var query = new Parse.Query(Parse.User); - query.get(user.id, { - success: function(freshUser) { - equal(freshUser.id, user.id); - // Should be alice, but it depends on batch support. - equal(freshUser.get("username"), "bob"); - Parse.Object.enableSingleInstance(); - done(); - } - }); - } + it_only_db('postgres')('should reject duplicate authData when masterKey locks user out (postgres)', async () => { + await reconfigureServer(); + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/_User', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + key: 'value', + authData: { anonymous: { id: '00000000-0000-0000-0000-000000000001' } }, + }, }); - ok(user.set("username", "bob")); + const body = response.data; + const objectId = body.objectId; + expect(body.sessionToken).toBeDefined(); + expect(objectId).toBeDefined(); + const user = new Parse.User(); + user.id = objectId; + const ACL = new Parse.ACL(); + user.setACL(ACL); + await user.save(null, { useMasterKey: true }); + const options = { + method: 'POST', + url: `http://localhost:8378/1/classes/_User/`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + key: 'otherValue', + authData: { + anonymous: { id: '00000000-0000-0000-0000-000000000001' }, + }, + }, + }; + try { + await request(options); + fail('should have thrown'); + } catch (err) { + expect(err.data.code).toBe(208); + expect(err.data.error).toBe('this auth is already used'); + } }); - it("user loaded from localStorage from signup", (done) => { - Parse.User.signUp("alice", "password", null, { - success: function(alice) { - ok(alice.id, "Alice should have an objectId"); - ok(alice.getSessionToken(), "Alice should have a session token"); - equal(alice.get("password"), undefined, - "Alice should not have a password"); - - // Simulate the environment getting reset. - Parse.User._currentUser = null; - Parse.User._currentUserMatchesDisk = false; - - var aliceAgain = Parse.User.current(); - equal(aliceAgain.get("username"), "alice"); - equal(aliceAgain.id, alice.id, "currentUser should have objectId"); - ok(aliceAgain.getSessionToken(), - "currentUser should have a sessionToken"); - equal(alice.get("password"), undefined, - "currentUser should not have password"); + it('user login with files', done => { + const file = new Parse.File('yolo.txt', [1, 2, 3], 'text/plain'); + file + .save() + .then(file => { + return Parse.User.signUp('asdf', 'zxcv', { file: file }); + }) + .then(() => { + return Parse.User.logIn('asdf', 'zxcv'); + }) + .then(user => { + const fileAgain = user.get('file'); + ok(fileAgain.name()); + ok(fileAgain.url()); done(); - } - }); + }) + .catch(err => { + jfail(err); + done(); + }); }); + it('become sends token back', done => { + let user = null; + let sessionToken = null; - it("user loaded from localStorage from login", (done) => { - var id; - Parse.User.signUp("alice", "password").then((alice) => { - id = alice.id; - return Parse.User.logOut(); - }).then(() => { - return Parse.User.logIn("alice", "password"); - }).then((user) => { - // Force the current user to read from disk - delete Parse.User._currentUser; - delete Parse.User._currentUserMatchesDisk; + Parse.User.signUp('Jason', 'Parse', { code: 'red' }) + .then(newUser => { + user = newUser; + expect(user.get('code'), 'red'); - var userFromDisk = Parse.User.current(); - equal(userFromDisk.get("password"), undefined, - "password should not be in attributes"); - equal(userFromDisk.id, id, "id should be set"); - ok(userFromDisk.getSessionToken(), - "currentUser should have a sessionToken"); - done(); - }); + sessionToken = newUser.getSessionToken(); + expect(sessionToken).toBeDefined(); + + return Parse.User.become(sessionToken); + }) + .then(newUser => { + expect(newUser.id).toEqual(user.id); + expect(newUser.get('username'), 'Jason'); + expect(newUser.get('code'), 'red'); + expect(newUser.getSessionToken()).toEqual(sessionToken); + }) + .then( + () => { + done(); + }, + error => { + jfail(error); + done(); + } + ); }); - it_exclude_dbs(['postgres'])("saving user after browser refresh", (done) => { - var _ = Parse._; - var id; + it('become', done => { + let user = null; + let sessionToken = null; - Parse.User.signUp("alice", "password", null).then(function(alice) { - id = alice.id; - return Parse.User.logOut(); - }).then(() => { - return Parse.User.logIn("alice", "password"); - }).then(function() { - // Simulate browser refresh by force-reloading user from localStorage - Parse.User._clearCache(); + Promise.resolve() + .then(function () { + return Parse.User.signUp('Jason', 'Parse', { code: 'red' }); + }) + .then(function (newUser) { + equal(Parse.User.current(), newUser); - // Test that this save works correctly - return Parse.User.current().save({some_field: 1}); - }).then(function() { - // Check the user in memory just after save operation - var userInMemory = Parse.User.current(); + user = newUser; + sessionToken = newUser.getSessionToken(); + ok(sessionToken); - equal(userInMemory.getUsername(), "alice", - "saving user should not remove existing fields"); + return Parse.User.logOut(); + }) + .then(() => { + ok(!Parse.User.current()); - equal(userInMemory.get('some_field'), 1, - "saving user should save specified field"); + return Parse.User.become(sessionToken); + }) + .then(function (newUser) { + equal(Parse.User.current(), newUser); - equal(userInMemory.get("password"), undefined, - "password should not be in attributes after saving user"); + ok(newUser); + equal(newUser.id, user.id); + equal(newUser.get('username'), 'Jason'); + equal(newUser.get('code'), 'red'); - equal(userInMemory.get("objectId"), undefined, - "objectId should not be in attributes after saving user"); + return Parse.User.logOut(); + }) + .then(() => { + ok(!Parse.User.current()); - equal(userInMemory.get("_id"), undefined, - "_id should not be in attributes after saving user"); + return Parse.User.become('somegarbage'); + }) + .then( + function () { + // This should have failed actually. + ok(false, "Shouldn't have been able to log in with garbage session token."); + }, + function (error) { + ok(error); + // Handle the error. + return Promise.resolve(); + } + ) + .then( + function () { + done(); + }, + function (error) { + ok(false, error); + done(); + } + ); + }); + + it('should not call beforeLogin with become', async done => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + + let hit = 0; + Parse.Cloud.beforeLogin(() => { + hit++; + }); + + await Parse.User._logInWith('facebook'); + const sessionToken = Parse.User.current().getSessionToken(); + await Parse.User.become(sessionToken); + expect(hit).toBe(0); + done(); + }); - equal(userInMemory.id, id, "id should be set"); + it('cannot save non-authed user', async done => { + let user = new Parse.User(); + user.set({ + password: 'asdf', + email: 'asdf@example.com', + username: 'zxcv', + }); + let userAgain = await user.signUp(); + equal(userAgain, user); + const query = new Parse.Query(Parse.User); + const userNotAuthed = await query.get(user.id); + user = new Parse.User(); + user.set({ + username: 'hacker', + password: 'password', + }); + userAgain = await user.signUp(); + equal(userAgain, user); + userNotAuthed.set('username', 'changed'); + userNotAuthed.save().then(fail, err => { + expect(err.code).toEqual(Parse.Error.SESSION_MISSING); + done(); + }); + }); - expect(userInMemory.updatedAt instanceof Date).toBe(true); + it('cannot delete non-authed user', async done => { + let user = new Parse.User(); + await user.signUp({ + password: 'asdf', + email: 'asdf@example.com', + username: 'zxcv', + }); + const query = new Parse.Query(Parse.User); + const userNotAuthed = await query.get(user.id); + user = new Parse.User(); + const userAgain = await user.signUp({ + username: 'hacker', + password: 'password', + }); + equal(userAgain, user); + userNotAuthed.set('username', 'changed'); + try { + await userNotAuthed.destroy(); + done.fail(); + } catch (e) { + expect(e.code).toBe(Parse.Error.SESSION_MISSING); + done(); + } + }); - ok(userInMemory.createdAt instanceof Date); + it('cannot saveAll with non-authed user', async done => { + let user = new Parse.User(); + await user.signUp({ + password: 'asdf', + email: 'asdf@example.com', + username: 'zxcv', + }); + const query = new Parse.Query(Parse.User); + const userNotAuthed = await query.get(user.id); + user = new Parse.User(); + await user.signUp({ + username: 'hacker', + password: 'password', + }); + const userNotAuthedNotChanged = await query.get(user.id); + userNotAuthed.set('username', 'changed'); + const object = new TestObject(); + await object.save({ + user: userNotAuthedNotChanged, + }); + const item1 = new TestObject(); + await item1.save({ + number: 0, + }); + item1.set('number', 1); + const item2 = new TestObject(); + item2.set('number', 2); + try { + await Parse.Object.saveAll([item1, item2, userNotAuthed]); + done.fail(); + } catch (e) { + expect(e.code).toBe(Parse.Error.SESSION_MISSING); + done(); + } + }); - ok(userInMemory.getSessionToken(), - "user should have a sessionToken after saving"); + it('never locks himself up', async () => { + const user = new Parse.User(); + await user.signUp({ + username: 'username', + password: 'password', + }); + user.setACL(new Parse.ACL()); + await user.save(); + await user.fetch(); + expect(user.getACL().getReadAccess(user)).toBe(true); + expect(user.getACL().getWriteAccess(user)).toBe(true); + const publicReadACL = new Parse.ACL(); + publicReadACL.setPublicReadAccess(true); + + // Create an administrator role with a single admin user + const role = new Parse.Role('admin', publicReadACL); + const admin = new Parse.User(); + await admin.signUp({ + username: 'admin', + password: 'admin', + }); + role.getUsers().add(admin); + await role.save(null, { useMasterKey: true }); + + // Grant the admins write rights on the user + const acl = user.getACL(); + acl.setRoleWriteAccess(role, true); + acl.setRoleReadAccess(role, true); + + // Update with the masterKey just to be sure + await user.save({ ACL: acl }, { useMasterKey: true }); + + // Try to update from admin... should all work fine + await user.save({ key: 'fromAdmin' }, { sessionToken: admin.getSessionToken() }); + await user.fetch(); + expect(user.toJSON().key).toEqual('fromAdmin'); + + // Try to save when logged out (public) + let failed = false; + try { + // Ensure no session token is sent + await Parse.User.logOut(); + await user.save({ key: 'fromPublic' }); + } catch (e) { + failed = true; + expect(e.code).toBe(Parse.Error.SESSION_MISSING); + } + expect({ failed }).toEqual({ failed: true }); + + // Try to save with a random user, should fail + failed = false; + const anyUser = new Parse.User(); + await anyUser.signUp({ + username: 'randomUser', + password: 'password', + }); + try { + await user.save({ key: 'fromAnyUser' }); + } catch (e) { + failed = true; + expect(e.code).toBe(Parse.Error.SESSION_MISSING); + } + expect({ failed }).toEqual({ failed: true }); + }); - // Force the current user to read from localStorage, and check again - delete Parse.User._currentUser; - delete Parse.User._currentUserMatchesDisk; - var userFromDisk = Parse.User.current(); + it('current user', done => { + const user = new Parse.User(); + user.set('password', 'asdf'); + user.set('email', 'asdf@example.com'); + user.set('username', 'zxcv'); + user + .signUp() + .then(() => { + const currentUser = Parse.User.current(); + equal(user.id, currentUser.id); + ok(user.getSessionToken()); - equal(userFromDisk.getUsername(), "alice", - "userFromDisk should have previously existing fields"); + const currentUserAgain = Parse.User.current(); + // should be the same object + equal(currentUser, currentUserAgain); - equal(userFromDisk.get('some_field'), 1, - "userFromDisk should have saved field"); + // test logging out the current user + return Parse.User.logOut(); + }) + .then(() => { + equal(Parse.User.current(), null); + done(); + }); + }); - equal(userFromDisk.get("password"), undefined, - "password should not be in attributes of userFromDisk"); + it('user.isCurrent', done => { + const user1 = new Parse.User(); + const user2 = new Parse.User(); + const user3 = new Parse.User(); + + user1.set('username', 'a'); + user2.set('username', 'b'); + user3.set('username', 'c'); + + user1.set('password', 'password'); + user2.set('password', 'password'); + user3.set('password', 'password'); + + user1 + .signUp() + .then(() => { + equal(user1.isCurrent(), true); + equal(user2.isCurrent(), false); + equal(user3.isCurrent(), false); + return user2.signUp(); + }) + .then(() => { + equal(user1.isCurrent(), false); + equal(user2.isCurrent(), true); + equal(user3.isCurrent(), false); + return user3.signUp(); + }) + .then(() => { + equal(user1.isCurrent(), false); + equal(user2.isCurrent(), false); + equal(user3.isCurrent(), true); + return Parse.User.logIn('a', 'password'); + }) + .then(() => { + equal(user1.isCurrent(), true); + equal(user2.isCurrent(), false); + equal(user3.isCurrent(), false); + return Parse.User.logIn('b', 'password'); + }) + .then(() => { + equal(user1.isCurrent(), false); + equal(user2.isCurrent(), true); + equal(user3.isCurrent(), false); + return Parse.User.logIn('b', 'password'); + }) + .then(() => { + equal(user1.isCurrent(), false); + equal(user2.isCurrent(), true); + equal(user3.isCurrent(), false); + return Parse.User.logOut(); + }) + .then(() => { + equal(user2.isCurrent(), false); + done(); + }); + }); - equal(userFromDisk.get("objectId"), undefined, - "objectId should not be in attributes of userFromDisk"); + it('user associations', async done => { + const child = new TestObject(); + await child.save(); + const user = new Parse.User(); + user.set('password', 'asdf'); + user.set('email', 'asdf@example.com'); + user.set('username', 'zxcv'); + user.set('child', child); + await user.signUp(); + const object = new TestObject(); + object.set('user', user); + await object.save(); + const query = new Parse.Query(TestObject); + const objectAgain = await query.get(object.id); + const userAgain = objectAgain.get('user'); + await userAgain.fetch(); + equal(user.id, userAgain.id); + equal(userAgain.get('child').id, child.id); + done(); + }); - equal(userFromDisk.get("_id"), undefined, - "_id should not be in attributes of userFromDisk"); + it('user queries', async done => { + const user = new Parse.User(); + user.set('password', 'asdf'); + user.set('email', 'asdf@example.com'); + user.set('username', 'zxcv'); + await user.signUp(); + const query = new Parse.Query(Parse.User); + const userAgain = await query.get(user.id); + equal(userAgain.id, user.id); + const users = await query.find(); + equal(users.length, 1); + equal(users[0].id, user.id); + ok(userAgain.get('email'), 'asdf@example.com'); + done(); + }); - equal(userFromDisk.id, id, "id should be set on userFromDisk"); + function signUpAll(list, optionsOrCallback) { + let promise = Promise.resolve(); + list.forEach(user => { + promise = promise.then(function () { + return user.signUp(); + }); + }); + promise = promise.then(function () { + return list; + }); + return promise.then(optionsOrCallback); + } - ok(userFromDisk.updatedAt instanceof Date); + it('contained in user array queries', async done => { + const USERS = 4; + const MESSAGES = 5; - ok(userFromDisk.createdAt instanceof Date); + // Make a list of users. + const userList = range(USERS).map(function (i) { + const user = new Parse.User(); + user.set('password', 'user_num_' + i); + user.set('email', 'user_num_' + i + '@example.com'); + user.set('username', 'xinglblog_num_' + i); + return user; + }); - ok(userFromDisk.getSessionToken(), - "userFromDisk should have a sessionToken"); + signUpAll(userList, async function (users) { + // Make a list of messages. + if (!users || users.length != USERS) { + fail('signupAll failed'); + done(); + return; + } + const messageList = range(MESSAGES).map(function (i) { + const message = new TestObject(); + message.set('to', users[(i + 1) % USERS]); + message.set('from', users[i % USERS]); + return message; + }); - done(); - }, function(error) { - ok(false, error); + // Save all the messages. + await Parse.Object.saveAll(messageList); + + // Assemble an "in" list. + const inList = [users[0], users[3], users[3]]; // Intentional dupe + const query = new Parse.Query(TestObject); + query.containedIn('from', inList); + const results = await query.find(); + equal(results.length, 3); done(); }); }); - it("user with missing username", (done) => { - var user = new Parse.User(); - user.set("password", "foo"); - user.signUp(null, { - success: function() { - ok(null, "This should have failed"); - done(); - }, - error: function(userAgain, error) { - equal(error.code, Parse.Error.OTHER_CAUSE); - done(); - } + it("saving a user signs them up but doesn't log them in", async done => { + const user = new Parse.User(); + await user.save({ + password: 'asdf', + email: 'asdf@example.com', + username: 'zxcv', }); + equal(Parse.User.current(), null); + done(); }); - it("user with missing password", (done) => { - var user = new Parse.User(); - user.set("username", "foo"); - user.signUp(null, { - success: function() { - ok(null, "This should have failed"); - done(); - }, - error: function(userAgain, error) { - equal(error.code, Parse.Error.OTHER_CAUSE); - done(); - } + it('user updates', async done => { + const user = new Parse.User(); + await user.signUp({ + password: 'asdf', + email: 'asdf@example.com', + username: 'zxcv', }); + + user.set('username', 'test'); + await user.save(); + equal(Object.keys(user.attributes).length, 5); + ok(user.attributes['username']); + ok(user.attributes['email']); + await user.destroy(); + const query = new Parse.Query(Parse.User); + try { + await query.get(user.id); + done.fail(); + } catch (error) { + // The user should no longer exist. + equal(error.code, Parse.Error.OBJECT_NOT_FOUND); + done(); + } }); - it("user stupid subclassing", (done) => { + it('count users', async done => { + const james = new Parse.User(); + james.set('username', 'james'); + james.set('password', 'mypass'); + await james.signUp(); + const kevin = new Parse.User(); + kevin.set('username', 'kevin'); + kevin.set('password', 'mypass'); + await kevin.signUp(); + const query = new Parse.Query(Parse.User); + const count = await query.find({ useMasterKey: true }); + equal(count.length, 2); + done(); + }); - var SuperUser = Parse.Object.extend("User"); - var user = new SuperUser(); - user.set("username", "bob"); - user.set("password", "welcome"); - ok(user instanceof Parse.User, "Subclassing User should have worked"); - user.signUp(null, { - success: function() { - done(); - }, - error: function() { - ok(false, "Signing up should have worked"); + it('user sign up with container class', async done => { + await Parse.User.signUp('ilya', 'mypass', { array: ['hello'] }); + done(); + }); + + it('user modified while saving', async done => { + Parse.Object.disableSingleInstance(); + await reconfigureServer(); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'password'); + user.signUp().then(function (userAgain) { + equal(userAgain.get('username'), 'bob'); + ok(userAgain.dirty('username')); + const query = new Parse.Query(Parse.User); + query.get(user.id).then(freshUser => { + equal(freshUser.id, user.id); + equal(freshUser.get('username'), 'alice'); done(); - } + }); + }); + // Jump a frame so the signup call is properly sent + // This is due to the fact that now, we use real promises + process.nextTick(() => { + ok(user.set('username', 'bob')); }); }); - it("user signup class method uses subclassing", (done) => { - - var SuperUser = Parse.User.extend({ - secret: function() { - return 1337; - } + it('user modified while saving with unsaved child', done => { + Parse.Object.disableSingleInstance(); + const user = new Parse.User(); + user.set('username', 'alice'); + user.set('password', 'password'); + user.set('child', new TestObject()); + user.signUp().then(userAgain => { + equal(userAgain.get('username'), 'bob'); + // Should be dirty, but it depends on batch support. + // ok(userAgain.dirty("username")); + const query = new Parse.Query(Parse.User); + query.get(user.id).then(freshUser => { + equal(freshUser.id, user.id); + // Should be alice, but it depends on batch support. + equal(freshUser.get('username'), 'bob'); + done(); + }); }); + ok(user.set('username', 'bob')); + }); + + it('user loaded from localStorage from signup', async done => { + const alice = await Parse.User.signUp('alice', 'password'); + ok(alice.id, 'Alice should have an objectId'); + ok(alice.getSessionToken(), 'Alice should have a session token'); + equal(alice.get('password'), undefined, 'Alice should not have a password'); + + // Simulate the environment getting reset. + Parse.User._currentUser = null; + Parse.User._currentUserMatchesDisk = false; + + const aliceAgain = Parse.User.current(); + equal(aliceAgain.get('username'), 'alice'); + equal(aliceAgain.id, alice.id, 'currentUser should have objectId'); + ok(aliceAgain.getSessionToken(), 'currentUser should have a sessionToken'); + equal(alice.get('password'), undefined, 'currentUser should not have password'); + done(); + }); - Parse.User.signUp("bob", "welcome", null, { - success: function(user) { - ok(user instanceof SuperUser, "Subclassing User should have worked"); - equal(user.secret(), 1337); + it('user loaded from localStorage from login', done => { + let id; + Parse.User.signUp('alice', 'password') + .then(alice => { + id = alice.id; + return Parse.User.logOut(); + }) + .then(() => { + return Parse.User.logIn('alice', 'password'); + }) + .then(() => { + // Force the current user to read from disk + delete Parse.User._currentUser; + delete Parse.User._currentUserMatchesDisk; + + const userFromDisk = Parse.User.current(); + equal(userFromDisk.get('password'), undefined, 'password should not be in attributes'); + equal(userFromDisk.id, id, 'id should be set'); + ok(userFromDisk.getSessionToken(), 'currentUser should have a sessionToken'); done(); + }); + }); + + it('saving user after browser refresh', done => { + let id; + + Parse.User.signUp('alice', 'password', null) + .then(function (alice) { + id = alice.id; + return Parse.User.logOut(); + }) + .then(() => { + return Parse.User.logIn('alice', 'password'); + }) + .then(function () { + // Simulate browser refresh by force-reloading user from localStorage + Parse.User._clearCache(); + + // Test that this save works correctly + return Parse.User.current().save({ some_field: 1 }); + }) + .then( + function () { + // Check the user in memory just after save operation + const userInMemory = Parse.User.current(); + + equal( + userInMemory.getUsername(), + 'alice', + 'saving user should not remove existing fields' + ); + + equal(userInMemory.get('some_field'), 1, 'saving user should save specified field'); + + equal( + userInMemory.get('password'), + undefined, + 'password should not be in attributes after saving user' + ); + + equal( + userInMemory.get('objectId'), + undefined, + 'objectId should not be in attributes after saving user' + ); + + equal( + userInMemory.get('_id'), + undefined, + '_id should not be in attributes after saving user' + ); + + equal(userInMemory.id, id, 'id should be set'); + + expect(Utils.isDate(userInMemory.updatedAt)).toBe(true); + + ok(Utils.isDate(userInMemory.createdAt)); + + ok(userInMemory.getSessionToken(), 'user should have a sessionToken after saving'); + + // Force the current user to read from localStorage, and check again + delete Parse.User._currentUser; + delete Parse.User._currentUserMatchesDisk; + const userFromDisk = Parse.User.current(); + + equal( + userFromDisk.getUsername(), + 'alice', + 'userFromDisk should have previously existing fields' + ); + + equal(userFromDisk.get('some_field'), 1, 'userFromDisk should have saved field'); + + equal( + userFromDisk.get('password'), + undefined, + 'password should not be in attributes of userFromDisk' + ); + + equal( + userFromDisk.get('objectId'), + undefined, + 'objectId should not be in attributes of userFromDisk' + ); + + equal( + userFromDisk.get('_id'), + undefined, + '_id should not be in attributes of userFromDisk' + ); + + equal(userFromDisk.id, id, 'id should be set on userFromDisk'); + + ok(Utils.isDate(userFromDisk.updatedAt)); + + ok(Utils.isDate(userFromDisk.createdAt)); + + ok(userFromDisk.getSessionToken(), 'userFromDisk should have a sessionToken'); + + done(); + }, + function (error) { + ok(false, error); + done(); + } + ); + }); + + it('user with missing username', async done => { + const user = new Parse.User(); + user.set('password', 'foo'); + try { + await user.signUp(); + done.fail(); + } catch (error) { + equal(error.code, Parse.Error.OTHER_CAUSE); + done(); + } + }); + + it('user with missing password', async done => { + const user = new Parse.User(); + user.set('username', 'foo'); + try { + await user.signUp(); + done.fail(); + } catch (error) { + equal(error.code, Parse.Error.OTHER_CAUSE); + done(); + } + }); + + it('user stupid subclassing', async done => { + const SuperUser = Parse.Object.extend('User'); + const user = new SuperUser(); + user.set('username', 'bob'); + user.set('password', 'welcome'); + ok(user instanceof Parse.User, 'Subclassing User should have worked'); + await user.signUp(); + done(); + }); + + it('user signup class method uses subclassing', async done => { + const SuperUser = Parse.User.extend({ + secret: function () { + return 1337; }, - error: function() { - ok(false, "Signing up should have worked"); - done(); - } }); + + const user = await Parse.User.signUp('bob', 'welcome'); + ok(user instanceof SuperUser, 'Subclassing User should have worked'); + equal(user.secret(), 1337); + done(); }); - it_exclude_dbs(['postgres'])("user on disk gets updated after save", (done) => { - var SuperUser = Parse.User.extend({ - isSuper: function() { + it('user on disk gets updated after save', async done => { + Parse.User.extend({ + isSuper: function () { return true; - } + }, }); - Parse.User.signUp("bob", "welcome", null, { - success: function(user) { - // Modify the user and save. - user.save("secret", 1337, { - success: function() { - // Force the current user to read from disk - delete Parse.User._currentUser; - delete Parse.User._currentUserMatchesDisk; + const user = await Parse.User.signUp('bob', 'welcome'); + await user.save('secret', 1337); + delete Parse.User._currentUser; + delete Parse.User._currentUserMatchesDisk; - var userFromDisk = Parse.User.current(); - equal(userFromDisk.get("secret"), 1337); - ok(userFromDisk.isSuper(), "The subclass should have been used"); - done(); - }, - error: function() { - ok(false, "Saving should have worked"); - done(); - } - }); - }, - error: function() { - ok(false, "Sign up should have worked"); - done(); - } - }); + const userFromDisk = Parse.User.current(); + equal(userFromDisk.get('secret'), 1337); + ok(userFromDisk.isSuper(), 'The subclass should have been used'); + done(); }); - it("current user isn't dirty", (done) => { - - Parse.User.signUp("andrew", "oppa", { style: "gangnam" }, expectSuccess({ - success: function(user) { - ok(!user.dirty("style"), "The user just signed up."); - Parse.User._currentUser = null; - Parse.User._currentUserMatchesDisk = false; - var userAgain = Parse.User.current(); - ok(!userAgain.dirty("style"), "The user was just read from disk."); - done(); - } - })); + it("current user isn't dirty", async done => { + const user = await Parse.User.signUp('andrew', 'oppa', { + style: 'gangnam', + }); + ok(!user.dirty('style'), 'The user just signed up.'); + Parse.User._currentUser = null; + Parse.User._currentUserMatchesDisk = false; + const userAgain = Parse.User.current(); + ok(!userAgain.dirty('style'), 'The user was just read from disk.'); + done(); }); - var getMockFacebookProviderWithIdToken = function(id, token) { + const getMockFacebookProviderWithIdToken = function (id, token) { return { authData: { id: id, @@ -920,16 +1293,16 @@ describe('Parse.User testing', () => { synchronizedAuthToken: null, synchronizedExpiration: null, - authenticate: function(options) { + authenticate: function (options) { if (this.shouldError) { - options.error(this, "An error occurred"); + options.error(this, 'An error occurred'); } else if (this.shouldCancel) { options.error(this, null); } else { options.success(this, this.authData); } }, - restoreAuthentication: function(authData) { + restoreAuthentication: function (authData) { if (!authData) { this.synchronizedUserId = null; this.synchronizedAuthToken = null; @@ -941,27 +1314,27 @@ describe('Parse.User testing', () => { this.synchronizedExpiration = authData.expiration_date; return true; }, - getAuthType: function() { - return "facebook"; + getAuthType() { + return 'facebook'; }, - deauthenticate: function() { + deauthenticate: function () { this.loggedOut = true; this.restoreAuthentication(null); - } + }, }; - } + }; // Note that this mocks out client-side Facebook action rather than // server-side. - var getMockFacebookProvider = function() { + const getMockFacebookProvider = function () { return getMockFacebookProviderWithIdToken('8675309', 'jenny'); }; - var getMockMyOauthProvider = function() { + const getMockMyOauthProvider = function () { return { authData: { - id: "12345", - access_token: "12345", + id: '12345', + access_token: '12345', expiration_date: new Date().toJSON(), }, shouldError: false, @@ -970,16 +1343,16 @@ describe('Parse.User testing', () => { synchronizedAuthToken: null, synchronizedExpiration: null, - authenticate: function(options) { + authenticate(options) { if (this.shouldError) { - options.error(this, "An error occurred"); + options.error(this, 'An error occurred'); } else if (this.shouldCancel) { options.error(this, null); } else { options.success(this, this.authData); } }, - restoreAuthentication: function(authData) { + restoreAuthentication(authData) { if (!authData) { this.synchronizedUserId = null; this.synchronizedAuthToken = null; @@ -991,677 +1364,744 @@ describe('Parse.User testing', () => { this.synchronizedExpiration = authData.expiration_date; return true; }, - getAuthType: function() { - return "myoauth"; + getAuthType() { + return 'myoauth'; }, - deauthenticate: function() { + deauthenticate() { this.loggedOut = true; this.restoreAuthentication(null); - } + }, }; }; - var ExtendedUser = Parse.User.extend({ - extended: function() { + Parse.User.extend({ + extended: function () { return true; - } + }, }); - it_exclude_dbs(['postgres'])("log in with provider", (done) => { - var provider = getMockFacebookProvider(); + it('log in with provider', async done => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(model) { - ok(model instanceof Parse.User, "Model should be a Parse.User"); - strictEqual(Parse.User.current(), model); - ok(model.extended(), "Should have used subclass."); - strictEqual(provider.authData.id, provider.synchronizedUserId); - strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); - strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); - ok(model._isLinked("facebook"), "User should be linked to facebook"); - done(); - }, - error: function(model, error) { - console.error(model, error); - ok(false, "linking should have worked"); - done(); - } - }); + const model = await Parse.User._logInWith('facebook'); + ok(model instanceof Parse.User, 'Model should be a Parse.User'); + strictEqual(Parse.User.current(), model); + ok(model.extended(), 'Should have used subclass.'); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); + ok(model._isLinked('facebook'), 'User should be linked to facebook'); + done(); + }); + + it('can not set authdata to null', async () => { + try { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + const user = await Parse.User._logInWith('facebook'); + user.set('authData', null); + await user.save(); + fail(); + } catch (e) { + expect(e.message).toBe('This authentication method is unsupported.'); + } }); - it_exclude_dbs(['postgres'])("user authData should be available in cloudcode (#2342)", (done) => { + it('ignore setting authdata to undefined', async () => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + const user = await Parse.User._logInWith('facebook'); + user.set('authData', undefined); + await user.save(); + let authData = user.get('authData'); + expect(authData).toBe(undefined); + await user.fetch(); + authData = user.get('authData'); + expect(authData.facebook.id).toBeDefined(); + }); - Parse.Cloud.define('checkLogin', (req, res) => { + it('user authData should be available in cloudcode (#2342)', async done => { + Parse.Cloud.define('checkLogin', req => { expect(req.user).not.toBeUndefined(); expect(Parse.FacebookUtils.isLinked(req.user)).toBe(true); - res.success(); + return 'ok'; }); - var provider = getMockFacebookProvider(); + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(model) { - ok(model instanceof Parse.User, "Model should be a Parse.User"); - strictEqual(Parse.User.current(), model); - ok(model.extended(), "Should have used subclass."); - strictEqual(provider.authData.id, provider.synchronizedUserId); - strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); - strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); - ok(model._isLinked("facebook"), "User should be linked to facebook"); - - Parse.Cloud.run('checkLogin').then(done, done); - }, - error: function(model, error) { - console.error(model, error); - ok(false, "linking should have worked"); - done(); - } - }); + const model = await Parse.User._logInWith('facebook'); + ok(model instanceof Parse.User, 'Model should be a Parse.User'); + strictEqual(Parse.User.current(), model); + ok(model.extended(), 'Should have used subclass.'); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); + ok(model._isLinked('facebook'), 'User should be linked to facebook'); + + Parse.Cloud.run('checkLogin').then(done, done); }); - it_exclude_dbs(['postgres'])("log in with provider and update token", (done) => { - var provider = getMockFacebookProvider(); - var secondProvider = getMockFacebookProviderWithIdToken('8675309', 'jenny_valid_token'); - var errorHandler = function(err) { - fail('should not fail'); - done(); - } + it('log in with provider and update token', async done => { + const provider = getMockFacebookProvider(); + const secondProvider = getMockFacebookProviderWithIdToken('8675309', 'jenny_valid_token'); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: (model) => { - Parse.User._registerAuthenticationProvider(secondProvider); - return Parse.User.logOut().then(() => { - Parse.User._logInWith("facebook", { - success: (model) => { - expect(secondProvider.synchronizedAuthToken).toEqual('jenny_valid_token'); - // Make sure we can login with the new token again - Parse.User.logOut().then(() => { - Parse.User._logInWith("facebook", { - success: done, - error: errorHandler - }); - }); - }, - error: errorHandler - }); - }) - }, - error: errorHandler - }).catch((err) => { - errorHandler(err); + await Parse.User._logInWith('facebook'); + Parse.User._registerAuthenticationProvider(secondProvider); + await Parse.User.logOut(); + await Parse.User._logInWith('facebook'); + expect(secondProvider.synchronizedAuthToken).toEqual('jenny_valid_token'); + // Make sure we can login with the new token again + await Parse.User.logOut(); + await Parse.User._logInWith('facebook'); + done(); + }); + + it('returns authData when authed and logged in with provider (regression test for #1498)', async done => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + const user = await Parse.User._logInWith('facebook'); + const userQuery = new Parse.Query(Parse.User); + userQuery.get(user.id).then(user => { + expect(user.get('authData')).not.toBeUndefined(); done(); }); }); - it_exclude_dbs(['postgres'])('returns authData when authed and logged in with provider (regression test for #1498)', done => { - Parse.Object.enableSingleInstance(); - let provider = getMockFacebookProvider(); + it('should return authData when select authData with masterKey', async () => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith('facebook', { - success: user => { - let userQuery = new Parse.Query(Parse.User); - userQuery.get(user.id) - .then(user => { - expect(user.get('authData')).not.toBeUndefined(); - Parse.Object.disableSingleInstance(); - done(); - }); - } - }); + const user = await Parse.User._logInWith('facebook'); + const query = new Parse.Query(Parse.User); + query.select('authData'); + const result = await query.get(user.id, { useMasterKey: true }); + expect(result.get('authData')).toBeDefined(); + expect(result.get('authData').facebook).toBeDefined(); + expect(result.get('authData').facebook.id).toBe('8675309'); + expect(result.get('authData').facebook.access_token).toBe('jenny'); }); - it_exclude_dbs(['postgres'])('log in with provider with files', done => { - let provider = getMockFacebookProvider(); + it('only creates a single session for an installation / user pair (#2885)', async () => { + Parse.Object.disableSingleInstance(); + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - let file = new Parse.File("yolo.txt", [1, 2, 3], "text/plain"); - file.save().then(file => { - let user = new Parse.User(); - user.set('file', file); - return user._linkWith('facebook', {}); - }).then(user => { - expect(user._isLinked("facebook")).toBeTruthy(); - return Parse.User._logInWith('facebook', {}); - }).then(user => { - let fileAgain = user.get('file'); - expect(fileAgain.name()).toMatch(/yolo.txt$/); - expect(fileAgain.url()).toMatch(/yolo.txt$/); - }).then(() => { - done(); - }, error => { - fail(error); - done(); + await Parse.User.logInWith('facebook'); + await Parse.User.logInWith('facebook'); + const user = await Parse.User.logInWith('facebook'); + const sessionToken = user.getSessionToken(); + const query = new Parse.Query('_Session'); + const results = await query.find({ useMasterKey: true }); + expect(results.length).toBe(1); + expect(results[0].get('sessionToken')).toBe(sessionToken); + expect(results[0].get('createdWith')).toEqual({ + action: 'login', + authProvider: 'facebook', }); }); - it_exclude_dbs(['postgres'])("log in with provider twice", (done) => { - var provider = getMockFacebookProvider(); + it('log in with provider with files', done => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(model) { - ok(model instanceof Parse.User, "Model should be a Parse.User"); - strictEqual(Parse.User.current(), model); - ok(model.extended(), "Should have used the subclass."); - strictEqual(provider.authData.id, provider.synchronizedUserId); - strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); - strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); - ok(model._isLinked("facebook"), "User should be linked to facebook"); - - Parse.User.logOut(); - ok(provider.loggedOut); - provider.loggedOut = false; - - Parse.User._logInWith("facebook", { - success: function(innerModel) { - ok(innerModel instanceof Parse.User, - "Model should be a Parse.User"); - ok(innerModel === Parse.User.current(), - "Returned model should be the current user"); - ok(provider.authData.id === provider.synchronizedUserId); - ok(provider.authData.access_token === provider.synchronizedAuthToken); - ok(innerModel._isLinked("facebook"), - "User should be linked to facebook"); - ok(innerModel.existed(), "User should not be newly-created"); - done(); - }, - error: function(model, error) { - fail(error); - ok(false, "LogIn should have worked"); - done(); - } - }); - }, - error: function(model, error) { - console.error(model, error); - ok(false, "LogIn should have worked"); + const file = new Parse.File('yolo.txt', [1, 2, 3], 'text/plain'); + file + .save() + .then(file => { + const user = new Parse.User(); + user.set('file', file); + return user._linkWith('facebook', {}); + }) + .then(user => { + expect(user._isLinked('facebook')).toBeTruthy(); + return Parse.User._logInWith('facebook', {}); + }) + .then(user => { + const fileAgain = user.get('file'); + expect(fileAgain.name()).toMatch(/yolo.txt$/); + expect(fileAgain.url()).toMatch(/yolo.txt$/); + }) + .then(() => { done(); - } - }); + }) + .catch(done.fail); + }); + + it('log in with provider twice', async done => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + const model = await Parse.User._logInWith('facebook'); + ok(model instanceof Parse.User, 'Model should be a Parse.User'); + strictEqual(Parse.User.current(), model); + ok(model.extended(), 'Should have used the subclass.'); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); + ok(model._isLinked('facebook'), 'User should be linked to facebook'); + + Parse.User.logOut().then(async () => { + ok(provider.loggedOut); + provider.loggedOut = false; + const innerModel = await Parse.User._logInWith('facebook'); + ok(innerModel instanceof Parse.User, 'Model should be a Parse.User'); + ok(innerModel === Parse.User.current(), 'Returned model should be the current user'); + ok(provider.authData.id === provider.synchronizedUserId); + ok(provider.authData.access_token === provider.synchronizedAuthToken); + ok(innerModel._isLinked('facebook'), 'User should be linked to facebook'); + ok(innerModel.existed(), 'User should not be newly-created'); + done(); + }, done.fail); }); - it("log in with provider failed", (done) => { - var provider = getMockFacebookProvider(); + it('log in with provider failed', async done => { + const provider = getMockFacebookProvider(); provider.shouldError = true; Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(model) { - ok(false, "logIn should not have succeeded"); - }, - error: function(model, error) { - ok(error, "Error should be non-null"); - done(); - } - }); + try { + await Parse.User._logInWith('facebook'); + done.fail(); + } catch (error) { + ok(error, 'Error should be non-null'); + done(); + } }); - it("log in with provider cancelled", (done) => { - var provider = getMockFacebookProvider(); + it('log in with provider cancelled', async done => { + const provider = getMockFacebookProvider(); provider.shouldCancel = true; Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(model) { - ok(false, "logIn should not have succeeded"); - }, - error: function(model, error) { - ok(error === null, "Error should be null"); - done(); - } - }); + try { + await Parse.User._logInWith('facebook'); + done.fail(); + } catch (error) { + ok(error === null, 'Error should be null'); + done(); + } }); - it_exclude_dbs(['postgres'])("login with provider should not call beforeSave trigger", (done) => { - var provider = getMockFacebookProvider(); + it('login with provider should not call beforeSave trigger', async done => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(model) { - Parse.User.logOut(); - - Parse.Cloud.beforeSave(Parse.User, function(req, res) { - res.error("Before save shouldn't be called on login"); - }); - - Parse.User._logInWith("facebook", { - success: function(innerModel) { - done(); - }, - error: function(model, error) { - ok(undefined, error); - done(); - } - }); - } + await Parse.User._logInWith('facebook'); + Parse.User.logOut().then(async () => { + Parse.Cloud.beforeSave(Parse.User, function (req, res) { + res.error("Before save shouldn't be called on login"); + }); + await Parse.User._logInWith('facebook'); + done(); }); }); - it_exclude_dbs(['postgres'])("link with provider", (done) => { - var provider = getMockFacebookProvider(); + it('signup with provider should not call beforeLogin trigger', async done => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - var user = new Parse.User(); - user.set("username", "testLinkWithProvider"); - user.set("password", "mypass"); - user.signUp(null, { - success: function(model) { - user._linkWith("facebook", { - success: function(model) { - ok(model instanceof Parse.User, "Model should be a Parse.User"); - strictEqual(Parse.User.current(), model); - strictEqual(provider.authData.id, provider.synchronizedUserId); - strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); - strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); - ok(model._isLinked("facebook"), "User should be linked"); - done(); - }, - error: function(model, error) { - ok(false, "linking should have succeeded"); - done(); - } - }); - }, - error: function(model, error) { - ok(false, "signup should not have failed"); - done(); - } + + let hit = 0; + Parse.Cloud.beforeLogin(() => { + hit++; }); + + await Parse.User._logInWith('facebook'); + expect(hit).toBe(0); + done(); }); - // What this means is, only one Parse User can be linked to a - // particular Facebook account. - it_exclude_dbs(['postgres'])("link with provider for already linked user", (done) => { - var provider = getMockFacebookProvider(); + it('login with provider should call beforeLogin trigger', async done => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - var user = new Parse.User(); - user.set("username", "testLinkWithProviderToAlreadyLinkedUser"); - user.set("password", "mypass"); - user.signUp(null, { - success: function(model) { - user._linkWith("facebook", { - success: function(model) { - ok(model instanceof Parse.User, "Model should be a Parse.User"); - strictEqual(Parse.User.current(), model); - strictEqual(provider.authData.id, provider.synchronizedUserId); - strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); - strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); - ok(model._isLinked("facebook"), "User should be linked."); - var user2 = new Parse.User(); - user2.set("username", "testLinkWithProviderToAlreadyLinkedUser2"); - user2.set("password", "mypass"); - user2.signUp(null, { - success: function(model) { - user2._linkWith('facebook', { - success: fail, - error: function(model, error) { - expect(error.code).toEqual( - Parse.Error.ACCOUNT_ALREADY_LINKED); - done(); - }, - }); - }, - error: function(model, error) { - ok(false, "linking should have failed"); - done(); - } - }); - }, - error: function(model, error) { - ok(false, "linking should have succeeded"); - done(); - } - }); - }, - error: function(model, error) { - ok(false, "signup should not have failed"); - done(); - } + + let hit = 0; + Parse.Cloud.beforeLogin(req => { + hit++; + expect(req.object.get('authData')).toBeDefined(); + expect(req.object.get('name')).toBe('tupac shakur'); }); + await Parse.User._logInWith('facebook'); + await Parse.User.current().save({ name: 'tupac shakur' }); + await Parse.User.logOut(); + await Parse.User._logInWith('facebook'); + expect(hit).toBe(1); + done(); }); - it("link with provider failed", (done) => { - var provider = getMockFacebookProvider(); - provider.shouldError = true; + it('incorrect login with provider should not call beforeLogin trigger', async done => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - var user = new Parse.User(); - user.set("username", "testLinkWithProvider"); - user.set("password", "mypass"); - user.signUp(null, { - success: function(model) { - user._linkWith("facebook", { - success: function(model) { - ok(false, "linking should fail"); - done(); - }, - error: function(model, error) { - ok(error, "Linking should fail"); - ok(!model._isLinked("facebook"), - "User should not be linked to facebook"); - done(); - } - }); - }, - error: function(model, error) { - ok(false, "signup should not have failed"); - done(); - } + + let hit = 0; + Parse.Cloud.beforeLogin(() => { + hit++; }); + await Parse.User._logInWith('facebook'); + await Parse.User.logOut(); + provider.shouldError = true; + try { + await Parse.User._logInWith('facebook'); + } catch (e) { + expect(e).toBeDefined(); + } + expect(hit).toBe(0); + done(); }); - it("link with provider cancelled", (done) => { - var provider = getMockFacebookProvider(); - provider.shouldCancel = true; + it('login with provider should be blockable by beforeLogin', async done => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - var user = new Parse.User(); - user.set("username", "testLinkWithProvider"); - user.set("password", "mypass"); - user.signUp(null, { - success: function(model) { - user._linkWith("facebook", { - success: function(model) { - ok(false, "linking should fail"); - done(); - }, - error: function(model, error) { - ok(!error, "Linking should be cancelled"); - ok(!model._isLinked("facebook"), - "User should not be linked to facebook"); - done(); - } - }); - }, - error: function(model, error) { - ok(false, "signup should not have failed"); - done(); + + let hit = 0; + Parse.Cloud.beforeLogin(req => { + hit++; + if (req.object.get('isBanned')) { + throw new Error('banned account'); } }); + await Parse.User._logInWith('facebook'); + await Parse.User.current().save({ isBanned: true }); + await Parse.User.logOut(); + + try { + await Parse.User._logInWith('facebook'); + throw new Error('should not have continued login.'); + } catch (e) { + expect(e.message).toBe('banned account'); + } + + expect(hit).toBe(1); + done(); }); - it_exclude_dbs(['postgres'])("unlink with provider", (done) => { - var provider = getMockFacebookProvider(); + it('login with provider should be blockable by beforeLogin even when the user has a attached file', async done => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(model) { - ok(model instanceof Parse.User, "Model should be a Parse.User."); - strictEqual(Parse.User.current(), model); - ok(model.extended(), "Should have used the subclass."); - strictEqual(provider.authData.id, provider.synchronizedUserId); - strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); - strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); - ok(model._isLinked("facebook"), "User should be linked to facebook."); - - model._unlinkFrom("facebook", { - success: function(model) { - ok(!model._isLinked("facebook"), "User should not be linked."); - ok(!provider.synchronizedUserId, "User id should be cleared."); - ok(!provider.synchronizedAuthToken, - "Auth token should be cleared."); - ok(!provider.synchronizedExpiration, - "Expiration should be cleared."); - done(); - }, - error: function(model, error) { - ok(false, "unlinking should succeed"); - done(); - } - }); - }, - error: function(model, error) { - ok(false, "linking should have worked"); - done(); + + let hit = 0; + Parse.Cloud.beforeLogin(req => { + hit++; + if (req.object.get('isBanned')) { + throw new Error('banned account'); } }); + + const user = await Parse.User._logInWith('facebook'); + const base64 = 'aHR0cHM6Ly9naXRodWIuY29tL2t2bmt1YW5n'; + const file = new Parse.File('myfile.txt', { base64 }); + await file.save(); + await user.save({ isBanned: true, file }); + await Parse.User.logOut(); + + try { + await Parse.User._logInWith('facebook'); + throw new Error('should not have continued login.'); + } catch (e) { + expect(e.message).toBe('banned account'); + } + + expect(hit).toBe(1); + done(); }); - it_exclude_dbs(['postgres'])("unlink and link", (done) => { - var provider = getMockFacebookProvider(); + it('logout with provider should call afterLogout trigger', async done => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(model) { - ok(model instanceof Parse.User, "Model should be a Parse.User"); - strictEqual(Parse.User.current(), model); - ok(model.extended(), "Should have used the subclass."); - strictEqual(provider.authData.id, provider.synchronizedUserId); - strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); - strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); - ok(model._isLinked("facebook"), "User should be linked to facebook"); - - model._unlinkFrom("facebook", { - success: function(model) { - ok(!model._isLinked("facebook"), - "User should not be linked to facebook"); - ok(!provider.synchronizedUserId, "User id should be cleared"); - ok(!provider.synchronizedAuthToken, "Auth token should be cleared"); - ok(!provider.synchronizedExpiration, - "Expiration should be cleared"); - - model._linkWith("facebook", { - success: function(model) { - ok(provider.synchronizedUserId, "User id should have a value"); - ok(provider.synchronizedAuthToken, - "Auth token should have a value"); - ok(provider.synchronizedExpiration, - "Expiration should have a value"); - ok(model._isLinked("facebook"), - "User should be linked to facebook"); - done(); - }, - error: function(model, error) { - ok(false, "linking again should succeed"); - done(); - } - }); - }, - error: function(model, error) { - ok(false, "unlinking should succeed"); - done(); - } - }); - }, - error: function(model, error) { - ok(false, "linking should have worked"); - done(); - } + + let userId; + Parse.Cloud.afterLogout(req => { + expect(req.object.className).toEqual('_Session'); + expect(req.object.id).toBeDefined(); + const user = req.object.get('user'); + expect(user).toBeDefined(); + userId = user.id; }); + const user = await Parse.User._logInWith('facebook'); + await Parse.User.logOut(); + expect(user.id).toBe(userId); + done(); }); - it_exclude_dbs(['postgres'])("link multiple providers", (done) => { - var provider = getMockFacebookProvider(); - var mockProvider = getMockMyOauthProvider(); + it('link with provider', async done => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(model) { - ok(model instanceof Parse.User, "Model should be a Parse.User"); - strictEqual(Parse.User.current(), model); - ok(model.extended(), "Should have used the subclass."); - strictEqual(provider.authData.id, provider.synchronizedUserId); - strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); - strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); - ok(model._isLinked("facebook"), "User should be linked to facebook"); - Parse.User._registerAuthenticationProvider(mockProvider); - let objectId = model.id; - model._linkWith("myoauth", { - success: function(model) { - expect(model.id).toEqual(objectId); - ok(model._isLinked("facebook"), "User should be linked to facebook"); - ok(model._isLinked("myoauth"), "User should be linked to myoauth"); - done(); - }, - error: function(error) { - console.error(error); - fail('SHould not fail'); - done(); - } - }) - }, - error: function(model, error) { - ok(false, "linking should have worked"); - done(); - } - }); + const user = new Parse.User(); + user.set('username', 'testLinkWithProvider'); + user.set('password', 'mypass'); + await user.signUp(); + const model = await user._linkWith('facebook'); + ok(model instanceof Parse.User, 'Model should be a Parse.User'); + strictEqual(Parse.User.current(), model); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); + ok(model._isLinked('facebook'), 'User should be linked'); + done(); + }); + + // What this means is, only one Parse User can be linked to a + // particular Facebook account. + it('link with provider for already linked user', async done => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + const user = new Parse.User(); + user.set('username', 'testLinkWithProviderToAlreadyLinkedUser'); + user.set('password', 'mypass'); + await user.signUp(); + const model = await user._linkWith('facebook'); + ok(model instanceof Parse.User, 'Model should be a Parse.User'); + strictEqual(Parse.User.current(), model); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); + ok(model._isLinked('facebook'), 'User should be linked.'); + const user2 = new Parse.User(); + user2.set('username', 'testLinkWithProviderToAlreadyLinkedUser2'); + user2.set('password', 'mypass'); + await user2.signUp(); + try { + await user2._linkWith('facebook'); + done.fail(); + } catch (error) { + expect(error.code).toEqual(Parse.Error.ACCOUNT_ALREADY_LINKED); + done(); + } + }); + + it('link with provider should return sessionToken', async () => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + const user = new Parse.User(); + user.set('username', 'testLinkWithProvider'); + user.set('password', 'mypass'); + await user.signUp(); + const query = new Parse.Query(Parse.User); + const u2 = await query.get(user.id); + const model = await u2._linkWith('facebook', {}, { useMasterKey: true }); + expect(u2.getSessionToken()).toBeDefined(); + expect(model.getSessionToken()).toBeDefined(); + expect(u2.getSessionToken()).toBe(model.getSessionToken()); + }); + + it('link with provider via sessionToken should not create new sessionToken (Regression #5799)', async () => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + const user = new Parse.User(); + user.set('username', 'testLinkWithProviderNoOverride'); + user.set('password', 'mypass'); + await user.signUp(); + const sessionToken = user.getSessionToken(); + + await user._linkWith('facebook', {}, { sessionToken }); + expect(sessionToken).toBe(user.getSessionToken()); + + expect(user._isLinked(provider)).toBe(true); + await user._unlinkFrom(provider, { sessionToken }); + expect(user._isLinked(provider)).toBe(false); + + const become = await Parse.User.become(sessionToken); + expect(sessionToken).toBe(become.getSessionToken()); }); - it_exclude_dbs(['postgres'])("link multiple providers and updates token", (done) => { - var provider = getMockFacebookProvider(); - var secondProvider = getMockFacebookProviderWithIdToken('8675309', 'jenny_valid_token'); + it('link with provider failed', async done => { + const provider = getMockFacebookProvider(); + provider.shouldError = true; + Parse.User._registerAuthenticationProvider(provider); + const user = new Parse.User(); + user.set('username', 'testLinkWithProvider'); + user.set('password', 'mypass'); + await user.signUp(); + try { + await user._linkWith('facebook'); + done.fail(); + } catch (error) { + ok(error, 'Linking should fail'); + ok(!user._isLinked('facebook'), 'User should not be linked to facebook'); + done(); + } + }); - var errorHandler = function(model, error) { - console.error(error); - fail('Should not fail'); + it('link with provider cancelled', async done => { + const provider = getMockFacebookProvider(); + provider.shouldCancel = true; + Parse.User._registerAuthenticationProvider(provider); + const user = new Parse.User(); + user.set('username', 'testLinkWithProvider'); + user.set('password', 'mypass'); + await user.signUp(); + try { + await user._linkWith('facebook'); + done.fail(); + } catch (error) { + ok(!error, 'Linking should be cancelled'); + ok(!user._isLinked('facebook'), 'User should not be linked to facebook'); done(); } - var mockProvider = getMockMyOauthProvider(); + }); + + it('unlink with provider', async done => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(model) { - Parse.User._registerAuthenticationProvider(mockProvider); - let objectId = model.id; - model._linkWith("myoauth", { - success: function(model) { - Parse.User._registerAuthenticationProvider(secondProvider); - Parse.User.logOut().then(() => { - return Parse.User._logInWith("facebook", { - success: () => { - Parse.User.logOut().then(() => { - return Parse.User._logInWith("myoauth", { - success: (user) => { - expect(user.id).toBe(objectId); - done(); - } - }) - }) - }, - error: errorHandler - }); - }) - }, - error: errorHandler - }) - }, - error: errorHandler - }); + const model = await Parse.User._logInWith('facebook'); + ok(model instanceof Parse.User, 'Model should be a Parse.User.'); + strictEqual(Parse.User.current(), model); + ok(model.extended(), 'Should have used the subclass.'); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); + ok(model._isLinked('facebook'), 'User should be linked to facebook.'); + await model._unlinkFrom('facebook'); + ok(!model._isLinked('facebook'), 'User should not be linked.'); + ok(!provider.synchronizedUserId, 'User id should be cleared.'); + ok(!provider.synchronizedAuthToken, 'Auth token should be cleared.'); + ok(!provider.synchronizedExpiration, 'Expiration should be cleared.'); + done(); }); - it_exclude_dbs(['postgres'])("link multiple providers and update token", (done) => { - var provider = getMockFacebookProvider(); - var mockProvider = getMockMyOauthProvider(); + it('unlink and link', async done => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(model) { - ok(model instanceof Parse.User, "Model should be a Parse.User"); - strictEqual(Parse.User.current(), model); - ok(model.extended(), "Should have used the subclass."); - strictEqual(provider.authData.id, provider.synchronizedUserId); - strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); - strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); - ok(model._isLinked("facebook"), "User should be linked to facebook"); - Parse.User._registerAuthenticationProvider(mockProvider); - let objectId = model.id; - model._linkWith("myoauth", { - success: function(model) { - expect(model.id).toEqual(objectId); - ok(model._isLinked("facebook"), "User should be linked to facebook"); - ok(model._isLinked("myoauth"), "User should be linked to myoauth"); - model._linkWith("facebook", { - success: () => { - ok(model._isLinked("facebook"), "User should be linked to facebook"); - ok(model._isLinked("myoauth"), "User should be linked to myoauth"); - done(); - }, - error: () => { - fail('should link again'); - done(); - } - }) - }, - error: function(error) { - console.error(error); - fail('SHould not fail'); - done(); - } - }) - }, - error: function(model, error) { - ok(false, "linking should have worked"); + const model = await Parse.User._logInWith('facebook'); + ok(model instanceof Parse.User, 'Model should be a Parse.User'); + strictEqual(Parse.User.current(), model); + ok(model.extended(), 'Should have used the subclass.'); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); + ok(model._isLinked('facebook'), 'User should be linked to facebook'); + + await model._unlinkFrom('facebook'); + ok(!model._isLinked('facebook'), 'User should not be linked to facebook'); + ok(!provider.synchronizedUserId, 'User id should be cleared'); + ok(!provider.synchronizedAuthToken, 'Auth token should be cleared'); + ok(!provider.synchronizedExpiration, 'Expiration should be cleared'); + + await model._linkWith('facebook'); + ok(provider.synchronizedUserId, 'User id should have a value'); + ok(provider.synchronizedAuthToken, 'Auth token should have a value'); + ok(provider.synchronizedExpiration, 'Expiration should have a value'); + ok(model._isLinked('facebook'), 'User should be linked to facebook'); + done(); + }); + + it('link multiple providers', async done => { + const provider = getMockFacebookProvider(); + const mockProvider = getMockMyOauthProvider(); + Parse.User._registerAuthenticationProvider(provider); + const model = await Parse.User._logInWith('facebook'); + ok(model instanceof Parse.User, 'Model should be a Parse.User'); + strictEqual(Parse.User.current(), model); + ok(model.extended(), 'Should have used the subclass.'); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); + ok(model._isLinked('facebook'), 'User should be linked to facebook'); + Parse.User._registerAuthenticationProvider(mockProvider); + const objectId = model.id; + await model._linkWith('myoauth'); + expect(model.id).toEqual(objectId); + ok(model._isLinked('facebook'), 'User should be linked to facebook'); + ok(model._isLinked('myoauth'), 'User should be linked to myoauth'); + done(); + }); + + it('link multiple providers and updates token', async done => { + const provider = getMockFacebookProvider(); + const secondProvider = getMockFacebookProviderWithIdToken('8675309', 'jenny_valid_token'); + + const mockProvider = getMockMyOauthProvider(); + Parse.User._registerAuthenticationProvider(provider); + const model = await Parse.User._logInWith('facebook'); + Parse.User._registerAuthenticationProvider(mockProvider); + const objectId = model.id; + await model._linkWith('myoauth'); + Parse.User._registerAuthenticationProvider(secondProvider); + await Parse.User.logOut(); + await Parse.User._logInWith('facebook'); + await Parse.User.logOut(); + const user = await Parse.User._logInWith('myoauth'); + expect(user.id).toBe(objectId); + done(); + }); + + it('link multiple providers and update token', async done => { + const provider = getMockFacebookProvider(); + const mockProvider = getMockMyOauthProvider(); + Parse.User._registerAuthenticationProvider(provider); + const model = await Parse.User._logInWith('facebook'); + ok(model instanceof Parse.User, 'Model should be a Parse.User'); + strictEqual(Parse.User.current(), model); + ok(model.extended(), 'Should have used the subclass.'); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); + ok(model._isLinked('facebook'), 'User should be linked to facebook'); + Parse.User._registerAuthenticationProvider(mockProvider); + const objectId = model.id; + await model._linkWith('myoauth'); + expect(model.id).toEqual(objectId); + ok(model._isLinked('facebook'), 'User should be linked to facebook'); + ok(model._isLinked('myoauth'), 'User should be linked to myoauth'); + await model._linkWith('facebook'); + ok(model._isLinked('facebook'), 'User should be linked to facebook'); + ok(model._isLinked('myoauth'), 'User should be linked to myoauth'); + done(); + }); + + it('should fail linking with existing', async done => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + await Parse.User._logInWith('facebook'); + await Parse.User.logOut(); + const user = new Parse.User(); + user.setUsername('user'); + user.setPassword('password'); + await user.signUp(); + // try to link here + try { + await user._linkWith('facebook'); + done.fail(); + } catch (e) { + done(); + } + }); + + it('should fail linking with existing through REST', async done => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + const model = await Parse.User._logInWith('facebook'); + const userId = model.id; + Parse.User.logOut().then(() => { + request({ + method: 'POST', + url: Parse.serverURL + '/classes/_User', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { authData: { facebook: provider.authData } }, + }).then(response => { + const body = response.data; + // make sure the location header is properly set + expect(userId).not.toBeUndefined(); + expect(body.objectId).toEqual(userId); + expect(response.headers.location).toEqual(Parse.serverURL + '/users/' + userId); done(); - } + }); }); }); - it_exclude_dbs(['postgres'])('should fail linking with existing', (done) => { - var provider = getMockFacebookProvider(); + it('should not allow login with expired authData token since allowExpiredAuthDataToken is set to false by default', async () => { + const provider = { + authData: { + id: '12345', + access_token: 'token', + }, + restoreAuthentication: function () { + return true; + }, + deauthenticate: function () { + provider.authData = {}; + }, + authenticate: function (options) { + options.success(this, provider.authData); + }, + getAuthType: function () { + return 'shortLivedAuth'; + }, + }; + defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('token'); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(model) { - Parse.User.logOut().then(() => { - let user = new Parse.User(); - user.setUsername('user'); - user.setPassword('password'); - return user.signUp().then(() => { - // try to link here - user._linkWith('facebook', { - success: () => { - fail('should not succeed'); - done(); - }, - error: (err) => { - done(); - } - }); - }); - }); - } - }); + await Parse.User._logInWith('shortLivedAuth', {}); + // Simulate a remotely expired token (like a short lived one) + // In this case, we want success as it was valid once. + // If the client needs an updated token, do lock the user out + defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('otherToken'); + await expectAsync(Parse.User._logInWith('shortLivedAuth', {})).toBeRejected(); }); - it_exclude_dbs(['postgres'])('should fail linking with existing', (done) => { - var provider = getMockFacebookProvider(); + it('should allow PUT request with stale auth Data', done => { + const provider = { + authData: { + id: '12345', + access_token: 'token', + }, + restoreAuthentication: function () { + return true; + }, + deauthenticate: function () { + provider.authData = {}; + }, + authenticate: function (options) { + options.success(this, provider.authData); + }, + getAuthType: function () { + return 'shortLivedAuth'; + }, + }; + defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('token'); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(model) { - let userId = model.id; - Parse.User.logOut().then(() => { - request.post({ - url:Parse.serverURL+'/classes/_User', - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest' - }, - json: {authData: {facebook: provider.authData}} - }, (err,res, body) => { - // make sure the location header is properly set - expect(userId).not.toBeUndefined(); - expect(body.objectId).toEqual(userId); - expect(res.headers.location).toEqual(Parse.serverURL+'/users/'+userId); - done(); - }); + Parse.User._logInWith('shortLivedAuth', {}) + .then(() => { + // Simulate a remotely expired token (like a short lived one) + // In this case, we want success as it was valid once. + // If the client needs an updated one, do lock the user out + defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('otherToken'); + return request({ + method: 'PUT', + url: Parse.serverURL + '/users/' + Parse.User.current().id, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Javascript-Key': Parse.javaScriptKey, + 'X-Parse-Session-Token': Parse.User.current().getSessionToken(), + 'Content-Type': 'application/json', + }, + body: { + key: 'value', // update a key + authData: { + // pass the original auth data + shortLivedAuth: { + id: '12345', + access_token: 'token', + }, + }, + }, }); - } - }); + }) + .then( + () => { + done(); + }, + err => { + done.fail(err); + } + ); }); - it_exclude_dbs(['postgres'])('should properly error when password is missing', (done) => { - var provider = getMockFacebookProvider(); + it('should properly error when password is missing', async done => { + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(user) { - user.set('username', 'myUser'); - user.set('email', 'foo@example.com'); - user.save().then(() => { - return Parse.User.logOut(); - }).then(() => { - return Parse.User.logIn('myUser', 'password'); - }).then(() => { + const user = await Parse.User._logInWith('facebook'); + user.set('username', 'myUser'); + user.set('email', 'foo@example.com'); + user + .save() + .then(() => { + return Parse.User.logOut(); + }) + .then(() => { + return Parse.User.logIn('myUser', 'password'); + }) + .then( + () => { fail('should not succeed'); done(); - }, (err) => { + }, + err => { expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); expect(err.message).toEqual('Invalid username/password.'); done(); - }) - } - }); + } + ); }); - it_exclude_dbs(['postgres'])('should have authData in beforeSave and afterSave', (done) => { - - Parse.Cloud.beforeSave('_User', (request, response) => { - let authData = request.object.get('authData'); + it('should have authData in beforeSave and afterSave', async done => { + Parse.Cloud.beforeSave('_User', request => { + const authData = request.object.get('authData'); expect(authData).not.toBeUndefined(); if (authData) { expect(authData.facebook.id).toEqual('8675309'); @@ -1669,11 +2109,10 @@ describe('Parse.User testing', () => { } else { fail('authData should be set'); } - response.success(); }); - Parse.Cloud.afterSave('_User', (request, response) => { - let authData = request.object.get('authData'); + Parse.Cloud.afterSave('_User', request => { + const authData = request.object.get('authData'); expect(authData).not.toBeUndefined(); if (authData) { expect(authData.facebook.id).toEqual('8675309'); @@ -1681,987 +2120,2514 @@ describe('Parse.User testing', () => { } else { fail('authData should be set'); } - response.success(); }); - var provider = getMockFacebookProvider(); + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith("facebook", { - success: function(model) { - done(); - } - }); + await Parse.User._logInWith('facebook'); + done(); }); - it('set password then change password', (done) => { - Parse.User.signUp('bob', 'barker').then((bob) => { - bob.setPassword('meower'); - return bob.save(); - }).then(() => { - return Parse.User.logIn('bob', 'meower'); - }).then((bob) => { - expect(bob.getUsername()).toEqual('bob'); - done(); - }, (e) => { - console.log(e); - fail(); - }); + it('set password then change password', done => { + Parse.User.signUp('bob', 'barker') + .then(bob => { + bob.setPassword('meower'); + return bob.save(); + }) + .then(() => { + return Parse.User.logIn('bob', 'meower'); + }) + .then( + bob => { + expect(bob.getUsername()).toEqual('bob'); + done(); + }, + e => { + console.log(e); + fail(); + } + ); }); - it("authenticated check", (done) => { - var user = new Parse.User(); - user.set("username", "darkhelmet"); - user.set("password", "onetwothreefour"); + it('authenticated check', async done => { + const user = new Parse.User(); + user.set('username', 'darkhelmet'); + user.set('password', 'onetwothreefour'); ok(!user.authenticated()); - user.signUp(null, expectSuccess({ - success: function(result) { - ok(user.authenticated()); - done(); - } - })); - }); - - it_exclude_dbs(['postgres'])("log in with explicit facebook auth data", (done) => { - Parse.FacebookUtils.logIn({ - id: "8675309", - access_token: "jenny", - expiration_date: new Date().toJSON() - }, expectSuccess({success: done})); + await user.signUp(null); + ok(user.authenticated()); + done(); }); - it_exclude_dbs(['postgres'])("log in async with explicit facebook auth data", (done) => { - Parse.FacebookUtils.logIn({ - id: "8675309", - access_token: "jenny", - expiration_date: new Date().toJSON() - }).then(function() { - done(); - }, function(error) { - ok(false, error); - done(); + it('log in with explicit facebook auth data', async done => { + await Parse.FacebookUtils.logIn({ + id: '8675309', + access_token: 'jenny', + expiration_date: new Date().toJSON(), }); + done(); }); - it_exclude_dbs(['postgres'])("link with explicit facebook auth data", (done) => { - Parse.User.signUp("mask", "open sesame", null, expectSuccess({ - success: function(user) { - Parse.FacebookUtils.link(user, { - id: "8675309", - access_token: "jenny", - expiration_date: new Date().toJSON() - }).then(done, (error) => { - fail(error); - done(); - }); + it('log in async with explicit facebook auth data', done => { + Parse.FacebookUtils.logIn({ + id: '8675309', + access_token: 'jenny', + expiration_date: new Date().toJSON(), + }).then( + function () { + done(); + }, + function (error) { + ok(false, error); + done(); } - })); + ); }); - it_exclude_dbs(['postgres'])("link async with explicit facebook auth data", (done) => { - Parse.User.signUp("mask", "open sesame", null, expectSuccess({ - success: function(user) { - Parse.FacebookUtils.link(user, { - id: "8675309", - access_token: "jenny", - expiration_date: new Date().toJSON() - }).then(function() { - done(); - }, function(error) { - ok(false, error); - done(); - }); - } - })); - }); - - it("async methods", (done) => { - var data = { foo: "bar" }; - - Parse.User.signUp("finn", "human", data).then(function(user) { - equal(Parse.User.current(), user); - equal(user.get("foo"), "bar"); - return Parse.User.logOut(); - }).then(function() { - return Parse.User.logIn("finn", "human"); - }).then(function(user) { - equal(user, Parse.User.current()); - equal(user.get("foo"), "bar"); - return Parse.User.logOut(); - }).then(function() { - var user = new Parse.User(); - user.set("username", "jake"); - user.set("password", "dog"); - user.set("foo", "baz"); - return user.signUp(); - }).then(function(user) { - equal(user, Parse.User.current()); - equal(user.get("foo"), "baz"); - user = new Parse.User(); - user.set("username", "jake"); - user.set("password", "dog"); - return user.logIn(); - }).then(function(user) { - equal(user, Parse.User.current()); - equal(user.get("foo"), "baz"); - var userAgain = new Parse.User(); - userAgain.id = user.id; - return userAgain.fetch(); - }).then(function(userAgain) { - equal(userAgain.get("foo"), "baz"); + it('link with explicit facebook auth data', async done => { + const user = await Parse.User.signUp('mask', 'open sesame'); + Parse.FacebookUtils.link(user, { + id: '8675309', + access_token: 'jenny', + expiration_date: new Date().toJSON(), + }).then(done, error => { + jfail(error); done(); }); }); - xit("querying for users doesn't get session tokens", (done) => { - Parse.Promise.as().then(function() { - return Parse.User.signUp("finn", "human", { foo: "bar" }); - - }).then(function() { - return Parse.User.logOut(); - }).then(() => { - var user = new Parse.User(); - user.set("username", "jake"); - user.set("password", "dog"); - user.set("foo", "baz"); - return user.signUp(); + it('link async with explicit facebook auth data', async done => { + const user = await Parse.User.signUp('mask', 'open sesame'); + Parse.FacebookUtils.link(user, { + id: '8675309', + access_token: 'jenny', + expiration_date: new Date().toJSON(), + }).then( + function () { + done(); + }, + function (error) { + ok(false, error); + done(); + } + ); + }); - }).then(function() { - return Parse.User.logOut(); - }).then(() => { - var query = new Parse.Query(Parse.User); - return query.find(); + it('async methods', done => { + const data = { foo: 'bar' }; - }).then(function(users) { - equal(users.length, 2); - for (var user of users) { - ok(!user.getSessionToken(), "user should not have a session token."); - } + Parse.User.signUp('finn', 'human', data) + .then(function (user) { + equal(Parse.User.current(), user); + equal(user.get('foo'), 'bar'); + return Parse.User.logOut(); + }) + .then(function () { + return Parse.User.logIn('finn', 'human'); + }) + .then(function (user) { + equal(user, Parse.User.current()); + equal(user.get('foo'), 'bar'); + return Parse.User.logOut(); + }) + .then(function () { + const user = new Parse.User(); + user.set('username', 'jake'); + user.set('password', 'dog'); + user.set('foo', 'baz'); + return user.signUp(); + }) + .then(function (user) { + equal(user, Parse.User.current()); + equal(user.get('foo'), 'baz'); + user = new Parse.User(); + user.set('username', 'jake'); + user.set('password', 'dog'); + return user.logIn(); + }) + .then(function (user) { + equal(user, Parse.User.current()); + equal(user.get('foo'), 'baz'); + const userAgain = new Parse.User(); + userAgain.id = user.id; + return userAgain.fetch(); + }) + .then(function (userAgain) { + equal(userAgain.get('foo'), 'baz'); + done(); + }); + }); - done(); - }, function(error) { - ok(false, error); - done(); - }); + it("querying for users doesn't get session tokens", done => { + const user = new Parse.User(); + user.set('username', 'finn'); + user.set('password', 'human'); + user.set('foo', 'bar'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + user.setACL(acl); + user + .signUp() + .then(function () { + return Parse.User.logOut(); + }) + .then(() => { + const user = new Parse.User(); + user.set('username', 'jake'); + user.set('password', 'dog'); + user.set('foo', 'baz'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + user.setACL(acl); + return user.signUp(); + }) + .then(function () { + return Parse.User.logOut(); + }) + .then(() => { + const query = new Parse.Query(Parse.User); + return query.find({ sessionToken: null }); + }) + .then( + function (users) { + equal(users.length, 2); + users.forEach(user => { + expect(user.getSessionToken()).toBeUndefined(); + ok(!user.getSessionToken(), 'user should not have a session token.'); + }); + done(); + }, + function (error) { + ok(false, error); + done(); + } + ); }); - it("querying for users only gets the expected fields", (done) => { - Parse.Promise.as().then(() => { - return Parse.User.signUp("finn", "human", { foo: "bar" }); - }).then(() => { - request.get({ - headers: {'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest'}, + it('querying for users only gets the expected fields', done => { + const user = new Parse.User(); + user.setUsername('finn'); + user.setPassword('human'); + user.set('foo', 'bar'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + user.setACL(acl); + user.signUp().then(() => { + request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, url: 'http://localhost:8378/1/users', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); + }).then(response => { + const b = response.data; expect(b.results.length).toEqual(1); - var user = b.results[0]; + const user = b.results[0]; expect(Object.keys(user).length).toEqual(6); done(); }); }); }); - it('retrieve user data from fetch, make sure the session token hasn\'t changed', (done) => { - var user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - var currentSessionToken = ""; - Parse.Promise.as().then(function() { + it("retrieve user data from fetch, make sure the session token hasn't changed", done => { + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + let currentSessionToken = ''; + Promise.resolve() + .then(function () { return user.signUp(); - }).then(function(){ + }) + .then(function () { currentSessionToken = user.getSessionToken(); return user.fetch(); - }).then(function(u){ - expect(currentSessionToken).toEqual(u.getSessionToken()); - done(); - }, function(error) { - ok(false, error); - done(); - }) - }); - - it('user save should fail with invalid email', (done) => { - var user = new Parse.User(); - user.set('username', 'teste'); - user.set('password', 'test'); + }) + .then( + function (u) { + expect(currentSessionToken).toEqual(u.getSessionToken()); + done(); + }, + function (error) { + ok(false, error); + done(); + } + ); + }); + + it('user save should fail with invalid email', done => { + const user = new Parse.User(); + user.set('username', 'teste'); + user.set('password', 'test'); user.set('email', 'invalid'); - user.signUp().then(() => { - fail('Should not have been able to save.'); - done(); - }, (error) => { - expect(error.code).toEqual(125); - done(); - }); + user.signUp().then( + () => { + fail('Should not have been able to save.'); + done(); + }, + error => { + expect(error.code).toEqual(125); + done(); + } + ); }); - it('user signup should error if email taken', (done) => { - var user = new Parse.User(); + it('user signup should error if email taken', done => { + const user = new Parse.User(); user.set('username', 'test1'); user.set('password', 'test'); user.set('email', 'test@test.com'); - user.signUp().then(() => { - var user2 = new Parse.User(); - user2.set('username', 'test2'); + user + .signUp() + .then(() => { + const user2 = new Parse.User(); + user2.set('username', 'test2'); + user2.set('password', 'test'); + user2.set('email', 'test@test.com'); + return user2.signUp(); + }) + .then( + () => { + fail('Should not have been able to sign up.'); + done(); + }, + () => { + done(); + } + ); + }); + + describe('case insensitive signup not allowed', () => { + it_id('464eddc2-7a46-413d-888e-b43b040f1511')(it)('signup should fail with duplicate case insensitive username with basic setter', async () => { + const user = new Parse.User(); + user.set('username', 'test1'); + user.set('password', 'test'); + await user.signUp(); + + const user2 = new Parse.User(); + user2.set('username', 'Test1'); user2.set('password', 'test'); - user2.set('email', 'test@test.com'); - return user2.signUp(); - }).then(() => { - fail('Should not have been able to sign up.'); - done(); - }, (error) => { - done(); + await expectAsync(user2.signUp()).toBeRejectedWith( + new Parse.Error(Parse.Error.USERNAME_TAKEN, 'Account already exists for this username.') + ); + }); + + it_id('1cef005b-d5f0-4699-af0c-bb0af27d2437')(it)('signup should fail with duplicate case insensitive username with field specific setter', async () => { + const user = new Parse.User(); + user.setUsername('test1'); + user.setPassword('test'); + await user.signUp(); + + const user2 = new Parse.User(); + user2.setUsername('Test1'); + user2.setPassword('test'); + await expectAsync(user2.signUp()).toBeRejectedWith( + new Parse.Error(Parse.Error.USERNAME_TAKEN, 'Account already exists for this username.') + ); + }); + + it_id('12735529-98d1-42c0-b437-3b47fe78ddde')(it)('signup should fail with duplicate case insensitive email', async () => { + const user = new Parse.User(); + user.setUsername('test1'); + user.setPassword('test'); + user.setEmail('test@example.com'); + await user.signUp(); + + const user2 = new Parse.User(); + user2.setUsername('test2'); + user2.setPassword('test'); + user2.setEmail('Test@Example.Com'); + await expectAsync(user2.signUp()).toBeRejectedWith( + new Parse.Error(Parse.Error.EMAIL_TAKEN, 'Account already exists for this email address.') + ); + }); + + it_id('66e51d52-2420-4b62-8a0d-c7e1b384763e')(it)('edit should fail with duplicate case insensitive email', async () => { + const user = new Parse.User(); + user.setUsername('test1'); + user.setPassword('test'); + user.setEmail('test@example.com'); + await user.signUp(); + + const user2 = new Parse.User(); + user2.setUsername('test2'); + user2.setPassword('test'); + user2.setEmail('Foo@Example.Com'); + await user2.signUp(); + + user2.setEmail('Test@Example.Com'); + await expectAsync(user2.save()).toBeRejectedWith( + new Parse.Error(Parse.Error.EMAIL_TAKEN, 'Account already exists for this email address.') + ); + }); + + describe('anonymous users', () => { + it('should not fail on case insensitive matches', async () => { + spyOn(cryptoUtils, 'randomString').and.returnValue('abcdefghijklmnop'); + const logIn = id => Parse.User.logInWith('anonymous', { authData: { id } }); + const user1 = await logIn('test1'); + const username1 = user1.get('username'); + + cryptoUtils.randomString.and.returnValue('ABCDEFGHIJKLMNOp'); + const user2 = await logIn('test2'); + const username2 = user2.get('username'); + + expect(username1).not.toBeUndefined(); + expect(username2).not.toBeUndefined(); + expect(username1.toLowerCase()).toBe('abcdefghijklmnop'); + expect(username2.toLowerCase()).toBe('abcdefghijklmnop'); + expect(username2).not.toBe(username1); + expect(username2.toLowerCase()).toBe(username1.toLowerCase()); // this is redundant :). + }); }); }); - it('user cannot update email to existing user', (done) => { - var user = new Parse.User(); + it('user cannot update email to existing user', done => { + const user = new Parse.User(); user.set('username', 'test1'); user.set('password', 'test'); user.set('email', 'test@test.com'); - user.signUp().then(() => { - var user2 = new Parse.User(); - user2.set('username', 'test2'); - user2.set('password', 'test'); - return user2.signUp(); - }).then((user2) => { - user2.set('email', 'test@test.com'); - return user2.save(); - }).then(() => { - fail('Should not have been able to sign up.'); - done(); - }, (error) => { - done(); - }); + user + .signUp() + .then(() => { + const user2 = new Parse.User(); + user2.set('username', 'test2'); + user2.set('password', 'test'); + return user2.signUp(); + }) + .then(user2 => { + user2.set('email', 'test@test.com'); + return user2.save(); + }) + .then( + () => { + fail('Should not have been able to sign up.'); + done(); + }, + () => { + done(); + } + ); }); - it_exclude_dbs(['postgres'])('unset user email', (done) => { - var user = new Parse.User(); + it('unset user email', done => { + const user = new Parse.User(); user.set('username', 'test'); user.set('password', 'test'); user.set('email', 'test@test.com'); - user.signUp().then(() => { - user.unset('email'); - return user.save(); - }).then(() => { - return Parse.User.logIn('test', 'test'); - }).then((user) => { - expect(user.getEmail()).toBeUndefined(); - done(); - }); + user + .signUp() + .then(() => { + user.unset('email'); + return user.save(); + }) + .then(() => { + return Parse.User.logIn('test', 'test'); + }) + .then(user => { + expect(user.getEmail()).toBeUndefined(); + done(); + }); }); - it('create session from user', (done) => { - Parse.Promise.as().then(() => { - return Parse.User.signUp("finn", "human", { foo: "bar" }); - }).then((user) => { - request.post({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Session-Token': user.getSessionToken(), - 'X-Parse-REST-API-Key': 'rest' - }, - url: 'http://localhost:8378/1/sessions', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(typeof b.sessionToken).toEqual('string'); - expect(typeof b.createdWith).toEqual('object'); - expect(b.createdWith.action).toEqual('create'); - expect(typeof b.user).toEqual('object'); - expect(b.user.objectId).toEqual(user.id); - done(); + it('create session from user', done => { + Promise.resolve() + .then(() => { + return Parse.User.signUp('finn', 'human', { foo: 'bar' }); + }) + .then(user => { + request({ + method: 'POST', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + 'X-Parse-REST-API-Key': 'rest', + }, + url: 'http://localhost:8378/1/sessions', + }).then(response => { + const b = response.data; + expect(typeof b.sessionToken).toEqual('string'); + expect(typeof b.createdWith).toEqual('object'); + expect(b.createdWith.action).toEqual('create'); + expect(typeof b.user).toEqual('object'); + expect(b.user.objectId).toEqual(user.id); + done(); + }); }); + }); + + it('user get session from token on signup', async () => { + const user = await Parse.User.signUp('finn', 'human', { foo: 'bar' }); + const response = await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + 'X-Parse-REST-API-Key': 'rest', + }, + url: 'http://localhost:8378/1/sessions/me', }); + const data = response.data; + expect(typeof data.sessionToken).toEqual('string'); + expect(typeof data.createdWith).toEqual('object'); + expect(data.createdWith.action).toEqual('signup'); + expect(data.createdWith.authProvider).toEqual('password'); + expect(typeof data.user).toEqual('object'); + expect(data.user.objectId).toEqual(user.id); }); - it('user get session from token on signup', (done) => { - Parse.Promise.as().then(() => { - return Parse.User.signUp("finn", "human", { foo: "bar" }); - }).then((user) => { - request.get({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Session-Token': user.getSessionToken(), - 'X-Parse-REST-API-Key': 'rest' - }, - url: 'http://localhost:8378/1/sessions/me', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(typeof b.sessionToken).toEqual('string'); - expect(typeof b.createdWith).toEqual('object'); - expect(b.createdWith.action).toEqual('signup'); - expect(typeof b.user).toEqual('object'); - expect(b.user.objectId).toEqual(user.id); - done(); - }); + it('user get session from token on username/password login', async () => { + await Parse.User.signUp('finn', 'human', { foo: 'bar' }); + await Parse.User.logOut(); + const user = await Parse.User.logIn('finn', 'human'); + const response = await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + 'X-Parse-REST-API-Key': 'rest', + }, + url: 'http://localhost:8378/1/sessions/me', + }); + const data = response.data; + expect(typeof data.sessionToken).toEqual('string'); + expect(typeof data.createdWith).toEqual('object'); + expect(data.createdWith.action).toEqual('login'); + expect(data.createdWith.authProvider).toEqual('password'); + expect(typeof data.user).toEqual('object'); + expect(data.user.objectId).toEqual(user.id); + }); + + it('user get session from token on anonymous login', async () => { + const user = await Parse.AnonymousUtils.logIn(); + const response = await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + 'X-Parse-REST-API-Key': 'rest', + }, + url: 'http://localhost:8378/1/sessions/me', }); + const data = response.data; + expect(typeof data.sessionToken).toEqual('string'); + expect(typeof data.createdWith).toEqual('object'); + expect(data.createdWith.action).toEqual('login'); + expect(data.createdWith.authProvider).toEqual('anonymous'); + expect(typeof data.user).toEqual('object'); + expect(data.user.objectId).toEqual(user.id); }); - it('user get session from token on login', (done) => { - Parse.Promise.as().then(() => { - return Parse.User.signUp("finn", "human", { foo: "bar" }); - }).then((user) => { - return Parse.User.logOut().then(() => { - return Parse.User.logIn("finn", "human"); + it('user update session with other field', done => { + Promise.resolve() + .then(() => { + return Parse.User.signUp('finn', 'human', { foo: 'bar' }); }) - }).then((user) => { - request.get({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Session-Token': user.getSessionToken(), - 'X-Parse-REST-API-Key': 'rest' - }, - url: 'http://localhost:8378/1/sessions/me', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(typeof b.sessionToken).toEqual('string'); - expect(typeof b.createdWith).toEqual('object'); - expect(b.createdWith.action).toEqual('login'); - expect(typeof b.user).toEqual('object'); - expect(b.user.objectId).toEqual(user.id); - done(); + .then(user => { + request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + 'X-Parse-REST-API-Key': 'rest', + }, + url: 'http://localhost:8378/1/sessions/me', + }).then(response => { + const b = response.data; + request({ + method: 'PUT', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + 'X-Parse-REST-API-Key': 'rest', + }, + url: 'http://localhost:8378/1/sessions/' + b.objectId, + body: JSON.stringify({ foo: 'bar' }), + }).then(() => { + done(); + }); + }); }); - }); }); - it('user update session with other field', (done) => { - Parse.Promise.as().then(() => { - return Parse.User.signUp("finn", "human", { foo: "bar" }); - }).then((user) => { - request.get({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Session-Token': user.getSessionToken(), - 'X-Parse-REST-API-Key': 'rest' - }, - url: 'http://localhost:8378/1/sessions/me', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - request.put({ + it('cannot update session if invalid or no session token', done => { + Promise.resolve() + .then(() => { + return Parse.User.signUp('finn', 'human', { foo: 'bar' }); + }) + .then(user => { + request({ headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-Session-Token': user.getSessionToken() + 'X-Parse-Session-Token': user.getSessionToken(), + 'X-Parse-REST-API-Key': 'rest', }, - url: 'http://localhost:8378/1/sessions/' + b.objectId, - body: JSON.stringify({ foo: 'bar' }) - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - done(); + url: 'http://localhost:8378/1/sessions/me', + }).then(response => { + const b = response.data; + request({ + method: 'PUT', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Session-Token': 'foo', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + url: 'http://localhost:8378/1/sessions/' + b.objectId, + body: JSON.stringify({ foo: 'bar' }), + }).then(fail, response => { + const b = response.data; + expect(b.error).toBe('Invalid session token'); + request({ + method: 'PUT', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + url: 'http://localhost:8378/1/sessions/' + b.objectId, + body: JSON.stringify({ foo: 'bar' }), + }).then(fail, response => { + const b = response.data; + expect(b.error).toBe('Session token required.'); + done(); + }); + }); }); }); - }); }); - it_exclude_dbs(['postgres'])('get session only for current user', (done) => { - Parse.Promise.as().then(() => { - return Parse.User.signUp("test1", "test", { foo: "bar" }); - }).then(() => { - return Parse.User.signUp("test2", "test", { foo: "bar" }); - }).then((user) => { - request.get({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Session-Token': user.getSessionToken(), - 'X-Parse-REST-API-Key': 'rest' - }, - url: 'http://localhost:8378/1/sessions' - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(b.results.length).toEqual(1); - expect(typeof b.results[0].user).toEqual('object'); - expect(b.results[0].user.objectId).toEqual(user.id); - done(); + it('get session only for current user', done => { + Promise.resolve() + .then(() => { + return Parse.User.signUp('test1', 'test', { foo: 'bar' }); + }) + .then(() => { + return Parse.User.signUp('test2', 'test', { foo: 'bar' }); + }) + .then(user => { + request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + 'X-Parse-REST-API-Key': 'rest', + }, + url: 'http://localhost:8378/1/sessions', + }).then(response => { + const b = response.data; + expect(b.results.length).toEqual(1); + expect(typeof b.results[0].user).toEqual('object'); + expect(b.results[0].user.objectId).toEqual(user.id); + done(); + }); }); - }); }); - it_exclude_dbs(['postgres'])('delete session by object', (done) => { - Parse.Promise.as().then(() => { - return Parse.User.signUp("test1", "test", { foo: "bar" }); - }).then(() => { - return Parse.User.signUp("test2", "test", { foo: "bar" }); - }).then((user) => { - request.get({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Session-Token': user.getSessionToken(), - 'X-Parse-REST-API-Key': 'rest' - }, - url: 'http://localhost:8378/1/sessions' - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(b.results.length).toEqual(1); - var objId = b.results[0].objectId; - request.del({ + it('delete session by object', done => { + Promise.resolve() + .then(() => { + return Parse.User.signUp('test1', 'test', { foo: 'bar' }); + }) + .then(() => { + return Parse.User.signUp('test2', 'test', { foo: 'bar' }); + }) + .then(user => { + request({ headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-Session-Token': user.getSessionToken(), - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', }, - url: 'http://localhost:8378/1/sessions/' + objId - }, (error, response, body) => { - expect(error).toBe(null); - request.get({ + url: 'http://localhost:8378/1/sessions', + }).then(response => { + const b = response.data; + let objId; + try { + expect(b.results.length).toEqual(1); + objId = b.results[0].objectId; + } catch (e) { + jfail(e); + done(); + return; + } + request({ + method: 'DELETE', headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-Session-Token': user.getSessionToken(), - 'X-Parse-REST-API-Key': 'rest' + 'X-Parse-REST-API-Key': 'rest', + }, + url: 'http://localhost:8378/1/sessions/' + objId, + }).then(() => { + request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + 'X-Parse-REST-API-Key': 'rest', + }, + url: 'http://localhost:8378/1/sessions', + }).then(fail, response => { + const b = response.data; + expect(b.code).toEqual(209); + expect(b.error).toBe('Invalid session token'); + done(); + }); + }); + }); + }); + }); + + it('cannot delete session if no sessionToken', done => { + Promise.resolve() + .then(() => { + return Parse.User.signUp('test1', 'test', { foo: 'bar' }); + }) + .then(() => { + return Parse.User.signUp('test2', 'test', { foo: 'bar' }); + }) + .then(user => { + request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + 'X-Parse-REST-API-Key': 'rest', + }, + url: 'http://localhost:8378/1/sessions', + }).then(response => { + const b = response.data; + expect(b.results.length).toEqual(1); + const objId = b.results[0].objectId; + loggerErrorSpy.calls.reset(); + request({ + method: 'DELETE', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', }, - url: 'http://localhost:8378/1/sessions' - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); + url: 'http://localhost:8378/1/sessions/' + objId, + }).then(fail, response => { + const b = response.data; expect(b.code).toEqual(209); + expect(b.error).toBe('Permission denied'); + + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('Invalid session token')); done(); }); }); }); - }); }); - it('password format matches hosted parse', (done) => { - var hashed = '$2a$10$8/wZJyEuiEaobBBqzTG.jeY.XSFJd0rzaN//ososvEI4yLqI.4aie'; - passwordCrypto.compare('test', hashed) - .then((pass) => { - expect(pass).toBe(true); - done(); - }, (e) => { - fail('Password format did not match.'); - done(); - }); + it('password format matches hosted parse', done => { + const hashed = '$2a$10$8/wZJyEuiEaobBBqzTG.jeY.XSFJd0rzaN//ososvEI4yLqI.4aie'; + passwordCrypto.compare('test', hashed).then( + pass => { + expect(pass).toBe(true); + done(); + }, + () => { + fail('Password format did not match.'); + done(); + } + ); }); - it('changing password clears sessions', (done) => { - var sessionToken = null; + it('changing password clears sessions', done => { + let sessionToken = null; - Parse.Promise.as().then(function() { - return Parse.User.signUp("fosco", "parse"); - }).then(function(newUser) { - equal(Parse.User.current(), newUser); - sessionToken = newUser.getSessionToken(); - ok(sessionToken); - newUser.set('password', 'facebook'); - return newUser.save(); - }).then(function() { - return Parse.User.become(sessionToken); - }).then(function(newUser) { - fail('Session should have been invalidated'); - done(); - }, function(err) { - expect(err.code).toBe(Parse.Error.INVALID_SESSION_TOKEN); - expect(err.message).toBe('invalid session token'); - done(); - }); + Promise.resolve() + .then(function () { + return Parse.User.signUp('fosco', 'parse'); + }) + .then(function (newUser) { + equal(Parse.User.current(), newUser); + sessionToken = newUser.getSessionToken(); + ok(sessionToken); + newUser.set('password', 'facebook'); + return newUser.save(); + }) + .then(function () { + return Parse.User.become(sessionToken); + }) + .then( + function () { + fail('Session should have been invalidated'); + done(); + }, + function (err) { + expect(err.code).toBe(Parse.Error.INVALID_SESSION_TOKEN); + expect(err.message).toBe('Invalid session token'); + done(); + } + ); }); - it_exclude_dbs(['postgres'])('test parse user become', (done) => { - var sessionToken = null; - Parse.Promise.as().then(function() { - return Parse.User.signUp("flessard", "folo",{'foo':1}); - }).then(function(newUser) { - equal(Parse.User.current(), newUser); - sessionToken = newUser.getSessionToken(); - ok(sessionToken); - newUser.set('foo',2); - return newUser.save(); - }).then(function() { - return Parse.User.become(sessionToken); - }).then(function(newUser) { - equal(newUser.get('foo'), 2); - done(); - }, function(e) { - fail('The session should still be valid'); - done(); - }); + it('test parse user become', done => { + let sessionToken = null; + Promise.resolve() + .then(function () { + return Parse.User.signUp('flessard', 'folo', { foo: 1 }); + }) + .then(function (newUser) { + equal(Parse.User.current(), newUser); + sessionToken = newUser.getSessionToken(); + ok(sessionToken); + newUser.set('foo', 2); + return newUser.save(); + }) + .then(function () { + return Parse.User.become(sessionToken); + }) + .then( + function (newUser) { + equal(newUser.get('foo'), 2); + done(); + }, + function () { + fail('The session should still be valid'); + done(); + } + ); }); - it('ensure logout works', (done) => { - var user = null; - var sessionToken = null; + it('ensure logout works', done => { + let user = null; + let sessionToken = null; - Parse.Promise.as().then(function() { - return Parse.User.signUp('log', 'out'); - }).then((newUser) => { - user = newUser; - sessionToken = user.getSessionToken(); - return Parse.User.logOut(); - }).then(() => { - user.set('foo', 'bar'); - return user.save(null, { sessionToken: sessionToken }); - }).then(() => { - fail('Save should have failed.'); - done(); - }, (e) => { - expect(e.code).toEqual(Parse.Error.INVALID_SESSION_TOKEN); - done(); - }); + Promise.resolve() + .then(function () { + return Parse.User.signUp('log', 'out'); + }) + .then(newUser => { + user = newUser; + sessionToken = user.getSessionToken(); + return Parse.User.logOut(); + }) + .then(() => { + user.set('foo', 'bar'); + return user.save(null, { sessionToken: sessionToken }); + }) + .then( + () => { + fail('Save should have failed.'); + done(); + }, + e => { + expect(e.code).toEqual(Parse.Error.INVALID_SESSION_TOKEN); + done(); + } + ); }); - it('support user/password signup with empty authData block', (done) => { + it('support user/password signup with empty authData block', done => { // The android SDK can send an empty authData object along with username and password. - Parse.User.signUp('artof', 'thedeal', { authData: {} }).then((user) => { - done(); - }, (error) => { - fail('Signup should have succeeded.'); + Parse.User.signUp('artof', 'thedeal', { authData: {} }).then( + () => { + done(); + }, + () => { + fail('Signup should have succeeded.'); + done(); + } + ); + }); + + it('session expiresAt correct format', async done => { + await Parse.User.signUp('asdf', 'zxcv'); + request({ + url: 'http://localhost:8378/1/classes/_Session', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }).then(response => { + const body = response.data; + expect(body.results[0].expiresAt.__type).toEqual('Date'); done(); }); }); - it_exclude_dbs(['postgres'])("session expiresAt correct format", (done) => { - Parse.User.signUp("asdf", "zxcv", null, { - success: function(user) { - request.get({ - url: 'http://localhost:8378/1/classes/_Session', - json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - }, - }, (error, response, body) => { - expect(body.results[0].expiresAt.__type).toEqual('Date'); - done(); - }) - } + it('Invalid session tokens are rejected', async done => { + await Parse.User.signUp('asdf', 'zxcv'); + request({ + url: 'http://localhost:8378/1/classes/AClass', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Rest-API-Key': 'rest', + 'X-Parse-Session-Token': 'text', + }, + }).then(fail, response => { + const body = response.data; + expect(body.code).toBe(209); + expect(body.error).toBe('Invalid session token'); + done(); }); }); - it("invalid session tokens are rejected", (done) => { - Parse.User.signUp("asdf", "zxcv", null, { - success: function(user) { - request.get({ - url: 'http://localhost:8378/1/classes/AClass', - json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Rest-API-Key': 'rest', - 'X-Parse-Session-Token': 'text' + it_exclude_dbs(['postgres'])( + 'should cleanup null authData keys (regression test for #935)', + done => { + const database = Config.get(Parse.applicationId).database; + database + .create( + '_User', + { + username: 'user', + _hashed_password: '$2a$10$8/wZJyEuiEaobBBqzTG.jeY.XSFJd0rzaN//ososvEI4yLqI.4aie', + _auth_data_facebook: null, }, - }, (error, response, body) => { - expect(body.code).toBe(209); - expect(body.error).toBe('invalid session token'); + {} + ) + .then(() => { + return request({ + url: 'http://localhost:8378/1/login?username=user&password=test', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }).then(res => res.data); + }) + .then(user => { + const authData = user.authData; + expect(user.username).toEqual('user'); + expect(authData).toBeUndefined(); done(); }) - } - }); + .catch(() => { + fail('this should not fail'); + done(); + }); + } + ); + + it_exclude_dbs(['postgres'])('should not serve null authData keys', done => { + const database = Config.get(Parse.applicationId).database; + database + .create( + '_User', + { + username: 'user', + _hashed_password: '$2a$10$8/wZJyEuiEaobBBqzTG.jeY.XSFJd0rzaN//ososvEI4yLqI.4aie', + _auth_data_facebook: null, + }, + {} + ) + .then(() => { + return new Parse.Query(Parse.User) + .equalTo('username', 'user') + .first({ useMasterKey: true }); + }) + .then(user => { + const authData = user.get('authData'); + expect(user.get('username')).toEqual('user'); + expect(authData).toBeUndefined(); + done(); + }) + .catch(() => { + fail('this should not fail'); + done(); + }); }); - it_exclude_dbs(['postgres'])('should cleanup null authData keys (regression test for #935)', (done) => { - let database = new Config(Parse.applicationId).database; - database.create('_User', { - username: 'user', - _hashed_password: '$2a$10$8/wZJyEuiEaobBBqzTG.jeY.XSFJd0rzaN//ososvEI4yLqI.4aie', - _auth_data_facebook: null - }, {}).then(() => { - return new Promise((resolve, reject) => { - request.get({ - url: 'http://localhost:8378/1/login?username=user&password=test', - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - }, - json: true - }, (err, res, body) => { - if (err) { - reject(err); - } else { - resolve(body); - } - }) - }) - }).then((user) => { - let authData = user.authData; - expect(user.username).toEqual('user'); - expect(authData).toBeUndefined(); - done(); - }).catch((err) => { - fail('this should not fail'); - done(); - }) - }); - - it_exclude_dbs(['postgres'])('should not serve null authData keys', (done) => { - let database = new Config(Parse.applicationId).database; - database.create('_User', { - username: 'user', - _hashed_password: '$2a$10$8/wZJyEuiEaobBBqzTG.jeY.XSFJd0rzaN//ososvEI4yLqI.4aie', - _auth_data_facebook: null - }, {}).then(() => { - return new Parse.Query(Parse.User) - .equalTo('username', 'user') - .first({useMasterKey: true}); - }).then((user) => { - let authData = user.get('authData'); - expect(user.get('username')).toEqual('user'); - expect(authData).toBeUndefined(); - done(); - }).catch((err) => { - fail('this should not fail'); - done(); - }) - }); - - it_exclude_dbs(['postgres'])('should cleanup null authData keys ParseUser update (regression test for #1198, #2252)', (done) => { - Parse.Cloud.beforeSave('_User', (req, res) => { + it('should cleanup null authData keys ParseUser update (regression test for #1198, #2252)', done => { + Parse.Cloud.beforeSave('_User', req => { req.object.set('foo', 'bar'); - res.success(); }); - + let originalSessionToken; let originalUserId; // Simulate anonymous user save - new Promise((resolve, reject) => { - request.post({ - url: 'http://localhost:8378/1/classes/_User', - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', + request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/_User', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + authData: { + anonymous: { id: '00000000-0000-0000-0000-000000000001' }, }, - json: {authData: {anonymous: {id: '00000000-0000-0000-0000-000000000001'}}} - }, (err, res, body) => { - if (err) { - reject(err); - } else { - resolve(body); - } - }); - }).then((user) => { - originalSessionToken = user.sessionToken; - originalUserId = user.objectId; - // Simulate registration - return new Promise((resolve, reject) => { - request.put({ + }, + }) + .then(response => response.data) + .then(user => { + originalSessionToken = user.sessionToken; + originalUserId = user.objectId; + // Simulate registration + return request({ + method: 'PUT', url: 'http://localhost:8378/1/classes/_User/' + user.objectId, headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-Session-Token': user.sessionToken, 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', }, - json: { - authData: {anonymous: null}, + body: { + authData: { anonymous: null }, username: 'user', password: 'password', - } - }, (err, res, body) => { - if (err) { - reject(err); - } else { - resolve(body); - } + }, + }).then(response => { + return response.data; }); - }); - }).then((user) => { - expect(typeof user).toEqual('object'); - expect(user.authData).toBeUndefined(); - expect(user.sessionToken).not.toBeUndefined(); - // Session token should have changed - expect(user.sessionToken).not.toEqual(originalSessionToken); - // test that the sessionToken is valid - return new Promise((resolve, reject) => { - request.get({ + }) + .then(user => { + expect(typeof user).toEqual('object'); + expect(user.authData).toBeUndefined(); + expect(user.sessionToken).not.toBeUndefined(); + // Session token should have changed + expect(user.sessionToken).not.toEqual(originalSessionToken); + // test that the sessionToken is valid + return request({ url: 'http://localhost:8378/1/users/me', headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-Session-Token': user.sessionToken, 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', }, - json: true - }, (err, res, body) => { - expect(body.username).toEqual(user.username); + }).then(response => { + const body = response.data; + expect(body.username).toEqual('user'); expect(body.objectId).toEqual(originalUserId); - if (err) { - reject(err); - } else { - resolve(body); - } done(); }); + }) + .catch(err => { + fail('no request should fail: ' + JSON.stringify(err)); + done(); }); - }).catch((err) => { - fail('no request should fail: ' + JSON.stringify(err)); - done(); - }); }); - it_exclude_dbs(['postgres'])('should send email when upgrading from anon', (done) => { - + it_id('1be98368-19ac-4c77-8531-762a114f43fb')(it)('should send email when upgrading from anon', async done => { + await reconfigureServer(); let emailCalled = false; let emailOptions; - var emailAdapter = { - sendVerificationEmail: (options) => { + const emailAdapter = { + sendVerificationEmail: options => { emailOptions = options; emailCalled = true; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => Promise.resolve() - } - reconfigureServer({ + sendMail: () => Promise.resolve(), + }; + await reconfigureServer({ appName: 'unused', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" - }) + publicServerURL: 'http://localhost:8378/1', + }); // Simulate anonymous user save - return rp.post({ - url: 'http://localhost:8378/1/classes/_User', - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-REST-API-Key': 'rest', - }, - json: {authData: {anonymous: {id: '00000000-0000-0000-0000-000000000001'}}} - }).then((user) => { - return rp.put({ - url: 'http://localhost:8378/1/classes/_User/' + user.objectId, - headers: { - 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Session-Token': user.sessionToken, - 'X-Parse-REST-API-Key': 'rest', + return request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/_User', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + authData: { + anonymous: { id: '00000000-0000-0000-0000-000000000001' }, }, - json: { - authData: {anonymous: null}, - username: 'user', - email: 'user@email.com', - password: 'password', - } + }, + }) + .then(response => { + const user = response.data; + return request({ + method: 'PUT', + url: 'http://localhost:8378/1/classes/_User/' + user.objectId, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Session-Token': user.sessionToken, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + authData: { anonymous: null }, + username: 'user', + email: 'user@email.com', + password: 'password', + }, + }); + }) + .then(() => jasmine.timeout()) + .then(() => { + expect(emailCalled).toBe(true); + expect(emailOptions).not.toBeUndefined(); + expect(emailOptions.user.get('email')).toEqual('user@email.com'); + done(); + }) + .catch(err => { + jfail(err); + fail('no request should fail: ' + JSON.stringify(err)); + done(); }); - }).then(() => { - expect(emailCalled).toBe(true); - expect(emailOptions).not.toBeUndefined(); - expect(emailOptions.user.get('email')).toEqual('user@email.com'); - done(); - }).catch((err) => { - console.error(err); - fail('no request should fail: ' + JSON.stringify(err)); - done(); - }); }); + it_id('bf668670-39fa-44d3-a9a9-cad52f36d272')(it)('should not send email when email is not a string', async done => { + let emailCalled = false; + let emailOptions; + const emailAdapter = { + sendVerificationEmail: options => { + emailOptions = options; + emailCalled = true; + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve(), + }; + await reconfigureServer({ + appName: 'unused', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }); + const user = new Parse.User(); + user.set('username', 'asdf@jkl.com'); + user.set('password', 'zxcv'); + user.set('email', 'asdf@jkl.com'); + await user.signUp(); + request({ + method: 'POST', + url: 'http://localhost:8378/1/requestPasswordReset', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Session-Token': user.sessionToken, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + email: { $regex: '^asd' }, + }, + }) + .then(res => { + fail('no request should succeed: ' + JSON.stringify(res)); + done(); + }) + .catch(err => { + expect(emailCalled).toBeTruthy(); + expect(emailOptions).toBeDefined(); + expect(err.status).toBe(400); + expect(err.text).toMatch('{"code":125,"error":"you must provide a valid email string"}'); + done(); + }); + }); - it('should aftersave with full object', (done) => { - var hit = 0; + it('should aftersave with full object', done => { + let hit = 0; Parse.Cloud.afterSave('_User', (req, res) => { hit++; expect(req.object.get('username')).toEqual('User'); res.success(); }); - let user = new Parse.User() + const user = new Parse.User(); user.setUsername('User'); user.setPassword('pass'); - user.signUp().then(()=> { - user.set('hello', 'world'); - return user.save(); - }).then(() => { - done(); - }); + user + .signUp() + .then(() => { + user.set('hello', 'world'); + return user.save(); + }) + .then(() => { + expect(hit).toBe(2); + done(); + }); }); - it('changes to a user should update the cache', (done) => { - Parse.Cloud.define('testUpdatedUser', (req, res) => { + it('changes to a user should update the cache', done => { + Parse.Cloud.define('testUpdatedUser', req => { expect(req.user.get('han')).toEqual('solo'); - res.success({}); + return {}; }); - let user = new Parse.User(); + const user = new Parse.User(); user.setUsername('harrison'); user.setPassword('ford'); - user.signUp().then(() => { - user.set('han', 'solo'); - return user.save(); - }).then(() => { - return Parse.Cloud.run('testUpdatedUser'); - }).then(() => { - done(); - }, (e) => { - fail('Should not have failed.'); - done(); - }); - + user + .signUp() + .then(() => { + user.set('han', 'solo'); + return user.save(); + }) + .then(() => { + return Parse.Cloud.run('testUpdatedUser'); + }) + .then( + () => { + done(); + }, + () => { + fail('Should not have failed.'); + done(); + } + ); }); - it_exclude_dbs(['postgres'])('should fail to become user with expired token', (done) => { + it('should fail to become user with expired token', done => { let token; - Parse.User.signUp("auser", "somepass", null) - .then(user => rp({ + Parse.User.signUp('auser', 'somepass', null) + .then(() => + request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/_Session', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }) + ) + .then(response => { + const body = response.data; + const id = body.results[0].objectId; + const expiresAt = new Date(new Date().setYear(2015)); + token = body.results[0].sessionToken; + return request({ + method: 'PUT', + url: 'http://localhost:8378/1/classes/_Session/' + id, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', + }, + body: { + expiresAt: { __type: 'Date', iso: expiresAt.toISOString() }, + }, + }); + }) + .then(() => Parse.User.become(token)) + .then( + () => { + fail('Should not have succeded'); + done(); + }, + error => { + expect(error.code).toEqual(209); + expect(error.message).toEqual('Session token is expired.'); + done(); + } + ) + .catch(done.fail); + }); + + it('should return current session with expired expiration date', async () => { + await Parse.User.signUp('buser', 'somepass', null); + const response = await request({ method: 'GET', url: 'http://localhost:8378/1/classes/_Session', - json: true, headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-Master-Key': 'test', }, - })) - .then(body => { - var id = body.results[0].objectId; - var expiresAt = new Date((new Date()).setYear(2015)); - token = body.results[0].sessionToken; - return rp({ - method: 'PUT', - url: "http://localhost:8378/1/classes/_Session/" + id, - json: true, + }); + const body = response.data; + const id = body.results[0].objectId; + const expiresAt = new Date(new Date().setYear(2015)); + await request({ + method: 'PUT', + url: 'http://localhost:8378/1/classes/_Session/' + id, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', + }, + body: { + expiresAt: { __type: 'Date', iso: expiresAt.toISOString() }, + }, + }); + const session = await Parse.Session.current(); + expect(session.get('expiresAt')).toEqual(expiresAt); + }); + + it('should reject expired session token even when served from cache', async () => { + // Use a 1-second session length with a 5-second cache TTL (default) + // so the session expires while the cache entry is still alive + await reconfigureServer({ sessionLength: 1 }); + + // Sign up user — creates a session with expiresAt = now + 1 second + const user = await Parse.User.signUp('cacheuser', 'somepass'); + const sessionToken = user.getSessionToken(); + + // Make an authenticated request to prime the user cache + await request({ + method: 'GET', + url: 'http://localhost:8378/1/users/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + + // Wait for the session to expire (1 second), but cache entry (5s TTL) is still alive + await new Promise(resolve => setTimeout(resolve, 1500)); + + // This request should be served from cache but still reject the expired session + try { + await request({ + method: 'GET', + url: 'http://localhost:8378/1/users/me', headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - }, - body: { - expiresAt: { __type: "Date", iso: expiresAt.toISOString() }, + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, }, - }) - }) - .then(() => Parse.User.become(token)) - .then(() => { - fail("Should not have succeded") - done(); - }, error => { - expect(error.code).toEqual(209); - expect(error.message).toEqual("Session token is expired."); - done(); - }) + }); + fail('Should have rejected expired session token from cache'); + } catch (error) { + expect(error.data.code).toEqual(209); + expect(error.data.error).toEqual('Session token is expired.'); + } }); - it('should not create extraneous session tokens', (done) => { - let config = new Config(Parse.applicationId); - config.database.loadSchema().then((s) => { - // Lock down the _User class for creation - return s.addClassIfNotExists('_User', {}, {create: {}}) - }).then((res) => { - let user = new Parse.User(); - return user.save({'username': 'user', 'password': 'pass'}); - }).then(() => { - fail('should not be able to save the user'); - }, (err) => { - return Promise.resolve(); - }).then(() => { - let q = new Parse.Query('_Session'); - return q.find({useMasterKey: true}) - }).then((res) => { - // We should have no session created - expect(res.length).toBe(0); - done(); - }, (err) => { - fail('should not fail'); - done(); - }); + it('should not create extraneous session tokens', done => { + const config = Config.get(Parse.applicationId); + config.database + .loadSchema() + .then(s => { + // Lock down the _User class for creation + return s.addClassIfNotExists('_User', {}, { create: {} }); + }) + .then(() => { + const user = new Parse.User(); + return user.save({ username: 'user', password: 'pass' }); + }) + .then( + () => { + fail('should not be able to save the user'); + }, + () => { + return Promise.resolve(); + } + ) + .then(() => { + const q = new Parse.Query('_Session'); + return q.find({ useMasterKey: true }); + }) + .then( + res => { + // We should have no session created + expect(res.length).toBe(0); + done(); + }, + () => { + fail('should not fail'); + done(); + } + ); }); - it_exclude_dbs(['postgres'])('should not overwrite username when unlinking facebook user (regression test for #1532)', done => { + it('should not overwrite username when unlinking facebook user (regression test for #1532)', async done => { Parse.Object.disableSingleInstance(); - var provider = getMockFacebookProvider(); + const provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); - var user = new Parse.User(); - user.set("username", "testLinkWithProvider"); - user.set("password", "mypass"); - user.signUp() - .then(user => user._linkWith("facebook", { - success: user => { - expect(user.get('username')).toEqual('testLinkWithProvider'); - expect(Parse.FacebookUtils.isLinked(user)).toBeTruthy(); - return user._unlinkFrom('facebook') - .then(() => user.fetch()) - .then(user => { - expect(user.get('username')).toEqual('testLinkWithProvider'); - expect(Parse.FacebookUtils.isLinked(user)).toBeFalsy(); - done(); - }); - }, - error: error => { - fail('Unexpected failure testing linking'); - fail(JSON.stringify(error)); - done(); - } - })) - .catch(error => { - fail('Unexpected failure testing in unlink user test'); - fail(error); - done(); - }); + let user = new Parse.User(); + user.set('username', 'testLinkWithProvider'); + user.set('password', 'mypass'); + await user.signUp(); + await user._linkWith('facebook'); + expect(user.get('username')).toEqual('testLinkWithProvider'); + expect(Parse.FacebookUtils.isLinked(user)).toBeTruthy(); + await user._unlinkFrom('facebook'); + user = await user.fetch(); + expect(user.get('username')).toEqual('testLinkWithProvider'); + expect(Parse.FacebookUtils.isLinked(user)).toBeFalsy(); + done(); }); - it_exclude_dbs(['postgres'])('should revoke sessions when converting anonymous user to "normal" user', done => { - request.post({ + it('should revoke sessions when converting anonymous user to "normal" user', done => { + request({ + method: 'POST', url: 'http://localhost:8378/1/classes/_User', headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', }, - json: {authData: {anonymous: {id: '00000000-0000-0000-0000-000000000001'}}} - }, (err, res, body) => { - Parse.User.become(body.sessionToken) - .then(user => { - let obj = new Parse.Object('TestObject'); + body: { + authData: { + anonymous: { id: '00000000-0000-0000-0000-000000000001' }, + }, + }, + }).then(response => { + const body = response.data; + Parse.User.become(body.sessionToken).then(user => { + const obj = new Parse.Object('TestObject'); obj.setACL(new Parse.ACL(user)); - return obj.save() - .then(() => { - // Change password, revoking session - user.set('username', 'no longer anonymous'); - user.set('password', 'password'); - return user.save() - }) - .then(() => { - // Session token should have been recycled - expect(body.sessionToken).not.toEqual(user.getSessionToken()); - }) - .then(() => obj.fetch()) - .then((res) => { - done(); - }) - .catch(error => { - fail('should not fail') - done(); - }); - }) + return obj + .save() + .then(() => { + // Change password, revoking session + user.set('username', 'no longer anonymous'); + user.set('password', 'password'); + return user.save(); + }) + .then(() => { + // Session token should have been recycled + expect(body.sessionToken).not.toEqual(user.getSessionToken()); + }) + .then(() => obj.fetch()) + .then(() => { + done(); + }) + .catch(() => { + fail('should not fail'); + done(); + }); + }); }); }); - it_exclude_dbs(['postgres'])('should not revoke session tokens if the server is configures to not revoke session tokens', done => { - reconfigureServer({ revokeSessionOnPasswordReset: false }) - .then(() => { - request.post({ + it('should not revoke session tokens if the server is configures to not revoke session tokens', done => { + reconfigureServer({ revokeSessionOnPasswordReset: false }).then(() => { + request({ + method: 'POST', url: 'http://localhost:8378/1/classes/_User', headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', }, - json: {authData: {anonymous: {id: '00000000-0000-0000-0000-000000000001'}}} - }, (err, res, body) => { - Parse.User.become(body.sessionToken) - .then(user => { - let obj = new Parse.Object('TestObject'); + body: { + authData: { + anonymous: { id: '00000000-0000-0000-0000-000000000001' }, + }, + }, + }).then(response => { + const body = response.data; + Parse.User.become(body.sessionToken).then(user => { + const obj = new Parse.Object('TestObject'); obj.setACL(new Parse.ACL(user)); - return obj.save() - .then(() => { - // Change password, revoking session - user.set('username', 'no longer anonymous'); - user.set('password', 'password'); - return user.save() - }) - .then(() => obj.fetch()) - // fetch should succeed as we still have our session token - .then(done, fail); - }) + return ( + obj + .save() + .then(() => { + // Change password, revoking session + user.set('username', 'no longer anonymous'); + user.set('password', 'password'); + return user.save(); + }) + .then(() => obj.fetch()) + // fetch should succeed as we still have our session token + .then(done, fail) + ); + }); }); }); }); - it_exclude_dbs(['postgres'])('should not fail querying non existing relations', done => { - let user = new Parse.User(); + it('should not fail querying non existing relations', done => { + const user = new Parse.User(); user.set({ username: 'hello', - password: 'world' - }) - user.signUp().then(() => { - return Parse.User.current().relation('relation').query().find(); - }).then((res) => { - expect(res.length).toBe(0); - done(); - }).catch((err) => { - fail(JSON.stringify(err)); - done(); + password: 'world', + }); + user + .signUp() + .then(() => { + return Parse.User.current().relation('relation').query().find(); + }) + .then(res => { + expect(res.length).toBe(0); + done(); + }) + .catch(err => { + fail(JSON.stringify(err)); + done(); + }); + }); + + it('should not allow updates to emailVerified', done => { + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve(), + }; + + let logger; + let loggerErrorSpy; + + const user = new Parse.User(); + user.set({ + username: 'hello', + password: 'world', + email: 'test@email.com', }); + + reconfigureServer({ + appName: 'unused', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + logger = require('../lib/logger').default; + loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + return user.signUp(); + }) + .then(() => { + loggerErrorSpy.calls.reset(); + return Parse.User.current().set('emailVerified', true).save(); + }) + .then(() => { + fail('Should not be able to update emailVerified'); + done(); + }) + .catch(err => { + expect(err.message).toBe('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining("Clients aren't allowed to manually update email verification.")); + + done(); + }); + }); + + it('should not retrieve hidden fields on GET users/me (#3432)', done => { + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve(), + }; + + const user = new Parse.User(); + user.set({ + username: 'hello', + password: 'world', + email: 'test@email.com', + }); + + reconfigureServer({ + appName: 'unused', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + return user.signUp(); + }) + .then(() => + request({ + method: 'GET', + url: 'http://localhost:8378/1/users/me', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Session-Token': Parse.User.current().getSessionToken(), + 'X-Parse-REST-API-Key': 'rest', + }, + }) + ) + .then(response => { + const res = response.data; + expect(res.emailVerified).toBe(false); + expect(res._email_verify_token).toBeUndefined(); + done(); + }) + .catch(done.fail); + }); + + it('should not retrieve hidden fields on GET users/id (#3432)', done => { + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve(), + }; + + const user = new Parse.User(); + user.set({ + username: 'hello', + password: 'world', + email: 'test@email.com', + }); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + user.setACL(acl); + + reconfigureServer({ + appName: 'unused', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + return user.signUp(); + }) + .then(() => + request({ + method: 'GET', + url: 'http://localhost:8378/1/users/' + Parse.User.current().id, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + }) + ) + .then(response => { + const res = response.data; + expect(res.emailVerified).toBe(false); + expect(res._email_verify_token).toBeUndefined(); + done(); + }) + .catch(err => { + fail(JSON.stringify(err)); + done(); + }); + }); + + it('should not retrieve hidden fields on login (#3432)', done => { + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve(), + }; + + const user = new Parse.User(); + user.set({ + username: 'hello', + password: 'world', + email: 'test@email.com', + }); + + reconfigureServer({ + appName: 'unused', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + return user.signUp(); + }) + .then(() => + request({ + url: 'http://localhost:8378/1/login?email=test@email.com&username=hello&password=world', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + }) + ) + .then(response => { + const res = response.data; + expect(res.emailVerified).toBe(false); + expect(res._email_verify_token).toBeUndefined(); + done(); + }) + .catch(err => { + fail(JSON.stringify(err)); + done(); + }); + }); + + it('should not allow updates to hidden fields', async () => { + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve(), + }; + const user = new Parse.User(); + user.set({ + username: 'hello', + password: 'world', + email: 'test@email.com', + }); + await reconfigureServer({ + appName: 'unused', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }); + await user.signUp(); + user.set('_email_verify_token', 'bad', { ignoreValidation: true }); + await expectAsync(user.save()).toBeRejectedWith( + new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'Invalid field name: _email_verify_token.') + ); + }); + + it('should allow updates to fields with maintenanceKey', async () => { + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve(), + }; + const user = new Parse.User(); + user.set({ + username: 'hello', + password: 'world', + email: 'test@example.com', + }); + await reconfigureServer({ + appName: 'unused', + maintenanceKey: 'test2', + verifyUserEmails: true, + emailVerifyTokenValidityDuration: 5, + accountLockout: { + duration: 1, + threshold: 1, + }, + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }); + await user.signUp(); + for (let i = 0; i < 2; i++) { + try { + await Parse.User.logIn(user.getEmail(), 'abc'); + } catch (e) { + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + expect( + e.message === 'Invalid username/password.' || + e.message === + 'Your account is locked due to multiple failed login attempts. Please try again after 1 minute(s)' + ).toBeTrue(); + } + } + await Parse.User.requestPasswordReset(user.getEmail()); + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Rest-API-Key': 'rest', + 'X-Parse-Maintenance-Key': 'test2', + 'Content-Type': 'application/json', + }; + const userMaster = await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/_User`, + json: true, + headers, + }).then(res => res.data.results[0]); + expect(Object.keys(userMaster).sort()).toEqual( + [ + 'ACL', + '_account_lockout_expires_at', + '_email_verify_token', + '_email_verify_token_expires_at', + '_failed_login_count', + '_perishable_token', + 'createdAt', + 'email', + 'emailVerified', + 'objectId', + 'updatedAt', + 'username', + ].sort() + ); + const toSet = { + _account_lockout_expires_at: new Date(), + _email_verify_token: 'abc', + _email_verify_token_expires_at: new Date(), + _failed_login_count: 0, + _perishable_token_expires_at: new Date(), + _perishable_token: 'abc', + }; + await request({ + method: 'PUT', + headers, + url: Parse.serverURL + '/users/' + userMaster.objectId, + json: true, + body: toSet, + }).then(res => res.data); + const update = await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/_User`, + json: true, + headers, + }).then(res => res.data.results[0]); + for (const key in toSet) { + const value = toSet[key]; + if (update[key] && update[key].iso) { + expect(update[key].iso).toEqual(value.toISOString()); + } else if (value.toISOString) { + expect(update[key]).toEqual(value.toISOString()); + } else { + expect(update[key]).toEqual(value); + } + } + }); + + it('should revoke sessions when setting paswword with masterKey (#3289)', done => { + let user; + Parse.User.signUp('username', 'password') + .then(newUser => { + user = newUser; + user.set('password', 'newPassword'); + return user.save(null, { useMasterKey: true }); + }) + .then(() => { + const query = new Parse.Query('_Session'); + query.equalTo('user', user); + return query.find({ useMasterKey: true }); + }) + .then(results => { + expect(results.length).toBe(0); + done(); + }, done.fail); + }); + + xit('should not send a verification email if the user signed up using oauth', done => { + pending('this test fails. See: https://github.com/parse-community/parse-server/issues/5097'); + let emailCalledCount = 0; + const emailAdapter = { + sendVerificationEmail: () => { + emailCalledCount++; + return Promise.resolve(); + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve(), + }; + reconfigureServer({ + appName: 'unused', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }); + const user = new Parse.User(); + user.set('email', 'email1@host.com'); + Parse.FacebookUtils.link(user, { + id: '8675309', + access_token: 'jenny', + expiration_date: new Date().toJSON(), + }).then(user => { + user.set('email', 'email2@host.com'); + user.save().then(() => { + expect(emailCalledCount).toBe(0); + done(); + }); + }); + }); + + it('should be able to update user with authData passed', done => { + let objectId; + let sessionToken; + + function validate(block) { + return request({ + url: `http://localhost:8378/1/classes/_User/${objectId}`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }).then(response => block(response.data)); + } + + request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/_User', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + key: 'value', + authData: { anonymous: { id: '00000000-0000-0000-0000-000000000001' } }, + }, + }) + .then(response => { + const body = response.data; + objectId = body.objectId; + sessionToken = body.sessionToken; + expect(sessionToken).toBeDefined(); + expect(objectId).toBeDefined(); + return validate(user => { + // validate that keys are set on creation + expect(user.key).toBe('value'); + }); + }) + .then(() => { + // update the user + const options = { + method: 'PUT', + url: `http://localhost:8378/1/classes/_User/${objectId}`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + key: 'otherValue', + authData: { + anonymous: { id: '00000000-0000-0000-0000-000000000001' }, + }, + }, + }; + return request(options); + }) + .then(() => { + return validate(user => { + // validate that keys are set on update + expect(user.key).toBe('otherValue'); + }); + }) + .then(() => { + done(); + }) + .then(done) + .catch(done.fail); + }); + + it('can login with email', done => { + const user = new Parse.User(); + user + .save({ + username: 'yolo', + password: 'yolopass', + email: 'yo@lo.com', + }) + .then(() => { + const options = { + url: `http://localhost:8378/1/login`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { email: 'yo@lo.com', password: 'yolopass' }, + }; + return request(options); + }) + .then(done) + .catch(done.fail); + }); + + it('cannot login with email and invalid password', done => { + const user = new Parse.User(); + user + .save({ + username: 'yolo', + password: 'yolopass', + email: 'yo@lo.com', + }) + .then(() => { + const options = { + method: 'POST', + url: `http://localhost:8378/1/login`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { email: 'yo@lo.com', password: 'yolopass2' }, + }; + return request(options); + }) + .then(done.fail) + .catch(() => done()); + }); + + it('can login with email through query string', done => { + const user = new Parse.User(); + user + .save({ + username: 'yolo', + password: 'yolopass', + email: 'yo@lo.com', + }) + .then(() => { + const options = { + url: `http://localhost:8378/1/login?email=yo@lo.com&password=yolopass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + }; + return request(options); + }) + .then(done) + .catch(done.fail); + }); + + it('can login when both email and username are passed', done => { + const user = new Parse.User(); + user + .save({ + username: 'yolo', + password: 'yolopass', + email: 'yo@lo.com', + }) + .then(() => { + const options = { + url: `http://localhost:8378/1/login?email=yo@lo.com&username=yolo&password=yolopass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + }; + return request(options); + }) + .then(done) + .catch(done.fail); + }); + + it("fails to login when username doesn't match email", done => { + const user = new Parse.User(); + user + .save({ + username: 'yolo', + password: 'yolopass', + email: 'yo@lo.com', + }) + .then(() => { + const options = { + url: `http://localhost:8378/1/login?email=yo@lo.com&username=yolo2&password=yolopass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + }; + return request(options); + }) + .then(done.fail) + .catch(err => { + expect(err.data.error).toEqual('Invalid username/password.'); + done(); + }); + }); + + it("fails to login when email doesn't match username", done => { + const user = new Parse.User(); + user + .save({ + username: 'yolo', + password: 'yolopass', + email: 'yo@lo.com', + }) + .then(() => { + const options = { + url: `http://localhost:8378/1/login?email=yo@lo2.com&username=yolo&password=yolopass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + }; + return request(options); + }) + .then(done.fail) + .catch(err => { + expect(err.data.error).toEqual('Invalid username/password.'); + done(); + }); + }); + + it('fails to login when email and username are not provided', done => { + const user = new Parse.User(); + user + .save({ + username: 'yolo', + password: 'yolopass', + email: 'yo@lo.com', + }) + .then(() => { + const options = { + url: `http://localhost:8378/1/login?password=yolopass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + }; + return request(options); + }) + .then(done.fail) + .catch(err => { + expect(err.data.error).toEqual('username/email is required.'); + done(); + }); + }); + + it('allows login when providing email as username', done => { + const user = new Parse.User(); + user + .save({ + username: 'yolo', + password: 'yolopass', + email: 'yo@lo.com', + }) + .then(() => { + return Parse.User.logIn('yo@lo.com', 'yolopass'); + }) + .then(user => { + expect(user.get('username')).toBe('yolo'); + }) + .then(done) + .catch(done.fail); + }); + + it('handles properly when 2 users share username / email pairs', done => { + const user = new Parse.User({ + username: 'yo@loname.com', + password: 'yolopass', + email: 'yo@lo.com', + }); + const user2 = new Parse.User({ + username: 'yo@lo.com', + email: 'yo@loname.com', + password: 'yolopass2', // different passwords + }); + + Parse.Object.saveAll([user, user2]) + .then(() => { + return Parse.User.logIn('yo@loname.com', 'yolopass'); + }) + .then(user => { + // the username takes precedence over the email, + // so we get the user with username as passed in + expect(user.get('username')).toBe('yo@loname.com'); + }) + .then(done) + .catch(done.fail); + }); + + it('handles properly when 2 users share username / email pairs, counterpart', done => { + const user = new Parse.User({ + username: 'yo@loname.com', + password: 'yolopass', + email: 'yo@lo.com', + }); + const user2 = new Parse.User({ + username: 'yo@lo.com', + email: 'yo@loname.com', + password: 'yolopass2', // different passwords + }); + + Parse.Object.saveAll([user, user2]) + .then(() => { + return Parse.User.logIn('yo@loname.com', 'yolopass2'); + }) + .then(done.fail) + .catch(err => { + expect(err.message).toEqual('Invalid username/password.'); + done(); + }); + }); + + it('fails to login when password is not provided', done => { + const user = new Parse.User(); + user + .save({ + username: 'yolo', + password: 'yolopass', + email: 'yo@lo.com', + }) + .then(() => { + const options = { + url: `http://localhost:8378/1/login?username=yolo`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + }; + return request(options); + }) + .then(done.fail) + .catch(err => { + expect(err.data.error).toEqual('password is required.'); + done(); + }); + }); + + it('does not duplicate session when logging in multiple times #3451', done => { + const user = new Parse.User(); + user + .signUp({ + username: 'yolo', + password: 'yolo', + email: 'yo@lo.com', + }) + .then(() => { + const token = user.getSessionToken(); + let promise = Promise.resolve(); + let count = 0; + while (count < 5) { + promise = promise.then(() => { + return Parse.User.logIn('yolo', 'yolo').then(res => { + // ensure a new session token is generated at each login + expect(res.getSessionToken()).not.toBe(token); + }); + }); + count++; + } + return promise; + }) + .then(() => { + // wait because session destruction is not synchronous + return new Promise(resolve => { + setTimeout(resolve, 100); + }); + }) + .then(() => { + const query = new Parse.Query('_Session'); + return query.find({ useMasterKey: true }); + }) + .then(results => { + // only one session in the end + expect(results.length).toBe(1); + }) + .then(done, done.fail); + }); + + it('should throw OBJECT_NOT_FOUND instead of SESSION_MISSING when using masterKey', async () => { + await reconfigureServer(); + // create a fake user (just so we simulate an object not found) + const non_existent_user = Parse.User.createWithoutData('fake_id'); + try { + await non_existent_user.destroy({ useMasterKey: true }); + throw ''; + } catch (e) { + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + } + try { + await non_existent_user.save({}, { useMasterKey: true }); + throw ''; + } catch (e) { + expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + } + try { + await non_existent_user.save(); + throw ''; + } catch (e) { + expect(e.code).toBe(Parse.Error.SESSION_MISSING); + } + try { + await non_existent_user.destroy(); + throw ''; + } catch (e) { + expect(e.code).toBe(Parse.Error.SESSION_MISSING); + } + }); + + it('should throw when enforcePrivateUsers is invalid', async () => { + const options = [[], 'a', 0, {}]; + for (const option of options) { + await expectAsync(reconfigureServer({ enforcePrivateUsers: option })).toBeRejected(); + } + }); + + it('user login with enforcePrivateUsers', async done => { + await reconfigureServer({ enforcePrivateUsers: true }); + await Parse.User.signUp('asdf', 'zxcv'); + const user = await Parse.User.logIn('asdf', 'zxcv'); + equal(user.get('username'), 'asdf'); + const ACL = user.getACL(); + expect(ACL.getReadAccess(user)).toBe(true); + expect(ACL.getWriteAccess(user)).toBe(true); + expect(ACL.getPublicReadAccess()).toBe(false); + expect(ACL.getPublicWriteAccess()).toBe(false); + const perms = ACL.permissionsById; + expect(Object.keys(perms).length).toBe(1); + expect(perms[user.id].read).toBe(true); + expect(perms[user.id].write).toBe(true); + expect(perms['*']).toBeUndefined(); + done(); + }); + + describe('issue #4897', () => { + it_only_db('mongo')('should be able to login with a legacy user (no ACL)', async () => { + // This issue is a side effect of the locked users and legacy users which don't have ACL's + // In this scenario, a legacy user wasn't be able to login as there's no ACL on it + await reconfigureServer(); + const database = Config.get(Parse.applicationId).database; + const collection = await database.adapter._adaptiveCollection('_User'); + await collection.insertOne({ + _id: 'ABCDEF1234', + name: '', + email: '', + username: '', + _hashed_password: '', + _auth_data_facebook: { + id: '8675309', + access_token: 'jenny', + }, + sessionToken: '', + }); + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + const model = await Parse.User._logInWith('facebook', {}); + expect(model.id).toBe('ABCDEF1234'); + ok(model instanceof Parse.User, 'Model should be a Parse.User'); + strictEqual(Parse.User.current(), model); + ok(model.extended(), 'Should have used subclass.'); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); + ok(model._isLinked('facebook'), 'User should be linked to facebook'); + }); + }); + + it('should strip out authdata in LiveQuery', async () => { + const provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + + await reconfigureServer({ + liveQuery: { classNames: ['_User'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + Parse.Cloud.beforeSave(Parse.User, ({ object }) => { + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + object.setACL(acl); + }); + + const query = new Parse.Query(Parse.User); + query.doesNotExist('foo'); + const subscription = await query.subscribe(); + + const events = ['create', 'update', 'enter', 'leave', 'delete']; + const response = (obj, prev) => { + expect(obj.get('authData')).toBeUndefined(); + expect(obj.authData).toBeUndefined(); + expect(prev && prev.authData).toBeUndefined(); + if (prev && prev.get) { + expect(prev.get('authData')).toBeUndefined(); + } + }; + const calls = {}; + for (const key of events) { + calls[key] = response; + spyOn(calls, key).and.callThrough(); + subscription.on(key, calls[key]); + } + const user = await Parse.User._logInWith('facebook'); + user.set('foo', 'bar'); + await user.save(); + user.unset('foo'); + await user.save(); + user.set('yolo', 'bar'); + await user.save(); + await user.destroy(); + await new Promise(resolve => setTimeout(resolve, 10)); + for (const key of events) { + expect(calls[key]).toHaveBeenCalled(); + } + subscription.unsubscribe(); + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + client.close(); + await new Promise(resolve => setTimeout(resolve, 10)); + }); +}); + +describe('Security Advisory GHSA-8w3j-g983-8jh5', function () { + it_only_db('mongo')( + 'should validate credentials first and check if account already linked afterwards ()', + async done => { + await reconfigureServer(); + // Add User to Database with authData + const database = Config.get(Parse.applicationId).database; + const collection = await database.adapter._adaptiveCollection('_User'); + await collection.insertOne({ + _id: 'ABCDEF1234', + name: '', + email: '', + username: '', + _hashed_password: '', + _auth_data_custom: { + id: 'linkedID', // Already linked userid + }, + sessionToken: '', + }); + const provider = { + getAuthType: () => 'custom', + restoreAuthentication: () => true, + }; // AuthProvider checks if password is 'password' + Parse.User._registerAuthenticationProvider(provider); + + // Try to link second user with wrong password + try { + const user = await Parse.AnonymousUtils.logIn(); + await user._linkWith(provider.getAuthType(), { + authData: { id: 'linkedID', password: 'wrong' }, + }); + } catch (error) { + // This should throw Parse.Error.SESSION_MISSING and not Parse.Error.ACCOUNT_ALREADY_LINKED + expect(error.code).toEqual(Parse.Error.SESSION_MISSING); + done(); + return; + } + fail(); + done(); + } + ); + it_only_db('mongo')('should ignore authData field', async () => { + // Add User to Database with authData + await reconfigureServer(); + const database = Config.get(Parse.applicationId).database; + const collection = await database.adapter._adaptiveCollection('_User'); + await collection.insertOne({ + _id: '1234ABCDEF', + name: '', + email: '', + username: '', + _hashed_password: '', + _auth_data_custom: { + id: 'linkedID', + }, + sessionToken: '', + authData: null, // should ignore + }); + const provider = { + getAuthType: () => 'custom', + restoreAuthentication: () => true, + }; + Parse.User._registerAuthenticationProvider(provider); + const query = new Parse.Query(Parse.User); + const user = await query.get('1234ABCDEF', { useMasterKey: true }); + expect(user.get('authData')).toEqual({ custom: { id: 'linkedID' } }); + }); +}); + +describe('login as other user', () => { + let loggerErrorSpy; + beforeEach(() => { + const logger = require('../lib/logger').default; + loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + }); + + it('allows creating a session for another user with the master key', async done => { + await Parse.User.signUp('some_user', 'some_password'); + const userId = Parse.User.current().id; + await Parse.User.logOut(); + + try { + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/loginAs', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + }, + body: { + userId, + }, + }); + + expect(response.data.sessionToken).toBeDefined(); + } catch (err) { + fail(`no request should fail: ${JSON.stringify(err)}`); + done(); + } + + const sessionsQuery = new Parse.Query(Parse.Session); + const sessionsAfterRequest = await sessionsQuery.find({ useMasterKey: true }); + expect(sessionsAfterRequest.length).toBe(1); + + done(); + }); + + it('rejects creating a session for another user if the user does not exist', async done => { + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/loginAs', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + }, + body: { + userId: 'bogus-user', + }, + }); + + fail('Request should fail without a valid user ID'); + done(); + } catch (err) { + expect(err.data.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + expect(err.data.error).toBe('user not found'); + } + + const sessionsQuery = new Parse.Query(Parse.Session); + const sessionsAfterRequest = await sessionsQuery.find({ useMasterKey: true }); + expect(sessionsAfterRequest.length).toBe(0); + + done(); + }); + + it('rejects creating a session for another user with invalid parameters', async done => { + const invalidUserIds = [undefined, null, '']; + + for (const invalidUserId of invalidUserIds) { + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/loginAs', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + }, + body: { + userId: invalidUserId, + }, + }); + + fail('Request should fail without a valid user ID'); + done(); + } catch (err) { + expect(err.data.code).toBe(Parse.Error.INVALID_VALUE); + expect(err.data.error).toBe('userId must not be empty, null, or undefined'); + } + + const sessionsQuery = new Parse.Query(Parse.Session); + const sessionsAfterRequest = await sessionsQuery.find({ useMasterKey: true }); + expect(sessionsAfterRequest.length).toBe(0); + } + + done(); + }); + + it('rejects creating a session for another user without the master key', async done => { + await Parse.User.signUp('some_user', 'some_password'); + const userId = Parse.User.current().id; + await Parse.User.logOut(); + + loggerErrorSpy.calls.reset(); + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/loginAs', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + body: { + userId, + }, + }); + + fail('Request should fail without the master key'); + done(); + } catch (err) { + expect(err.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(err.data.error).toBe('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('master key is required')); + } + + const sessionsQuery = new Parse.Query(Parse.Session); + const sessionsAfterRequest = await sessionsQuery.find({ useMasterKey: true }); + expect(sessionsAfterRequest.length).toBe(0); + + done(); + }); +}); + +describe('allowClientClassCreation option', () => { + it('should enforce boolean values', async () => { + const options = [[], 'a', '', 0, 1, {}, 'true', 'false']; + for (const option of options) { + await expectAsync(reconfigureServer({ allowClientClassCreation: option })).toBeRejected(); + } + }); + + it('should accept true value', async () => { + await reconfigureServer({ allowClientClassCreation: true }); + expect(Config.get(Parse.applicationId).allowClientClassCreation).toBe(true); + }); + + it('should accept false value', async () => { + await reconfigureServer({ allowClientClassCreation: false }); + expect(Config.get(Parse.applicationId).allowClientClassCreation).toBe(false); + }); + + it('should default false', async () => { + // remove predefined allowClientClassCreation:true on global defaultConfiguration + delete defaultConfiguration.allowClientClassCreation; + await reconfigureServer(defaultConfiguration); + expect(Config.get(Parse.applicationId).allowClientClassCreation).toBe(false); + // Need to set it back to true to avoid other test fails + defaultConfiguration.allowClientClassCreation = true; }); }); diff --git a/spec/ParseWebSocket.spec.js b/spec/ParseWebSocket.spec.js index 11a7ae214b..fe64bce1be 100644 --- a/spec/ParseWebSocket.spec.js +++ b/spec/ParseWebSocket.spec.js @@ -1,42 +1,41 @@ -var ParseWebSocket = require('../src/LiveQuery/ParseWebSocketServer').ParseWebSocket; +const ParseWebSocket = require('../lib/LiveQuery/ParseWebSocketServer').ParseWebSocket; -describe('ParseWebSocket', function() { - - it('can be initialized', function() { - var ws = {}; - var parseWebSocket = new ParseWebSocket(ws); +describe('ParseWebSocket', function () { + it('can be initialized', function () { + const ws = {}; + const parseWebSocket = new ParseWebSocket(ws); expect(parseWebSocket.ws).toBe(ws); }); - it('can handle events defined in typeMap', function() { - var ws = { - on: jasmine.createSpy('on') + it('can handle disconnect event', function (done) { + const ws = { + onclose: () => {}, }; - var callback = {}; - var parseWebSocket = new ParseWebSocket(ws); - parseWebSocket.on('disconnect', callback); - - expect(parseWebSocket.ws.on).toHaveBeenCalledWith('close', callback); + const parseWebSocket = new ParseWebSocket(ws); + parseWebSocket.on('disconnect', () => { + done(); + }); + ws.onclose(); }); - it('can handle events which are not defined in typeMap', function() { - var ws = { - on: jasmine.createSpy('on') + it('can handle message event', function (done) { + const ws = { + onmessage: () => {}, }; - var callback = {}; - var parseWebSocket = new ParseWebSocket(ws); - parseWebSocket.on('open', callback); - - expect(parseWebSocket.ws.on).toHaveBeenCalledWith('open', callback); + const parseWebSocket = new ParseWebSocket(ws); + parseWebSocket.on('message', () => { + done(); + }); + ws.onmessage(); }); - it('can send a message', function() { - var ws = { - send: jasmine.createSpy('send') + it('can send a message', function () { + const ws = { + send: jasmine.createSpy('send'), }; - var parseWebSocket = new ParseWebSocket(ws); - parseWebSocket.send('message') + const parseWebSocket = new ParseWebSocket(ws); + parseWebSocket.send('message'); expect(parseWebSocket.ws.send).toHaveBeenCalledWith('message'); }); diff --git a/spec/ParseWebSocketServer.spec.js b/spec/ParseWebSocketServer.spec.js index 1ccba41543..5955ee3241 100644 --- a/spec/ParseWebSocketServer.spec.js +++ b/spec/ParseWebSocketServer.spec.js @@ -1,37 +1,137 @@ -var ParseWebSocketServer = require('../src/LiveQuery/ParseWebSocketServer').ParseWebSocketServer; +const { ParseWebSocketServer } = require('../lib/LiveQuery/ParseWebSocketServer'); +const EventEmitter = require('events'); -describe('ParseWebSocketServer', function() { - - beforeEach(function(done) { +describe('ParseWebSocketServer', function () { + beforeEach(function (done) { // Mock ws server - var EventEmitter = require('events'); - var mockServer = function() { + + const mockServer = function () { return new EventEmitter(); }; jasmine.mockLibrary('ws', 'Server', mockServer); done(); }); - it('can handle connect event when ws is open', function(done) { - var onConnectCallback = jasmine.createSpy('onConnectCallback'); - var parseWebSocketServer = new ParseWebSocketServer({}, onConnectCallback, 5).server; - var ws = { - readyState: 0, - OPEN: 0, - ping: jasmine.createSpy('ping') - }; - parseWebSocketServer.emit('connection', ws); + it('can handle connect event when ws is open', function (done) { + const onConnectCallback = jasmine.createSpy('onConnectCallback'); + const http = require('http'); + const server = http.createServer(); + const parseWebSocketServer = new ParseWebSocketServer(server, onConnectCallback, { + websocketTimeout: 5, + }).server; + const ws = new EventEmitter(); + ws.readyState = 0; + ws.OPEN = 0; + ws.ping = jasmine.createSpy('ping'); + ws.terminate = () => {}; + + parseWebSocketServer.onConnection(ws); // Make sure callback is called expect(onConnectCallback).toHaveBeenCalled(); // Make sure we ping to the client - setTimeout(function() { + setTimeout(function () { expect(ws.ping).toHaveBeenCalled(); + server.close(); done(); - }, 10) + }, 10); + }); + + it('can handle error event', async () => { + jasmine.restoreLibrary('ws', 'Server'); + const WebSocketServer = require('ws').Server; + let wssError; + class WSSAdapter { + constructor(options) { + this.options = options; + } + onListen() {} + onConnection() {} + onError() {} + start() { + const wss = new WebSocketServer({ server: this.options.server }); + wss.on('listening', this.onListen); + wss.on('connection', this.onConnection); + wss.on('error', error => { + wssError = error; + this.onError(error); + }); + this.wss = wss; + } + } + + const server = await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + liveQueryServerOptions: { + wssAdapter: WSSAdapter, + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const wssAdapter = server.liveQueryServer.parseWebSocketServer.server; + wssAdapter.wss.emit('error', 'Invalid Packet'); + expect(wssError).toBe('Invalid Packet'); + }); + + it('can handle ping/pong', async () => { + const onConnectCallback = jasmine.createSpy('onConnectCallback'); + const http = require('http'); + const server = http.createServer(); + const parseWebSocketServer = new ParseWebSocketServer(server, onConnectCallback, { + websocketTimeout: 10, + }).server; + + const ws = new EventEmitter(); + ws.readyState = 0; + ws.OPEN = 0; + ws.ping = jasmine.createSpy('ping'); + ws.terminate = jasmine.createSpy('terminate'); + + parseWebSocketServer.onConnection(ws); + + expect(onConnectCallback).toHaveBeenCalled(); + expect(ws.waitingForPong).toBe(false); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(ws.ping).toHaveBeenCalled(); + expect(ws.waitingForPong).toBe(true); + ws.emit('pong'); + expect(ws.waitingForPong).toBe(false); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(ws.waitingForPong).toBe(true); + expect(ws.terminate).not.toHaveBeenCalled(); + server.close(); + }); + + it('closes interrupted connection', async () => { + const onConnectCallback = jasmine.createSpy('onConnectCallback'); + const http = require('http'); + const server = http.createServer(); + const parseWebSocketServer = new ParseWebSocketServer(server, onConnectCallback, { + websocketTimeout: 5, + }).server; + const ws = new EventEmitter(); + ws.readyState = 0; + ws.OPEN = 0; + ws.ping = jasmine.createSpy('ping'); + ws.terminate = jasmine.createSpy('terminate'); + + parseWebSocketServer.onConnection(ws); + + // Make sure callback is called + expect(onConnectCallback).toHaveBeenCalled(); + expect(ws.waitingForPong).toBe(false); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(ws.ping).toHaveBeenCalled(); + expect(ws.waitingForPong).toBe(true); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(ws.terminate).toHaveBeenCalled(); + server.close(); }); - afterEach(function(){ + afterEach(function () { jasmine.restoreLibrary('ws', 'Server'); }); }); diff --git a/spec/PasswordPolicy.spec.js b/spec/PasswordPolicy.spec.js new file mode 100644 index 0000000000..27145015bc --- /dev/null +++ b/spec/PasswordPolicy.spec.js @@ -0,0 +1,1715 @@ +'use strict'; + +const request = require('../lib/request'); + +describe('Password Policy: ', () => { + it_id('b400a867-9f05-496f-af79-933aa588dde5')(it)('should show the invalid link page if the user clicks on the password reset link after the token expires', done => { + const user = new Parse.User(); + let sendEmailOptions; + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + sendEmailOptions = options; + }, + sendMail: () => {}, + }; + reconfigureServer({ + appName: 'passwordPolicy', + emailAdapter: emailAdapter, + passwordPolicy: { + resetTokenValidityDuration: 0.5, // 0.5 second + }, + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + user.setUsername('testResetTokenValidity'); + user.setPassword('original'); + user.set('email', 'user@parse.com'); + return user.signUp(); + }) + .then(() => { + Parse.User.requestPasswordReset('user@parse.com').catch(err => { + jfail(err); + fail('Reset password request should not fail'); + done(); + }); + }) + .then(() => { + // wait for a bit more than the validity duration set + setTimeout(() => { + expect(sendEmailOptions).not.toBeUndefined(); + + request({ + url: sendEmailOptions.link, + followRedirects: false, + simple: false, + resolveWithFullResponse: true, + }) + .then(response => { + expect(response.status).toEqual(200); + expect(response.text).toContain('Invalid password reset link!'); + done(); + }) + .catch(error => { + fail(error); + }); + }, 1000); + }) + .catch(err => { + jfail(err); + done(); + }); + }); + + it('should show the reset password page if the user clicks on the password reset link before the token expires', done => { + const user = new Parse.User(); + let sendEmailOptions; + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + sendEmailOptions = options; + }, + sendMail: () => {}, + }; + reconfigureServer({ + appName: 'passwordPolicy', + emailAdapter: emailAdapter, + passwordPolicy: { + resetTokenValidityDuration: 5, // 5 seconds + }, + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + user.setUsername('testResetTokenValidity'); + user.setPassword('original'); + user.set('email', 'user@parse.com'); + return user.signUp(); + }) + .then(() => { + Parse.User.requestPasswordReset('user@parse.com').catch(err => { + jfail(err); + fail('Reset password request should not fail'); + done(); + }); + }) + .then(() => { + // wait for a bit but less than the validity duration + setTimeout(() => { + expect(sendEmailOptions).not.toBeUndefined(); + + request({ + url: sendEmailOptions.link, + simple: false, + resolveWithFullResponse: true, + followRedirects: false, + }) + .then(response => { + expect(response.status).toEqual(200); + expect(response.text).toContain('password'); + done(); + }) + .catch(error => { + fail(error); + }); + }, 1000); + }) + .catch(err => { + jfail(err); + done(); + }); + }); + + it('should not keep reset token by default', async done => { + const sendEmailOptions = []; + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + sendEmailOptions.push(options); + }, + sendMail: () => {}, + }; + await reconfigureServer({ + appName: 'passwordPolicy', + emailAdapter: emailAdapter, + passwordPolicy: { + resetTokenValidityDuration: 5 * 60, // 5 minutes + }, + publicServerURL: 'http://localhost:8378/1', + }); + const user = new Parse.User(); + user.setUsername('testResetTokenValidity'); + user.setPassword('original'); + user.set('email', 'user@example.com'); + await user.signUp(); + await Parse.User.requestPasswordReset('user@example.com'); + await Parse.User.requestPasswordReset('user@example.com'); + expect(sendEmailOptions[0].link).not.toBe(sendEmailOptions[1].link); + done(); + }); + + it_id('7d98e1f2-ae89-4038-9ea7-5254854ea42e')(it)('should keep reset token with resetTokenReuseIfValid', async done => { + const sendEmailOptions = []; + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + sendEmailOptions.push(options); + }, + sendMail: () => {}, + }; + await reconfigureServer({ + appName: 'passwordPolicy', + emailAdapter: emailAdapter, + passwordPolicy: { + resetTokenValidityDuration: 5 * 60, // 5 minutes + resetTokenReuseIfValid: true, + }, + publicServerURL: 'http://localhost:8378/1', + }); + const user = new Parse.User(); + user.setUsername('testResetTokenValidity'); + user.setPassword('original'); + user.set('email', 'user@example.com'); + await user.signUp(); + await Parse.User.requestPasswordReset('user@example.com'); + await Parse.User.requestPasswordReset('user@example.com'); + expect(sendEmailOptions[0].link).toBe(sendEmailOptions[1].link); + done(); + }); + + it('should throw with invalid resetTokenReuseIfValid', async done => { + const sendEmailOptions = []; + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + sendEmailOptions.push(options); + }, + sendMail: () => {}, + }; + try { + await reconfigureServer({ + appName: 'passwordPolicy', + emailAdapter: emailAdapter, + passwordPolicy: { + resetTokenValidityDuration: 5 * 60, // 5 minutes + resetTokenReuseIfValid: [], + }, + publicServerURL: 'http://localhost:8378/1', + }); + fail('should have thrown.'); + } catch (e) { + expect(e).toBe('resetTokenReuseIfValid must be a boolean value'); + } + try { + await reconfigureServer({ + appName: 'passwordPolicy', + emailAdapter: emailAdapter, + passwordPolicy: { + resetTokenReuseIfValid: true, + }, + publicServerURL: 'http://localhost:8378/1', + }); + fail('should have thrown.'); + } catch (e) { + expect(e).toBe('You cannot use resetTokenReuseIfValid without resetTokenValidityDuration'); + } + done(); + }); + + it('should fail if passwordPolicy.resetTokenValidityDuration is not a number', done => { + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + resetTokenValidityDuration: 'not a number', + }, + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + fail('passwordPolicy.resetTokenValidityDuration "not a number" test failed'); + done(); + }) + .catch(err => { + expect(err).toEqual('passwordPolicy.resetTokenValidityDuration must be a positive number'); + done(); + }); + }); + + it('should fail if passwordPolicy.resetTokenValidityDuration is zero or a negative number', done => { + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + resetTokenValidityDuration: 0, + }, + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + fail('resetTokenValidityDuration negative number test failed'); + done(); + }) + .catch(err => { + expect(err).toEqual('passwordPolicy.resetTokenValidityDuration must be a positive number'); + done(); + }); + }); + + it('should fail if passwordPolicy.validatorPattern setting is invalid type', done => { + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + validatorPattern: 1234, // number is not a valid setting + }, + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + fail('passwordPolicy.validatorPattern type test failed'); + done(); + }) + .catch(err => { + expect(err).toEqual( + 'passwordPolicy.validatorPattern must be a regex string or RegExp object.' + ); + done(); + }); + }); + + it('should fail if passwordPolicy.validatorCallback setting is invalid type', done => { + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + validatorCallback: 'abc', // string is not a valid setting + }, + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + fail('passwordPolicy.validatorCallback type test failed'); + done(); + }) + .catch(err => { + expect(err).toEqual('passwordPolicy.validatorCallback must be a function.'); + done(); + }); + }); + + it('signup should fail if password does not conform to the policy enforced using validatorPattern', done => { + const user = new Parse.User(); + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + validatorPattern: /[0-9]+/, // password should contain at least one digit + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword('nodigit'); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + fail('Should have failed as password does not conform to the policy.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(142); + done(); + }); + }); + }); + + it('signup should fail if password does not conform to the policy enforced using validatorPattern string', done => { + const user = new Parse.User(); + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + validatorPattern: '^.{8,}', // password should contain at least 8 char + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword('less'); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + fail('Should have failed as password does not conform to the policy.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(142); + done(); + }); + }); + }); + + it('signup should fail if password is empty', done => { + const user = new Parse.User(); + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + validatorPattern: '^.{8,}', // password should contain at least 8 char + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword(''); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + fail('Should have failed as password does not conform to the policy.'); + done(); + }) + .catch(error => { + expect(error.message).toEqual('Cannot sign up user with an empty password.'); + done(); + }); + }); + }); + + it('signup should succeed if password conforms to the policy enforced using validatorPattern', done => { + const user = new Parse.User(); + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + validatorPattern: /[0-9]+/, // password should contain at least one digit + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword('1digit'); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + Parse.User.logOut() + .then(() => { + Parse.User.logIn('user1', '1digit') + .then(function () { + done(); + }) + .catch(err => { + jfail(err); + fail('Should be able to login'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('logout should have succeeded'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('Signup should have succeeded as password conforms to the policy.'); + done(); + }); + }); + }); + + it('signup should succeed if password conforms to the policy enforced using validatorPattern string', done => { + const user = new Parse.User(); + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + validatorPattern: '[!@#$]+', // password should contain at least one special char + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword('p@sswrod'); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + Parse.User.logOut() + .then(() => { + Parse.User.logIn('user1', 'p@sswrod') + .then(function () { + done(); + }) + .catch(err => { + jfail(err); + fail('Should be able to login'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('logout should have succeeded'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('Signup should have succeeded as password conforms to the policy.'); + done(); + }); + }); + }); + + it('signup should fail if password does not conform to the policy enforced using validatorCallback', done => { + const user = new Parse.User(); + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + validatorCallback: () => false, // just fail + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword('any'); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + fail('Should have failed as password does not conform to the policy.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(142); + done(); + }); + }); + }); + + it('signup should succeed if password conforms to the policy enforced using validatorCallback', done => { + const user = new Parse.User(); + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + validatorCallback: () => true, // never fail + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword('oneUpper'); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + Parse.User.logOut() + .then(() => { + Parse.User.logIn('user1', 'oneUpper') + .then(function () { + done(); + }) + .catch(err => { + jfail(err); + fail('Should be able to login'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('Logout should have succeeded'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('Should have succeeded as password conforms to the policy.'); + done(); + }); + }); + }); + + it('signup should fail if password does not match validatorPattern but succeeds validatorCallback', done => { + const user = new Parse.User(); + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + validatorPattern: /[A-Z]+/, // password should contain at least one UPPER case letter + validatorCallback: () => true, + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword('all lower'); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + fail('Should have failed as password does not conform to the policy.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(142); + done(); + }); + }); + }); + + it('signup should fail if password matches validatorPattern but fails validatorCallback', done => { + const user = new Parse.User(); + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + validatorPattern: /[A-Z]+/, // password should contain at least one UPPER case letter + validatorCallback: () => false, + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword('oneUpper'); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + fail('Should have failed as password does not conform to the policy.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(142); + done(); + }); + }); + }); + + it('signup should succeed if password conforms to both validatorPattern and validatorCallback', done => { + const user = new Parse.User(); + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + validatorPattern: /[A-Z]+/, // password should contain at least one digit + validatorCallback: () => true, + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword('oneUpper'); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + Parse.User.logOut() + .then(() => { + Parse.User.logIn('user1', 'oneUpper') + .then(function () { + done(); + }) + .catch(err => { + jfail(err); + fail('Should be able to login'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('logout should have succeeded'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('Should have succeeded as password conforms to the policy.'); + done(); + }); + }); + }); + + it('should reset password if new password conforms to password policy', done => { + const user = new Parse.User(); + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + request({ + url: options.link, + followRedirects: false, + simple: false, + resolveWithFullResponse: true, + }) + .then(response => { + expect(response.status).toEqual(200); + const re = /name="token"[^>]*value="([^"]+)"/; + const match = response.text.match(re); + if (!match) { + fail('should have a token'); + done(); + return; + } + const token = match[1]; + + request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=has2init&token=${token}`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + followRedirects: false, + simple: false, + resolveWithFullResponse: true, + }) + .then(response => { + expect(response.status).toEqual(200); + expect(response.text).toContain('Success!'); + + Parse.User.logIn('user1', 'has2init') + .then(function () { + done(); + }) + .catch(err => { + jfail(err); + fail('should login with new password'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('Failed to POST request password reset'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('Failed to get the reset link'); + done(); + }); + }, + sendMail: () => {}, + }; + reconfigureServer({ + appName: 'passwordPolicy', + verifyUserEmails: false, + emailAdapter: emailAdapter, + passwordPolicy: { + validatorPattern: /[0-9]+/, // password should contain at least one digit + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword('has 1 digit'); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + Parse.User.requestPasswordReset('user1@parse.com').catch(err => { + jfail(err); + fail('Reset password request should not fail'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('signUp should not fail'); + done(); + }); + }); + }); + + it('should fail to reset password if the new password does not conform to password policy', done => { + const user = new Parse.User(); + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + request({ + url: options.link, + followRedirects: false, + simple: false, + resolveWithFullResponse: true, + }) + .then(response => { + expect(response.status).toEqual(200); + const re = /name="token"[^>]*value="([^"]+)"/; + const match = response.text.match(re); + if (!match) { + fail('should have a token'); + done(); + return; + } + const token = match[1]; + + request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=hasnodigit&token=${token}`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + followRedirects: false, + simple: false, + resolveWithFullResponse: true, + }) + .then(response => { + expect(response.status).toEqual(200); + expect(response.text).toContain('Password should contain at least one digit.'); + + Parse.User.logIn('user1', 'has 1 digit') + .then(function () { + done(); + }) + .catch(err => { + jfail(err); + fail('should login with old password'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('Failed to POST request password reset'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('Failed to get the reset link'); + done(); + }); + }, + sendMail: () => {}, + }; + reconfigureServer({ + appName: 'passwordPolicy', + verifyUserEmails: false, + emailAdapter: emailAdapter, + passwordPolicy: { + validatorPattern: /[0-9]+/, // password should contain at least one digit + validationError: 'Password should contain at least one digit.', + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword('has 1 digit'); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + Parse.User.requestPasswordReset('user1@parse.com').catch(err => { + jfail(err); + fail('Reset password request should not fail'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('signUp should not fail'); + done(); + }); + }); + }); + + it('should fail if passwordPolicy.doNotAllowUsername is not a boolean value', done => { + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + doNotAllowUsername: 'no', + }, + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + fail('passwordPolicy.doNotAllowUsername type test failed'); + done(); + }) + .catch(err => { + expect(err).toEqual('passwordPolicy.doNotAllowUsername must be a boolean value.'); + done(); + }); + }); + + it('signup should fail if password contains the username and is not allowed by policy', done => { + const user = new Parse.User(); + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + validatorPattern: /[0-9]+/, + doNotAllowUsername: true, + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword('@user11'); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + fail('Should have failed as password contains username.'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(142); + expect(error.message).toEqual('Password cannot contain your username.'); + done(); + }); + }); + }); + + it('signup should succeed if password does not contain the username and is not allowed by policy', done => { + const user = new Parse.User(); + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + doNotAllowUsername: true, + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword('r@nd0m'); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + done(); + }) + .catch(() => { + fail('Should have succeeded as password does not contain username.'); + done(); + }); + }); + }); + + it('signup should succeed if password contains the username and it is allowed by policy', done => { + const user = new Parse.User(); + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + validatorPattern: /[0-9]+/, + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword('user1'); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + done(); + }) + .catch(() => { + fail('Should have succeeded as policy allows username in password.'); + done(); + }); + }); + }); + + it('should fail to reset password if the new password contains username and not allowed by password policy', done => { + const user = new Parse.User(); + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + request({ + url: options.link, + followRedirects: false, + simple: false, + resolveWithFullResponse: true, + }) + .then(response => { + expect(response.status).toEqual(200); + const re = /name="token"[^>]*value="([^"]+)"/; + const match = response.text.match(re); + if (!match) { + fail('should have a token'); + done(); + return; + } + const token = match[1]; + + request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=xuser12&token=${token}`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + followRedirects: false, + simple: false, + resolveWithFullResponse: true, + }) + .then(response => { + expect(response.status).toEqual(200); + expect(response.text).toContain('Password cannot contain your username.'); + + Parse.User.logIn('user1', 'r@nd0m') + .then(function () { + done(); + }) + .catch(err => { + jfail(err); + fail('should login with old password'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('Failed to POST request password reset'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('Failed to get the reset link'); + done(); + }); + }, + sendMail: () => {}, + }; + reconfigureServer({ + appName: 'passwordPolicy', + verifyUserEmails: false, + emailAdapter: emailAdapter, + passwordPolicy: { + doNotAllowUsername: true, + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword('r@nd0m'); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + Parse.User.requestPasswordReset('user1@parse.com').catch(err => { + jfail(err); + fail('Reset password request should not fail'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('signUp should not fail'); + done(); + }); + }); + }); + + it('Should return error when password violates Password Policy and reset through ajax', async done => { + const user = new Parse.User(); + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: async options => { + const response = await request({ + url: options.link, + followRedirects: false, + simple: false, + resolveWithFullResponse: true, + }); + expect(response.status).toEqual(200); + const re = /name="token"[^>]*value="([^"]+)"/; + const match = response.text.match(re); + if (!match) { + fail('should have a token'); + return; + } + const token = match[1]; + + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=xuser12&token=${token}`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest', + }, + followRedirects: false, + }); + } catch (error) { + expect(error.status).not.toBe(302); + expect(error.text).toEqual( + '{"code":-1,"error":"Password cannot contain your username."}' + ); + } + await Parse.User.logIn('user1', 'r@nd0m'); + done(); + }, + sendMail: () => {}, + }; + await reconfigureServer({ + appName: 'passwordPolicy', + verifyUserEmails: false, + emailAdapter: emailAdapter, + passwordPolicy: { + doNotAllowUsername: true, + }, + publicServerURL: 'http://localhost:8378/1', + }); + user.setUsername('user1'); + user.setPassword('r@nd0m'); + user.set('email', 'user1@parse.com'); + await user.signUp(); + + await Parse.User.requestPasswordReset('user1@parse.com'); + }); + + it('should reset password even if the new password contains user name while the policy allows', done => { + const user = new Parse.User(); + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + request({ + url: options.link, + followRedirects: false, + simple: false, + resolveWithFullResponse: true, + }) + .then(response => { + expect(response.status).toEqual(200); + const re = /name="token"[^>]*value="([^"]+)"/; + const match = response.text.match(re); + if (!match) { + fail('should have a token'); + done(); + return; + } + const token = match[1]; + + request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=uuser11&token=${token}`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + followRedirects: false, + simple: false, + resolveWithFullResponse: true, + }) + .then(response => { + expect(response.status).toEqual(200); + expect(response.text).toContain('Success!'); + + Parse.User.logIn('user1', 'uuser11') + .then(function () { + done(); + }) + .catch(err => { + jfail(err); + fail('should login with new password'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('Failed to POST request password reset'); + }); + }) + .catch(error => { + jfail(error); + fail('Failed to get the reset link'); + }); + }, + sendMail: () => {}, + }; + reconfigureServer({ + appName: 'passwordPolicy', + verifyUserEmails: false, + emailAdapter: emailAdapter, + passwordPolicy: { + validatorPattern: /[0-9]+/, + doNotAllowUsername: false, + }, + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + user.setUsername('user1'); + user.setPassword('has 1 digit'); + user.set('email', 'user1@parse.com'); + user.signUp().then(() => { + Parse.User.requestPasswordReset('user1@parse.com').catch(err => { + jfail(err); + fail('Reset password request should not fail'); + done(); + }); + }); + }) + .catch(error => { + jfail(error); + fail('signUp should not fail'); + done(); + }); + }); + + it('should fail if passwordPolicy.maxPasswordAge is not a number', done => { + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + maxPasswordAge: 'not a number', + }, + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + fail('passwordPolicy.maxPasswordAge "not a number" test failed'); + done(); + }) + .catch(err => { + expect(err).toEqual('passwordPolicy.maxPasswordAge must be a positive number'); + done(); + }); + }); + + it('should fail if passwordPolicy.maxPasswordAge is a negative number', done => { + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + maxPasswordAge: -100, + }, + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + fail('passwordPolicy.maxPasswordAge negative number test failed'); + done(); + }) + .catch(err => { + expect(err).toEqual('passwordPolicy.maxPasswordAge must be a positive number'); + done(); + }); + }); + + it_id('d7d0a93e-efe6-48c0-b622-0f7fb570ccc1')(it)('should succeed if logged in before password expires', done => { + const user = new Parse.User(); + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + maxPasswordAge: 1, // 1 day + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword('user1'); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + Parse.User.logIn('user1', 'user1') + .then(() => { + done(); + }) + .catch(error => { + jfail(error); + fail('Login should have succeeded before password expiry.'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('Signup failed.'); + done(); + }); + }); + }); + + it_id('22428408-8763-445d-9833-2b2d92008f62')(it)('should fail if logged in after password expires', done => { + const user = new Parse.User(); + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + maxPasswordAge: 0.5 / (24 * 60 * 60), // 0.5 sec + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword('user1'); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + // wait for a bit more than the validity duration set + setTimeout(() => { + Parse.User.logIn('user1', 'user1') + .then(() => { + fail('logIn should have failed'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + expect(error.message).toEqual( + 'Your password has expired. Please reset your password.' + ); + done(); + }); + }, 1000); + }) + .catch(error => { + jfail(error); + fail('Signup failed.'); + done(); + }); + }); + }); + + it_id('cc97a109-e35f-4f94-b942-3a6134921cdd')(it)('should apply password expiry policy to existing user upon first login after policy is enabled', done => { + const user = new Parse.User(); + reconfigureServer({ + appName: 'passwordPolicy', + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword('user1'); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + Parse.User.logOut() + .then(() => { + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + maxPasswordAge: 0.5 / (24 * 60 * 60), // 0.5 sec + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + Parse.User.logIn('user1', 'user1') + .then(() => { + Parse.User.logOut() + .then(() => { + // wait for a bit more than the validity duration set + setTimeout(() => { + Parse.User.logIn('user1', 'user1') + .then(() => { + fail('logIn should have failed'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + expect(error.message).toEqual( + 'Your password has expired. Please reset your password.' + ); + done(); + }); + }, 2000); + }) + .catch(error => { + jfail(error); + fail('logout should have succeeded'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('Login failed.'); + done(); + }); + }); + }) + .catch(error => { + jfail(error); + fail('logout should have succeeded'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('Signup failed.'); + done(); + }); + }); + }); + + it_id('d1e6ab9d-c091-4fea-b952-08b7484bfc89')(it)('should reset password timestamp when password is reset', done => { + const user = new Parse.User(); + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + request({ + url: options.link, + followRedirects: false, + simple: false, + resolveWithFullResponse: true, + }) + .then(response => { + expect(response.status).toEqual(200); + const re = /name="token"[^>]*value="([^"]+)"/; + const match = response.text.match(re); + if (!match) { + fail('should have a token'); + done(); + return; + } + const token = match[1]; + + request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=uuser11&token=${token}`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + followRedirects: false, + simple: false, + resolveWithFullResponse: true, + }) + .then(response => { + expect(response.status).toEqual(200); + expect(response.text).toContain('Success!'); + + Parse.User.logIn('user1', 'uuser11') + .then(function () { + done(); + }) + .catch(err => { + jfail(err); + fail('should login with new password'); + done(); + }); + }) + .catch(error => { + jfail(error); + fail('Failed to POST request password reset'); + }); + }) + .catch(error => { + jfail(error); + fail('Failed to get the reset link'); + }); + }, + sendMail: () => {}, + }; + reconfigureServer({ + appName: 'passwordPolicy', + emailAdapter: emailAdapter, + passwordPolicy: { + maxPasswordAge: 0.5 / (24 * 60 * 60), // 0.5 sec + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword('user1'); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + // wait for a bit more than the validity duration set + setTimeout(() => { + Parse.User.logIn('user1', 'user1') + .then(() => { + fail('logIn should have failed'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + expect(error.message).toEqual( + 'Your password has expired. Please reset your password.' + ); + Parse.User.requestPasswordReset('user1@parse.com').catch(err => { + jfail(err); + fail('Reset password request should not fail'); + done(); + }); + }); + }, 1000); + }) + .catch(error => { + jfail(error); + fail('Signup failed.'); + done(); + }); + }); + }); + + it('should fail if passwordPolicy.maxPasswordHistory is not a number', done => { + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + maxPasswordHistory: 'not a number', + }, + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + fail('passwordPolicy.maxPasswordHistory "not a number" test failed'); + done(); + }) + .catch(err => { + expect(err).toEqual('passwordPolicy.maxPasswordHistory must be an integer ranging 0 - 20'); + done(); + }); + }); + + it('should fail if passwordPolicy.maxPasswordHistory is a negative number', done => { + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + maxPasswordHistory: -10, + }, + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + fail('passwordPolicy.maxPasswordHistory negative number test failed'); + done(); + }) + .catch(err => { + expect(err).toEqual('passwordPolicy.maxPasswordHistory must be an integer ranging 0 - 20'); + done(); + }); + }); + + it('should fail if passwordPolicy.maxPasswordHistory is greater than 20', done => { + reconfigureServer({ + appName: 'passwordPolicy', + passwordPolicy: { + maxPasswordHistory: 21, + }, + publicServerURL: 'http://localhost:8378/1', + }) + .then(() => { + fail('passwordPolicy.maxPasswordHistory negative number test failed'); + done(); + }) + .catch(err => { + expect(err).toEqual('passwordPolicy.maxPasswordHistory must be an integer ranging 0 - 20'); + done(); + }); + }); + + it('should fail to reset if the new password is same as the last password', done => { + const user = new Parse.User(); + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + request({ + url: options.link, + followRedirects: false, + }) + .then(response => { + expect(response.status).toEqual(200); + const re = /name="token"[^>]*value="([^"]+)"/; + const match = response.text.match(re); + if (!match) { + fail('should have a token'); + return Promise.reject('Invalid password link'); + } + return Promise.resolve(match[1]); // token + }) + .then(token => { + return request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=user1&token=${token}`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + followRedirects: false, + simple: false, + resolveWithFullResponse: true, + }).then(response => { + return [response, token]; + }); + }) + .then(data => { + const response = data[0]; + const token = data[1]; + expect(response.status).toEqual(200); + expect(response.text).toContain('New password should not be the same as last 1 passwords.'); + done(); + return Promise.resolve(); + }) + .catch(error => { + fail(error); + fail('Repeat password test failed'); + done(); + }); + }, + sendMail: () => {}, + }; + reconfigureServer({ + appName: 'passwordPolicy', + verifyUserEmails: false, + emailAdapter: emailAdapter, + passwordPolicy: { + maxPasswordHistory: 1, + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword('user1'); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + return Parse.User.logOut(); + }) + .then(() => { + return Parse.User.requestPasswordReset('user1@parse.com'); + }) + .catch(error => { + jfail(error); + fail('SignUp or reset request failed'); + done(); + }); + }); + }); + + it('should fail if the new password is same as the previous one', done => { + const user = new Parse.User(); + + reconfigureServer({ + appName: 'passwordPolicy', + verifyUserEmails: false, + passwordPolicy: { + maxPasswordHistory: 5, + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword('user1'); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + // try to set the same password as the previous one + user.setPassword('user1'); + return user.save(); + }) + .then(() => { + fail('should have failed because the new password is same as the old'); + done(); + }) + .catch(error => { + expect(error.message).toEqual('New password should not be the same as last 5 passwords.'); + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + done(); + }); + }); + }); + + it('should fail if the new password is same as the 5th oldest one and policy does not allow the previous 5', done => { + const user = new Parse.User(); + + reconfigureServer({ + appName: 'passwordPolicy', + verifyUserEmails: false, + passwordPolicy: { + maxPasswordHistory: 5, + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword('user1'); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + // build history + user.setPassword('user2'); + return user.save(); + }) + .then(() => { + user.setPassword('user3'); + return user.save(); + }) + .then(() => { + user.setPassword('user4'); + return user.save(); + }) + .then(() => { + user.setPassword('user5'); + return user.save(); + }) + .then(() => { + // set the same password as the initial one + user.setPassword('user1'); + return user.save(); + }) + .then(() => { + fail('should have failed because the new password is same as the old'); + done(); + }) + .catch(error => { + expect(error.message).toEqual('New password should not be the same as last 5 passwords.'); + expect(error.code).toEqual(Parse.Error.VALIDATION_ERROR); + done(); + }); + }); + }); + + it('should succeed if the new password is same as the 6th oldest one and policy does not allow only previous 5', done => { + const user = new Parse.User(); + + reconfigureServer({ + appName: 'passwordPolicy', + verifyUserEmails: false, + passwordPolicy: { + maxPasswordHistory: 5, + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setUsername('user1'); + user.setPassword('user1'); + user.set('email', 'user1@parse.com'); + user + .signUp() + .then(() => { + // build history + user.setPassword('user2'); + return user.save(); + }) + .then(() => { + user.setPassword('user3'); + return user.save(); + }) + .then(() => { + user.setPassword('user4'); + return user.save(); + }) + .then(() => { + user.setPassword('user5'); + return user.save(); + }) + .then(() => { + user.setPassword('user6'); // this pushes initial password out of history + return user.save(); + }) + .then(() => { + // set the same password as the initial one + user.setPassword('user1'); + return user.save(); + }) + .then(() => { + done(); + }) + .catch(() => { + fail('should have succeeded because the new password is not in history'); + done(); + }); + }); + }); + + it('should not infinitely loop if maxPasswordHistory is 1 (#4918)', async () => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Rest-API-Key': 'test', + 'X-Parse-Maintenance-Key': 'test2', + 'Content-Type': 'application/json', + }; + const user = new Parse.User(); + const query = new Parse.Query(Parse.User); + + await reconfigureServer({ + appName: 'passwordPolicy', + verifyUserEmails: false, + maintenanceKey: 'test2', + passwordPolicy: { + maxPasswordHistory: 1, + }, + publicServerURL: 'http://localhost:8378/1', + }); + user.setUsername('user1'); + user.setPassword('user1'); + user.set('email', 'user1@parse.com'); + await user.signUp(); + + user.setPassword('user2'); + await user.save(); + + const user1 = await query.get(user.id, { useMasterKey: true }); + expect(user1.get('_password_history')).toBeUndefined(); + + const result1 = await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers, + }).then(res => res.data); + expect(result1._password_history.length).toBe(1); + + user.setPassword('user3'); + await user.save(); + + const result2 = await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers, + }).then(res => res.data); + expect(result2._password_history.length).toBe(1); + + expect(result1._password_history).not.toEqual(result2._password_history); + }); +}); diff --git a/spec/PointerPermissions.spec.js b/spec/PointerPermissions.spec.js index 4a1b048f64..a4cf43899d 100644 --- a/spec/PointerPermissions.spec.js +++ b/spec/PointerPermissions.spec.js @@ -1,698 +1,3073 @@ 'use strict'; -var Schema = require('../src/Controllers/SchemaController'); - -var Config = require('../src/Config'); - -describe('Pointer Permissions', () => { +const Config = require('../lib/Config'); +describe('Pointer Permissions', () => { beforeEach(() => { - new Config(Parse.applicationId).database.schemaCache.clear(); + Config.get(Parse.applicationId).schemaCache.clear(); }); - it_exclude_dbs(['postgres'])('should work with find', (done) => { - let config = new Config(Parse.applicationId); - let user = new Parse.User(); - let user2 = new Parse.User(); - user.set({ - username: 'user1', - password: 'password' + describe('using single user-pointers', () => { + it('should work with find', done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + Parse.Object.saveAll([user, user2]) + .then(() => { + obj.set('owner', user); + obj2.set('owner', user2); + return Parse.Object.saveAll([obj, obj2]); + }) + .then(() => { + return config.database.loadSchema().then(schema => { + return schema.updateClass('AnObject', {}, { readUserFields: ['owner'] }); + }); + }) + .then(() => { + return Parse.User.logIn('user1', 'password'); + }) + .then(() => { + const q = new Parse.Query('AnObject'); + return q.find(); + }) + .then(res => { + expect(res.length).toBe(1); + expect(res[0].id).toBe(obj.id); + done(); + }) + .catch(error => { + fail(JSON.stringify(error)); + done(); + }); }); - user2.set({ - username: 'user2', - password: 'password' + + it('should work with write', done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + Parse.Object.saveAll([user, user2]) + .then(() => { + obj.set('owner', user); + obj.set('reader', user2); + obj2.set('owner', user2); + obj2.set('reader', user); + return Parse.Object.saveAll([obj, obj2]); + }) + .then(() => { + return config.database.loadSchema().then(schema => { + return schema.updateClass( + 'AnObject', + {}, + { + writeUserFields: ['owner'], + readUserFields: ['reader', 'owner'], + } + ); + }); + }) + .then(() => { + return Parse.User.logIn('user1', 'password'); + }) + .then(() => { + obj2.set('hello', 'world'); + return obj2.save(); + }) + .then( + () => { + fail('User should not be able to update obj2'); + }, + err => { + // User 1 should not be able to update obj2 + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + return Promise.resolve(); + } + ) + .then(() => { + obj.set('hello', 'world'); + return obj.save(); + }) + .then( + () => { + return Parse.User.logIn('user2', 'password'); + }, + () => { + fail('User should be able to update'); + return Promise.resolve(); + } + ) + .then( + () => { + const q = new Parse.Query('AnObject'); + return q.find(); + }, + () => { + fail('should login with user 2'); + } + ) + .then( + res => { + expect(res.length).toBe(2); + res.forEach(result => { + if (result.id == obj.id) { + expect(result.get('hello')).toBe('world'); + } else { + expect(result.id).toBe(obj2.id); + } + }); + done(); + }, + () => { + fail('failed'); + done(); + } + ); }); - let obj = new Parse.Object('AnObject'); - let obj2 = new Parse.Object('AnObject'); - Parse.Object.saveAll([user, user2]).then(() => { - obj.set('owner', user); - obj2.set('owner', user2); - return Parse.Object.saveAll([obj, obj2]); - }).then(() => { - return config.database.loadSchema().then((schema) => { - return schema.updateClass('AnObject', {}, {readUserFields: ['owner']}) - }); - }).then(() => { - return Parse.User.logIn('user1', 'password'); - }).then(() => { - let q = new Parse.Query('AnObject'); - return q.find(); - }).then((res) => { - expect(res.length).toBe(1); - expect(res[0].id).toBe(obj.id); - done(); - }).catch(error => { - fail(JSON.stringify(error)); - done(); + it('should let a proper user find', done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + user + .signUp() + .then(() => { + return user2.signUp(); + }) + .then(() => { + Parse.User.logOut(); + }) + .then(() => { + obj.set('owner', user); + return Parse.Object.saveAll([obj, obj2]); + }) + .then(() => { + return config.database.loadSchema().then(schema => { + return schema.updateClass( + 'AnObject', + {}, + { find: {}, get: {}, readUserFields: ['owner'] } + ); + }); + }) + .then(() => { + const q = new Parse.Query('AnObject'); + return q.find(); + }) + .then(res => { + expect(res.length).toBe(0); + }) + .then(() => { + return Parse.User.logIn('user2', 'password'); + }) + .then(() => { + const q = new Parse.Query('AnObject'); + return q.find(); + }) + .then(res => { + expect(res.length).toBe(0); + const q = new Parse.Query('AnObject'); + return q.get(obj.id); + }) + .then( + () => { + fail('User 2 should not get the obj1 object'); + }, + err => { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + expect(err.message).toBe('Object not found.'); + return Promise.resolve(); + } + ) + .then(() => { + return Parse.User.logIn('user1', 'password'); + }) + .then(() => { + const q = new Parse.Query('AnObject'); + return q.find(); + }) + .then(res => { + expect(res.length).toBe(1); + done(); + }) + .catch(err => { + jfail(err); + fail('should not fail'); + done(); + }); + }); + + it_id('f38c35e7-d804-4d32-986d-2579e25d2461')(it)('should query on pointer permission enabled column', done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + user + .signUp() + .then(() => { + return user2.signUp(); + }) + .then(() => { + Parse.User.logOut(); + }) + .then(() => { + obj.set('owner', user); + return Parse.Object.saveAll([obj, obj2]); + }) + .then(() => { + return config.database.loadSchema().then(schema => { + return schema.updateClass( + 'AnObject', + {}, + { find: {}, get: {}, readUserFields: ['owner'] } + ); + }); + }) + .then(() => { + return Parse.User.logIn('user1', 'password'); + }) + .then(() => { + const q = new Parse.Query('AnObject'); + q.equalTo('owner', user2); + return q.find(); + }) + .then(res => { + expect(res.length).toBe(0); + done(); + }) + .catch(err => { + jfail(err); + fail('should not fail'); + done(); + }); + }); + + it('should not allow creating objects', done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + user + .save() + .then(() => { + return config.database.loadSchema().then(schema => { + return schema.addClassIfNotExists( + 'AnObject', + { owner: { type: 'Pointer', targetClass: '_User' } }, + { + create: {}, + writeUserFields: ['owner'], + readUserFields: ['owner'], + } + ); + }); + }) + .then(() => { + return Parse.User.logIn('user1', 'password'); + }) + .then(() => { + obj.set('owner', user); + return obj.save(); + }) + .then( + () => { + fail('should not succeed'); + done(); + }, + err => { + expect(err.code).toBe(119); + done(); + } + ); + }); + + it('should handle multiple writeUserFields', done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + Parse.Object.saveAll([user, user2]) + .then(() => { + obj.set('owner', user); + obj.set('otherOwner', user2); + return obj.save(); + }) + .then(() => config.database.loadSchema()) + .then(schema => + schema.updateClass( + 'AnObject', + {}, + { find: { '*': true }, writeUserFields: ['owner', 'otherOwner'] } + ) + ) + .then(() => Parse.User.logIn('user1', 'password')) + .then(() => obj.save({ hello: 'fromUser1' })) + .then(() => Parse.User.logIn('user2', 'password')) + .then(() => obj.save({ hello: 'fromUser2' })) + .then(() => Parse.User.logOut()) + .then(() => { + const q = new Parse.Query('AnObject'); + return q.first(); + }) + .then(result => { + expect(result.get('hello')).toBe('fromUser2'); + done(); + }) + .catch(() => { + fail('should not fail'); + done(); + }); + }); + + it('should prevent creating pointer permission on missing field', done => { + const config = Config.get(Parse.applicationId); + config.database + .loadSchema() + .then(schema => { + return schema.addClassIfNotExists( + 'AnObject', + {}, + { + create: {}, + writeUserFields: ['owner'], + readUserFields: ['owner'], + } + ); + }) + .then(() => { + fail('should not succeed'); + }) + .catch(err => { + expect(err.code).toBe(107); + expect(err.message).toBe( + "'owner' is not a valid column for class level pointer permissions writeUserFields" + ); + done(); + }); + }); + + it('should prevent creating pointer permission on bad field (of wrong type)', done => { + const config = Config.get(Parse.applicationId); + config.database + .loadSchema() + .then(schema => { + return schema.addClassIfNotExists( + 'AnObject', + { owner: { type: 'String' } }, + { + create: {}, + writeUserFields: ['owner'], + readUserFields: ['owner'], + } + ); + }) + .then(() => { + fail('should not succeed'); + }) + .catch(err => { + expect(err.code).toBe(107); + expect(err.message).toBe( + "'owner' is not a valid column for class level pointer permissions writeUserFields" + ); + done(); + }); + }); + + it('should prevent creating pointer permission on bad field (non-user pointer)', done => { + const config = Config.get(Parse.applicationId); + config.database + .loadSchema() + .then(schema => { + return schema.addClassIfNotExists( + 'AnObject', + { owner: { type: 'Pointer', targetClass: '_Session' } }, + { + create: {}, + writeUserFields: ['owner'], + readUserFields: ['owner'], + } + ); + }) + .then(() => { + fail('should not succeed'); + }) + .catch(err => { + expect(err.code).toBe(107); + expect(err.message).toBe( + "'owner' is not a valid column for class level pointer permissions writeUserFields" + ); + done(); + }); + }); + + it('should prevent creating pointer permission on bad field (non-existing)', done => { + const config = Config.get(Parse.applicationId); + const object = new Parse.Object('AnObject'); + object.set('owner', 'value'); + object + .save() + .then(() => { + return config.database.loadSchema(); + }) + .then(schema => { + return schema.updateClass( + 'AnObject', + {}, + { + create: {}, + writeUserFields: ['owner'], + readUserFields: ['owner'], + } + ); + }) + .then(() => { + fail('should not succeed'); + }) + .catch(err => { + expect(err.code).toBe(107); + expect(err.message).toBe( + "'owner' is not a valid column for class level pointer permissions writeUserFields" + ); + done(); + }); + }); + + it('tests CLP / Pointer Perms / ACL write (PP Locked)', done => { + /* + tests: + CLP: update closed ({}) + PointerPerm: "owner" + ACL: logged in user has access + + The owner is another user than the ACL + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + Parse.Object.saveAll([user, user2]) + .then(() => { + const ACL = new Parse.ACL(); + ACL.setReadAccess(user, true); + ACL.setWriteAccess(user, true); + obj.setACL(ACL); + obj.set('owner', user2); + return obj.save(); + }) + .then(() => { + return config.database.loadSchema().then(schema => { + // Lock the update, and let only owner write + return schema.updateClass('AnObject', {}, { update: {}, writeUserFields: ['owner'] }); + }); + }) + .then(() => { + return Parse.User.logIn('user1', 'password'); + }) + .then(() => { + // user1 has ACL read/write but should be blocked by PP + return obj.save({ key: 'value' }); + }) + .then( + () => { + fail('Should not succeed saving'); + done(); + }, + err => { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + done(); + } + ); + }); + + it('tests CLP / Pointer Perms / ACL write (ACL Locked)', done => { + /* + tests: + CLP: update closed ({}) + PointerPerm: "owner" + ACL: logged in user has access + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + Parse.Object.saveAll([user, user2]) + .then(() => { + const ACL = new Parse.ACL(); + ACL.setReadAccess(user, true); + ACL.setWriteAccess(user, true); + obj.setACL(ACL); + obj.set('owner', user2); + return obj.save(); + }) + .then(() => { + return config.database.loadSchema().then(schema => { + // Lock the update, and let only owner write + return schema.updateClass('AnObject', {}, { update: {}, writeUserFields: ['owner'] }); + }); + }) + .then(() => { + return Parse.User.logIn('user2', 'password'); + }) + .then(() => { + // user1 has ACL read/write but should be blocked by ACL + return obj.save({ key: 'value' }); + }) + .then( + () => { + fail('Should not succeed saving'); + done(); + }, + err => { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + done(); + } + ); + }); + + it('tests CLP / Pointer Perms / ACL write (ACL/PP OK)', done => { + /* + tests: + CLP: update closed ({}) + PointerPerm: "owner" + ACL: logged in user has access + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + Parse.Object.saveAll([user, user2]) + .then(() => { + const ACL = new Parse.ACL(); + ACL.setWriteAccess(user, true); + ACL.setWriteAccess(user2, true); + obj.setACL(ACL); + obj.set('owner', user2); + return obj.save(); + }) + .then(() => { + return config.database.loadSchema().then(schema => { + // Lock the update, and let only owner write + return schema.updateClass('AnObject', {}, { update: {}, writeUserFields: ['owner'] }); + }); + }) + .then(() => { + return Parse.User.logIn('user2', 'password'); + }) + .then(() => { + // user1 has ACL read/write but should be blocked by ACL + return obj.save({ key: 'value' }); + }) + .then( + objAgain => { + expect(objAgain.get('key')).toBe('value'); + done(); + }, + () => { + fail('Should not fail saving'); + done(); + } + ); + }); + + it('tests CLP / Pointer Perms / ACL read (PP locked)', done => { + /* + tests: + CLP: find/get open ({}) + PointerPerm: "owner" : read + ACL: logged in user has access + + The owner is another user than the ACL + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + Parse.Object.saveAll([user, user2]) + .then(() => { + const ACL = new Parse.ACL(); + ACL.setReadAccess(user, true); + ACL.setWriteAccess(user, true); + obj.setACL(ACL); + obj.set('owner', user2); + return obj.save(); + }) + .then(() => { + return config.database.loadSchema().then(schema => { + // Lock the update, and let only owner write + return schema.updateClass( + 'AnObject', + {}, + { find: {}, get: {}, readUserFields: ['owner'] } + ); + }); + }) + .then(() => { + return Parse.User.logIn('user1', 'password'); + }) + .then(() => { + // user1 has ACL read/write but should be block + return obj.fetch(); + }) + .then( + () => { + fail('Should not succeed saving'); + done(); + }, + err => { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + done(); + } + ); + }); + + it('tests CLP / Pointer Perms / ACL read (PP/ACL OK)', done => { + /* + tests: + CLP: find/get open ({"*": true}) + PointerPerm: "owner" : read + ACL: logged in user has access + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + Parse.Object.saveAll([user, user2]) + .then(() => { + const ACL = new Parse.ACL(); + ACL.setReadAccess(user, true); + ACL.setWriteAccess(user, true); + ACL.setReadAccess(user2, true); + ACL.setWriteAccess(user2, true); + obj.setACL(ACL); + obj.set('owner', user2); + return obj.save(); + }) + .then(() => { + return config.database.loadSchema().then(schema => { + // Lock the update, and let only owner write + return schema.updateClass( + 'AnObject', + {}, + { + find: { '*': true }, + get: { '*': true }, + readUserFields: ['owner'], + } + ); + }); + }) + .then(() => { + return Parse.User.logIn('user2', 'password'); + }) + .then(() => { + // user1 has ACL read/write but should be block + return obj.fetch(); + }) + .then( + objAgain => { + expect(objAgain.id).toBe(obj.id); + done(); + }, + () => { + fail('Should not fail fetching'); + done(); + } + ); + }); + + it('tests CLP / Pointer Perms / ACL read (ACL locked)', done => { + /* + tests: + CLP: find/get open ({"*": true}) + PointerPerm: "owner" : read // proper owner + ACL: logged in user has not access + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + Parse.Object.saveAll([user, user2]) + .then(() => { + const ACL = new Parse.ACL(); + ACL.setReadAccess(user, true); + ACL.setWriteAccess(user, true); + obj.setACL(ACL); + obj.set('owner', user2); + return obj.save(); + }) + .then(() => { + return config.database.loadSchema().then(schema => { + // Lock the update, and let only owner write + return schema.updateClass( + 'AnObject', + {}, + { + find: { '*': true }, + get: { '*': true }, + readUserFields: ['owner'], + } + ); + }); + }) + .then(() => { + return Parse.User.logIn('user2', 'password'); + }) + .then(() => { + // user2 has ACL read/write but should be block by ACL + return obj.fetch(); + }) + .then( + () => { + fail('Should not succeed saving'); + done(); + }, + err => { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + done(); + } + ); + }); + + it('should let master key find objects', done => { + const config = Config.get(Parse.applicationId); + const object = new Parse.Object('AnObject'); + object.set('hello', 'world'); + return object + .save() + .then(() => { + return config.database.loadSchema().then(schema => { + // Lock the update, and let only owner write + return schema.updateClass( + 'AnObject', + { owner: { type: 'Pointer', targetClass: '_User' } }, + { find: {}, get: {}, readUserFields: ['owner'] } + ); + }); + }) + .then(() => { + const q = new Parse.Query('AnObject'); + return q.find(); + }) + .then( + () => {}, + err => { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + return Promise.resolve(); + } + ) + .then(() => { + const q = new Parse.Query('AnObject'); + return q.find({ useMasterKey: true }); + }) + .then( + objects => { + expect(objects.length).toBe(1); + done(); + }, + () => { + fail('master key should find the object'); + done(); + } + ); + }); + + it('should let master key get objects', done => { + const config = Config.get(Parse.applicationId); + const object = new Parse.Object('AnObject'); + object.set('hello', 'world'); + return object + .save() + .then(() => { + return config.database.loadSchema().then(schema => { + // Lock the update, and let only owner write + return schema.updateClass( + 'AnObject', + { owner: { type: 'Pointer', targetClass: '_User' } }, + { find: {}, get: {}, readUserFields: ['owner'] } + ); + }); + }) + .then(() => { + const q = new Parse.Query('AnObject'); + return q.get(object.id); + }) + .then( + () => {}, + err => { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + return Promise.resolve(); + } + ) + .then(() => { + const q = new Parse.Query('AnObject'); + return q.get(object.id, { useMasterKey: true }); + }) + .then( + objectAgain => { + expect(objectAgain).not.toBeUndefined(); + expect(objectAgain.id).toBe(object.id); + done(); + }, + () => { + fail('master key should find the object'); + done(); + } + ); + }); + + it('should let master key update objects', done => { + const config = Config.get(Parse.applicationId); + const object = new Parse.Object('AnObject'); + object.set('hello', 'world'); + return object + .save() + .then(() => { + return config.database.loadSchema().then(schema => { + // Lock the update, and let only owner write + return schema.updateClass( + 'AnObject', + { owner: { type: 'Pointer', targetClass: '_User' } }, + { update: {}, writeUserFields: ['owner'] } + ); + }); + }) + .then(() => { + return object.save({ hello: 'bar' }); + }) + .then( + () => {}, + err => { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + return Promise.resolve(); + } + ) + .then(() => { + return object.save({ hello: 'baz' }, { useMasterKey: true }); + }) + .then( + objectAgain => { + expect(objectAgain.get('hello')).toBe('baz'); + done(); + }, + () => { + fail('master key should save the object'); + done(); + } + ); + }); + + it('should let master key delete objects', done => { + const config = Config.get(Parse.applicationId); + const object = new Parse.Object('AnObject'); + object.set('hello', 'world'); + return object + .save() + .then(() => { + return config.database.loadSchema().then(schema => { + // Lock the update, and let only owner write + return schema.updateClass( + 'AnObject', + { owner: { type: 'Pointer', targetClass: '_User' } }, + { delete: {}, writeUserFields: ['owner'] } + ); + }); + }) + .then(() => { + return object.destroy(); + }) + .then( + () => { + fail(); + }, + err => { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + return Promise.resolve(); + } + ) + .then(() => { + return object.destroy({ useMasterKey: true }); + }) + .then( + () => { + done(); + }, + () => { + fail('master key should destroy the object'); + done(); + } + ); }); - }); + it('should fail with invalid pointer perms (not array)', done => { + const config = Config.get(Parse.applicationId); + config.database + .loadSchema() + .then(schema => { + // Lock the update, and let only owner write + return schema.addClassIfNotExists( + 'AnObject', + { owner: { type: 'Pointer', targetClass: '_User' } }, + { delete: {}, writeUserFields: 'owner' } + ); + }) + .catch(err => { + expect(err.code).toBe(Parse.Error.INVALID_JSON); + done(); + }); + }); - it_exclude_dbs(['postgres'])('should work with write', (done) => { - let config = new Config(Parse.applicationId); - let user = new Parse.User(); - let user2 = new Parse.User(); - user.set({ - username: 'user1', - password: 'password' + it('should fail with invalid pointer perms (non-existing field)', done => { + const config = Config.get(Parse.applicationId); + config.database + .loadSchema() + .then(schema => { + // Lock the update, and let only owner write + return schema.addClassIfNotExists( + 'AnObject', + { owner: { type: 'Pointer', targetClass: '_User' } }, + { delete: {}, writeUserFields: ['owner', 'invalid'] } + ); + }) + .catch(err => { + expect(err.code).toBe(Parse.Error.INVALID_JSON); + done(); + }); }); - user2.set({ - username: 'user2', - password: 'password' + }); + + describe('using arrays of user-pointers', () => { + it('should work with find', async done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + await Parse.Object.saveAll([user, user2]); + + obj.set('owners', [user]); + obj2.set('owners', [user2]); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass('AnObject', {}, { readUserFields: ['owners'] }); + + await Parse.User.logIn('user1', 'password'); + + try { + const q = new Parse.Query('AnObject'); + const res = await q.find(); + expect(res.length).toBe(1); + expect(res[0].id).toBe(obj.id); + done(); + } catch (err) { + done.fail(JSON.stringify(err)); + } }); - let obj = new Parse.Object('AnObject'); - let obj2 = new Parse.Object('AnObject'); - Parse.Object.saveAll([user, user2]).then(() => { + it_id('1bbb9ed6-5558-4ce5-a238-b1a2015d273f')(it)('should work with write', async done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + await Parse.Object.saveAll([user, user2]); + obj.set('owner', user); - obj.set('reader', user2); + obj.set('readers', [user2]); obj2.set('owner', user2); - obj2.set('reader', user); - return Parse.Object.saveAll([obj, obj2]); - }).then(() => { - return config.database.loadSchema().then((schema) => { - return schema.updateClass('AnObject', {}, {writeUserFields: ['owner'], readUserFields: ['reader', 'owner']}); - }); - }).then(() => { - return Parse.User.logIn('user1', 'password'); - }).then(() => { + obj2.set('readers', [user]); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + writeUserFields: ['owner'], + readUserFields: ['readers', 'owner'], + } + ); + + await Parse.User.logIn('user1', 'password'); + obj2.set('hello', 'world'); - return obj2.save(); - }).then((res) => { - fail('User should not be able to update obj2'); - }, (err) => { - // User 1 should not be able to update obj2 - expect(err.code).toBe(101); - return Promise.resolve(); - }).then(()=> { + try { + await obj2.save(); + done.fail('User should not be able to update obj2'); + } catch (err) { + // User 1 should not be able to update obj2 + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + } + obj.set('hello', 'world'); - return obj.save(); - }).then(() => { - return Parse.User.logIn('user2', 'password'); - }, (err) => { - fail('User should be able to update'); - return Promise.resolve(); - }).then(() => { + try { + await obj.save(); + } catch (err) { + done.fail('User should be able to update'); + } + + await Parse.User.logIn('user2', 'password'); + + try { + const q = new Parse.Query('AnObject'); + const res = await q.find(); + expect(res.length).toBe(2); + res.forEach(result => { + if (result.id == obj.id) { + expect(result.get('hello')).toBe('world'); + } else { + expect(result.id).toBe(obj2.id); + } + }); + done(); + } catch (err) { + done.fail('failed'); + } + }); + + it('should let a proper user find', async done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + const user3 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + user3.set({ + username: 'user3', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + await user.signUp(); + await user2.signUp(); + await user3.signUp(); + await Parse.User.logOut(); + + obj.set('owners', [user, user2]); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass('AnObject', {}, { find: {}, get: {}, readUserFields: ['owners'] }); + let q = new Parse.Query('AnObject'); - return q.find(); - }, (err) => { - fail('should login with user 2'); - }).then((res) => { - expect(res.length).toBe(2); - res.forEach((result) => { - if (result.id == obj.id) { - expect(result.get('hello')).toBe('world'); - } else { - expect(result.id).toBe(obj2.id); + let result = await q.find(); + expect(result.length).toBe(0); + + Parse.User.logIn('user3', 'password'); + q = new Parse.Query('AnObject'); + result = await q.find(); + + expect(result.length).toBe(0); + q = new Parse.Query('AnObject'); + + try { + await q.get(obj.id); + done.fail('User 3 should not get the obj1 object'); + } catch (err) { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + expect(err.message).toBe('Object not found.'); + } + + for (const owner of ['user1', 'user2']) { + await Parse.User.logIn(owner, 'password'); + try { + const q = new Parse.Query('AnObject'); + result = await q.find(); + expect(result.length).toBe(1); + } catch (err) { + done.fail('should not fail'); } - }) - done(); - }, (err) =>  { - fail("failed"); + } done(); - }) - }); + }); - it_exclude_dbs(['postgres'])('should let a proper user find', (done) => { - let config = new Config(Parse.applicationId); - let user = new Parse.User(); - let user2 = new Parse.User(); - user.set({ - username: 'user1', - password: 'password' - }); - user2.set({ - username: 'user2', - password: 'password' - }); - let obj = new Parse.Object('AnObject'); - let obj2 = new Parse.Object('AnObject'); - user.signUp().then(() => { - return user2.signUp() - }).then(() => { - Parse.User.logOut(); - }).then(() => { - obj.set('owner', user); - return Parse.Object.saveAll([obj, obj2]); - }).then(() => { - return config.database.loadSchema().then((schema) => { - return schema.updateClass('AnObject', {}, {find: {}, get:{}, readUserFields: ['owner']}) + it_id('8a7d188c-b75c-4eac-90b6-9b0b11f873ae')(it)('should query on pointer permission enabled column', async done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + const user3 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', }); - }).then(() => { - let q = new Parse.Query('AnObject'); - return q.find(); - }).then((res) => { - expect(res.length).toBe(0); - }).then(() => { - return Parse.User.logIn('user2', 'password'); - }).then(() => { - let q = new Parse.Query('AnObject'); - return q.find(); - }).then((res) => { - expect(res.length).toBe(0); - let q = new Parse.Query('AnObject'); - return q.get(obj.id); - }).then(() => { - fail('User 2 should not get the obj1 object'); - }, (err) => { - expect(err.code).toBe(101); - expect(err.message).toBe('Object not found.'); - return Promise.resolve(); - }).then(() => { - return Parse.User.logIn('user1', 'password'); - }).then(() => { - let q = new Parse.Query('AnObject'); - return q.find(); - }).then((res) => { - expect(res.length).toBe(1); - done(); - }).catch((err) => { - console.error(err); - fail('should not fail'); - done(); - }) - }); + user2.set({ + username: 'user2', + password: 'password', + }); + user3.set({ + username: 'user3', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); - it_exclude_dbs(['postgres'])('should not allow creating objects', (done) => { - let config = new Config(Parse.applicationId); - let user = new Parse.User(); - user.set({ - username: 'user1', - password: 'password' - }); - let obj = new Parse.Object('AnObject'); - user.save().then(() => { - return config.database.loadSchema().then((schema) => { - return schema.addClassIfNotExists('AnObject', {owner: {type:'Pointer', targetClass: '_User'}}, {create: {}, writeUserFields: ['owner'], readUserFields: ['owner']}); - }); - }).then(() => { - return Parse.User.logIn('user1', 'password'); - }).then(() => { - obj.set('owner', user); - return obj.save(); - }).then(() => { - fail('should not succeed'); - done(); - }, (err) => { - expect(err.code).toBe(119); - done(); - }) - }); + await user.signUp(); + await user2.signUp(); + await user3.signUp(); + await Parse.User.logOut(); - it_exclude_dbs(['postgres'])('should handle multiple writeUserFields', done => { - let config = new Config(Parse.applicationId); - let user = new Parse.User(); - let user2 = new Parse.User(); - user.set({ - username: 'user1', - password: 'password' - }); - user2.set({ - username: 'user2', - password: 'password' - }); - let obj = new Parse.Object('AnObject'); - Parse.Object.saveAll([user, user2]) - .then(() => { - obj.set('owner', user); - obj.set('otherOwner', user2); - return obj.save(); - }) - .then(() => config.database.loadSchema()) - .then(schema => schema.updateClass('AnObject', {}, {find: {"*": true},writeUserFields: ['owner', 'otherOwner']})) - .then(() => Parse.User.logIn('user1', 'password')) - .then(() => obj.save({hello: 'fromUser1'})) - .then(() => Parse.User.logIn('user2', 'password')) - .then(() => obj.save({hello: 'fromUser2'})) - .then(() => Parse.User.logOut()) - .then(() => { - let q = new Parse.Query('AnObject'); - return q.first(); - }) - .then(result => { - expect(result.get('hello')).toBe('fromUser2'); - done(); - }).catch(err => { - fail('should not fail'); - done(); - }) - }); + obj.set('owners', [user, user2]); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass('AnObject', {}, { find: {}, get: {}, readUserFields: ['owners'] }); - it('should prevent creating pointer permission on missing field', (done) => { - let config = new Config(Parse.applicationId); - config.database.loadSchema().then((schema) => { - return schema.addClassIfNotExists('AnObject', {}, {create: {}, writeUserFields: ['owner'], readUserFields: ['owner']}); - }).then(() => { - fail('should not succeed'); - }).catch((err) => { - expect(err.code).toBe(107); - expect(err.message).toBe("'owner' is not a valid column for class level pointer permissions writeUserFields"); + for (const owner of ['user1', 'user2']) { + await Parse.User.logIn(owner, 'password'); + try { + const q = new Parse.Query('AnObject'); + q.equalTo('owners', user3); + const result = await q.find(); + expect(result.length).toBe(0); + } catch (err) { + done.fail('should not fail'); + } + } done(); - }) - }); + }); + + it('should not query using arrays on pointer permission enabled column', async done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + const user3 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + user3.set({ + username: 'user3', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + await user.signUp(); + await user2.signUp(); + await user3.signUp(); + await Parse.User.logOut(); - it('should prevent creating pointer permission on bad field', (done) => { - let config = new Config(Parse.applicationId); - config.database.loadSchema().then((schema) => { - return schema.addClassIfNotExists('AnObject', {owner: {type: 'String'}}, {create: {}, writeUserFields: ['owner'], readUserFields: ['owner']}); - }).then(() => { - fail('should not succeed'); - }).catch((err) => { - expect(err.code).toBe(107); - expect(err.message).toBe("'owner' is not a valid column for class level pointer permissions writeUserFields"); + obj.set('owners', [user, user2]); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass('AnObject', {}, { find: {}, get: {}, readUserFields: ['owners'] }); + + for (const owner of ['user1', 'user2']) { + try { + await Parse.User.logIn(owner, 'password'); + // Since querying for arrays is not supported this should throw an error + const q = new Parse.Query('AnObject'); + q.equalTo('owners', [user3]); + await q.find(); + done.fail('should fail'); + // eslint-disable-next-line no-empty + } catch (error) {} + } done(); - }) - }); + }); + + it('should not allow creating objects', async done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + await Parse.Object.saveAll([user, user2]); + + const schema = await config.database.loadSchema(); + await schema.addClassIfNotExists( + 'AnObject', + { owners: { type: 'Array' } }, + { + create: {}, + writeUserFields: ['owners'], + readUserFields: ['owners'], + } + ); - it('should prevent creating pointer permission on bad field', (done) => { - let config = new Config(Parse.applicationId); - let object = new Parse.Object('AnObject'); - object.set('owner', 'value'); - object.save().then(() => { - return config.database.loadSchema(); - }).then((schema) => { - return schema.updateClass('AnObject', {}, {create: {}, writeUserFields: ['owner'], readUserFields: ['owner']}); - }).then(() => { - fail('should not succeed'); - }).catch((err) => { - expect(err.code).toBe(107); - expect(err.message).toBe("'owner' is not a valid column for class level pointer permissions writeUserFields"); + for (const owner of ['user1', 'user2']) { + await Parse.User.logIn(owner, 'password'); + try { + obj.set('owners', [user, user2]); + await obj.save(); + done.fail('should not succeed'); + } catch (err) { + expect(err.code).toBe(119); + } + } done(); - }) - }); + }); + + it('should handle multiple writeUserFields', async done => { + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + + await Parse.Object.saveAll([user, user2]); + obj.set('owners', [user]); + obj.set('otherOwners', [user2]); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { find: { '*': true }, writeUserFields: ['owners', 'otherOwners'] } + ); + + await Parse.User.logIn('user1', 'password'); + await obj.save({ hello: 'fromUser1' }); + await Parse.User.logIn('user2', 'password'); + await obj.save({ hello: 'fromUser2' }); + await Parse.User.logOut(); + + try { + const q = new Parse.Query('AnObject'); + const result = await q.first(); + expect(result.get('hello')).toBe('fromUser2'); + done(); + } catch (err) { + done.fail('should not fail'); + } + }); + + it('should prevent creating pointer permission on missing field', async done => { + const config = Config.get(Parse.applicationId); + const schema = await config.database.loadSchema(); + try { + await schema.addClassIfNotExists( + 'AnObject', + {}, + { + create: {}, + writeUserFields: ['owners'], + readUserFields: ['owners'], + } + ); + done.fail('should not succeed'); + } catch (err) { + expect(err.code).toBe(107); + expect(err.message).toBe( + "'owners' is not a valid column for class level pointer permissions writeUserFields" + ); + done(); + } + }); + + it('should prevent creating pointer permission on bad field (of wrong type)', async done => { + const config = Config.get(Parse.applicationId); + const schema = await config.database.loadSchema(); + try { + await schema.addClassIfNotExists( + 'AnObject', + { owners: { type: 'String' } }, + { + create: {}, + writeUserFields: ['owners'], + readUserFields: ['owners'], + } + ); + done.fail('should not succeed'); + } catch (err) { + expect(err.code).toBe(107); + expect(err.message).toBe( + "'owners' is not a valid column for class level pointer permissions writeUserFields" + ); + done(); + } + }); - it_exclude_dbs(['postgres'])('tests CLP / Pointer Perms / ACL write (PP Locked)', (done) => { - /* - tests: - CLP: update closed ({}) - PointerPerm: "owner" - ACL: logged in user has access - - The owner is another user than the ACL - */ - let config = new Config(Parse.applicationId); - let user = new Parse.User(); - let user2 = new Parse.User(); - user.set({ - username: 'user1', - password: 'password' - }); - user2.set({ - username: 'user2', - password: 'password' - }); - let obj = new Parse.Object('AnObject'); - Parse.Object.saveAll([user, user2]).then(() => { - let ACL = new Parse.ACL(); + it('should prevent creating pointer permission on bad field (non-existing)', async done => { + const config = Config.get(Parse.applicationId); + const object = new Parse.Object('AnObject'); + object.set('owners', 'value'); + await object.save(); + + const schema = await config.database.loadSchema(); + try { + await schema.updateClass( + 'AnObject', + {}, + { + create: {}, + writeUserFields: ['owners'], + readUserFields: ['owners'], + } + ); + done.fail('should not succeed'); + } catch (err) { + expect(err.code).toBe(107); + expect(err.message).toBe( + "'owners' is not a valid column for class level pointer permissions writeUserFields" + ); + done(); + } + }); + + it('should work with arrays containing valid & invalid elements', async done => { + /* Since there is no way to check the validity of objects in arrays before querying invalid + elements in arrays should be ignored. */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + + await Parse.Object.saveAll([user, user2]); + + obj.set('owners', [user, '', -1, true, [], { invalid: -1 }]); + await Parse.Object.saveAll([obj]); + + const schema = await config.database.loadSchema(); + await schema.updateClass('AnObject', {}, { readUserFields: ['owners'] }); + + await Parse.User.logIn('user1', 'password'); + + try { + const q = new Parse.Query('AnObject'); + const res = await q.find(); + expect(res.length).toBe(1); + expect(res[0].id).toBe(obj.id); + } catch (err) { + done.fail(JSON.stringify(err)); + } + + await Parse.User.logOut(); + await Parse.User.logIn('user2', 'password'); + + try { + const q = new Parse.Query('AnObject'); + const res = await q.find(); + expect(res.length).toBe(0); + done(); + } catch (err) { + done.fail(JSON.stringify(err)); + } + }); + + it('tests CLP / Pointer Perms / ACL write (PP Locked)', async done => { + /* + tests: + CLP: update closed ({}) + PointerPerm: "owners" + ACL: logged in user has access + + The owner is another user than the ACL + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + const user3 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + user3.set({ + username: 'user3', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + + await Parse.Object.saveAll([user, user2, user3]); + + const ACL = new Parse.ACL(); ACL.setReadAccess(user, true); ACL.setWriteAccess(user, true); obj.setACL(ACL); - obj.set('owner', user2); - return obj.save(); - }).then(() => { - return config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.updateClass('AnObject', {}, {update: {}, writeUserFields: ['owner']}); - }); - }).then(() => { - return Parse.User.logIn('user1', 'password'); - }).then(() => { - // user1 has ACL read/write but should be blocked by PP - return obj.save({key: 'value'}); - }).then(() => { - fail('Should not succeed saving'); - done(); - }, (err) => { - expect(err.code).toBe(101); - done(); + obj.set('owners', [user2, user3]); + await obj.save(); + + const schema = await config.database.loadSchema(); + // Lock the update, and let only owners write + await schema.updateClass('AnObject', {}, { update: {}, writeUserFields: ['owners'] }); + + await Parse.User.logIn('user1', 'password'); + try { + // user1 has ACL read/write but should be blocked by PP + await obj.save({ key: 'value' }); + done.fail('Should not succeed saving'); + } catch (err) { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + done(); + } }); - }); - it_exclude_dbs(['postgres'])('tests CLP / Pointer Perms / ACL write (ACL Locked)', (done) => { - /* - tests: - CLP: update closed ({}) - PointerPerm: "owner" - ACL: logged in user has access - */ - let config = new Config(Parse.applicationId); - let user = new Parse.User(); - let user2 = new Parse.User(); - user.set({ - username: 'user1', - password: 'password' - }); - user2.set({ - username: 'user2', - password: 'password' - }); - let obj = new Parse.Object('AnObject'); - Parse.Object.saveAll([user, user2]).then(() => { - let ACL = new Parse.ACL(); + it('tests CLP / Pointer Perms / ACL write (ACL Locked)', async done => { + /* + tests: + CLP: update closed ({}) + PointerPerm: "owners" + ACL: logged in user has access + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + const user3 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + user3.set({ + username: 'user3', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + + await Parse.Object.saveAll([user, user2, user3]); + + const ACL = new Parse.ACL(); ACL.setReadAccess(user, true); ACL.setWriteAccess(user, true); obj.setACL(ACL); - obj.set('owner', user2); - return obj.save(); - }).then(() => { - return config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.updateClass('AnObject', {}, {update: {}, writeUserFields: ['owner']}); - }); - }).then(() => { - return Parse.User.logIn('user2', 'password'); - }).then(() => { - // user1 has ACL read/write but should be blocked by ACL - return obj.save({key: 'value'}); - }).then(() => { - fail('Should not succeed saving'); - done(); - }, (err) => { - expect(err.code).toBe(101); + obj.set('owners', [user2, user3]); + await obj.save(); + + const schema = await config.database.loadSchema(); + // Lock the update, and let only owners write + await schema.updateClass('AnObject', {}, { update: {}, writeUserFields: ['owners'] }); + + for (const owner of ['user2', 'user3']) { + await Parse.User.logIn(owner, 'password'); + try { + await obj.save({ key: 'value' }); + done.fail('Should not succeed saving'); + } catch (err) { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + } + } done(); }); - }); - it_exclude_dbs(['postgres'])('tests CLP / Pointer Perms / ACL write (ACL/PP OK)', (done) => { - /* - tests: - CLP: update closed ({}) - PointerPerm: "owner" - ACL: logged in user has access - */ - let config = new Config(Parse.applicationId); - let user = new Parse.User(); - let user2 = new Parse.User(); - user.set({ - username: 'user1', - password: 'password' - }); - user2.set({ - username: 'user2', - password: 'password' - }); - let obj = new Parse.Object('AnObject'); - Parse.Object.saveAll([user, user2]).then(() => { - let ACL = new Parse.ACL(); + it('tests CLP / Pointer Perms / ACL write (ACL/PP OK)', async done => { + /* + tests: + CLP: update closed ({}) + PointerPerm: "owners" + ACL: logged in user has access + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + const user3 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + user3.set({ + username: 'user3', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + + await Parse.Object.saveAll([user, user2, user3]); + const ACL = new Parse.ACL(); ACL.setWriteAccess(user, true); ACL.setWriteAccess(user2, true); + ACL.setWriteAccess(user3, true); obj.setACL(ACL); - obj.set('owner', user2); - return obj.save(); - }).then(() => { - return config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.updateClass('AnObject', {}, {update: {}, writeUserFields: ['owner']}); - }); - }).then(() => { - return Parse.User.logIn('user2', 'password'); - }).then(() => { - // user1 has ACL read/write but should be blocked by ACL - return obj.save({key: 'value'}); - }).then((objAgain) => { - expect(objAgain.get('key')).toBe('value'); - done(); - }, (err) => { - fail('Should not fail saving'); + obj.set('owners', [user2, user3]); + await obj.save(); + + const schema = await config.database.loadSchema(); + // Lock the update, and let only owners write + await schema.updateClass('AnObject', {}, { update: {}, writeUserFields: ['owners'] }); + + for (const owner of ['user2', 'user3']) { + await Parse.User.logIn(owner, 'password'); + try { + const objectAgain = await obj.save({ key: 'value' }); + expect(objectAgain.get('key')).toBe('value'); + } catch (err) { + done.fail('Should not fail saving'); + } + } done(); }); - }); - it_exclude_dbs(['postgres'])('tests CLP / Pointer Perms / ACL read (PP locked)', (done) => { - /* - tests: - CLP: find/get open ({}) - PointerPerm: "owner" : read - ACL: logged in user has access - - The owner is another user than the ACL - */ - let config = new Config(Parse.applicationId); - let user = new Parse.User(); - let user2 = new Parse.User(); - user.set({ - username: 'user1', - password: 'password' - }); - user2.set({ - username: 'user2', - password: 'password' - }); - let obj = new Parse.Object('AnObject'); - Parse.Object.saveAll([user, user2]).then(() => { - let ACL = new Parse.ACL(); + it('tests CLP / Pointer Perms / ACL read (PP locked)', async done => { + /* + tests: + CLP: find/get open ({}) + PointerPerm: "owners" : read + ACL: logged in user has access + + The owner is another user than the ACL + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + const user3 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + user3.set({ + username: 'user3', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + + await Parse.Object.saveAll([user, user2, user3]); + + const ACL = new Parse.ACL(); ACL.setReadAccess(user, true); ACL.setWriteAccess(user, true); obj.setACL(ACL); - obj.set('owner', user2); - return obj.save(); - }).then(() => { - return config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.updateClass('AnObject', {}, {find: {}, get: {}, readUserFields: ['owner']}); - }); - }).then(() => { - return Parse.User.logIn('user1', 'password'); - }).then(() => { - // user1 has ACL read/write but should be block - return obj.fetch(); - }).then(() => { - fail('Should not succeed saving'); - done(); - }, (err) => { - expect(err.code).toBe(101); + obj.set('owners', [user2, user3]); + await obj.save(); + + const schema = await config.database.loadSchema(); + // Lock reading, and let only owners read + await schema.updateClass('AnObject', {}, { find: {}, get: {}, readUserFields: ['owners'] }); + + await Parse.User.logIn('user1', 'password'); + try { + // user1 has ACL read/write but should be blocked + await obj.fetch(); + done.fail('Should not succeed fetching'); + } catch (err) { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + done(); + } done(); }); - }); - it_exclude_dbs(['postgres'])('tests CLP / Pointer Perms / ACL read (PP/ACL OK)', (done) => { - /* - tests: - CLP: find/get open ({"*": true}) - PointerPerm: "owner" : read - ACL: logged in user has access - */ - let config = new Config(Parse.applicationId); - let user = new Parse.User(); - let user2 = new Parse.User(); - user.set({ - username: 'user1', - password: 'password' - }); - user2.set({ - username: 'user2', - password: 'password' - }); - let obj = new Parse.Object('AnObject'); - Parse.Object.saveAll([user, user2]).then(() => { - let ACL = new Parse.ACL(); + it('tests CLP / Pointer Perms / ACL read (PP/ACL OK)', async done => { + /* + tests: + CLP: find/get open ({"*": true}) + PointerPerm: "owners" : read + ACL: logged in user has access + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + const user3 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + user3.set({ + username: 'user3', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + + await Parse.Object.saveAll([user, user2, user3]); + + const ACL = new Parse.ACL(); ACL.setReadAccess(user, true); ACL.setWriteAccess(user, true); ACL.setReadAccess(user2, true); ACL.setWriteAccess(user2, true); + ACL.setReadAccess(user3, true); + ACL.setWriteAccess(user3, true); obj.setACL(ACL); - obj.set('owner', user2); - return obj.save(); - }).then(() => { - return config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.updateClass('AnObject', {}, {find: {"*": true}, get: {"*": true}, readUserFields: ['owner']}); - }); - }).then(() => { - return Parse.User.logIn('user2', 'password'); - }).then(() => { - // user1 has ACL read/write but should be block - return obj.fetch(); - }).then((objAgain) => { - expect(objAgain.id).toBe(obj.id); - done(); - }, (err) => { - fail('Should not fail fetching'); + obj.set('owners', [user2, user3]); + await obj.save(); + + const schema = await config.database.loadSchema(); + // Allow public and owners read + await schema.updateClass( + 'AnObject', + {}, + { + find: { '*': true }, + get: { '*': true }, + readUserFields: ['owners'], + } + ); + + for (const owner of ['user2', 'user3']) { + await Parse.User.logIn(owner, 'password'); + try { + const objectAgain = await obj.fetch(); + expect(objectAgain.id).toBe(obj.id); + } catch (err) { + done.fail('Should not fail fetching'); + } + } done(); }); - }); - it_exclude_dbs(['postgres'])('tests CLP / Pointer Perms / ACL read (ACL locked)', (done) => { - /* - tests: - CLP: find/get open ({"*": true}) - PointerPerm: "owner" : read // proper owner - ACL: logged in user has not access - */ - let config = new Config(Parse.applicationId); - let user = new Parse.User(); - let user2 = new Parse.User(); - user.set({ - username: 'user1', - password: 'password' - }); - user2.set({ - username: 'user2', - password: 'password' - }); - let obj = new Parse.Object('AnObject'); - Parse.Object.saveAll([user, user2]).then(() => { - let ACL = new Parse.ACL(); + it('tests CLP / Pointer Perms / ACL read (ACL locked)', async done => { + /* + tests: + CLP: find/get open ({"*": true}) + PointerPerm: "owners" : read // proper owner + ACL: logged in user has not access + */ + const config = Config.get(Parse.applicationId); + const user = new Parse.User(); + const user2 = new Parse.User(); + const user3 = new Parse.User(); + user.set({ + username: 'user1', + password: 'password', + }); + user2.set({ + username: 'user2', + password: 'password', + }); + user3.set({ + username: 'user3', + password: 'password', + }); + const obj = new Parse.Object('AnObject'); + await Parse.Object.saveAll([user, user2, user3]); + + const ACL = new Parse.ACL(); ACL.setReadAccess(user, true); ACL.setWriteAccess(user, true); obj.setACL(ACL); - obj.set('owner', user2); - return obj.save(); - }).then(() => { - return config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.updateClass('AnObject', {}, {find: {"*": true}, get: {"*": true}, readUserFields: ['owner']}); - }); - }).then(() => { - return Parse.User.logIn('user2', 'password'); - }).then(() => { - // user2 has ACL read/write but should be block by ACL - return obj.fetch(); - }).then(() => { - fail('Should not succeed saving'); - done(); - }, (err) => { - expect(err.code).toBe(101); + obj.set('owners', [user2, user3]); + await obj.save(); + + const schema = await config.database.loadSchema(); + // Allow public and owners read + await schema.updateClass( + 'AnObject', + {}, + { + find: { '*': true }, + get: { '*': true }, + readUserFields: ['owners'], + } + ); + + for (const owner of ['user2', 'user3']) { + await Parse.User.logIn(owner, 'password'); + try { + await obj.fetch(); + done.fail('Should not succeed fetching'); + } catch (err) { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + } + } done(); }); - }); - it_exclude_dbs(['postgres'])('should let master key find objects', (done) => { - let config = new Config(Parse.applicationId); - let user = new Parse.User(); - let object = new Parse.Object('AnObject'); - object.set('hello', 'world'); - return object.save().then(() => { - return config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.updateClass('AnObject', {owner: {type: 'Pointer', targetClass: '_User'}}, {find: {}, get: {}, readUserFields: ['owner']}); - }); - }).then(() => { - let q = new Parse.Query('AnObject'); - return q.find(); - }).then(() => { + it('should let master key find objects', async done => { + const config = Config.get(Parse.applicationId); + const object = new Parse.Object('AnObject'); + object.set('hello', 'world'); + await object.save(); - }, (err) => { - expect(err.code).toBe(101); - return Promise.resolve(); - }).then(() => { - let q = new Parse.Query('AnObject'); - return q.find({useMasterKey: true}); - }).then((objects) => { - expect(objects.length).toBe(1); - done(); - }, (err) => { - fail('master key should find the object'); - done(); - }) - }); + const schema = await config.database.loadSchema(); + // Lock the find/get, and let only owners read + await schema.updateClass( + 'AnObject', + { owners: { type: 'Array' } }, + { find: {}, get: {}, readUserFields: ['owners'] } + ); - it_exclude_dbs(['postgres'])('should let master key get objects', (done) => { - let config = new Config(Parse.applicationId); - let user = new Parse.User(); - let object = new Parse.Object('AnObject'); - object.set('hello', 'world'); - return object.save().then(() => { - return config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.updateClass('AnObject', {owner: {type: 'Pointer', targetClass: '_User'}}, {find: {}, get: {}, readUserFields: ['owner']}); - }); - }).then(() => { - let q = new Parse.Query('AnObject'); - return q.get(object.id); - }).then(() => { + const q = new Parse.Query('AnObject'); + const objects = await q.find(); + expect(objects.length).toBe(0); - }, (err) => { - expect(err.code).toBe(101); - return Promise.resolve(); - }).then(() => { - let q = new Parse.Query('AnObject'); - return q.get(object.id, {useMasterKey: true}); - }).then((objectAgain) => { - expect(objectAgain).not.toBeUndefined(); - expect(objectAgain.id).toBe(object.id); - done(); - }, (err) => { - fail('master key should find the object'); - done(); - }) - }); + try { + const objects = await q.find({ useMasterKey: true }); + expect(objects.length).toBe(1); + done(); + } catch (err) { + done.fail('master key should find the object'); + } + }); + it('should let master key get objects', async done => { + const config = Config.get(Parse.applicationId); + const object = new Parse.Object('AnObject'); + object.set('hello', 'world'); - it_exclude_dbs(['postgres'])('should let master key update objects', (done) => { - let config = new Config(Parse.applicationId); - let user = new Parse.User(); - let object = new Parse.Object('AnObject'); - object.set('hello', 'world'); - return object.save().then(() => { - return config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.updateClass('AnObject', {owner: {type: 'Pointer', targetClass: '_User'}}, {update: {}, writeUserFields: ['owner']}); - }); - }).then(() => { - return object.save({'hello': 'bar'}); - }).then(() => { - - }, (err) => { - expect(err.code).toBe(101); - return Promise.resolve(); - }).then(() => { - return object.save({'hello': 'baz'}, {useMasterKey: true}); - }).then((objectAgain) => { - expect(objectAgain.get('hello')).toBe('baz'); - done(); - }, (err) => { - fail('master key should save the object'); - done(); - }) - }); + await object.save(); + const schema = await config.database.loadSchema(); + // Lock the find/get, and let only owners read + await schema.updateClass( + 'AnObject', + { owners: { type: 'Array' } }, + { find: {}, get: {}, readUserFields: ['owners'] } + ); - it_exclude_dbs(['postgres'])('should let master key delete objects', (done) => { - let config = new Config(Parse.applicationId); - let user = new Parse.User(); - let object = new Parse.Object('AnObject'); - object.set('hello', 'world'); - return object.save().then(() => { - return config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.updateClass('AnObject', {owner: {type: 'Pointer', targetClass: '_User'}}, {delete: {}, writeUserFields: ['owner']}); - }); - }).then(() => { - return object.destroy(); - }).then(() => { - fail(); - }, (err) => { - expect(err.code).toBe(101); - return Promise.resolve(); - }).then(() => { - return object.destroy({useMasterKey: true}); - }).then((objectAgain) => { - done(); - }, (err) => { - fail('master key should destroy the object'); - done(); - }) - }); + const q = new Parse.Query('AnObject'); + try { + await q.get(object.id); + done.fail(); + } catch (err) { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + } - it('should fail with invalid pointer perms', () => { - let config = new Config(Parse.applicationId); - config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.addClassIfNotExists('AnObject', {owner: {type: 'Pointer', targetClass: '_User'}}, {delete: {}, writeUserFields: 'owner'}); - }).catch((err) => { - expect(err.code).toBe(Parse.Error.INVALID_JSON); - done(); - }); + try { + const objectAgain = await q.get(object.id, { useMasterKey: true }); + expect(objectAgain).not.toBeUndefined(); + expect(objectAgain.id).toBe(object.id); + done(); + } catch (err) { + done.fail('master key should get the object'); + } + }); + + it('should let master key update objects', async done => { + const config = Config.get(Parse.applicationId); + const object = new Parse.Object('AnObject'); + object.set('hello', 'world'); + await object.save(); + + const schema = await config.database.loadSchema(); + // Lock the update, and let only owners write + await schema.updateClass( + 'AnObject', + { owners: { type: 'Array' } }, + { update: {}, writeUserFields: ['owners'] } + ); + + try { + await object.save({ hello: 'bar' }); + done.fail(); + } catch (err) { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + } + + try { + const objectAgain = await object.save({ hello: 'baz' }, { useMasterKey: true }); + expect(objectAgain.get('hello')).toBe('baz'); + done(); + } catch (err) { + done.fail('master key should save the object'); + } + }); + + it('should let master key delete objects', async done => { + const config = Config.get(Parse.applicationId); + + const object = new Parse.Object('AnObject'); + object.set('hello', 'world'); + await object.save(); + + const schema = await config.database.loadSchema(); + // Lock the delete, and let only owners write + await schema.updateClass( + 'AnObject', + { owners: { type: 'Array' } }, + { delete: {}, writeUserFields: ['owners'] } + ); + + try { + await object.destroy(); + done.fail(); + } catch (err) { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + } + try { + await object.destroy({ useMasterKey: true }); + done(); + } catch (err) { + done.fail('master key should destroy the object'); + } + }); + + it('should fail with invalid pointer perms (not array)', async done => { + const config = Config.get(Parse.applicationId); + const schema = await config.database.loadSchema(); + try { + // Lock the delete, and let only owners write + await schema.addClassIfNotExists( + 'AnObject', + { owners: { type: 'Array' } }, + { delete: {}, writeUserFields: 'owners' } + ); + } catch (err) { + expect(err.code).toBe(Parse.Error.INVALID_JSON); + done(); + } + }); + + it('should fail with invalid pointer perms (non-existing field)', async done => { + const config = Config.get(Parse.applicationId); + const schema = await config.database.loadSchema(); + try { + // Lock the delete, and let only owners write + await schema.addClassIfNotExists( + 'AnObject', + { owners: { type: 'Array' } }, + { delete: {}, writeUserFields: ['owners', 'invalid'] } + ); + } catch (err) { + expect(err.code).toBe(Parse.Error.INVALID_JSON); + done(); + } + }); }); - it('should fail with invalid pointer perms', () => { - let config = new Config(Parse.applicationId); - config.database.loadSchema().then((schema) => { - // Lock the update, and let only owner write - return schema.addClassIfNotExists('AnObject', {owner: {type: 'Pointer', targetClass: '_User'}}, {delete: {}, writeUserFields: ['owner', 'invalid']}); - }).catch((err) => { - expect(err.code).toBe(Parse.Error.INVALID_JSON); - done(); - }); - }) + describe('Granular ', () => { + const className = 'AnObject'; + + const actionGet = id => new Parse.Query(className).get(id); + const actionFind = () => new Parse.Query(className).find(); + const actionCount = () => new Parse.Query(className).count(); + const actionCreate = () => new Parse.Object(className).save(); + const actionUpdate = obj => obj.save({ revision: 2 }); + const actionDelete = obj => obj.destroy(); + const actionAddFieldOnCreate = () => + new Parse.Object(className, { ['extra' + Date.now()]: 'field' }).save(); + const actionAddFieldOnUpdate = obj => obj.save({ ['another' + Date.now()]: 'field' }); + + const OBJECT_NOT_FOUND = new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); + const PERMISSION_DENIED = jasmine.stringMatching('Permission denied'); + + async function createUser(username, password = 'password') { + const user = new Parse.User({ + username: username + Date.now(), + password, + }); + + await user.save(); + + return user; + } + + async function logIn(userObject) { + return await Parse.User.logIn(userObject.getUsername(), 'password'); + } + + async function updateCLP(clp) { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + + await schemaController.updateClass(className, {}, clp); + } + + describe('on single-pointer fields', () => { + /** owns: **obj1** */ + let user1; + + /** owns: **obj2** */ + let user2; + + /** owned by: **user1** */ + let obj1; + + /** owned by: **user2** */ + let obj2; + + async function initialize() { + await Config.get(Parse.applicationId).schemaCache.clear(); + + [user1, user2] = await Promise.all([createUser('user1'), createUser('user2')]); + + obj1 = new Parse.Object(className, { + owner: user1, + revision: 0, + }); + + obj2 = new Parse.Object(className, { + owner: user2, + revision: 0, + }); + + await Parse.Object.saveAll([obj1, obj2], { + useMasterKey: true, + }); + } + + beforeEach(async () => { + await initialize(); + }); + + describe('get action', () => { + it('should be allowed', async done => { + await updateCLP({ + get: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + const result = await actionGet(obj1.id); + expect(result).toBeDefined(); + done(); + }); + + it_id('9ba681d5-59f5-4996-b36d-6647d23e6a44')(it)('should fail for user not listed', async done => { + await updateCLP({ + get: { + pointerFields: ['owner'], + }, + }); + + await logIn(user2); + + await expectAsync(actionGet(obj1.id)).toBeRejectedWith(OBJECT_NOT_FOUND); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + get: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionFind(), + actionCount(), + actionCreate(), + actionUpdate(obj1), + actionAddFieldOnCreate(), + actionDelete(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('find action', () => { + it('should be allowed', async done => { + await updateCLP({ + find: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await expectAsync(actionFind()).toBeResolved(); + done(); + }); + + it('should be limited to objects where user is listed in field', async done => { + await updateCLP({ + find: { + pointerFields: ['owner'], + }, + }); + + await logIn(user2); + + const results = await actionFind(); + expect(results.length).toBe(1); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + find: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionCount(), + actionCreate(), + actionUpdate(obj1), + actionAddFieldOnCreate(), + actionDelete(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('count action', () => { + it('should be allowed', async done => { + await updateCLP({ + count: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + const count = await actionCount(); + expect(count).toBe(1); + done(); + }); + + it('should be limited to objects where user is listed in field', async done => { + await updateCLP({ + count: { + pointerFields: ['owner'], + }, + }); + + const user3 = await createUser('user3'); + await logIn(user3); + + const p = await actionCount(); + expect(p).toBe(0); + + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + count: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionFind(), + actionCreate(), + actionUpdate(obj1), + actionAddFieldOnCreate(), + actionDelete(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('update action', () => { + it('should be allowed', async done => { + await updateCLP({ + update: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + await expectAsync(actionUpdate(obj1)).toBeResolved(); + done(); + }); + + it_id('bcdb158d-c0b6-45e3-84ab-a3636f7cb470')(it)('should fail for user not listed', async done => { + await updateCLP({ + update: { + pointerFields: ['owner'], + }, + }); + + await logIn(user2); + + await expectAsync(actionUpdate(obj1)).toBeRejectedWith(OBJECT_NOT_FOUND); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + update: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionFind(), + actionCount(), + actionCreate(), + actionAddFieldOnCreate(), + actionDelete(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('delete action', () => { + it('should be allowed', async done => { + await updateCLP({ + delete: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await expectAsync(actionDelete(obj1)).toBeResolved(); + done(); + }); + + it_id('70aa3853-6e26-4c38-a927-2ddb24ced7d4')(it)('should fail for user not listed', async done => { + await updateCLP({ + delete: { + pointerFields: ['owner'], + }, + }); + + await logIn(user2); + + await expectAsync(actionDelete(obj1)).toBeRejectedWith(OBJECT_NOT_FOUND); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + delete: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionFind(), + actionCount(), + actionCreate(), + actionUpdate(obj1), + actionAddFieldOnCreate(), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('create action', () => { + // For Pointer permissions create is different from other operations + // since there's no object holding the pointer before created + it('should be denied (writelock) when no other permissions on class', async done => { + await updateCLP({ + create: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + await expectAsync(actionCreate()).toBeRejectedWith(PERMISSION_DENIED); + done(); + }); + }); + + describe('addField action', () => { + xit('should have no effect when creating object (and allowed by explicit userid permission)', async done => { + await updateCLP({ + create: { + '*': true, + }, + addField: { + [user1.id]: true, + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await expectAsync(actionAddFieldOnCreate()).toBeResolved(); + done(); + }); + + xit('should be denied when creating object (and no explicit permission)', async done => { + await updateCLP({ + create: { + '*': true, + }, + addField: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + const newObject = new Parse.Object(className, { + owner: user1, + extra: 'field', + }); + await expectAsync(newObject.save()).toBeRejectedWith(PERMISSION_DENIED); + done(); + }); + + it('should be allowed when updating object', async done => { + await updateCLP({ + update: { + '*': true, + }, + addField: { + pointerFields: ['owner'], + }, + }); + + await logIn(user1); + + await expectAsync(actionAddFieldOnUpdate(obj1)).toBeResolved(); + + done(); + }); + + it('should be denied when updating object for user without addField permission', async done => { + await updateCLP({ + update: { + '*': true, + }, + addField: { + pointerFields: ['owner'], + }, + }); + + await logIn(user2); + + await expectAsync(actionAddFieldOnUpdate(obj1)).toBeRejectedWith(OBJECT_NOT_FOUND); + + done(); + }); + }); + }); + + describe('on array of pointers', () => { + /** + * owns: **obj1** + * + * moderates: **obj1** */ + let user1; + + /** + * owns: **obj2** + * + * moderates: **obj1, obj2** */ + let user2; + + /** + * owns: **obj3** + * + * moderates: **obj1, obj2, obj3 ** */ + let user3; + + /** + * owned by: **user1** + * + * moderated by: **user1, user2, user3** */ + let obj1; + + /** + * owned by: **user2** + * + * moderated by: **user2, user3** */ + let obj2; + + /** + * owned by: **user3** + * + * moderated by: **user3** */ + let obj3; + + /** + * owned by: **noboody** + * + * moderated by: **nobody** */ + let objNobody; + + async function initialize() { + await Config.get(Parse.applicationId).schemaCache.clear(); + + [user1, user2, user3] = await Promise.all([ + createUser('user1'), + createUser('user2'), + createUser('user3'), + ]); + + obj1 = new Parse.Object(className); + obj2 = new Parse.Object(className); + obj3 = new Parse.Object(className); + objNobody = new Parse.Object(className); + + obj1.set({ + owners: [user1], + moderators: [user3, user2, user1], + revision: 0, + }); + + obj2.set({ + owners: [user2], + moderators: [user3, user2], + revision: 0, + }); + + obj3.set({ + owners: [user3], + moderators: [user3], + revision: 0, + }); + + objNobody.set({ + owners: [], + moderators: [], + revision: 0, + }); + + await Parse.Object.saveAll([obj1, obj2, obj3, objNobody], { + useMasterKey: true, + }); + } + + beforeEach(async () => { + await initialize(); + }); + + describe('get action', () => { + it('should be allowed (1 user in array)', async done => { + await updateCLP({ + get: { + pointerFields: ['owners'], + }, + }); + + await logIn(user1); + + const result = await actionGet(obj1.id); + expect(result).toBeDefined(); + done(); + }); + + it('should be allowed (multiple users in array)', async done => { + await updateCLP({ + get: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user2); + + const result = await actionGet(obj1.id); + expect(result).toBeDefined(); + done(); + }); + + it_id('84a42339-c7b5-4735-a431-57b46535b073')(it)('should fail for user not listed', async done => { + await updateCLP({ + get: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + await expectAsync(actionGet(obj3.id)).toBeRejectedWith(OBJECT_NOT_FOUND); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + get: { + pointerFields: ['owners'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionFind(), + actionCount(), + actionCreate(), + actionUpdate(obj2), + actionAddFieldOnCreate(), + actionAddFieldOnUpdate(obj2), + actionDelete(obj2), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('find action', () => { + it('should be allowed (1 user in array)', async done => { + await updateCLP({ + find: { + pointerFields: ['owners'], + }, + }); + + await logIn(user1); + + const results = await actionFind(); + expect(results.length).toBe(1); + done(); + }); + + it('should be allowed (multiple users in array)', async done => { + await updateCLP({ + find: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user2); + + const results = await actionFind(); + expect(results.length).toBe(2); + done(); + }); + + it('should be limited to objects where user is listed in field', async done => { + await updateCLP({ + find: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + const results = await actionFind(); + expect(results.length).toBe(1); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + find: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionCount(), + actionCreate(), + actionUpdate(obj1), + actionAddFieldOnCreate(), + actionAddFieldOnUpdate(obj1), + actionDelete(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('count action', () => { + beforeEach(async () => { + await updateCLP({ + count: { + pointerFields: ['moderators'], + }, + }); + }); + + it('should be allowed', async done => { + await logIn(user1); + + const count = await actionCount(); + expect(count).toBe(1); + done(); + }); + + it('should be limited to objects where user is listed in field', async done => { + await logIn(user2); + + const count = await actionCount(); + expect(count).toBe(2); + + done(); + }); + + it('should not allow other actions', async done => { + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionFind(), + actionCreate(), + actionUpdate(obj1), + actionAddFieldOnCreate(), + actionAddFieldOnUpdate(obj1), + actionDelete(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('update action', () => { + it('should be allowed (1 user in array)', async done => { + await updateCLP({ + update: { + pointerFields: ['owners'], + }, + }); + + await logIn(user1); + + await expectAsync(actionUpdate(obj1)).toBeResolved(); + done(); + }); + + it_id('2b19234a-a471-48b4-bd1a-27bd286d066f')(it)('should be allowed (multiple users in array)', async done => { + await updateCLP({ + update: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user2); + + await expectAsync(actionUpdate(obj1)).toBeResolved(); + done(); + }); + + it_id('1abb9f4a-fb24-48c7-8025-3001d6cf8737')(it)('should fail for user not listed', async done => { + await updateCLP({ + update: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user2); + + await expectAsync(actionUpdate(obj3)).toBeRejectedWith(OBJECT_NOT_FOUND); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + update: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionFind(), + actionCount(), + actionCreate(), + actionAddFieldOnCreate(), + actionAddFieldOnUpdate(obj1), + actionDelete(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('delete action', () => { + it('should be allowed (1 user in array)', async done => { + await updateCLP({ + delete: { + pointerFields: ['owners'], + }, + }); + + await logIn(user1); + + await expectAsync(actionDelete(obj1)).toBeResolved(); + done(); + }); + + it('should be allowed (multiple users in array)', async done => { + await updateCLP({ + delete: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user3); + + await expectAsync(actionDelete(obj2)).toBeResolved(); + done(); + }); + + it_id('3175a0e3-e51e-4b84-a2e6-50bbcc582123')(it)('should fail for user not listed', async done => { + await updateCLP({ + delete: { + pointerFields: ['owners'], + }, + }); + + await logIn(user1); + + await expectAsync(actionDelete(obj3)).toBeRejectedWith(OBJECT_NOT_FOUND); + done(); + }); + + it('should not allow other actions', async done => { + await updateCLP({ + delete: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + await Promise.all( + [ + actionGet(obj1.id), + actionFind(), + actionCount(), + actionCreate(), + actionUpdate(obj1), + actionAddFieldOnCreate(), + actionAddFieldOnUpdate(obj1), + ].map(async p => { + await expectAsync(p).toBeRejectedWith(PERMISSION_DENIED); + }) + ); + done(); + }); + }); + + describe('create action', () => { + /* For Pointer permissions 'create' is different from other operations + since there's no object holding the pointer before created */ + it('should be denied (writelock) when no other permissions on class', async done => { + await updateCLP({ + create: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + await expectAsync(actionCreate()).toBeRejectedWith(PERMISSION_DENIED); + done(); + }); + }); + + describe('addField action', () => { + it('should have no effect on create (allowed by explicit userid)', async done => { + await updateCLP({ + create: { + '*': true, + }, + addField: { + [user1.id]: true, + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + await expectAsync(actionAddFieldOnCreate()).toBeResolved(); + done(); + }); + + it('should be denied when creating object (and no explicit permission)', async done => { + await updateCLP({ + create: { + '*': true, + }, + addField: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + const newObject = new Parse.Object(className, { + moderators: user1, + extra: 'field', + }); + await expectAsync(newObject.save()).toBeRejectedWith(PERMISSION_DENIED); + done(); + }); + + it('should be allowed when updating object', async done => { + await updateCLP({ + update: { + '*': true, + }, + addField: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user2); + + await expectAsync(actionAddFieldOnUpdate(obj1)).toBeResolved(); + + done(); + }); + + it_id('51e896e9-73b3-404f-b5ff-bdb99005a9f7')(it)('should be restricted when updating object without addField permission', async done => { + await updateCLP({ + update: { + '*': true, + }, + addField: { + pointerFields: ['moderators'], + }, + }); + + await logIn(user1); + + await expectAsync(actionAddFieldOnUpdate(obj2)).toBeRejectedWith(OBJECT_NOT_FOUND); + + done(); + }); + }); + }); + + describe('combined with grouped', () => { + /** + * owns: **obj1** + * + * moderates: **obj2** */ + let user1; + + /** + * owns: **obj2** + * + * moderates: **obj1, obj2** */ + let user2; + + /** + * owned by: **user1** + * + * moderated by: **user2** */ + let obj1; + + /** + * owned by: **user2** + * + * moderated by: **user1, user2** */ + let obj2; + + async function initialize() { + await Config.get(Parse.applicationId).schemaCache.clear(); + + [user1, user2] = await Promise.all([createUser('user1'), createUser('user2')]); + + // User1 owns object1 + // User2 owns object2 + obj1 = new Parse.Object(className, { + owner: user1, + moderators: [user2], + revision: 0, + }); + + obj2 = new Parse.Object(className, { + owner: user2, + moderators: [user1, user2], + revision: 0, + }); + + await Parse.Object.saveAll([obj1, obj2], { + useMasterKey: true, + }); + } + + beforeEach(async () => { + await initialize(); + }); + + it_id('b43db366-8cce-4a11-9cf2-eeee9603d40b')(it)('should not limit the scope of grouped read permissions', async done => { + await updateCLP({ + get: { + pointerFields: ['owner'], + }, + readUserFields: ['moderators'], + }); + + await logIn(user2); + + await expectAsync(actionGet(obj1.id)).toBeResolved(); + + const found = await actionFind(); + expect(found.length).toBe(2); + + const counted = await actionCount(); + expect(counted).toBe(2); + + done(); + }); + + it_id('bbb1686d-0e2a-4365-8b64-b5faa3e7b9cf')(it)('should not limit the scope of grouped write permissions', async done => { + await updateCLP({ + update: { + pointerFields: ['owner'], + }, + writeUserFields: ['moderators'], + }); + + await logIn(user2); + + await expectAsync(actionUpdate(obj1)).toBeResolved(); + await expectAsync(actionAddFieldOnUpdate(obj1)).toBeResolved(); + await expectAsync(actionDelete(obj1)).toBeResolved(); + // [create] and [addField on create] can't be enabled with pointer by design + + done(); + }); + + it('should not inherit scope of grouped read permissions from another field', async done => { + await updateCLP({ + get: { + pointerFields: ['owner'], + }, + readUserFields: ['moderators'], + }); + + await logIn(user1); + + const found = await actionFind(); + expect(found.length).toBe(1); + + const counted = await actionCount(); + expect(counted).toBe(1); + + done(); + }); + + it('should not inherit scope of grouped write permissions from another field', async done => { + await updateCLP({ + update: { + pointerFields: ['moderators'], + }, + writeUserFields: ['owner'], + }); + + await logIn(user1); + + await expectAsync(actionDelete(obj2)).toBeRejectedWith(OBJECT_NOT_FOUND); + + done(); + }); + }); + + describe('using pointer-fields and queries with keys projection', () => { + let user1; + /** + * owner: user1 + * + * testers: [user1] + */ + let obj; + + /** + * Clear cache, create user and object, login user + */ + async function initialize() { + await Config.get(Parse.applicationId).schemaCache.clear(); + + user1 = await createUser('user1'); + user1 = await logIn(user1); + + obj = new Parse.Object(className); + + obj.set('owner', user1); + obj.set('field', 'field'); + obj.set('test', 'test'); + + await Parse.Object.saveAll([obj], { useMasterKey: true }); + + await obj.fetch(); + } + + beforeEach(async () => { + await initialize(); + }); + + it('should be enforced regardless of pointer-field being included in keys (select)', async done => { + await updateCLP({ + get: { '*': true }, + find: { pointerFields: ['owner'] }, + update: { pointerFields: ['owner'] }, + }); + + const query = new Parse.Query('AnObject'); + query.select('field', 'test'); + + const [object] = await query.find({ objectId: obj.id }); + expect(object.get('field')).toBe('field'); + expect(object.get('test')).toBe('test'); + done(); + }); + }); + }); }); diff --git a/spec/PostgresConfigParser.spec.js b/spec/PostgresConfigParser.spec.js new file mode 100644 index 0000000000..f4efc42114 --- /dev/null +++ b/spec/PostgresConfigParser.spec.js @@ -0,0 +1,102 @@ +const parser = require('../lib/Adapters/Storage/Postgres/PostgresConfigParser'); +const fs = require('fs'); + +const queryParamTests = { + 'a=1&b=2': { a: '1', b: '2' }, + 'a=abcd%20efgh&b=abcd%3Defgh': { a: 'abcd efgh', b: 'abcd=efgh' }, + 'a=1&b&c=true': { a: '1', b: '', c: 'true' }, +}; + +describe('PostgresConfigParser.parseQueryParams', () => { + it('creates a map from a query string', () => { + for (const key in queryParamTests) { + const result = parser.parseQueryParams(key); + + const testObj = queryParamTests[key]; + + expect(Object.keys(result).length).toEqual(Object.keys(testObj).length); + + for (const k in result) { + expect(result[k]).toEqual(testObj[k]); + } + } + }); +}); + +const baseURI = 'postgres://username:password@localhost:5432/db-name'; +const testfile = fs.readFileSync('./Dockerfile').toString(); +const dbOptionsTest = {}; +dbOptionsTest[ + `${baseURI}?ssl=true&binary=true&application_name=app_name&fallback_application_name=f_app_name&poolSize=12` +] = { + ssl: true, + binary: true, + application_name: 'app_name', + fallback_application_name: 'f_app_name', + max: 12, +}; +dbOptionsTest[`${baseURI}?ssl=&binary=aa`] = { + binary: false, +}; +dbOptionsTest[ + `${baseURI}?ssl=true&ca=./Dockerfile&pfx=./Dockerfile&cert=./Dockerfile&key=./Dockerfile&binary=aa&passphrase=word&secureOptions=20` +] = { + ssl: { + ca: testfile, + pfx: testfile, + cert: testfile, + key: testfile, + passphrase: 'word', + secureOptions: 20, + }, + binary: false, +}; +dbOptionsTest[ + `${baseURI}?ssl=false&ca=./Dockerfile&pfx=./Dockerfile&cert=./Dockerfile&key=./Dockerfile&binary=aa` +] = { + ssl: { ca: testfile, pfx: testfile, cert: testfile, key: testfile }, + binary: false, +}; +dbOptionsTest[`${baseURI}?rejectUnauthorized=true`] = { + ssl: { rejectUnauthorized: true }, +}; +dbOptionsTest[`${baseURI}?max=5&query_timeout=100&idleTimeoutMillis=1000&keepAlive=true`] = { + max: 5, + query_timeout: 100, + idleTimeoutMillis: 1000, + keepAlive: true, +}; + +describe('PostgresConfigParser.getDatabaseOptionsFromURI', () => { + it('creates a db options map from a query string', () => { + for (const key in dbOptionsTest) { + const result = parser.getDatabaseOptionsFromURI(key); + + const testObj = dbOptionsTest[key]; + + for (const k in testObj) { + expect(result[k]).toEqual(testObj[k]); + } + } + }); + + it('sets the poolSize to 10 if the it is not a number', () => { + const result = parser.getDatabaseOptionsFromURI(`${baseURI}?poolSize=sdf`); + + expect(result.max).toEqual(10); + }); + + it('sets the max to 10 if the it is not a number', () => { + const result = parser.getDatabaseOptionsFromURI(`${baseURI}?&max=sdf`); + + expect(result.poolSize).toBeUndefined(); + expect(result.max).toEqual(10); + }); + + it('max should take precedence over poolSize', () => { + const result = parser.getDatabaseOptionsFromURI(`${baseURI}?poolSize=20&max=12`); + + expect(result.poolSize).toBeUndefined(); + expect(result.max).toEqual(12); + }); +}); diff --git a/spec/PostgresInitOptions.spec.js b/spec/PostgresInitOptions.spec.js new file mode 100644 index 0000000000..1e3282ad77 --- /dev/null +++ b/spec/PostgresInitOptions.spec.js @@ -0,0 +1,78 @@ +const Parse = require('parse/node').Parse; +const PostgresStorageAdapter = require('../lib/Adapters/Storage/Postgres/PostgresStorageAdapter') + .default; +const postgresURI = + process.env.PARSE_SERVER_TEST_DATABASE_URI || + 'postgres://localhost:5432/parse_server_postgres_adapter_test_database'; + +//public schema +const databaseOptions1 = { + initOptions: { + schema: 'public', + }, +}; + +//not exists schema +const databaseOptions2 = { + initOptions: { + schema: 'not_exists_schema', + }, +}; + +const GameScore = Parse.Object.extend({ + className: 'GameScore', +}); + +describe_only_db('postgres')('Postgres database init options', () => { + it('should create server with public schema databaseOptions', async () => { + const adapter = new PostgresStorageAdapter({ + uri: postgresURI, + collectionPrefix: 'test_', + databaseOptions: databaseOptions1, + }); + await reconfigureServer({ + databaseAdapter: adapter, + }); + const score = new GameScore({ + score: 1337, + playerName: 'Sean Plott', + cheatMode: false, + }); + await score.save(); + }); + + it('should create server using postgresql uri with public schema databaseOptions', async () => { + const postgresURI2 = new URL(postgresURI); + postgresURI2.protocol = 'postgresql:'; + const adapter = new PostgresStorageAdapter({ + uri: postgresURI2.toString(), + collectionPrefix: 'test_', + databaseOptions: databaseOptions1, + }); + await reconfigureServer({ + databaseAdapter: adapter, + }); + const score = new GameScore({ + score: 1337, + playerName: 'Sean Plott', + cheatMode: false, + }); + await score.save(); + }); + + it('should fail to create server if schema databaseOptions does not exist', async () => { + const adapter = new PostgresStorageAdapter({ + uri: postgresURI, + collectionPrefix: 'test_', + databaseOptions: databaseOptions2, + }); + try { + await reconfigureServer({ + databaseAdapter: adapter, + }); + fail('Should have thrown error'); + } catch (error) { + expect(error).toBeDefined(); + } + }); +}); diff --git a/spec/PostgresStorageAdapter.spec.js b/spec/PostgresStorageAdapter.spec.js new file mode 100644 index 0000000000..9c0c5c49ce --- /dev/null +++ b/spec/PostgresStorageAdapter.spec.js @@ -0,0 +1,801 @@ +const PostgresStorageAdapter = require('../lib/Adapters/Storage/Postgres/PostgresStorageAdapter') + .default; +const databaseURI = + process.env.PARSE_SERVER_TEST_DATABASE_URI || + 'postgres://localhost:5432/parse_server_postgres_adapter_test_database'; +const Config = require('../lib/Config'); + +const getColumns = (client, className) => { + return client.map( + 'SELECT column_name FROM information_schema.columns WHERE table_name = $', + { className }, + a => a.column_name + ); +}; + +const dropTable = (client, className) => { + return client.none('DROP TABLE IF EXISTS $', { className }); +}; + +describe_only_db('postgres')('PostgresStorageAdapter', () => { + let adapter; + beforeEach(async () => { + const config = Config.get('test'); + adapter = config.database.adapter; + }); + + it('schemaUpgrade, upgrade the database schema when schema changes', async done => { + await adapter.deleteAllClasses(); + const config = Config.get('test'); + config.schemaCache.clear(); + await adapter.performInitialization({ VolatileClassesSchemas: [] }); + const client = adapter._client; + const className = '_PushStatus'; + const schema = { + fields: { + pushTime: { type: 'String' }, + source: { type: 'String' }, + query: { type: 'String' }, + }, + }; + + adapter + .createTable(className, schema) + .then(() => getColumns(client, className)) + .then(columns => { + expect(columns).toContain('pushTime'); + expect(columns).toContain('source'); + expect(columns).toContain('query'); + expect(columns).not.toContain('expiration_interval'); + + schema.fields.expiration_interval = { type: 'Number' }; + return adapter.schemaUpgrade(className, schema); + }) + .then(() => getColumns(client, className)) + .then(async columns => { + expect(columns).toContain('pushTime'); + expect(columns).toContain('source'); + expect(columns).toContain('query'); + expect(columns).toContain('expiration_interval'); + await reconfigureServer(); + done(); + }) + .catch(error => done.fail(error)); + }); + + it('schemaUpgrade, maintain correct schema', done => { + const client = adapter._client; + const className = 'Table'; + const schema = { + fields: { + columnA: { type: 'String' }, + columnB: { type: 'String' }, + columnC: { type: 'String' }, + }, + }; + + adapter + .createTable(className, schema) + .then(() => getColumns(client, className)) + .then(columns => { + expect(columns).toContain('columnA'); + expect(columns).toContain('columnB'); + expect(columns).toContain('columnC'); + + return adapter.schemaUpgrade(className, schema); + }) + .then(() => getColumns(client, className)) + .then(columns => { + expect(columns.length).toEqual(3); + expect(columns).toContain('columnA'); + expect(columns).toContain('columnB'); + expect(columns).toContain('columnC'); + + done(); + }) + .catch(error => done.fail(error)); + }); + + it('Create a table without columns and upgrade with columns', done => { + const client = adapter._client; + const className = 'EmptyTable'; + dropTable(client, className) + .then(() => adapter.createTable(className, {})) + .then(() => getColumns(client, className)) + .then(columns => { + expect(columns.length).toBe(0); + + const newSchema = { + fields: { + columnA: { type: 'String' }, + columnB: { type: 'String' }, + }, + }; + + return adapter.schemaUpgrade(className, newSchema); + }) + .then(() => getColumns(client, className)) + .then(columns => { + expect(columns.length).toEqual(2); + expect(columns).toContain('columnA'); + expect(columns).toContain('columnB'); + done(); + }) + .catch(done); + }); + + it('getClass if exists', async () => { + const schema = { + fields: { + array: { type: 'Array' }, + object: { type: 'Object' }, + date: { type: 'Date' }, + }, + }; + await adapter.createClass('MyClass', schema); + const myClassSchema = await adapter.getClass('MyClass'); + expect(myClassSchema).toBeDefined(); + }); + + it('getClass if not exists', async () => { + const schema = { + fields: { + array: { type: 'Array' }, + object: { type: 'Object' }, + date: { type: 'Date' }, + }, + }; + await adapter.createClass('MyClass', schema); + await expectAsync(adapter.getClass('UnknownClass')).toBeRejectedWith(undefined); + }); + + it('$relativeTime should error on $eq', async () => { + const tableName = '_User'; + const schema = { + fields: { + objectId: { type: 'String' }, + username: { type: 'String' }, + email: { type: 'String' }, + emailVerified: { type: 'Boolean' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + authData: { type: 'Object' }, + }, + }; + const client = adapter._client; + await adapter.createTable(tableName, schema); + await client.none('INSERT INTO $1:name ($2:name, $3:name) VALUES ($4, $5)', [ + tableName, + 'objectId', + 'username', + 'Bugs', + 'Bunny', + ]); + const database = Config.get(Parse.applicationId).database; + await database.loadSchema({ clearCache: true }); + try { + await database.find( + tableName, + { + createdAt: { + $eq: { + $relativeTime: '12 days ago', + }, + }, + }, + {} + ); + fail('Should have thrown error'); + } catch (error) { + expect(error.code).toBe(Parse.Error.INVALID_JSON); + } + await dropTable(client, tableName); + }); + + it('$relativeTime should error on $ne', async () => { + const tableName = '_User'; + const schema = { + fields: { + objectId: { type: 'String' }, + username: { type: 'String' }, + email: { type: 'String' }, + emailVerified: { type: 'Boolean' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + authData: { type: 'Object' }, + }, + }; + const client = adapter._client; + await adapter.createTable(tableName, schema); + await client.none('INSERT INTO $1:name ($2:name, $3:name) VALUES ($4, $5)', [ + tableName, + 'objectId', + 'username', + 'Bugs', + 'Bunny', + ]); + const database = Config.get(Parse.applicationId).database; + await database.loadSchema({ clearCache: true }); + try { + await database.find( + tableName, + { + createdAt: { + $ne: { + $relativeTime: '12 days ago', + }, + }, + }, + {} + ); + fail('Should have thrown error'); + } catch (error) { + expect(error.code).toBe(Parse.Error.INVALID_JSON); + } + await dropTable(client, tableName); + }); + + it('$relativeTime should error on $exists', async () => { + const tableName = '_User'; + const schema = { + fields: { + objectId: { type: 'String' }, + username: { type: 'String' }, + email: { type: 'String' }, + emailVerified: { type: 'Boolean' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + authData: { type: 'Object' }, + }, + }; + const client = adapter._client; + await adapter.createTable(tableName, schema); + await client.none('INSERT INTO $1:name ($2:name, $3:name) VALUES ($4, $5)', [ + tableName, + 'objectId', + 'username', + 'Bugs', + 'Bunny', + ]); + const database = Config.get(Parse.applicationId).database; + await database.loadSchema({ clearCache: true }); + try { + await database.find( + tableName, + { + createdAt: { + $exists: { + $relativeTime: '12 days ago', + }, + }, + }, + {} + ); + fail('Should have thrown error'); + } catch (error) { + expect(error.code).toBe(Parse.Error.INVALID_JSON); + } + await dropTable(client, tableName); + }); + + it('should use index for caseInsensitive query using Postgres', async () => { + const tableName = '_User'; + const schema = { + fields: { + objectId: { type: 'String' }, + username: { type: 'String' }, + email: { type: 'String' }, + emailVerified: { type: 'Boolean' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + authData: { type: 'Object' }, + }, + }; + const client = adapter._client; + await adapter.createTable(tableName, schema); + await client.none('INSERT INTO $1:name ($2:name, $3:name) VALUES ($4, $5)', [ + tableName, + 'objectId', + 'username', + 'Bugs', + 'Bunny', + ]); + //Postgres won't take advantage of the index until it has a lot of records because sequential is faster for small db's + await client.none( + 'INSERT INTO $1:name ($2:name, $3:name) SELECT gen_random_uuid(), gen_random_uuid() FROM generate_series(1,5000)', + [tableName, 'objectId', 'username'] + ); + const caseInsensitiveData = 'bugs'; + const originalQuery = 'SELECT * FROM $1:name WHERE lower($2:name)=lower($3)'; + const analyzedExplainQuery = adapter.createExplainableQuery(originalQuery, true); + const preIndexPlan = await client.one(analyzedExplainQuery, [ + tableName, + 'objectId', + caseInsensitiveData, + ]); + preIndexPlan['QUERY PLAN'].forEach(element => { + //Make sure search returned with only 1 result + expect(element.Plan['Actual Rows']).toBe(1); + expect(element.Plan['Node Type']).toBe('Seq Scan'); + }); + const indexName = 'test_case_insensitive_column'; + await adapter.ensureIndex(tableName, schema, ['objectId'], indexName, true); + + const postIndexPlan = await client.one(analyzedExplainQuery, [ + tableName, + 'objectId', + caseInsensitiveData, + ]); + postIndexPlan['QUERY PLAN'].forEach(element => { + //Make sure search returned with only 1 result + expect(element.Plan['Actual Rows']).toBe(1); + //Should not be a sequential scan + expect(element.Plan['Node Type']).not.toContain('Seq Scan'); + + //Should be using the index created for this + element.Plan.Plans.forEach(innerElement => { + expect(innerElement['Index Name']).toBe(indexName); + }); + }); + + //These are the same query so should be the same size + for (let i = 0; i < preIndexPlan['QUERY PLAN'].length; i++) { + //Sequential should take more time to execute than indexed + expect(preIndexPlan['QUERY PLAN'][i]['Execution Time']).toBeGreaterThan( + postIndexPlan['QUERY PLAN'][i]['Execution Time'] + ); + } + //Test explaining without analyzing + const basicExplainQuery = adapter.createExplainableQuery(originalQuery); + const explained = await client.one(basicExplainQuery, [ + tableName, + 'objectId', + caseInsensitiveData, + ]); + explained['QUERY PLAN'].forEach(element => { + //Check that basic query plans isn't a sequential scan + expect(element.Plan['Node Type']).not.toContain('Seq Scan'); + + //Basic query plans shouldn't have an execution time + expect(element['Execution Time']).toBeUndefined(); + }); + await dropTable(client, tableName); + }); + + it('should use index for caseInsensitive query with user', async () => { + await adapter.deleteAllClasses(); + const config = Config.get('test'); + config.schemaCache.clear(); + await adapter.performInitialization({ VolatileClassesSchemas: [] }); + + const database = Config.get(Parse.applicationId).database; + await database.loadSchema({ clearCache: true }); + const tableName = '_User'; + + const user = new Parse.User(); + user.set('username', 'Elmer'); + user.set('password', 'Fudd'); + await user.signUp(); + + //Postgres won't take advantage of the index until it has a lot of records because sequential is faster for small db's + const client = adapter._client; + await client.none( + 'INSERT INTO $1:name ($2:name, $3:name) SELECT gen_random_uuid(), gen_random_uuid() FROM generate_series(1,5000)', + [tableName, 'objectId', 'username'] + ); + const caseInsensitiveData = 'elmer'; + const fieldToSearch = 'username'; + //Check using find method for Parse + const preIndexPlan = await database.find( + tableName, + { username: caseInsensitiveData }, + { caseInsensitive: true, explain: true } + ); + + preIndexPlan.forEach(element => { + element['QUERY PLAN'].forEach(innerElement => { + //Check that basic query plans isn't a sequential scan, be careful as find uses "any" to query + expect(innerElement.Plan['Node Type']).toBe('Seq Scan'); + //Basic query plans shouldn't have an execution time + expect(innerElement['Execution Time']).toBeUndefined(); + }); + }); + + const indexName = 'test_case_insensitive_column'; + const schema = await new Parse.Schema('_User').get(); + await adapter.ensureIndex(tableName, schema, [fieldToSearch], indexName, true); + + //Check using find method for Parse + const postIndexPlan = await database.find( + tableName, + { username: caseInsensitiveData }, + { caseInsensitive: true, explain: true } + ); + + postIndexPlan.forEach(element => { + element['QUERY PLAN'].forEach(innerElement => { + //Check that basic query plans isn't a sequential scan + expect(innerElement.Plan['Node Type']).not.toContain('Seq Scan'); + + //Basic query plans shouldn't have an execution time + expect(innerElement['Execution Time']).toBeUndefined(); + }); + }); + }); + + it('should use index for caseInsensitive query using default indexname', async () => { + await adapter.deleteAllClasses(); + const config = Config.get('test'); + config.schemaCache.clear(); + await adapter.performInitialization({ VolatileClassesSchemas: [] }); + + const database = Config.get(Parse.applicationId).database; + await database.loadSchema({ clearCache: true }); + const tableName = '_User'; + const user = new Parse.User(); + user.set('username', 'Tweety'); + user.set('password', 'Bird'); + await user.signUp(); + + const fieldToSearch = 'username'; + //Create index before data is inserted + const schema = await new Parse.Schema('_User').get(); + await adapter.ensureIndex(tableName, schema, [fieldToSearch], null, true); + + //Postgres won't take advantage of the index until it has a lot of records because sequential is faster for small db's + const client = adapter._client; + await client.none( + 'INSERT INTO $1:name ($2:name, $3:name) SELECT gen_random_uuid(), gen_random_uuid() FROM generate_series(1,5000)', + [tableName, 'objectId', 'username'] + ); + + const caseInsensitiveData = 'tweeTy'; + //Check using find method for Parse + const indexPlan = await database.find( + tableName, + { username: caseInsensitiveData }, + { caseInsensitive: true, explain: true } + ); + indexPlan.forEach(element => { + element['QUERY PLAN'].forEach(innerElement => { + expect(innerElement.Plan['Node Type']).not.toContain('Seq Scan'); + expect(innerElement.Plan['Index Name']).toContain('parse_default'); + }); + }); + }); + + it('should allow multiple unique indexes for same field name and different class', async () => { + const firstTableName = 'Test1'; + const firstTableSchema = new Parse.Schema(firstTableName); + const uniqueField = 'uuid'; + firstTableSchema.addString(uniqueField); + await firstTableSchema.save(); + await firstTableSchema.get(); + + const secondTableName = 'Test2'; + const secondTableSchema = new Parse.Schema(secondTableName); + secondTableSchema.addString(uniqueField); + await secondTableSchema.save(); + await secondTableSchema.get(); + + const database = Config.get(Parse.applicationId).database; + + //Create index before data is inserted + await adapter.ensureUniqueness(firstTableName, firstTableSchema, [uniqueField]); + await adapter.ensureUniqueness(secondTableName, secondTableSchema, [uniqueField]); + + //Postgres won't take advantage of the index until it has a lot of records because sequential is faster for small db's + const client = adapter._client; + await client.none( + 'INSERT INTO $1:name ($2:name, $3:name) SELECT gen_random_uuid(), gen_random_uuid() FROM generate_series(1,5000)', + [firstTableName, 'objectId', uniqueField] + ); + await client.none( + 'INSERT INTO $1:name ($2:name, $3:name) SELECT gen_random_uuid(), gen_random_uuid() FROM generate_series(1,5000)', + [secondTableName, 'objectId', uniqueField] + ); + + //Check using find method for Parse + const indexPlan = await database.find( + firstTableName, + { uuid: '1234' }, + { caseInsensitive: false, explain: true } + ); + indexPlan.forEach(element => { + element['QUERY PLAN'].forEach(innerElement => { + expect(innerElement.Plan['Node Type']).not.toContain('Seq Scan'); + expect(innerElement.Plan['Index Name']).toContain(uniqueField); + }); + }); + const indexPlan2 = await database.find( + secondTableName, + { uuid: '1234' }, + { caseInsensitive: false, explain: true } + ); + indexPlan2.forEach(element => { + element['QUERY PLAN'].forEach(innerElement => { + expect(innerElement.Plan['Node Type']).not.toContain('Seq Scan'); + expect(innerElement.Plan['Index Name']).toContain(uniqueField); + }); + }); + }); + + it('should watch _SCHEMA changes', async () => { + const enableSchemaHooks = true; + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + collectionPrefix: '', + databaseOptions: { + enableSchemaHooks, + }, + }); + const { database } = Config.get(Parse.applicationId); + const { adapter } = database; + expect(adapter.enableSchemaHooks).toBe(enableSchemaHooks); + spyOn(adapter, '_onchange'); + enableSchemaHooks; + + const otherInstance = new PostgresStorageAdapter({ + uri: databaseURI, + collectionPrefix: '', + databaseOptions: { enableSchemaHooks }, + }); + expect(otherInstance.enableSchemaHooks).toBe(enableSchemaHooks); + otherInstance._listenToSchema(); + + await otherInstance.createClass('Stuff', { + className: 'Stuff', + fields: { + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + _rperm: { type: 'Array' }, + _wperm: { type: 'Array' }, + }, + classLevelPermissions: undefined, + }); + await new Promise(resolve => setTimeout(resolve, 2000)); + expect(adapter._onchange).toHaveBeenCalled(); + }); + + it('Idempotency class should have function', async () => { + await reconfigureServer(); + const adapter = Config.get('test').database.adapter; + const client = adapter._client; + const qs = + "SELECT format('%I.%I(%s)', ns.nspname, p.proname, oidvectortypes(p.proargtypes)) FROM pg_proc p INNER JOIN pg_namespace ns ON (p.pronamespace = ns.oid) WHERE p.proname = 'idempotency_delete_expired_records'"; + const foundFunction = await client.one(qs); + expect(foundFunction.format).toBe('public.idempotency_delete_expired_records()'); + await adapter.deleteIdempotencyFunction(); + await client.none(qs); + }); +}); + +describe_only_db('postgres')('PostgresStorageAdapter shutdown', () => { + it('handleShutdown, close connection', () => { + const adapter = new PostgresStorageAdapter({ uri: databaseURI }); + expect(adapter._client.$pool.ending).toEqual(false); + adapter.handleShutdown(); + expect(adapter._client.$pool.ending).toEqual(true); + }); + + it('handleShutdown, close connection of postgresql uri', () => { + const databaseURI2 = new URL(databaseURI); + databaseURI2.protocol = 'postgresql:'; + const adapter = new PostgresStorageAdapter({ uri: databaseURI2.toString() }); + expect(adapter._client.$pool.ending).toEqual(false); + adapter.handleShutdown(); + expect(adapter._client.$pool.ending).toEqual(true); + }); +}); + +describe_only_db('postgres')('PostgresStorageAdapter Increment JSON key escaping', () => { + const request = require('../lib/request'); + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('does not inject additional JSONB keys via double-quote in sub-key name', async () => { + const obj = new Parse.Object('IncrementTest'); + obj.set('metadata', { score: 100, isAdmin: 0 }); + await obj.save(); + + // Advisory payload: sub-key `":0,"isAdmin` produces JSON `{"":0,"isAdmin":amount}` + // which would inject/overwrite the `isAdmin` key via JSONB `||` merge + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrementTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'metadata.":0,"isAdmin': { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + + const verify = await new Parse.Query('IncrementTest').get(obj.id); + // isAdmin must NOT have been changed by the injection + expect(verify.get('metadata').isAdmin).toBe(0); + // score must remain unchanged + expect(verify.get('metadata').score).toBe(100); + // No spurious empty-string key should exist + expect(verify.get('metadata')['']).toBeUndefined(); + }); + + it('does not overwrite existing JSONB keys via crafted sub-key injection', async () => { + const obj = new Parse.Object('IncrementTest'); + obj.set('metadata', { balance: 500 }); + await obj.save(); + + // Attempt to overwrite `balance` with 0 via injection, then set injected key to amount + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrementTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'metadata.":0,"balance': { __op: 'Increment', amount: 0 }, + }), + }).catch(() => {}); + + const verify = await new Parse.Query('IncrementTest').get(obj.id); + // balance must NOT have been overwritten + expect(verify.get('metadata').balance).toBe(500); + }); + + it('does not escalate write access beyond what CLP already grants', async () => { + // A user with write CLP can already overwrite any sub-key of an Object field + // directly, so the JSON key injection does not grant additional capabilities. + const schema = new Parse.Schema('IncrementCLPTest'); + schema.addObject('metadata'); + schema.setCLP({ + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + addField: {}, + }); + await schema.save(); + + const obj = new Parse.Object('IncrementCLPTest'); + obj.set('metadata', { score: 100, isAdmin: 0 }); + await obj.save(); + + // A user with write CLP can already directly overwrite any sub-key + const directResponse = await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrementCLPTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'metadata.isAdmin': { __op: 'Increment', amount: 1 }, + }), + }); + expect(directResponse.status).toBe(200); + + const afterDirect = await new Parse.Query('IncrementCLPTest').get(obj.id); + // Direct Increment already overwrites the key — no injection needed + expect(afterDirect.get('metadata').isAdmin).toBe(1); + }); + + it('does not bypass protectedFields — injection has same access as direct write', async () => { + const user = await Parse.User.signUp('protuser', 'password123'); + + const schema = new Parse.Schema('IncrementProtectedTest'); + schema.addObject('metadata'); + schema.setCLP({ + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + addField: {}, + protectedFields: { '*': ['metadata'] }, + }); + await schema.save(); + + const obj = new Parse.Object('IncrementProtectedTest'); + obj.set('metadata', { score: 100, isAdmin: 0 }); + await obj.save(null, { useMasterKey: true }); + + // Injection attempt on a protected field + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrementProtectedTest/${obj.id}`, + headers: { + ...headers, + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ + 'metadata.":0,"isAdmin': { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + + // Direct write to same protected field + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrementProtectedTest/${obj.id}`, + headers: { + ...headers, + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ + 'metadata.isAdmin': { __op: 'Increment', amount: 1 }, + }), + }); + + // Both succeed — protectedFields controls read access, not write access. + // The injection has the same access as a direct write. + const verify = await new Parse.Query('IncrementProtectedTest').get(obj.id, { useMasterKey: true }); + + // Direct write succeeded (protectedFields doesn't block writes) + expect(verify.get('metadata').isAdmin).toBeGreaterThanOrEqual(1); + + // Verify the field is indeed read-protected for the user + const userResult = await new Parse.Query('IncrementProtectedTest').get(obj.id, { sessionToken: user.getSessionToken() }); + expect(userResult.get('metadata')).toBeUndefined(); + }); + + it('rejects injection when user lacks write CLP', async () => { + const user = await Parse.User.signUp('testuser', 'password123'); + + const schema = new Parse.Schema('IncrementNoCLPTest'); + schema.addObject('metadata'); + schema.setCLP({ + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: {}, + addField: {}, + }); + await schema.save(); + + const obj = new Parse.Object('IncrementNoCLPTest'); + obj.set('metadata', { score: 100, isAdmin: 0 }); + await obj.save(null, { useMasterKey: true }); + + // Without write CLP, the injection attempt is rejected + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrementNoCLPTest/${obj.id}`, + headers: { + ...headers, + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ + 'metadata.":0,"isAdmin': { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + + const verify = await new Parse.Query('IncrementNoCLPTest').get(obj.id); + // isAdmin unchanged — CLP blocked the write + expect(verify.get('metadata').isAdmin).toBe(0); + }); + + it('rejects injection when user lacks write access via ACL', async () => { + const owner = await Parse.User.signUp('owner', 'password123'); + const attacker = await Parse.User.signUp('attacker', 'password456'); + + const obj = new Parse.Object('IncrementACLTest'); + obj.set('metadata', { score: 100, isAdmin: 0 }); + const acl = new Parse.ACL(owner); + acl.setPublicReadAccess(true); + obj.setACL(acl); + await obj.save(null, { useMasterKey: true }); + + // Attacker has public read but not write — injection attempt should fail + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrementACLTest/${obj.id}`, + headers: { + ...headers, + 'X-Parse-Session-Token': attacker.getSessionToken(), + }, + body: JSON.stringify({ + 'metadata.":0,"isAdmin': { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + + const verify = await new Parse.Query('IncrementACLTest').get(obj.id); + // isAdmin unchanged — ACL blocked the write + expect(verify.get('metadata').isAdmin).toBe(0); + }); +}); diff --git a/spec/PromiseRouter.spec.js b/spec/PromiseRouter.spec.js index 5ba68681e4..51a4ce21a1 100644 --- a/spec/PromiseRouter.spec.js +++ b/spec/PromiseRouter.spec.js @@ -1,25 +1,33 @@ -var PromiseRouter = require("../src/PromiseRouter").default; +const PromiseRouter = require('../lib/PromiseRouter').default; -describe("PromiseRouter", () => { - it("should properly handle rejects", (done) => { - var router = new PromiseRouter(); - router.route("GET", "/dummy", (req)=> { - return Promise.reject({ - error: "an error", - code: -1 - }) - }, (req) => { - fail("this should not be called"); - }); +describe('PromiseRouter', () => { + it('should properly handle rejects', done => { + const router = new PromiseRouter(); + router.route( + 'GET', + '/dummy', + () => { + return Promise.reject({ + error: 'an error', + code: -1, + }); + }, + () => { + fail('this should not be called'); + } + ); - router.routes[0].handler({}).then((result) => { - console.error(result); - fail("this should not be called"); - done(); - }, (error)=> { - expect(error.error).toEqual("an error"); - expect(error.code).toEqual(-1); - done(); - }); + router.routes[0].handler({}).then( + result => { + jfail(result); + fail('this should not be called'); + done(); + }, + error => { + expect(error.error).toEqual('an error'); + expect(error.code).toEqual(-1); + done(); + } + ); }); -}) +}); diff --git a/spec/ProtectedFields.spec.js b/spec/ProtectedFields.spec.js new file mode 100644 index 0000000000..8f9c5a5677 --- /dev/null +++ b/spec/ProtectedFields.spec.js @@ -0,0 +1,2518 @@ +const Config = require('../lib/Config'); +const Parse = require('parse/node'); +const request = require('../lib/request'); +const { className, createRole, createUser, logIn, updateCLP } = require('./support/dev'); + +describe('ProtectedFields', function () { + it('should handle and empty protectedFields', async function () { + const protectedFields = {}; + await reconfigureServer({ protectedFields }); + + const user = new Parse.User(); + user.setUsername('Alice'); + user.setPassword('sekrit'); + user.set('email', 'alice@aol.com'); + user.set('favoriteColor', 'yellow'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + user.setACL(acl); + await user.save(); + + const fetched = await new Parse.Query(Parse.User).get(user.id); + expect(fetched.has('email')).toBeFalsy(); + expect(fetched.has('favoriteColor')).toBeTruthy(); + }); + + describe('interaction with legacy userSensitiveFields', function () { + it('should fall back on sensitive fields if protected fields are not configured', async function () { + const userSensitiveFields = ['phoneNumber', 'timeZone']; + + const protectedFields = { _User: { '*': ['email'] } }; + + await reconfigureServer({ userSensitiveFields, protectedFields }); + const user = new Parse.User(); + user.setUsername('Alice'); + user.setPassword('sekrit'); + user.set('email', 'alice@aol.com'); + user.set('phoneNumber', 8675309); + user.set('timeZone', 'America/Los_Angeles'); + user.set('favoriteColor', 'yellow'); + user.set('favoriteFood', 'pizza'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + user.setACL(acl); + await user.save(); + + const fetched = await new Parse.Query(Parse.User).get(user.id); + expect(fetched.has('email')).toBeFalsy(); + expect(fetched.has('phoneNumber')).toBeFalsy(); + expect(fetched.has('favoriteColor')).toBeTruthy(); + }); + + it('should merge protected and sensitive for extra safety', async function () { + const userSensitiveFields = ['phoneNumber', 'timeZone']; + + const protectedFields = { _User: { '*': ['email', 'favoriteFood'] } }; + + await reconfigureServer({ userSensitiveFields, protectedFields }); + const user = new Parse.User(); + user.setUsername('Alice'); + user.setPassword('sekrit'); + user.set('email', 'alice@aol.com'); + user.set('phoneNumber', 8675309); + user.set('timeZone', 'America/Los_Angeles'); + user.set('favoriteColor', 'yellow'); + user.set('favoriteFood', 'pizza'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + user.setACL(acl); + await user.save(); + + const fetched = await new Parse.Query(Parse.User).get(user.id); + expect(fetched.has('email')).toBeFalsy(); + expect(fetched.has('phoneNumber')).toBeFalsy(); + expect(fetched.has('favoriteFood')).toBeFalsy(); + expect(fetched.has('favoriteColor')).toBeTruthy(); + }); + }); + + describe('non user class', function () { + it('should hide fields in a non user class', async function () { + const protectedFields = { + ClassA: { '*': ['foo'] }, + ClassB: { '*': ['bar'] }, + }; + await reconfigureServer({ protectedFields }); + + const objA = await new Parse.Object('ClassA').set('foo', 'zzz').set('bar', 'yyy').save(); + + const objB = await new Parse.Object('ClassB').set('foo', 'zzz').set('bar', 'yyy').save(); + + const [fetchedA, fetchedB] = await Promise.all([ + new Parse.Query('ClassA').get(objA.id), + new Parse.Query('ClassB').get(objB.id), + ]); + + expect(fetchedA.has('foo')).toBeFalsy(); + expect(fetchedA.has('bar')).toBeTruthy(); + + expect(fetchedB.has('foo')).toBeTruthy(); + expect(fetchedB.has('bar')).toBeFalsy(); + }); + + it('should hide fields in non user class and non standard user field at same time', async function () { + const protectedFields = { + _User: { '*': ['phoneNumber'] }, + ClassA: { '*': ['foo'] }, + ClassB: { '*': ['bar'] }, + }; + + await reconfigureServer({ protectedFields }); + + const user = new Parse.User(); + user.setUsername('Alice'); + user.setPassword('sekrit'); + user.set('email', 'alice@aol.com'); + user.set('phoneNumber', 8675309); + user.set('timeZone', 'America/Los_Angeles'); + user.set('favoriteColor', 'yellow'); + user.set('favoriteFood', 'pizza'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + user.setACL(acl); + await user.save(); + + const objA = await new Parse.Object('ClassA').set('foo', 'zzz').set('bar', 'yyy').save(); + + const objB = await new Parse.Object('ClassB').set('foo', 'zzz').set('bar', 'yyy').save(); + + const [fetchedUser, fetchedA, fetchedB] = await Promise.all([ + new Parse.Query(Parse.User).get(user.id), + new Parse.Query('ClassA').get(objA.id), + new Parse.Query('ClassB').get(objB.id), + ]); + + expect(fetchedA.has('foo')).toBeFalsy(); + expect(fetchedA.has('bar')).toBeTruthy(); + + expect(fetchedB.has('foo')).toBeTruthy(); + expect(fetchedB.has('bar')).toBeFalsy(); + + expect(fetchedUser.has('email')).toBeFalsy(); + expect(fetchedUser.has('phoneNumber')).toBeFalsy(); + expect(fetchedUser.has('favoriteColor')).toBeTruthy(); + }); + }); + + describe('using the pointer-permission variant', () => { + let user1, user2; + beforeEach(async () => { + Config.get(Parse.applicationId).schemaCache.clear(); + user1 = await Parse.User.signUp('user1', 'password'); + user2 = await Parse.User.signUp('user2', 'password'); + await Parse.User.logOut(); + }); + + describe('and get/fetch', () => { + it('should allow access using single user pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owner', user1); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owner'], 'userField:owner': [] }, + } + ); + + await Parse.User.logIn('user1', 'password'); + const objectAgain = await obj.fetch(); + expect(objectAgain.get('owner').id).toBe(user1.id); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + + it('should deny access to other users using single user pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owner', user1); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owner'], 'userField:owner': [] }, + } + ); + + await Parse.User.logIn('user2', 'password'); + const objectAgain = await obj.fetch(); + expect(objectAgain.get('owner')).toBe(undefined); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + + it('should deny access to public using single user pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owner', user1); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owner'], 'userField:owner': [] }, + } + ); + + const objectAgain = await obj.fetch(); + expect(objectAgain.get('owner')).toBe(undefined); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + + it('should allow access using user array pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owners', [user1, user2]); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owners'], 'userField:owners': [] }, + } + ); + + await Parse.User.logIn('user1', 'password'); + let objectAgain = await obj.fetch(); + expect(objectAgain.get('owners')[0].id).toBe(user1.id); + expect(objectAgain.get('test')).toBe('test'); + await Parse.User.logIn('user2', 'password'); + objectAgain = await obj.fetch(); + expect(objectAgain.get('owners')[1].id).toBe(user2.id); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + + it('should deny access to other users using user array pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owners', [user1]); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owners'], 'userField:owners': [] }, + } + ); + + await Parse.User.logIn('user2', 'password'); + const objectAgain = await obj.fetch(); + expect(objectAgain.get('owners')).toBe(undefined); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + + it('should deny access to public using user array pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owners', [user1, user2]); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owners'], 'userField:owners': [] }, + } + ); + + const objectAgain = await obj.fetch(); + expect(objectAgain.get('owners')).toBe(undefined); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + + it('should intersect protected fields when using multiple pointer-permission fields', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owners', [user1]); + obj.set('owner', user1); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['owners', 'owner', 'test'], + 'userField:owners': ['owners', 'owner'], + 'userField:owner': ['owner'], + }, + } + ); + + // Check if protectFields from pointer-permissions got combined + await Parse.User.logIn('user1', 'password'); + const objectAgain = await obj.fetch(); + expect(objectAgain.get('owners').length).toBe(1); + expect(objectAgain.get('owner')).toBe(undefined); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + + it('should ignore pointer-permission fields not present in object', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + + obj.set('owners', [user1]); + obj.set('owner', user1); + obj.set('test', 'test'); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': [], + 'userField:idontexist': ['owner'], + 'userField:idontexist2': ['owners'], + }, + } + ); + + await Parse.User.logIn('user1', 'password'); + const objectAgain = await obj.fetch(); + expect(objectAgain.get('owners')).not.toBe(undefined); + expect(objectAgain.get('owner')).not.toBe(undefined); + expect(objectAgain.get('test')).toBe('test'); + done(); + }); + }); + + describe('and find', () => { + it('should allow access using single user pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owner', user1); + obj.set('test', 'test'); + obj2.set('owner', user1); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owner'], 'userField:owner': [] }, + } + ); + + await Parse.User.logIn('user1', 'password'); + + const q = new Parse.Query('AnObject'); + const results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owner').id).toBe(user1.id); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owner').id).toBe(user1.id); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should deny access to other users using single user pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owner', user1); + obj.set('test', 'test'); + obj2.set('owner', user1); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owner'], 'userField:owner': [] }, + } + ); + + await Parse.User.logIn('user2', 'password'); + const q = new Parse.Query('AnObject'); + const results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owner')).toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owner')).toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should deny access to public using single user pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owner', user1); + obj.set('test', 'test'); + obj2.set('owner', user1); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owner'], 'userField:owner': [] }, + } + ); + + const q = new Parse.Query('AnObject'); + const results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owner')).toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owner')).toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should allow access using user array pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owners', [user1, user2]); + obj.set('test', 'test'); + obj2.set('owners', [user1, user2]); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owners'], 'userField:owners': [] }, + } + ); + + const q = new Parse.Query('AnObject'); + let results; + + await Parse.User.logIn('user1', 'password'); + results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owners')[0].id).toBe(user1.id); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owners')[0].id).toBe(user1.id); + expect(results[1].get('test')).toBe('test2'); + + await Parse.User.logIn('user2', 'password'); + results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owners')[1].id).toBe(user2.id); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owners')[1].id).toBe(user2.id); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should deny access to other users using user array pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owners', [user1]); + obj.set('test', 'test'); + obj2.set('owners', [user1]); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owners'], 'userField:owners': [] }, + } + ); + + await Parse.User.logIn('user2', 'password'); + const q = new Parse.Query('AnObject'); + const results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owners')).toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owners')).toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should deny access to public using user array pointer-permissions', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owners', [user1, user2]); + obj.set('test', 'test'); + obj2.set('owners', [user1, user2]); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { '*': ['owners'], 'userField:owners': [] }, + } + ); + + const q = new Parse.Query('AnObject'); + const results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owners')).toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owners')).toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should intersect protected fields when using multiple pointer-permission fields', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owners', [user1]); + obj.set('owner', user1); + obj.set('test', 'test'); + obj2.set('owners', [user1]); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['owners', 'owner', 'test'], + 'userField:owners': ['owners', 'owner'], + 'userField:owner': ['owner'], + }, + } + ); + + // Check if protectFields from pointer-permissions got combined + await Parse.User.logIn('user1', 'password'); + + const q = new Parse.Query('AnObject'); + const results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owners').length).toBe(1); + expect(results[0].get('owner')).toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owners')).toBe(undefined); + expect(results[1].get('owner')).toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should ignore pointer-permission fields not present in object', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + + obj.set('owners', [user1]); + obj.set('owner', user1); + obj.set('test', 'test'); + obj2.set('owners', [user1]); + obj2.set('owner', user1); + obj2.set('test', 'test2'); + await Parse.Object.saveAll([obj, obj2]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': [], + 'userField:idontexist': ['owner'], + 'userField:idontexist2': ['owners'], + }, + } + ); + + await Parse.User.logIn('user1', 'password'); + + const q = new Parse.Query('AnObject'); + const results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(2); + + expect(results[0].get('owners')).not.toBe(undefined); + expect(results[0].get('owner')).not.toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owners')).not.toBe(undefined); + expect(results[1].get('owner')).not.toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + done(); + }); + + it('should filter only fields from objects not owned by the user', async done => { + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('AnObject'); + const obj2 = new Parse.Object('AnObject'); + const obj3 = new Parse.Object('AnObject'); + + obj.set('owner', user1); + obj.set('test', 'test'); + obj2.set('owner', user2); + obj2.set('test', 'test2'); + obj3.set('owner', user2); + obj3.set('test', 'test3'); + await Parse.Object.saveAll([obj, obj2, obj3]); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'AnObject', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['owner'], + 'userField:owner': [], + }, + } + ); + + const q = new Parse.Query('AnObject'); + let results; + + await Parse.User.logIn('user1', 'password'); + + results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(3); + + expect(results[0].get('owner')).not.toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owner')).toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + expect(results[2].get('owner')).toBe(undefined); + expect(results[2].get('test')).toBe('test3'); + + await Parse.User.logIn('user2', 'password'); + + results = await q.find(); + // sort for checking in correct order + results.sort((a, b) => a.get('test').localeCompare(b.get('test'))); + expect(results.length).toBe(3); + + expect(results[0].get('owner')).toBe(undefined); + expect(results[0].get('test')).toBe('test'); + expect(results[1].get('owner')).not.toBe(undefined); + expect(results[1].get('test')).toBe('test2'); + expect(results[2].get('owner')).not.toBe(undefined); + expect(results[2].get('test')).toBe('test3'); + done(); + }); + }); + }); + + describe('schema setup', () => { + let object; + + async function initialize() { + await Config.get(Parse.applicationId).schemaCache.clear(); + + object = new Parse.Object(className); + + object.set('revision', 0); + object.set('test', 'test'); + + await object.save(null, { useMasterKey: true }); + } + + beforeEach(async () => { + await initialize(); + }); + + it('should fail setting non-existing protected field', async done => { + const field = 'non-existing'; + const entity = '*'; + + await expectAsync( + updateCLP({ + protectedFields: { + [entity]: [field], + }, + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_JSON, + `Field '${field}' in protectedFields:${entity} does not exist` + ) + ); + done(); + }); + + it('should allow setting authenticated', async () => { + await expectAsync( + updateCLP({ + protectedFields: { + authenticated: ['test'], + }, + }) + ).toBeResolved(); + }); + + it('should not allow protecting default fields', async () => { + const defaultFields = ['objectId', 'createdAt', 'updatedAt', 'ACL']; + for (const field of defaultFields) { + await expectAsync( + updateCLP({ + protectedFields: { + '*': [field], + }, + }) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.INVALID_JSON, `Default field '${field}' can not be protected`) + ); + } + }); + }); + + describe('targeting public access', () => { + let obj1; + + async function initialize() { + await Config.get(Parse.applicationId).schemaCache.clear(); + + obj1 = new Parse.Object(className); + + obj1.set('foo', 'foo'); + obj1.set('bar', 'bar'); + obj1.set('qux', 'qux'); + + await obj1.save(null, { + useMasterKey: true, + }); + } + + beforeEach(async () => { + await initialize(); + }); + + it('should hide field', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['foo'], + }, + }); + + // unauthenticated + const object = await obj1.fetch(); + + expect(object.get('foo')).toBe(undefined); + expect(object.get('bar')).toBeDefined(); + expect(object.get('qux')).toBeDefined(); + + done(); + }); + + it('should hide mutiple fields', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['foo', 'bar'], + }, + }); + + // unauthenticated + const object = await obj1.fetch(); + + expect(object.get('foo')).toBe(undefined); + expect(object.get('bar')).toBe(undefined); + expect(object.get('qux')).toBeDefined(); + + done(); + }); + + it('should not hide any fields when set as empty array', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': [], + }, + }); + + // unauthenticated + const object = await obj1.fetch(); + + expect(object.get('foo')).toBeDefined(); + expect(object.get('bar')).toBeDefined(); + expect(object.get('qux')).toBeDefined(); + expect(object.id).toBeDefined(); + expect(object.createdAt).toBeDefined(); + expect(object.updatedAt).toBeDefined(); + expect(object.getACL()).toBeDefined(); + + done(); + }); + }); + + describe('targeting authenticated', () => { + /** + * is **owner** of: _obj1_ + * + * is **tester** of: [ _obj1, obj2_ ] + */ + let user1; + + /** + * is **owner** of: _obj2_ + * + * is **tester** of: [ _obj1_ ] + */ + let user2; + + /** + * **owner**: _user1_ + * + * **testers**: [ _user1,user2_ ] + */ + let obj1; + + /** + * **owner**: _user2_ + * + * **testers**: [ _user1_ ] + */ + let obj2; + + async function initialize() { + await Config.get(Parse.applicationId).schemaCache.clear(); + + await Parse.User.logOut(); + + [user1, user2] = await Promise.all([createUser('user1'), createUser('user2')]); + + obj1 = new Parse.Object(className); + obj2 = new Parse.Object(className); + + obj1.set('owner', user1); + obj1.set('testers', [user1, user2]); + obj1.set('test', 'test'); + + obj2.set('owner', user2); + obj2.set('testers', [user1]); + obj2.set('test', 'test'); + + await Parse.Object.saveAll([obj1, obj2], { + useMasterKey: true, + }); + } + + beforeEach(async () => { + await initialize(); + }); + + it('should not hide any fields when set as empty array', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + authenticated: [], + }, + }); + + // authenticated + await logIn(user1); + + const object = await obj1.fetch(); + + expect(object.get('owner')).toBeDefined(); + expect(object.get('testers')).toBeDefined(); + expect(object.get('test')).toBeDefined(); + expect(object.id).toBeDefined(); + expect(object.createdAt).toBeDefined(); + expect(object.updatedAt).toBeDefined(); + expect(object.getACL()).toBeDefined(); + + done(); + }); + + it('should hide fields for authenticated users only (* not set)', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + authenticated: ['test'], + }, + }); + + // not authenticated + const objectNonAuth = await obj1.fetch(); + + expect(objectNonAuth.get('test')).toBeDefined(); + + // authenticated + await logIn(user1); + const object = await obj1.fetch(); + + expect(object.get('test')).toBe(undefined); + + done(); + }); + + it('should intersect public and auth for authenticated user', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['owner', 'testers'], + authenticated: ['testers'], + }, + }); + + // authenticated + await logIn(user1); + const objectAuth = await obj1.fetch(); + + // ( {A,B} intersect {B} ) == {B} + + expect(objectAuth.get('testers')).not.toBeDefined( + 'Should not be visible - protected for * and authenticated' + ); + expect(objectAuth.get('test')).toBeDefined( + 'Should be visible - not protected for everyone (* and authenticated)' + ); + expect(objectAuth.get('owner')).toBeDefined( + 'Should be visible - not protected for authenticated' + ); + + done(); + }); + + it('should have higher prio than public for logged in users (intersect)', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['test'], + authenticated: [], + }, + }); + // authenticated, permitted + await logIn(user1); + + const object = await obj1.fetch(); + expect(object.get('test')).toBe('test'); + + done(); + }); + + it('should have no effect on unauthenticated users (public not set)', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + authenticated: ['test'], + }, + }); + + // unauthenticated, protected + const objectNonAuth = await obj1.fetch(); + expect(objectNonAuth.get('test')).toBe('test'); + + done(); + }); + + it('should protect multiple fields for authenticated users', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + authenticated: ['test', 'owner'], + }, + }); + + // authenticated + await logIn(user1); + const object = await obj1.fetch(); + + expect(object.get('test')).toBe(undefined); + expect(object.get('owner')).toBe(undefined); + + done(); + }); + + it('should not be affected by rules not applicable to user (smoke)', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + authenticated: ['owner', 'testers'], + [`role:${roleName}`]: ['test'], + 'userField:owner': [], + [user1.id]: [], + }, + }); + + // authenticated, non-owner, no role + await logIn(user2); + const objectNotOwned = await obj1.fetch(); + + expect(objectNotOwned.get('owner')).toBe(undefined); + expect(objectNotOwned.get('testers')).toBe(undefined); + expect(objectNotOwned.get('test')).toBeDefined(); + + done(); + }); + }); + + describe('targeting roles', () => { + let user1, user2; + + /** + * owner: user1 + * + * testers: [user1,user2] + */ + let obj1; + + /** + * owner: user2 + * + * testers: [user1] + */ + let obj2; + + async function initialize() { + await Config.get(Parse.applicationId).schemaCache.clear(); + + [user1, user2] = await Promise.all([createUser('user1'), createUser('user2')]); + + obj1 = new Parse.Object(className); + obj2 = new Parse.Object(className); + + obj1.set('owner', user1); + obj1.set('testers', [user1, user2]); + obj1.set('test', 'test'); + + obj2.set('owner', user2); + obj2.set('testers', [user1]); + obj2.set('test', 'test'); + + await Parse.Object.saveAll([obj1, obj2], { + useMasterKey: true, + }); + } + + beforeEach(async () => { + await initialize(); + }); + + it('should hide field when user belongs to a role', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + await updateCLP({ + protectedFields: { + [`role:${roleName}`]: ['test'], + }, + get: { '*': true }, + find: { '*': true }, + }); + + // user has role + await logIn(user1); + + const object = await obj1.fetch(); + expect(object.get('test')).toBe(undefined); // field protected + expect(object.get('owner')).toBeDefined(); + expect(object.get('testers')).toBeDefined(); + + done(); + }); + + it('should not hide any fields when set as empty array', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + await updateCLP({ + protectedFields: { + [`role:${roleName}`]: [], + }, + get: { '*': true }, + find: { '*': true }, + }); + + // user has role + await logIn(user1); + + const object = await obj1.fetch(); + + expect(object.get('owner')).toBeDefined(); + expect(object.get('testers')).toBeDefined(); + expect(object.get('test')).toBeDefined(); + expect(object.id).toBeDefined(); + expect(object.createdAt).toBeDefined(); + expect(object.updatedAt).toBeDefined(); + expect(object.getACL()).toBeDefined(); + + done(); + }); + + it('should hide multiple fields when user belongs to a role', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + [`role:${roleName}`]: ['test', 'owner'], + }, + }); + + // user has role + await logIn(user1); + + const object = await obj1.fetch(); + + expect(object.get('test')).toBe(undefined, 'Field should not be visible - protected by role'); + expect(object.get('owner')).toBe( + undefined, + 'Field should not be visible - protected by role' + ); + expect(object.get('testers')).toBeDefined(); + + done(); + }); + + it('should not protect when user does not belong to a role', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + [`role:${roleName}`]: ['test', 'owner'], + }, + }); + + // user doesn't have role + await logIn(user2); + const object = await obj1.fetch(); + + expect(object.get('test')).toBeDefined(); + expect(object.get('owner')).toBeDefined(); + expect(object.get('testers')).toBeDefined(); + + done(); + }); + + it('should intersect protected fields when user belongs to multiple roles', async done => { + const role1 = await createRole({ users: user1 }); + const role2 = await createRole({ users: user1 }); + + const role1name = role1.get('name'); + const role2name = role2.get('name'); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + [`role:${role1name}`]: ['owner'], + [`role:${role2name}`]: ['test', 'owner'], + }, + }); + + // user has both roles + await logIn(user1); + const object = await obj1.fetch(); + + // "owner" is a result of intersection + expect(object.get('owner')).toBe( + undefined, + 'Must not be visible - protected for all roles the user belongs to' + ); + expect(object.get('test')).toBeDefined( + 'Has to be visible - is not protected for users with role1' + ); + done(); + }); + + it('should intersect protected fields when user belongs to multiple roles hierarchy', async done => { + const admin = await createRole({ + users: user1, + roleName: 'admin', + }); + + const moder = await createRole({ + users: [user1, user2], + roleName: 'moder', + }); + + const tester = await createRole({ + roleName: 'tester', + }); + + // admin supersets moder role + moder.relation('roles').add(admin); + await moder.save(null, { useMasterKey: true }); + + tester.relation('roles').add(moder); + await tester.save(null, { useMasterKey: true }); + + const roleAdmin = `role:${admin.get('name')}`; + const roleModer = `role:${moder.get('name')}`; + const roleTester = `role:${tester.get('name')}`; + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + [roleAdmin]: [], + [roleModer]: ['owner'], + [roleTester]: ['test', 'owner'], + }, + }); + + // user1 has admin & moder & tester roles, (moder includes tester). + await logIn(user1); + const object = await obj1.fetch(); + + // being admin makes all fields visible + expect(object.get('test')).toBeDefined( + 'Should be visible - admin role explicitly removes protection for all fields ( [] )' + ); + expect(object.get('owner')).toBeDefined( + 'Should be visible - admin role explicitly removes protection for all fields ( [] )' + ); + + // user2 has moder & tester role, moder includes tester. + await logIn(user2); + const objectAgain = await obj1.fetch(); + + // being moder allows "test" field + expect(objectAgain.get('owner')).toBe( + undefined, + '"owner" should not be visible - protected for each role user belongs to' + ); + expect(objectAgain.get('test')).toBeDefined( + 'Should be visible - moder role does not protect "test" field' + ); + + done(); + }); + + it('should be able to clear protected fields for role (protected for authenticated)', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + authenticated: ['test'], + [`role:${roleName}`]: [], + }, + }); + + // user has role, test field visible + await logIn(user1); + const object = await obj1.fetch(); + expect(object.get('test')).toBe('test'); + + done(); + }); + + it('should determine protectedFields as intersection of field sets for public and role', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['test', 'owner'], + [`role:${roleName}`]: ['owner', 'testers'], + }, + }); + + // user has role + await logIn(user1); + + const object = await obj1.fetch(); + expect(object.get('test')).toBeDefined( + 'Should be visible - "test" is not protected for role user belongs to' + ); + expect(object.get('testers')).toBeDefined( + 'Should be visible - "testers" is allowed for everyone (*)' + ); + expect(object.get('owner')).toBe( + undefined, + 'Should not be visible - "test" is not allowed for both public(*) and role' + ); + done(); + }); + + it('should be determined as an intersection of protecedFields for authenticated and role', async done => { + const role = await createRole({ users: user1 }); + const roleName = role.get('name'); + + // this is an example of misunderstood configuration. + // If you allow (== do not restrict) some field for broader audience + // (having a role implies user inheres to 'authenticated' group) + // it's not possible to narrow by protecting field for a role. + // You'd have to protect it for 'authenticated' as well. + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + authenticated: ['test'], + [`role:${roleName}`]: ['owner'], + }, + }); + + // user has role + await logIn(user1); + const object = await obj1.fetch(); + + // + expect(object.get('test')).toBeDefined( + "Being both auhenticated and having a role leads to clearing protection on 'test' (by role rules)" + ); + expect(object.get('owner')).toBeDefined('All authenticated users allowed to see "owner"'); + expect(object.get('testers')).toBeDefined(); + + done(); + }); + + it('should not hide fields when user does not belong to a role protectedFields set for', async done => { + const role = await createRole({ users: user2 }); + const roleName = role.get('name'); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + [`role:${roleName}`]: ['test'], + }, + }); + + // relate user1 to some role, no protectedFields for it + await createRole({ users: user1 }); + + await logIn(user1); + + const object = await obj1.fetch(); + expect(object.get('test')).toBeDefined( + 'Field should be visible - user belongs to a role that has no protectedFields set' + ); + + done(); + }); + }); + + describe('using pointer-fields and queries with keys projection', () => { + /* + * Pointer variant ("userField:column") relies on User ids + * returned after query executed (hides fields before sending it to client) + * If such column is excluded/not included (not returned from db because of 'project') + * there will be no user ids to check against + * and protectedFields won't be applied correctly. + */ + + let user1; + /** + * owner: user1 + * + * testers: [user1] + */ + let obj; + + let headers; + + /** + * Clear cache, create user and object, login user and setup rest headers with token + */ + async function initialize() { + await Config.get(Parse.applicationId).schemaCache.clear(); + + user1 = await createUser('user1'); + user1 = await logIn(user1); + + // await user1.fetch(); + obj = new Parse.Object(className); + + obj.set('owner', user1); + obj.set('field', 'field'); + obj.set('test', 'test'); + + await Parse.Object.saveAll([obj], { useMasterKey: true }); + + headers = { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Rest-API-Key': 'rest', + 'Content-Type': 'application/json', + 'X-Parse-Session-Token': user1.getSessionToken(), + }; + } + + beforeEach(async () => { + await initialize(); + }); + + it('should be enforced regardless of pointer-field being included in keys (select)', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['field', 'test'], + 'userField:owner': [], + }, + }); + + const query = new Parse.Query('AnObject'); + query.select('field', 'test'); + + const object = await query.get(obj.id); + expect(object.get('field')).toBe('field'); + expect(object.get('test')).toBe('test'); + done(); + }); + + it('should protect fields for query where pointer field is not included via keys (REST GET)', async done => { + const obj = new Parse.Object(className); + + obj.set('owner', user1); + obj.set('field', 'field'); + obj.set('test', 'test'); + + await Parse.Object.saveAll([obj], { useMasterKey: true }); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['field', 'test'], + 'userField:owner': ['test'], + }, + }); + + const { data: object } = await request({ + url: `${Parse.serverURL}/classes/${className}/${obj.id}`, + qs: { + keys: 'field,test', + }, + headers: headers, + }); + + expect(object.field).toBe( + 'field', + 'Should BE in response - not protected by "userField:owner"' + ); + expect(object.test).toBe( + undefined, + 'Should NOT be in response - protected by "userField:owner"' + ); + expect(object.owner).toBe(undefined, 'Should not be in response - not included in "keys"'); + done(); + }); + + it('should protect fields for query where pointer field is not included via keys (REST FIND)', async done => { + const obj = new Parse.Object(className); + + obj.set('owner', user1); + obj.set('field', 'field'); + obj.set('test', 'test'); + + await Parse.Object.saveAll([obj], { useMasterKey: true }); + + await obj.fetch(); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['field', 'test'], + 'userField:owner': ['test'], + }, + }); + + const { data } = await request({ + url: `${Parse.serverURL}/classes/${className}`, + qs: { + keys: 'field,test', + where: JSON.stringify({ objectId: obj.id }), + }, + headers, + }); + + const object = data.results[0]; + + expect(object.field).toBe( + 'field', + 'Should be in response - not protected by "userField:owner"' + ); + expect(object.test).toBe( + undefined, + 'Should not be in response - protected by "userField:owner"' + ); + expect(object.owner).toBe(undefined, 'Should not be in response - not included in "keys"'); + done(); + }); + + it('should protect fields for query where pointer field is in excludeKeys (REST GET)', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['field', 'test'], + 'userField:owner': ['test'], + }, + }); + + const { data: object } = await request({ + qs: { + excludeKeys: 'owner', + }, + headers, + url: `${Parse.serverURL}/classes/${className}/${obj.id}`, + }); + + expect(object.field).toBe( + 'field', + 'Should be in response - not protected by "userField:owner"' + ); + expect(object['test']).toBe( + undefined, + 'Should not be in response - protected by "userField:owner"' + ); + expect(object['owner']).toBe(undefined, 'Should not be in response - not included in "keys"'); + done(); + }); + + it('should protect fields for query where pointer field is in excludedKeys (REST FIND)', async done => { + await updateCLP({ + protectedFields: { + '*': ['field', 'test'], + 'userField:owner': ['test'], + }, + get: { '*': true }, + find: { '*': true }, + }); + + const { data } = await request({ + qs: { + excludeKeys: 'owner', + where: JSON.stringify({ objectId: obj.id }), + }, + headers, + url: `${Parse.serverURL}/classes/${className}`, + }); + + const object = data.results[0]; + + expect(object.field).toBe( + 'field', + 'Should be in response - not protected by "userField:owner"' + ); + expect(object.test).toBe( + undefined, + 'Should not be in response - protected by "userField:owner"' + ); + expect(object.owner).toBe(undefined, 'Should not be in response - not included in "keys"'); + done(); + }); + + xit('todo: should be enforced regardless of pointer-field being excluded', async done => { + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + protectedFields: { + '*': ['field', 'test'], + 'userField:owner': [], + }, + }); + + const query = new Parse.Query('AnObject'); + + /* TODO: this has some caching problems on JS-SDK (2.11.) side */ + // query.exclude('owner') + + const object = await query.get(obj.id); + expect(object.get('field')).toBe('field'); + expect(object.get('test')).toBe('test'); + expect(object.get('owner')).toBe(undefined); + done(); + }); + }); + + describe('query on protected fields via logical operators', function () { + let user; + let otherUser; + const testEmail = 'victim@example.com'; + const otherEmail = 'other@example.com'; + + beforeEach(async function () { + await reconfigureServer({ + protectedFields: { + _User: { '*': ['email'] }, + }, + }); + user = new Parse.User(); + user.setUsername('victim' + Date.now()); + user.setPassword('password'); + user.setEmail(testEmail); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + user.setACL(acl); + await user.save(null, { useMasterKey: true }); + + otherUser = new Parse.User(); + otherUser.setUsername('attacker' + Date.now()); + otherUser.setPassword('password'); + otherUser.setEmail(otherEmail); + const acl2 = new Parse.ACL(); + acl2.setPublicReadAccess(true); + otherUser.setACL(acl2); + await otherUser.save(null, { useMasterKey: true }); + await Parse.User.logIn(otherUser.getUsername(), 'password'); + }); + + it('should deny query on protected field via $or', async function () { + const q1 = new Parse.Query(Parse.User); + q1.equalTo('email', testEmail); + const query = Parse.Query.or(q1); + await expectAsync(query.find()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.OPERATION_FORBIDDEN, + }) + ); + }); + + it('should deny query on protected field via $and', async function () { + const query = new Parse.Query(Parse.User); + query.withJSON({ where: { $and: [{ email: testEmail }] } }); + await expectAsync(query.find()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.OPERATION_FORBIDDEN, + }) + ); + }); + + it('should deny query on protected field via $nor', async function () { + const query = new Parse.Query(Parse.User); + query.withJSON({ where: { $nor: [{ email: testEmail }] } }); + await expectAsync(query.find()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.OPERATION_FORBIDDEN, + }) + ); + }); + + it('should deny query on protected field via nested $or inside $and', async function () { + const query = new Parse.Query(Parse.User); + query.withJSON({ where: { $and: [{ $or: [{ email: testEmail }] }] } }); + await expectAsync(query.find()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.OPERATION_FORBIDDEN, + }) + ); + }); + + it('should deny query on protected field via $or with $regex', async function () { + const query = new Parse.Query(Parse.User); + query.withJSON({ where: { $or: [{ email: { $regex: '^victim' } }] } }); + await expectAsync(query.find()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.OPERATION_FORBIDDEN, + }) + ); + }); + + it('should allow $or query on non-protected fields', async function () { + const q1 = new Parse.Query(Parse.User); + q1.equalTo('username', user.getUsername()); + const query = Parse.Query.or(q1); + const results = await query.find(); + expect(results.length).toBe(1); + expect(results[0].id).toBe(user.id); + }); + + it('should allow master key to query on protected fields via $or', async function () { + const q1 = new Parse.Query(Parse.User); + q1.equalTo('email', testEmail); + const query = Parse.Query.or(q1); + const results = await query.find({ useMasterKey: true }); + expect(results.length).toBe(1); + expect(results[0].id).toBe(user.id); + }); + + it('should deny query on protected field with falsy value', async function () { + const query = new Parse.Query(Parse.User); + query.withJSON({ where: { email: null } }); + await expectAsync(query.find()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.OPERATION_FORBIDDEN, + }) + ); + }); + + it('should deny query on protected field with falsy value via $or', async function () { + const query = new Parse.Query(Parse.User); + query.withJSON({ where: { $or: [{ email: null }] } }); + await expectAsync(query.find()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.OPERATION_FORBIDDEN, + }) + ); + }); + + it('should not throw TypeError in denyProtectedFields for null element in $or', async function () { + const Config = require('../lib/Config'); + const authModule = require('../lib/Auth'); + const RestQuery = require('../lib/RestQuery'); + const config = Config.get(Parse.applicationId); + const restQuery = await RestQuery({ + method: RestQuery.Method.find, + config, + auth: authModule.nobody(config), + className: '_User', + restWhere: { $or: [null, { username: 'test' }] }, + }); + await expectAsync(restQuery.denyProtectedFields()).toBeResolved(); + }); + }); + + describe('protectedFieldsOwnerExempt', function () { + it('owner sees own protectedFields when protectedFieldsOwnerExempt is true', async function () { + const protectedFields = { + _User: { + '*': ['phone'], + }, + }; + await reconfigureServer({ protectedFields, protectedFieldsOwnerExempt: true }); + const user1 = new Parse.User(); + user1.setUsername('user1'); + user1.setPassword('password'); + user1.set('phone', '555-1234'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + user1.setACL(acl); + await user1.signUp(); + const sessionToken1 = user1.getSessionToken(); + + // Owner fetches own object — phone should be visible + const response = await request({ + url: `http://localhost:8378/1/users/${user1.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken1, + }, + }); + expect(response.data.phone).toBe('555-1234'); + + // Another user fetches the first user — phone should be hidden + const user2 = new Parse.User(); + user2.setUsername('user2'); + user2.setPassword('password'); + await user2.signUp(); + const response2 = await request({ + url: `http://localhost:8378/1/users/${user1.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user2.getSessionToken(), + }, + }); + expect(response2.data.phone).toBeUndefined(); + }); + + it('owner does NOT see own protectedFields when protectedFieldsOwnerExempt is false', async function () { + await reconfigureServer({ + protectedFields: { + _User: { + '*': ['phone'], + }, + }, + protectedFieldsOwnerExempt: false, + }); + const user = await Parse.User.signUp('user1', 'password'); + const sessionToken = user.getSessionToken(); + user.set('phone', '555-1234'); + await user.save(null, { sessionToken }); + + // Owner fetches own object — phone should be hidden + const response = await request({ + url: `http://localhost:8378/1/users/${user.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + expect(response.data.phone).toBeUndefined(); + + // Master key — phone should be visible + const masterResponse = await request({ + url: `http://localhost:8378/1/users/${user.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }); + expect(masterResponse.data.phone).toBe('555-1234'); + }); + + it('non-_User classes unaffected by protectedFieldsOwnerExempt', async function () { + await reconfigureServer({ + protectedFields: { + TestClass: { + '*': ['secret'], + }, + }, + protectedFieldsOwnerExempt: true, + }); + const user = await Parse.User.signUp('user1', 'password'); + const obj = new Parse.Object('TestClass'); + obj.set('secret', 'hidden-value'); + obj.setACL(new Parse.ACL(user)); + await obj.save(null, { sessionToken: user.getSessionToken() }); + + // Owner fetches own object — secret should still be hidden (non-_User class) + const response = await request({ + url: `http://localhost:8378/1/classes/TestClass/${obj.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }); + expect(response.data.secret).toBeUndefined(); + }); + + it('/users/me respects protectedFieldsOwnerExempt: false', async function () { + await reconfigureServer({ + protectedFields: { + _User: { + '*': ['phone'], + }, + }, + protectedFieldsOwnerExempt: false, + }); + const user = await Parse.User.signUp('user1', 'password'); + const sessionToken = user.getSessionToken(); + user.set('phone', '555-1234'); + await user.save(null, { sessionToken }); + + // GET /users/me — phone should be hidden + const response = await request({ + url: 'http://localhost:8378/1/users/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + expect(response.data.phone).toBeUndefined(); + expect(response.data.objectId).toBe(user.id); + }); + + it('/login respects protectedFieldsOwnerExempt: false', async function () { + await reconfigureServer({ + protectedFields: { + _User: { + '*': ['phone'], + }, + }, + protectedFieldsOwnerExempt: false, + }); + const user = await Parse.User.signUp('user1', 'password'); + const sessionToken = user.getSessionToken(); + user.set('phone', '555-1234'); + await user.save(null, { sessionToken }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username: 'user1', password: 'password' }), + }); + expect(response.data.phone).toBeUndefined(); + expect(response.data.objectId).toBe(user.id); + expect(response.data.sessionToken).toBeDefined(); + }); + + it('/verifyPassword respects protectedFieldsOwnerExempt: false', async function () { + await reconfigureServer({ + protectedFields: { + _User: { + '*': ['phone'], + }, + }, + protectedFieldsOwnerExempt: false, + verifyUserEmails: false, + }); + const user = await Parse.User.signUp('user1', 'password'); + const sessionToken = user.getSessionToken(); + user.set('phone', '555-1234'); + await user.save(null, { sessionToken }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/verifyPassword', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username: 'user1', password: 'password' }), + }); + expect(response.data.phone).toBeUndefined(); + expect(response.data.objectId).toBe(user.id); + }); + + it('/login with master key bypasses protectedFields', async function () { + await reconfigureServer({ + protectedFields: { + _User: { + '*': ['phone'], + }, + }, + protectedFieldsOwnerExempt: false, + }); + const user = await Parse.User.signUp('user1', 'password'); + const sessionToken = user.getSessionToken(); + user.set('phone', '555-1234'); + await user.save(null, { sessionToken }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username: 'user1', password: 'password' }), + }); + expect(response.data.phone).toBe('555-1234'); + expect(response.data.sessionToken).toBeDefined(); + }); + + it('/verifyPassword with master key bypasses protectedFields', async function () { + await reconfigureServer({ + protectedFields: { + _User: { + '*': ['phone'], + }, + }, + protectedFieldsOwnerExempt: false, + verifyUserEmails: false, + }); + const user = await Parse.User.signUp('user1', 'password'); + const sessionToken = user.getSessionToken(); + user.set('phone', '555-1234'); + await user.save(null, { sessionToken }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/verifyPassword', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username: 'user1', password: 'password' }), + }); + expect(response.data.phone).toBe('555-1234'); + }); + + it('owner sees non-protected fields like email when protectedFieldsOwnerExempt is true', async function () { + await reconfigureServer({ + protectedFields: { + _User: { + '*': ['phone'], + }, + }, + protectedFieldsOwnerExempt: true, + }); + const user = await Parse.User.signUp('user1', 'password'); + const sessionToken = user.getSessionToken(); + user.set('phone', '555-1234'); + user.set('email', 'user1@example.com'); + await user.save(null, { sessionToken }); + + // Owner fetches own object — phone and email should be visible (owner exempt) + const response = await request({ + url: `http://localhost:8378/1/users/${user.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + expect(response.data.phone).toBe('555-1234'); + expect(response.data.email).toBe('user1@example.com'); + }); + + it('owner sees non-protected fields like email when protectedFieldsOwnerExempt is false', async function () { + await reconfigureServer({ + protectedFields: { + _User: { + '*': ['phone'], + }, + }, + protectedFieldsOwnerExempt: false, + }); + const user = await Parse.User.signUp('user1', 'password'); + const sessionToken = user.getSessionToken(); + user.set('phone', '555-1234'); + user.set('email', 'user1@example.com'); + await user.save(null, { sessionToken }); + + // Owner fetches own object — phone should be hidden, email should be visible + const response = await request({ + url: `http://localhost:8378/1/users/${user.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + expect(response.data.phone).toBeUndefined(); + expect(response.data.email).toBe('user1@example.com'); + }); + + it('protectedFields can hide createdAt and updatedAt from non-owners', async function () { + await reconfigureServer({ + protectedFields: { + _User: { + '*': ['createdAt', 'updatedAt'], + }, + }, + }); + const user = await Parse.User.signUp('user1', 'password'); + const user2 = await Parse.User.signUp('user2', 'password'); + const sessionToken2 = user2.getSessionToken(); + + // Make user1 publicly readable + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + acl.setWriteAccess(user.id, true); + user.setACL(acl); + await user.save(null, { useMasterKey: true }); + + // Another user fetches user1 — createdAt and updatedAt should be hidden + const response = await request({ + url: `http://localhost:8378/1/users/${user.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken2, + }, + }); + expect(response.data.createdAt).toBeUndefined(); + expect(response.data.updatedAt).toBeUndefined(); + }); + }); + + describe('protectedFieldsTriggerExempt', function () { + it('should expose protected fields in beforeSave trigger for a custom class', async function () { + await reconfigureServer({ + protectedFields: { MyClass: { '*': ['secretField'] } }, + protectedFieldsTriggerExempt: true, + }); + + // Create object with master key so both fields are stored + const obj = new Parse.Object('MyClass'); + obj.set('secretField', 'hidden-value'); + obj.set('publicField', 'visible-value'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + acl.setPublicWriteAccess(true); + obj.setACL(acl); + await obj.save(null, { useMasterKey: true }); + + // Set up beforeSave trigger to capture field visibility + let triggerObject; + let triggerOriginal; + Parse.Cloud.beforeSave('MyClass', request => { + triggerObject = { + hasSecret: request.object.has('secretField'), + hasPublic: request.object.has('publicField'), + secretValue: request.object.get('secretField'), + }; + if (request.original) { + triggerOriginal = { + hasSecret: request.original.has('secretField'), + hasPublic: request.original.has('publicField'), + secretValue: request.original.get('secretField'), + }; + } + }); + + // Update using a user session (not master key) + const user = await Parse.User.signUp('testuser', 'password'); + obj.set('publicField', 'updated-value'); + await obj.save(null, { sessionToken: user.getSessionToken() }); + + // request.object should have all fields (original + changes merged) + expect(triggerObject.hasPublic).toBe(true); + expect(triggerObject.hasSecret).toBe(true); + expect(triggerObject.secretValue).toBe('hidden-value'); + + // request.original should have all fields unfiltered + expect(triggerOriginal.hasPublic).toBe(true); + expect(triggerOriginal.hasSecret).toBe(true); + expect(triggerOriginal.secretValue).toBe('hidden-value'); + }); + + it('should expose protected fields in beforeSave trigger for _User class with protectedFieldsOwnerExempt false', async function () { + await reconfigureServer({ + protectedFields: { _User: { '*': ['email'] } }, + protectedFieldsOwnerExempt: false, + protectedFieldsTriggerExempt: true, + }); + + // Create user + const user = new Parse.User(); + user.setUsername('testuser'); + user.setPassword('password'); + user.setEmail('test@example.com'); + user.set('publicField', 'visible-value'); + await user.signUp(); + + // Set up beforeSave trigger to capture field visibility + let triggerObject; + let triggerOriginal; + Parse.Cloud.beforeSave(Parse.User, request => { + triggerObject = { + hasEmail: request.object.has('email'), + hasPublic: request.object.has('publicField'), + emailValue: request.object.get('email'), + }; + if (request.original) { + triggerOriginal = { + hasEmail: request.original.has('email'), + hasPublic: request.original.has('publicField'), + emailValue: request.original.get('email'), + }; + } + }); + + // Update using the user's own session + user.set('publicField', 'updated-value'); + await user.save(null, { sessionToken: user.getSessionToken() }); + + // request.object should have all fields including email + expect(triggerObject.hasPublic).toBe(true); + expect(triggerObject.hasEmail).toBe(true); + expect(triggerObject.emailValue).toBe('test@example.com'); + + // request.original should have all fields including email + expect(triggerOriginal.hasPublic).toBe(true); + expect(triggerOriginal.hasEmail).toBe(true); + expect(triggerOriginal.emailValue).toBe('test@example.com'); + }); + + it('should still hide protected fields from query results when protectedFieldsTriggerExempt is true', async function () { + await reconfigureServer({ + protectedFields: { MyClass: { '*': ['secretField'] } }, + protectedFieldsTriggerExempt: true, + }); + + const obj = new Parse.Object('MyClass'); + obj.set('secretField', 'hidden-value'); + obj.set('publicField', 'visible-value'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + obj.setACL(acl); + await obj.save(null, { useMasterKey: true }); + + // Query as a regular user — protectedFields should still apply to reads + const user = await Parse.User.signUp('testuser', 'password'); + const fetched = await new Parse.Query('MyClass').get(obj.id, { sessionToken: user.getSessionToken() }); + expect(fetched.has('publicField')).toBe(true); + expect(fetched.has('secretField')).toBe(false); + }); + + it('should not expose protected fields in beforeSave trigger when protectedFieldsTriggerExempt is false', async function () { + await reconfigureServer({ + protectedFields: { MyClass: { '*': ['secretField'] } }, + protectedFieldsTriggerExempt: false, + }); + + const obj = new Parse.Object('MyClass'); + obj.set('secretField', 'hidden-value'); + obj.set('publicField', 'visible-value'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + acl.setPublicWriteAccess(true); + obj.setACL(acl); + await obj.save(null, { useMasterKey: true }); + + let triggerOriginal; + Parse.Cloud.beforeSave('MyClass', request => { + if (request.original) { + triggerOriginal = { + hasSecret: request.original.has('secretField'), + hasPublic: request.original.has('publicField'), + }; + } + }); + + const user = await Parse.User.signUp('testuser', 'password'); + obj.set('publicField', 'updated-value'); + await obj.save(null, { sessionToken: user.getSessionToken() }); + + // With protectedFieldsTriggerExempt: false, current behavior is preserved + expect(triggerOriginal.hasPublic).toBe(true); + expect(triggerOriginal.hasSecret).toBe(false); + }); + }); + + describe('protectedFieldsSaveResponseExempt', function () { + it('should strip protected fields from update response when protectedFieldsSaveResponseExempt is false', async function () { + await reconfigureServer({ + protectedFields: { MyClass: { '*': ['secretField'] } }, + protectedFieldsTriggerExempt: true, + protectedFieldsSaveResponseExempt: false, + }); + + // Create object with master key + const obj = new Parse.Object('MyClass'); + obj.set('secretField', 'hidden-value'); + obj.set('publicField', 'visible-value'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + acl.setPublicWriteAccess(true); + obj.setACL(acl); + await obj.save(null, { useMasterKey: true }); + + // beforeSave trigger modifies the protected field + Parse.Cloud.beforeSave('MyClass', req => { + req.object.set('secretField', 'trigger-modified-value'); + }); + + // Update via raw HTTP to inspect the actual server response + const user = await Parse.User.signUp('testuser', 'password'); + const response = await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/MyClass/${obj.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ publicField: 'updated-value' }), + }); + + // The server response should NOT contain the protected field + expect(response.data.updatedAt).toBeDefined(); + expect(response.data.secretField).toBeUndefined(); + }); + + it('should strip protected fields from update response for _User class when protectedFieldsSaveResponseExempt is false', async function () { + await reconfigureServer({ + protectedFields: { _User: { '*': ['email'] } }, + protectedFieldsOwnerExempt: false, + protectedFieldsTriggerExempt: true, + protectedFieldsSaveResponseExempt: false, + }); + + // Create user + const user = new Parse.User(); + user.setUsername('testuser'); + user.setPassword('password'); + user.setEmail('test@example.com'); + user.set('publicField', 'visible-value'); + await user.signUp(); + + // beforeSave trigger modifies the protected field + Parse.Cloud.beforeSave(Parse.User, req => { + req.object.set('email', 'trigger-modified@example.com'); + }); + + // Update via raw HTTP + const response = await request({ + method: 'PUT', + url: `http://localhost:8378/1/users/${user.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ publicField: 'updated-value' }), + }); + + // The server response should NOT contain the protected field + expect(response.data.updatedAt).toBeDefined(); + expect(response.data.email).toBeUndefined(); + }); + + it('should include protected fields in update response when protectedFieldsSaveResponseExempt is true', async function () { + await reconfigureServer({ + protectedFields: { MyClass: { '*': ['secretField'] } }, + protectedFieldsTriggerExempt: true, + protectedFieldsSaveResponseExempt: true, + }); + + // Create object with master key + const obj = new Parse.Object('MyClass'); + obj.set('secretField', 'hidden-value'); + obj.set('publicField', 'visible-value'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + acl.setPublicWriteAccess(true); + obj.setACL(acl); + await obj.save(null, { useMasterKey: true }); + + // beforeSave trigger modifies the protected field + Parse.Cloud.beforeSave('MyClass', req => { + req.object.set('secretField', 'trigger-modified-value'); + }); + + // Update via raw HTTP + const user = await Parse.User.signUp('testuser', 'password'); + const response = await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/MyClass/${obj.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ publicField: 'updated-value' }), + }); + + // The server response SHOULD contain the protected field (current behavior preserved) + expect(response.data.secretField).toBe('trigger-modified-value'); + }); + + it('should strip protected fields from create response when protectedFieldsSaveResponseExempt is false', async function () { + await reconfigureServer({ + protectedFields: { MyClass: { '*': ['secretField'] } }, + protectedFieldsSaveResponseExempt: false, + }); + + // Create via raw HTTP as a regular user + const user = await Parse.User.signUp('testuser', 'password'); + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/MyClass', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + secretField: 'hidden-value', + publicField: 'visible-value', + ACL: { '*': { read: true, write: true } }, + }), + }); + + // The server response should NOT contain the protected field + expect(response.data.objectId).toBeDefined(); + expect(response.data.createdAt).toBeDefined(); + expect(response.data.secretField).toBeUndefined(); + }); + }); + + describe('maintenance auth', function () { + it('should allow maintenance auth to query using protected fields as WHERE keys', async function () { + await reconfigureServer({ + protectedFields: { _User: { '*': ['email', 'emailVerified'] } }, + protectedFieldsOwnerExempt: false, + }); + + const user = new Parse.User(); + user.setUsername('testuser'); + user.setPassword('password'); + user.setEmail('test@example.com'); + await user.signUp(); + + // Query using a protected field as a WHERE key with maintenance auth + const Auth = require('../lib/Auth'); + const Config = require('../lib/Config'); + const RestQuery = require('../lib/RestQuery'); + const config = Config.get('test'); + const maintenanceAuth = Auth.maintenance(config); + const query = await RestQuery({ + method: RestQuery.Method.get, + config, + auth: maintenanceAuth, + className: '_User', + restWhere: { email: 'test@example.com' }, + runBeforeFind: false, + }); + const result = await query.execute(); + expect(result.results.length).toBe(1); + expect(result.results[0].objectId).toBe(user.id); + }); + }); +}); diff --git a/spec/PublicAPI.spec.js b/spec/PublicAPI.spec.js deleted file mode 100644 index 07853157cc..0000000000 --- a/spec/PublicAPI.spec.js +++ /dev/null @@ -1,65 +0,0 @@ - -var request = require('request'); - -describe("public API", () => { - it("should get invalid_link.html", (done) => { - request('http://localhost:8378/1/apps/invalid_link.html', (err, httpResponse, body) => { - expect(httpResponse.statusCode).toBe(200); - done(); - }); - }); - - it("should get choose_password", (done) => { - reconfigureServer({ - appName: 'unused', - publicServerURL: 'http://localhost:8378/1', - }) - .then(() => { - request('http://localhost:8378/1/apps/choose_password?id=test', (err, httpResponse, body) => { - expect(httpResponse.statusCode).toBe(200); - done(); - }); - }) - }); - - it("should get verify_email_success.html", (done) => { - request('http://localhost:8378/1/apps/verify_email_success.html', (err, httpResponse, body) => { - expect(httpResponse.statusCode).toBe(200); - done(); - }); - }); - - it("should get password_reset_success.html", (done) => { - request('http://localhost:8378/1/apps/password_reset_success.html', (err, httpResponse, body) => { - expect(httpResponse.statusCode).toBe(200); - done(); - }); - }); -}); - -describe("public API without publicServerURL", () => { - beforeEach(done => { - reconfigureServer({ appName: 'unused' }) - .then(done, fail); - }) - it("should get 404 on verify_email", (done) => { - request('http://localhost:8378/1/apps/test/verify_email', (err, httpResponse, body) => { - expect(httpResponse.statusCode).toBe(404); - done(); - }); - }); - - it("should get 404 choose_password", (done) => { - request('http://localhost:8378/1/apps/choose_password?id=test', (err, httpResponse, body) => { - expect(httpResponse.statusCode).toBe(404); - done(); - }); - }); - - it("should get 404 on request_password_reset", (done) => { - request('http://localhost:8378/1/apps/test/request_password_reset', (err, httpResponse, body) => { - expect(httpResponse.statusCode).toBe(404); - done(); - }); - }); -}); diff --git a/spec/PurchaseValidation.spec.js b/spec/PurchaseValidation.spec.js index 384a6665e2..231198e8dc 100644 --- a/spec/PurchaseValidation.spec.js +++ b/spec/PurchaseValidation.spec.js @@ -1,202 +1,234 @@ -var request = require("request"); +const request = require('../lib/request'); function createProduct() { - const file = new Parse.File("name", { - base64: new Buffer("download_file", "utf-8").toString("base64") - }, "text"); - return file.save().then(function(){ - var product = new Parse.Object("_Product"); + const file = new Parse.File( + 'name', + { + base64: new Buffer('download_file', 'utf-8').toString('base64'), + }, + 'text' + ); + return file.save().then(function () { + const product = new Parse.Object('_Product'); product.set({ download: file, icon: file, - title: "a product", - subtitle: "a product", + title: 'a product', + subtitle: 'a product', order: 1, - productIdentifier: "a-product" - }) + productIdentifier: 'a-product', + }); return product.save(); - }) - + }); } -describe("test validate_receipt endpoint", () => { - beforeEach( done => { - createProduct().then(done).fail(function(err){ - console.error(err); - done(); - }) - }) - - it_exclude_dbs(['postgres'])("should bypass appstore validation", (done) => { +describe('test validate_receipt endpoint', () => { + beforeEach(async () => { + await createProduct(); + }); - request.post({ + it('should bypass appstore validation', async () => { + const httpResponse = await request({ headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest'}, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + method: 'POST', url: 'http://localhost:8378/1/validate_purchase', - json: true, body: { - productIdentifier: "a-product", + productIdentifier: 'a-product', receipt: { - __type: "Bytes", - base64: new Buffer("receipt", "utf-8").toString("base64") + __type: 'Bytes', + base64: new Buffer('receipt', 'utf-8').toString('base64'), }, - bypassAppStoreValidation: true - } - }, function(err, res, body){ - if (typeof body != "object") { - fail("Body is not an object"); - done(); - } else { - expect(body.__type).toEqual("File"); - const url = body.url; - request.get({ - url: url - }, function(err, res, body) { - expect(body).toEqual("download_file"); - done(); - }); - } + bypassAppStoreValidation: true, + }, }); + const body = httpResponse.data; + if (typeof body != 'object') { + fail('Body is not an object'); + } else { + expect(body.__type).toEqual('File'); + const url = body.url; + const otherResponse = await request({ + url: url, + }); + expect(otherResponse.text).toBe('download_file'); + } }); - it("should fail for missing receipt", (done) => { - request.post({ + it('should fail for missing receipt', async () => { + const response = await request({ headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest'}, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, url: 'http://localhost:8378/1/validate_purchase', - json: true, + method: 'POST', body: { - productIdentifier: "a-product", - bypassAppStoreValidation: true - } - }, function(err, res, body){ - if (typeof body != "object") { - fail("Body is not an object"); - done(); - } else { - expect(body.code).toEqual(Parse.Error.INVALID_JSON); - done(); - } - }); + productIdentifier: 'a-product', + bypassAppStoreValidation: true, + }, + }).then(fail, res => res); + const body = response.data; + expect(body.code).toEqual(Parse.Error.INVALID_JSON); }); - it("should fail for missing product identifier", (done) => { - request.post({ + it('should fail for missing product identifier', async () => { + const response = await request({ headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest'}, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, url: 'http://localhost:8378/1/validate_purchase', - json: true, + method: 'POST', body: { receipt: { - __type: "Bytes", - base64: new Buffer("receipt", "utf-8").toString("base64") + __type: 'Bytes', + base64: new Buffer('receipt', 'utf-8').toString('base64'), }, - bypassAppStoreValidation: true - } - }, function(err, res, body){ - if (typeof body != "object") { - fail("Body is not an object"); - done(); - } else { - expect(body.code).toEqual(Parse.Error.INVALID_JSON); - done(); - } - }); + bypassAppStoreValidation: true, + }, + }).then(fail, res => res); + const body = response.data; + expect(body.code).toEqual(Parse.Error.INVALID_JSON); }); - it("should bypass appstore validation and not find product", (done) => { - - request.post({ + it('should bypass appstore validation and not find product', async () => { + const response = await request({ headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest'}, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, url: 'http://localhost:8378/1/validate_purchase', - json: true, + method: 'POST', body: { - productIdentifier: "another-product", + productIdentifier: 'another-product', receipt: { - __type: "Bytes", - base64: new Buffer("receipt", "utf-8").toString("base64") + __type: 'Bytes', + base64: new Buffer('receipt', 'utf8').toString('base64'), }, - bypassAppStoreValidation: true - } - }, function(err, res, body){ - if (typeof body != "object") { - fail("Body is not an object"); - done(); - } else { - expect(body.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - expect(body.error).toEqual('Object not found.'); - done(); - } - }); + bypassAppStoreValidation: true, + }, + }).catch(error => error); + const body = response.data; + if (typeof body != 'object') { + fail('Body is not an object'); + } else { + expect(body.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + expect(body.error).toEqual('Object not found.'); + } }); - it("should fail at appstore validation", done => { - request.post({ + it('should fail at appstore validation', async () => { + const response = await request({ headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest'}, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, url: 'http://localhost:8378/1/validate_purchase', - json: true, + method: 'POST', body: { - productIdentifier: "a-product", + productIdentifier: 'a-product', receipt: { - __type: "Bytes", - base64: new Buffer("receipt", "utf-8").toString("base64") + __type: 'Bytes', + base64: new Buffer('receipt', 'utf-8').toString('base64'), }, - } - }, function(err, res, body){ - if (typeof body != "object") { - fail("Body is not an object"); - } else { - expect(body.status).toBe(21002); - expect(body.error).toBe('The data in the receipt-data property was malformed or missing.'); - } - done(); + }, }); + const body = response.data; + if (typeof body != 'object') { + fail('Body is not an object'); + } else { + expect(body.status).toBe(21002); + expect(body.error).toBe('The data in the receipt-data property was malformed or missing.'); + } }); - it_exclude_dbs(['postgres'])("should not create a _Product", (done) => { - var product = new Parse.Object("_Product"); - product.save().then(function(){ - fail("Should not be able to save"); + it('should not create a _Product', done => { + const product = new Parse.Object('_Product'); + product.save().then( + function () { + fail('Should not be able to save'); done(); - }, function(err){ + }, + function (err) { expect(err.code).toEqual(Parse.Error.INCORRECT_TYPE); done(); - }) + } + ); }); - it_exclude_dbs(['postgres'])("should be able to update a _Product", (done) => { - var query = new Parse.Query("_Product"); - query.first().then(function(product){ - product.set("title", "a new title"); + it('should be able to update a _Product', done => { + const query = new Parse.Query('_Product'); + query + .first() + .then(function (product) { + if (!product) { + return Promise.reject(new Error('Product should be found')); + } + product.set('title', 'a new title'); return product.save(); - }).then(function(productAgain){ + }) + .then(function (productAgain) { expect(productAgain.get('downloadName')).toEqual(productAgain.get('download').name()); - expect(productAgain.get("title")).toEqual("a new title"); + expect(productAgain.get('title')).toEqual('a new title'); done(); - }).fail(function(err){ + }) + .catch(function (err) { fail(JSON.stringify(err)); done(); }); }); - it_exclude_dbs(['postgres'])("should not be able to remove a require key in a _Product", (done) => { - var query = new Parse.Query("_Product"); - query.first().then(function(product){ - product.unset("title"); + it('should disable validate_purchase endpoint when enableProductPurchaseLegacyApi is false', async () => { + await reconfigureServer({ enableProductPurchaseLegacyApi: false }); + const ParseServer = require('../lib/ParseServer').default; + const routers = ParseServer.promiseRouter({ + appId: 'test', + options: { enableProductPurchaseLegacyApi: false }, + }); + const hasValidatePurchase = routers.routes.some( + r => r.path === '/validate_purchase' && r.method === 'POST' + ); + expect(hasValidatePurchase).toBe(false); + }); + + it('should enable validate_purchase endpoint by default', async () => { + const ParseServer = require('../lib/ParseServer').default; + const routers = ParseServer.promiseRouter({ + appId: 'test', + options: {}, + }); + const hasValidatePurchase = routers.routes.some( + r => r.path === '/validate_purchase' && r.method === 'POST' + ); + expect(hasValidatePurchase).toBe(true); + }); + + it('should not be able to remove a require key in a _Product', done => { + const query = new Parse.Query('_Product'); + query + .first() + .then(function (product) { + if (!product) { + return Promise.reject(new Error('Product should be found')); + } + product.unset('title'); return product.save(); - }).then(function(productAgain){ - fail("Should not succeed"); + }) + .then(function () { + fail('Should not succeed'); done(); - }).fail(function(err){ + }) + .catch(function (err) { expect(err.code).toEqual(Parse.Error.INCORRECT_TYPE); - expect(err.message).toEqual("title is required."); + expect(err.message).toEqual('title is required.'); done(); }); }); diff --git a/spec/PushController.spec.js b/spec/PushController.spec.js index 344ffcfd85..fe04b334b4 100644 --- a/spec/PushController.spec.js +++ b/spec/PushController.spec.js @@ -1,404 +1,1304 @@ -"use strict"; -var PushController = require('../src/Controllers/PushController').PushController; -var pushStatusHandler = require('../src/pushStatusHandler'); -var Config = require('../src/Config'); +'use strict'; +const PushController = require('../lib/Controllers/PushController').PushController; +const StatusHandler = require('../lib/StatusHandler'); +const Config = require('../lib/Config'); +const validatePushType = require('../lib/Push/utils').validatePushType; +const Utils = require('../lib/Utils'); -const successfulTransmissions = function(body, installations) { - - let promises = installations.map((device) => { +const successfulTransmissions = function (body, installations) { + const promises = installations.map(device => { return Promise.resolve({ transmitted: true, device: device, - }) + }); }); return Promise.all(promises); -} +}; -const successfulIOS = function(body, installations) { - - let promises = installations.map((device) => { +const successfulIOS = function (body, installations) { + const promises = installations.map(device => { return Promise.resolve({ - transmitted: device.deviceType == "ios", + transmitted: device.deviceType == 'ios', device: device, - }) + }); }); return Promise.all(promises); -} +}; + +const pushCompleted = async pushId => { + const query = new Parse.Query('_PushStatus'); + query.equalTo('objectId', pushId); + let result = await query.first({ useMasterKey: true }); + while (!(result && result.get('status') === 'succeeded')) { + await jasmine.timeout(); + result = await query.first({ useMasterKey: true }); + } +}; + +const sendPush = (body, where, config, auth, now) => { + const pushController = new PushController(); + return new Promise((resolve, reject) => { + pushController.sendPush(body, where, config, auth, resolve, now).catch(reject); + }); +}; describe('PushController', () => { - it('can validate device type when no device type is set', (done) => { + it('can validate device type when no device type is set', done => { // Make query condition - var where = { - }; - var validPushTypes = ['ios', 'android']; + const where = {}; + const validPushTypes = ['ios', 'android']; - expect(function(){ - PushController.validatePushType(where, validPushTypes); + expect(function () { + validatePushType(where, validPushTypes); }).not.toThrow(); done(); }); - it('can validate device type when single valid device type is set', (done) => { + it('can validate device type when single valid device type is set', done => { // Make query condition - var where = { - 'deviceType': 'ios' + const where = { + deviceType: 'ios', }; - var validPushTypes = ['ios', 'android']; + const validPushTypes = ['ios', 'android']; - expect(function(){ - PushController.validatePushType(where, validPushTypes); + expect(function () { + validatePushType(where, validPushTypes); }).not.toThrow(); done(); }); - it('can validate device type when multiple valid device types are set', (done) => { + it('can validate device type when multiple valid device types are set', done => { // Make query condition - var where = { - 'deviceType': { - '$in': ['android', 'ios'] - } + const where = { + deviceType: { + $in: ['android', 'ios'], + }, }; - var validPushTypes = ['ios', 'android']; + const validPushTypes = ['ios', 'android']; - expect(function(){ - PushController.validatePushType(where, validPushTypes); + expect(function () { + validatePushType(where, validPushTypes); }).not.toThrow(); done(); }); - it('can throw on validateDeviceType when single invalid device type is set', (done) => { + it('can throw on validateDeviceType when single invalid device type is set', done => { // Make query condition - var where = { - 'deviceType': 'osx' + const where = { + deviceType: 'osx', }; - var validPushTypes = ['ios', 'android']; + const validPushTypes = ['ios', 'android']; - expect(function(){ - PushController.validatePushType(where, validPushTypes); + expect(function () { + validatePushType(where, validPushTypes); }).toThrow(); done(); }); - it('can throw on validateDeviceType when single invalid device type is set', (done) => { - // Make query condition - var where = { - 'deviceType': 'osx' + it('can get expiration time in string format', done => { + // Make mock request + const timeStr = '2015-03-19T22:05:08Z'; + const body = { + expiration_time: timeStr, }; - var validPushTypes = ['ios', 'android']; - expect(function(){ - PushController.validatePushType(where, validPushTypes); + const time = PushController.getExpirationTime(body); + expect(time).toEqual(new Date(timeStr).valueOf()); + done(); + }); + + it('can get expiration time in number format', done => { + // Make mock request + const timeNumber = 1426802708; + const body = { + expiration_time: timeNumber, + }; + + const time = PushController.getExpirationTime(body); + expect(time).toEqual(timeNumber * 1000); + done(); + }); + + it('can throw on getExpirationTime in invalid format', done => { + // Make mock request + const body = { + expiration_time: 'abcd', + }; + + expect(function () { + PushController.getExpirationTime(body); }).toThrow(); done(); }); - it('can get expiration time in string format', (done) => { + it('can get push time in string format', done => { // Make mock request - var timeStr = '2015-03-19T22:05:08Z'; - var body = { - 'expiration_time': timeStr - } + const timeStr = '2015-03-19T22:05:08Z'; + const body = { + push_time: timeStr, + }; - var time = PushController.getExpirationTime(body); - expect(time).toEqual(new Date(timeStr).valueOf()); + const { date } = PushController.getPushTime(body); + expect(date).toEqual(new Date(timeStr)); done(); }); - it('can get expiration time in number format', (done) => { + it('can get push time in number format', done => { // Make mock request - var timeNumber = 1426802708; - var body = { - 'expiration_time': timeNumber - } + const timeNumber = 1426802708; + const body = { + push_time: timeNumber, + }; - var time = PushController.getExpirationTime(body); - expect(time).toEqual(timeNumber * 1000); + const { date } = PushController.getPushTime(body); + expect(date.valueOf()).toEqual(timeNumber * 1000); done(); }); - it('can throw on getExpirationTime in invalid format', (done) => { + it('can throw on getPushTime in invalid format', done => { // Make mock request - var body = { - 'expiration_time': 'abcd' - } + const body = { + push_time: 'abcd', + }; - expect(function(){ - PushController.getExpirationTime(body); + expect(function () { + PushController.getPushTime(body); }).toThrow(); done(); }); - it_exclude_dbs(['postgres'])('properly increment badges', (done) => { - - var payload = {data:{ - alert: "Hello World!", - badge: "Increment", - }} - var installations = []; - while(installations.length != 10) { - var installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation_"+installations.length); - installation.set("deviceToken","device_token_"+installations.length) - installation.set("badge", installations.length); - installation.set("originalBadge", installations.length); - installation.set("deviceType", "ios"); - installations.push(installation); - } - - while(installations.length != 15) { - var installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation_"+installations.length); - installation.set("deviceToken","device_token_"+installations.length) - installation.set("deviceType", "android"); - installations.push(installation); - } - - var pushAdapter = { - send: function(body, installations) { - var badge = body.data.badge; - installations.forEach((installation) => { - if (installation.deviceType == "ios") { + it_id('01e3e1b8-fad2-4249-b664-5a3efaab8cb1')(it)('properly increment badges', async () => { + const pushAdapter = { + send: function (body, installations) { + const badge = body.data.badge; + installations.forEach(installation => { expect(installation.badge).toEqual(badge); - expect(installation.originalBadge+1).toEqual(installation.badge); - } else { - expect(installation.badge).toBeUndefined(); - } - }) - return successfulTransmissions(body, installations); - }, - getValidPushTypes: function() { - return ["ios", "android"]; + expect(installation.originalBadge + 1).toEqual(installation.badge); + }); + return successfulTransmissions(body, installations); + }, + getValidPushTypes: function () { + return ['ios', 'android']; + }, + }; + await reconfigureServer({ + push: { adapter: pushAdapter }, + }); + const payload = { + data: { + alert: 'Hello World!', + badge: 'Increment', + }, + }; + const installations = []; + while (installations.length != 10) { + const installation = new Parse.Object('_Installation'); + installation.set('installationId', 'installation_' + installations.length); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); + installations.push(installation); } - } - var config = new Config(Parse.applicationId); - var auth = { - isMaster: true - } - - var pushController = new PushController(pushAdapter, Parse.applicationId, defaultConfiguration.push); - Parse.Object.saveAll(installations).then((installations) => { - return pushController.sendPush(payload, {}, config, auth); - }).then((result) => { - done(); - }, (err) => { - fail("should not fail"); - done(); - }); - - }); - - it_exclude_dbs(['postgres'])('properly set badges to 1', (done) => { - - var payload = {data: { - alert: "Hello World!", - badge: 1, - }} - var installations = []; - while(installations.length != 10) { - var installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation_"+installations.length); - installation.set("deviceToken","device_token_"+installations.length) - installation.set("badge", installations.length); - installation.set("originalBadge", installations.length); - installation.set("deviceType", "ios"); - installations.push(installation); - } - - var pushAdapter = { - send: function(body, installations) { - var badge = body.data.badge; - installations.forEach((installation) => { - expect(installation.badge).toEqual(badge); - expect(1).toEqual(installation.badge); - }) - return successfulTransmissions(body, installations); - }, - getValidPushTypes: function() { - return ["ios"]; + while (installations.length != 15) { + const installation = new Parse.Object('_Installation'); + installation.set('installationId', 'installation_' + installations.length); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'android'); + installations.push(installation); } - } + const config = Config.get(Parse.applicationId); + const auth = { + isMaster: true, + }; + await Parse.Object.saveAll(installations); + const pushStatusId = await sendPush(payload, {}, config, auth); + await pushCompleted(pushStatusId); + + // Check we actually sent 15 pushes. + const pushStatus = await Parse.Push.getPushStatus(pushStatusId); + expect(pushStatus.get('numSent')).toBe(15); - var config = new Config(Parse.applicationId); - var auth = { - isMaster: true - } - - var pushController = new PushController(pushAdapter, Parse.applicationId, defaultConfiguration.push); - Parse.Object.saveAll(installations).then((installations) => { - return pushController.sendPush(payload, {}, config, auth); - }).then((result) => { - done(); - }, (err) => { - fail("should not fail"); - done(); - }); - - }); - - it_exclude_dbs(['postgres'])('properly creates _PushStatus', (done) => { - - var installations = []; - while(installations.length != 10) { - var installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation_"+installations.length); - installation.set("deviceToken","device_token_"+installations.length) - installation.set("badge", installations.length); - installation.set("originalBadge", installations.length); - installation.set("deviceType", "ios"); + // Check that the installations were actually updated. + const query = new Parse.Query('_Installation'); + const results = await query.find({ useMasterKey: true }); + expect(results.length).toBe(15); + for (let i = 0; i < 15; i++) { + const installation = results[i]; + expect(installation.get('badge')).toBe(parseInt(installation.get('originalBadge')) + 1); + } + }); + + it_id('14afcedf-e65d-41cd-981e-07f32df84c14')(it)('properly increment badges by more than 1', async () => { + const pushAdapter = { + send: function (body, installations) { + const badge = body.data.badge; + installations.forEach(installation => { + expect(installation.badge).toEqual(badge); + expect(installation.originalBadge + 3).toEqual(installation.badge); + }); + return successfulTransmissions(body, installations); + }, + getValidPushTypes: function () { + return ['ios', 'android']; + }, + }; + await reconfigureServer({ + push: { adapter: pushAdapter }, + }); + const payload = { + data: { + alert: 'Hello World!', + badge: { __op: 'Increment', amount: 3 }, + }, + }; + const installations = []; + while (installations.length != 10) { + const installation = new Parse.Object('_Installation'); + installation.set('installationId', 'installation_' + installations.length); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); installations.push(installation); } - while(installations.length != 15) { - var installation = new Parse.Object("_Installation"); - installation.set("installationId", "installation_"+installations.length); - installation.set("deviceToken","device_token_"+installations.length) - installation.set("deviceType", "android"); + while (installations.length != 15) { + const installation = new Parse.Object('_Installation'); + installation.set('installationId', 'installation_' + installations.length); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'android'); installations.push(installation); } - var payload = {data: { - alert: "Hello World!", - badge: 1, - }} - - var pushAdapter = { - send: function(body, installations) { - return successfulIOS(body, installations); - }, - getValidPushTypes: function() { - return ["ios"]; + const config = Config.get(Parse.applicationId); + const auth = { + isMaster: true, + }; + await Parse.Object.saveAll(installations); + const pushStatusId = await sendPush(payload, {}, config, auth); + await pushCompleted(pushStatusId); + const pushStatus = await Parse.Push.getPushStatus(pushStatusId); + expect(pushStatus.get('numSent')).toBe(15); + // Check that the installations were actually updated. + const query = new Parse.Query('_Installation'); + const results = await query.find({ useMasterKey: true }); + expect(results.length).toBe(15); + for (let i = 0; i < 15; i++) { + const installation = results[i]; + expect(installation.get('badge')).toBe(parseInt(installation.get('originalBadge')) + 3); } - } + }); - var config = new Config(Parse.applicationId); - var auth = { - isMaster: true - } - - var pushController = new PushController(pushAdapter, Parse.applicationId, defaultConfiguration.push); - Parse.Object.saveAll(installations).then(() => { - return pushController.sendPush(payload, {}, config, auth); - }).then((result) => { - return new Promise((resolve, reject) => { - setTimeout(() => { - resolve(); - }, 1000); - }); - }).then(() => { - let query = new Parse.Query('_PushStatus'); - return query.find({useMasterKey: true}); - }).then((results) => { - expect(results.length).toBe(1); - let result = results[0]; - expect(result.createdAt instanceof Date).toBe(true); - expect(result.updatedAt instanceof Date).toBe(true); - expect(result.id.length).toBe(10); - expect(result.get('source')).toEqual('rest'); - expect(result.get('query')).toEqual(JSON.stringify({})); - expect(typeof result.get('payload')).toEqual("string"); - expect(JSON.parse(result.get('payload'))).toEqual(payload.data); - expect(result.get('status')).toEqual('succeeded'); - expect(result.get('numSent')).toEqual(10); - expect(result.get('sentPerType')).toEqual({ - 'ios': 10 // 10 ios - }); - expect(result.get('numFailed')).toEqual(5); - expect(result.get('failedPerType')).toEqual({ - 'android': 5 // android - }); - // Try to get it without masterKey - let query = new Parse.Query('_PushStatus'); - return query.find(); - }).then((results) => { - expect(results.length).toBe(0); - done(); - }); - - }); - - it_exclude_dbs(['postgres'])('should properly report failures in _PushStatus', (done) => { - var pushAdapter = { - send: function(body, installations) { - return installations.map((installation) => { - return Promise.resolve({ - deviceType: installation.deviceType - }) - }) - }, - getValidPushTypes: function() { - return ["ios"]; - } - } - let where = { 'channels': { - '$ins': ['Giants', 'Mets'] - }}; - var payload = {data: { - alert: "Hello World!", - badge: 1, - }} - var config = new Config(Parse.applicationId); - var auth = { - isMaster: true - } - var pushController = new PushController(pushAdapter, Parse.applicationId, defaultConfiguration.push); - pushController.sendPush(payload, where, config, auth).then(() => { - fail('should not succeed'); - done(); - }).catch(() => { - let query = new Parse.Query('_PushStatus'); - query.find({useMasterKey: true}).then((results) => { - expect(results.length).toBe(1); - let pushStatus = results[0]; - expect(pushStatus.get('status')).toBe('failed'); - done(); - }); - }) - }); - - it_exclude_dbs(['postgres'])('should support full RESTQuery for increment', (done) => { - var payload = {data: { - alert: "Hello World!", - badge: 'Increment', - }} - - var pushAdapter = { - send: function(body, installations) { - return successfulTransmissions(body, installations); - }, - getValidPushTypes: function() { - return ["ios"]; + it_id('758dd579-aa91-4010-9033-8d48d3463644')(it)('properly set badges to 1', async () => { + const pushAdapter = { + send: function (body, installations) { + const badge = body.data.badge; + installations.forEach(installation => { + expect(installation.badge).toEqual(badge); + expect(1).toEqual(installation.badge); + }); + return successfulTransmissions(body, installations); + }, + getValidPushTypes: function () { + return ['ios']; + }, + }; + await reconfigureServer({ + push: { adapter: pushAdapter }, + }); + const payload = { + data: { + alert: 'Hello World!', + badge: 1, + }, + }; + const installations = []; + while (installations.length != 10) { + const installation = new Parse.Object('_Installation'); + installation.set('installationId', 'installation_' + installations.length); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); + installations.push(installation); } - } - var config = new Config(Parse.applicationId); - var auth = { - isMaster: true - } + const config = Config.get(Parse.applicationId); + const auth = { + isMaster: true, + }; + await Parse.Object.saveAll(installations); + const pushStatusId = await sendPush(payload, {}, config, auth); + await pushCompleted(pushStatusId); + const pushStatus = await Parse.Push.getPushStatus(pushStatusId); + expect(pushStatus.get('numSent')).toBe(10); - let where = { - 'deviceToken': { - '$inQuery': { - 'where': { - 'deviceType': 'ios' - }, - className: '_Installation' - } - } - } + // Check that the installations were actually updated. + const query = new Parse.Query('_Installation'); + const results = await query.find({ useMasterKey: true }); + expect(results.length).toBe(10); + for (let i = 0; i < 10; i++) { + const installation = results[i]; + expect(installation.get('badge')).toBe(1); + } + }); - var pushController = new PushController(pushAdapter, Parse.applicationId, defaultConfiguration.push); - pushController.sendPush(payload, where, config, auth).then((result) => { - done(); - }).catch((err) => { - fail('should not fail'); - done(); + it_id('75c39ae3-06ac-4354-b321-931e81c5a927')(it)('properly set badges to 1 with complex query #2903 #3022', async () => { + const payload = { + data: { + alert: 'Hello World!', + badge: 1, + }, + }; + const installations = []; + while (installations.length != 10) { + const installation = new Parse.Object('_Installation'); + installation.set('installationId', 'installation_' + installations.length); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); + installations.push(installation); + } + let matchedInstallationsCount = 0; + const pushAdapter = { + send: function (body, installations) { + matchedInstallationsCount += installations.length; + const badge = body.data.badge; + installations.forEach(installation => { + expect(installation.badge).toEqual(badge); + expect(1).toEqual(installation.badge); + }); + return successfulTransmissions(body, installations); + }, + getValidPushTypes: function () { + return ['ios']; + }, + }; + await reconfigureServer({ + push: { adapter: pushAdapter }, + }); + const config = Config.get(Parse.applicationId); + const auth = { + isMaster: true, + }; + await Parse.Object.saveAll(installations); + const objectIds = installations.map(installation => { + return installation.id; }); + const where = { + objectId: { $in: objectIds.slice(0, 5) }, + }; + const pushStatusId = await sendPush(payload, where, config, auth); + await pushCompleted(pushStatusId); + expect(matchedInstallationsCount).toBe(5); + const query = new Parse.Query(Parse.Installation); + query.equalTo('badge', 1); + const results = await query.find({ useMasterKey: true }); + expect(results.length).toBe(5); + }); + + it_id('667f31c0-b458-4f61-ab57-668c04e3cc0b')(it)('properly creates _PushStatus', async () => { + const pushStatusAfterSave = { + handler: function () {}, + }; + const spy = spyOn(pushStatusAfterSave, 'handler').and.callThrough(); + Parse.Cloud.afterSave('_PushStatus', pushStatusAfterSave.handler); + const installations = []; + while (installations.length != 10) { + const installation = new Parse.Object('_Installation'); + installation.set('installationId', 'installation_' + installations.length); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); + installations.push(installation); + } + + while (installations.length != 15) { + const installation = new Parse.Object('_Installation'); + installation.set('installationId', 'installation_' + installations.length); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('deviceType', 'android'); + installations.push(installation); + } + const payload = { + data: { + alert: 'Hello World!', + badge: 1, + }, + }; + + const pushAdapter = { + send: function (body, installations) { + return successfulIOS(body, installations); + }, + getValidPushTypes: function () { + return ['ios']; + }, + }; + await reconfigureServer({ + push: { adapter: pushAdapter }, + }); + const config = Config.get(Parse.applicationId); + const auth = { + isMaster: true, + }; + await Parse.Object.saveAll(installations); + const pushStatusId = await sendPush(payload, {}, config, auth); + await pushCompleted(pushStatusId); + const result = await Parse.Push.getPushStatus(pushStatusId); + expect(Utils.isDate(result.createdAt)).toBe(true); + expect(Utils.isDate(result.updatedAt)).toBe(true); + expect(result.id.length).toBe(10); + expect(result.get('source')).toEqual('rest'); + expect(result.get('query')).toEqual(JSON.stringify({})); + expect(typeof result.get('payload')).toEqual('string'); + expect(JSON.parse(result.get('payload'))).toEqual(payload.data); + expect(result.get('status')).toEqual('succeeded'); + expect(result.get('numSent')).toEqual(10); + expect(result.get('sentPerType')).toEqual({ + ios: 10, // 10 ios + }); + expect(result.get('numFailed')).toEqual(5); + expect(result.get('failedPerType')).toEqual({ + android: 5, // android + }); + try { + // Try to get it without masterKey + const query = new Parse.Query('_PushStatus'); + await query.find(); + fail(); + } catch (error) { + expect(error.code).toBe(119); + } + + function getPushStatus(callIndex) { + return spy.calls.all()[callIndex].args[0].object; + } + expect(spy).toHaveBeenCalled(); + expect(spy.calls.count()).toBe(4); + const allCalls = spy.calls.all(); + let pendingCount = 0; + let runningCount = 0; + let succeedCount = 0; + allCalls.forEach((call, index) => { + expect(call.args.length).toBe(1); + const object = call.args[0].object; + expect(object instanceof Parse.Object).toBe(true); + const pushStatus = getPushStatus(index); + if (pushStatus.get('status') === 'pending') { + pendingCount += 1; + } + if (pushStatus.get('status') === 'running') { + runningCount += 1; + } + if (pushStatus.get('status') === 'succeeded') { + succeedCount += 1; + } + if (pushStatus.get('status') === 'running' && pushStatus.get('numSent') > 0) { + expect(pushStatus.get('numSent')).toBe(10); + expect(pushStatus.get('numFailed')).toBe(5); + expect(pushStatus.get('failedPerType')).toEqual({ + android: 5, + }); + expect(pushStatus.get('sentPerType')).toEqual({ + ios: 10, + }); + } + }); + expect(pendingCount).toBe(1); + expect(runningCount).toBe(2); + expect(succeedCount).toBe(1); + }); + + it_id('30e0591a-56de-4720-8c60-7d72291b532a')(it)('properly creates _PushStatus without serverURL', async () => { + const pushStatusAfterSave = { + handler: function () {}, + }; + Parse.Cloud.afterSave('_PushStatus', pushStatusAfterSave.handler); + const installation = new Parse.Object('_Installation'); + installation.set('installationId', 'installation'); + installation.set('deviceToken', 'device_token'); + installation.set('badge', 0); + installation.set('originalBadge', 0); + installation.set('deviceType', 'ios'); + + const payload = { + data: { + alert: 'Hello World!', + badge: 1, + }, + }; + + const pushAdapter = { + send: function (body, installations) { + return successfulIOS(body, installations); + }, + getValidPushTypes: function () { + return ['ios']; + }, + }; + await installation.save(); + await reconfigureServer({ + serverURL: 'http://localhost:8378/', // server with borked URL + push: { adapter: pushAdapter }, + }); + const config = Config.get(Parse.applicationId); + const auth = { + isMaster: true, + }; + const pushStatusId = await sendPush(payload, {}, config, auth); + // it is enqueued so it can take time + await jasmine.timeout(1000); + Parse.serverURL = 'http://localhost:8378/1'; // GOOD url + const result = await Parse.Push.getPushStatus(pushStatusId); + expect(result).toBeDefined(); + await pushCompleted(pushStatusId); + }); + + it('should properly report failures in _PushStatus', async () => { + const pushAdapter = { + send: function (body, installations) { + return installations.map(installation => { + return Promise.resolve({ + deviceType: installation.deviceType, + }); + }); + }, + getValidPushTypes: function () { + return ['ios']; + }, + }; + await reconfigureServer({ + push: { adapter: pushAdapter }, + }); + // $ins is invalid query + const where = { + channels: { + $ins: ['Giants', 'Mets'], + }, + }; + const payload = { + data: { + alert: 'Hello World!', + badge: 1, + }, + }; + const auth = { + isMaster: true, + }; + const pushController = new PushController(); + const config = Config.get(Parse.applicationId); + try { + await pushController.sendPush(payload, where, config, auth); + fail(); + } catch (e) { + const query = new Parse.Query('_PushStatus'); + let results = await query.find({ useMasterKey: true }); + while (results.length === 0) { + results = await query.find({ useMasterKey: true }); + } + expect(results.length).toBe(1); + const pushStatus = results[0]; + expect(pushStatus.get('status')).toBe('failed'); + } + }); + + it_id('53551fc3-b975-4774-92e6-7e5f3c05e105')(it)('should support full RESTQuery for increment', async () => { + const payload = { + data: { + alert: 'Hello World!', + badge: 'Increment', + }, + }; + + const pushAdapter = { + send: function (body, installations) { + return successfulTransmissions(body, installations); + }, + getValidPushTypes: function () { + return ['ios']; + }, + }; + await reconfigureServer({ + push: { adapter: pushAdapter }, + }); + const config = Config.get(Parse.applicationId); + const auth = { + isMaster: true, + }; + + const where = { + deviceToken: { + $in: ['device_token_0', 'device_token_1', 'device_token_2'], + }, + }; + const installations = []; + while (installations.length != 5) { + const installation = new Parse.Object('_Installation'); + installation.set('installationId', 'installation_' + installations.length); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); + installations.push(installation); + } + await Parse.Object.saveAll(installations); + const pushStatusId = await sendPush(payload, where, config, auth); + await pushCompleted(pushStatusId); + const pushStatus = await Parse.Push.getPushStatus(pushStatusId); + expect(pushStatus.get('numSent')).toBe(3); + }); + + it('should support object type for alert', async () => { + const payload = { + data: { + alert: { + 'loc-key': 'hello_world', + }, + }, + }; + + const pushAdapter = { + send: function (body, installations) { + return successfulTransmissions(body, installations); + }, + getValidPushTypes: function () { + return ['ios']; + }, + }; + await reconfigureServer({ + push: { adapter: pushAdapter }, + }); + const config = Config.get(Parse.applicationId); + const auth = { + isMaster: true, + }; + const where = { + deviceType: 'ios', + }; + const installations = []; + while (installations.length != 5) { + const installation = new Parse.Object('_Installation'); + installation.set('installationId', 'installation_' + installations.length); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); + installations.push(installation); + } + await Parse.Object.saveAll(installations); + const pushStatusId = await sendPush(payload, where, config, auth); + await pushCompleted(pushStatusId); + const pushStatus = await Parse.Push.getPushStatus(pushStatusId); + expect(pushStatus.get('numSent')).toBe(5); }); it('should flatten', () => { - var res = pushStatusHandler.flatten([1, [2], [[3, 4], 5], [[[6]]]]) - expect(res).toEqual([1,2,3,4,5,6]); - }) + const res = StatusHandler.flatten([1, [2], [[3, 4], 5], [[[6]]]]); + expect(res).toEqual([1, 2, 3, 4, 5, 6]); + }); + + it('properly transforms push time', () => { + expect(PushController.getPushTime()).toBe(undefined); + expect( + PushController.getPushTime({ + push_time: 1000, + }).date + ).toEqual(new Date(1000 * 1000)); + expect( + PushController.getPushTime({ + push_time: '2017-01-01', + }).date + ).toEqual(new Date('2017-01-01')); + + expect(() => { + PushController.getPushTime({ + push_time: 'gibberish-time', + }); + }).toThrow(); + expect(() => { + PushController.getPushTime({ + push_time: Number.NaN, + }); + }).toThrow(); + + expect( + PushController.getPushTime({ + push_time: '2017-09-06T13:42:48.369Z', + }) + ).toEqual({ + date: new Date('2017-09-06T13:42:48.369Z'), + isLocalTime: false, + }); + expect( + PushController.getPushTime({ + push_time: '2007-04-05T12:30-02:00', + }) + ).toEqual({ + date: new Date('2007-04-05T12:30-02:00'), + isLocalTime: false, + }); + expect( + PushController.getPushTime({ + push_time: '2007-04-05T12:30', + }) + ).toEqual({ + date: new Date('2007-04-05T12:30'), + isLocalTime: true, + }); + }); + + it('should not schedule push when not configured', async () => { + const pushAdapter = { + send: function (body, installations) { + return successfulTransmissions(body, installations); + }, + getValidPushTypes: function () { + return ['ios']; + }, + }; + await reconfigureServer({ + push: { adapter: pushAdapter }, + }); + const config = Config.get(Parse.applicationId); + const auth = { + isMaster: true, + }; + const pushController = new PushController(); + const payload = { + data: { + alert: 'hello', + }, + push_time: new Date().getTime(), + }; + + const installations = []; + while (installations.length != 10) { + const installation = new Parse.Object('_Installation'); + installation.set('installationId', 'installation_' + installations.length); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); + installations.push(installation); + } + await Parse.Object.saveAll(installations); + await pushController.sendPush(payload, {}, config, auth); + await jasmine.timeout(1000); + const query = new Parse.Query('_PushStatus'); + const results = await query.find({ useMasterKey: true }); + expect(results.length).toBe(1); + const pushStatus = results[0]; + expect(pushStatus.get('status')).not.toBe('scheduled'); + }); + + it('should schedule push when configured', async () => { + const auth = { + isMaster: true, + }; + const pushAdapter = { + send: function (body, installations) { + const promises = installations.map(device => { + if (!device.deviceToken) { + // Simulate error when device token is not set + return Promise.reject(); + } + return Promise.resolve({ + transmitted: true, + device: device, + }); + }); + + return Promise.all(promises); + }, + getValidPushTypes: function () { + return ['ios']; + }, + }; + const pushController = new PushController(); + const payload = { + data: { + alert: 'hello', + }, + push_time: new Date().getTime() / 1000, + }; + const installations = []; + while (installations.length != 10) { + const installation = new Parse.Object('_Installation'); + installation.set('installationId', 'installation_' + installations.length); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); + installations.push(installation); + } + await reconfigureServer({ + push: { adapter: pushAdapter }, + scheduledPush: true, + }); + const config = Config.get(Parse.applicationId); + await Parse.Object.saveAll(installations); + await pushController.sendPush(payload, {}, config, auth); + await jasmine.timeout(1000); + const query = new Parse.Query('_PushStatus'); + const results = await query.find({ useMasterKey: true }); + expect(results.length).toBe(1); + const pushStatus = results[0]; + expect(pushStatus.get('status')).toBe('scheduled'); + }); + + it('should not enqueue push when device token is not set', async () => { + const auth = { + isMaster: true, + }; + const pushAdapter = { + send: function (body, installations) { + const promises = installations.map(device => { + if (!device.deviceToken) { + // Simulate error when device token is not set + return Promise.reject(); + } + return Promise.resolve({ + transmitted: true, + device: device, + }); + }); + + return Promise.all(promises); + }, + getValidPushTypes: function () { + return ['ios']; + }, + }; + const payload = { + data: { + alert: 'hello', + }, + push_time: new Date().getTime() / 1000, + }; + const installations = []; + while (installations.length != 5) { + const installation = new Parse.Object('_Installation'); + installation.set('installationId', 'installation_' + installations.length); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); + installations.push(installation); + } + while (installations.length != 15) { + const installation = new Parse.Object('_Installation'); + installation.set('installationId', 'installation_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); + installations.push(installation); + } + await reconfigureServer({ + push: { adapter: pushAdapter }, + }); + const config = Config.get(Parse.applicationId); + await Parse.Object.saveAll(installations); + const pushStatusId = await sendPush(payload, {}, config, auth); + await pushCompleted(pushStatusId); + const pushStatus = await Parse.Push.getPushStatus(pushStatusId); + expect(pushStatus.get('numSent')).toBe(5); + expect(pushStatus.get('status')).toBe('succeeded'); + }); + + it('should not mark the _PushStatus as failed when audience has no deviceToken', async () => { + const auth = { + isMaster: true, + }; + const pushAdapter = { + send: function (body, installations) { + const promises = installations.map(device => { + if (!device.deviceToken) { + // Simulate error when device token is not set + return Promise.reject(); + } + return Promise.resolve({ + transmitted: true, + device: device, + }); + }); + + return Promise.all(promises); + }, + getValidPushTypes: function () { + return ['ios']; + }, + }; + const payload = { + data: { + alert: 'hello', + }, + push_time: new Date().getTime() / 1000, + }; + const installations = []; + while (installations.length != 5) { + const installation = new Parse.Object('_Installation'); + installation.set('installationId', 'installation_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); + installations.push(installation); + } + await reconfigureServer({ + push: { adapter: pushAdapter }, + }); + const config = Config.get(Parse.applicationId); + await Parse.Object.saveAll(installations); + const pushStatusId = await sendPush(payload, {}, config, auth); + await pushCompleted(pushStatusId); + const pushStatus = await Parse.Push.getPushStatus(pushStatusId); + expect(pushStatus.get('status')).toBe('succeeded'); + }); + + it('should support localized payload data', async () => { + const payload = { + data: { + alert: 'Hello!', + 'alert-fr': 'Bonjour', + 'alert-es': 'Ola', + }, + }; + const pushAdapter = { + send: function (body, installations) { + return successfulTransmissions(body, installations); + }, + getValidPushTypes: function () { + return ['ios']; + }, + }; + spyOn(pushAdapter, 'send').and.callThrough(); + await reconfigureServer({ + push: { adapter: pushAdapter }, + }); + const config = Config.get(Parse.applicationId); + const auth = { + isMaster: true, + }; + const where = { + deviceType: 'ios', + }; + const installations = []; + while (installations.length != 5) { + const installation = new Parse.Object('_Installation'); + installation.set('installationId', 'installation_' + installations.length); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); + installations.push(installation); + } + installations[0].set('localeIdentifier', 'fr-CA'); + installations[1].set('localeIdentifier', 'fr-FR'); + installations[2].set('localeIdentifier', 'en-US'); + + await Parse.Object.saveAll(installations); + const pushStatusId = await sendPush(payload, where, config, auth); + await pushCompleted(pushStatusId); + + expect(pushAdapter.send.calls.count()).toBe(2); + const firstCall = pushAdapter.send.calls.first(); + expect(firstCall.args[0].data).toEqual({ + alert: 'Hello!', + }); + expect(firstCall.args[1].length).toBe(3); // 3 installations + + const lastCall = pushAdapter.send.calls.mostRecent(); + expect(lastCall.args[0].data).toEqual({ + alert: 'Bonjour', + }); + expect(lastCall.args[1].length).toBe(2); // 2 installations + // No installation is in es so only 1 call for fr, and another for default + }); + + it_id('ef2e5569-50c3-40c2-ab49-175cdbd5f024')(it)('should update audiences', async () => { + const pushAdapter = { + send: function (body, installations) { + return successfulTransmissions(body, installations); + }, + getValidPushTypes: function () { + return ['ios']; + }, + }; + spyOn(pushAdapter, 'send').and.callThrough(); + await reconfigureServer({ + push: { adapter: pushAdapter }, + }); + const config = Config.get(Parse.applicationId); + const auth = { + isMaster: true, + }; + let audienceId = null; + const now = new Date(); + let timesUsed = 0; + const where = { + deviceType: 'ios', + }; + const installations = []; + while (installations.length != 5) { + const installation = new Parse.Object('_Installation'); + installation.set('installationId', 'installation_' + installations.length); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', installations.length); + installation.set('originalBadge', installations.length); + installation.set('deviceType', 'ios'); + installations.push(installation); + } + await Parse.Object.saveAll(installations); + + // Create an audience + const query = new Parse.Query('_Audience'); + query.descending('createdAt'); + query.equalTo('query', JSON.stringify(where)); + const parseResults = results => { + if (results.length > 0) { + audienceId = results[0].id; + timesUsed = results[0].get('timesUsed'); + if (!isFinite(timesUsed)) { + timesUsed = 0; + } + } + }; + const audience = new Parse.Object('_Audience'); + audience.set('name', 'testAudience'); + audience.set('query', JSON.stringify(where)); + await audience.save(null, { useMasterKey: true }); + await query.find({ useMasterKey: true }).then(parseResults); + + const body = { + data: { alert: 'hello' }, + audience_id: audienceId, + }; + const pushStatusId = await sendPush(body, where, config, auth); + await pushCompleted(pushStatusId); + expect(pushAdapter.send.calls.count()).toBe(1); + const firstCall = pushAdapter.send.calls.first(); + expect(firstCall.args[0].data).toEqual({ + alert: 'hello', + }); + expect(firstCall.args[1].length).toBe(5); + + // Get the audience we used above. + const audienceQuery = new Parse.Query('_Audience'); + audienceQuery.equalTo('objectId', audienceId); + const results = await audienceQuery.find({ useMasterKey: true }); + + expect(results[0].get('query')).toBe(JSON.stringify(where)); + expect(results[0].get('timesUsed')).toBe(timesUsed + 1); + expect(results[0].get('lastUsed')).not.toBeLessThan(now); + }); + + describe('pushTimeHasTimezoneComponent', () => { + it('should be accurate', () => { + expect(PushController.pushTimeHasTimezoneComponent('2017-09-06T17:14:01.048Z')).toBe( + true, + 'UTC time' + ); + expect(PushController.pushTimeHasTimezoneComponent('2007-04-05T12:30-02:00')).toBe( + true, + 'Timezone offset' + ); + expect(PushController.pushTimeHasTimezoneComponent('2007-04-05T12:30:00.000Z-02:00')).toBe( + true, + 'Seconds + Milliseconds + Timezone offset' + ); + + expect(PushController.pushTimeHasTimezoneComponent('2017-09-06T17:14:01.048')).toBe( + false, + 'No timezone' + ); + expect(PushController.pushTimeHasTimezoneComponent('2017-09-06')).toBe(false, 'YY-MM-DD'); + }); + }); + + describe('formatPushTime', () => { + it('should format as ISO string', () => { + expect( + PushController.formatPushTime({ + date: new Date('2017-09-06T17:14:01.048Z'), + isLocalTime: false, + }) + ).toBe('2017-09-06T17:14:01.048Z', 'UTC time'); + expect( + PushController.formatPushTime({ + date: new Date('2007-04-05T12:30-02:00'), + isLocalTime: false, + }) + ).toBe('2007-04-05T14:30:00.000Z', 'Timezone offset'); + + const noTimezone = new Date('2017-09-06T17:14:01.048'); + let expectedHour = 17 + noTimezone.getTimezoneOffset() / 60; + let day = '06'; + if (expectedHour >= 24) { + expectedHour = expectedHour - 24; + day = '07'; + } + expect( + PushController.formatPushTime({ + date: noTimezone, + isLocalTime: true, + }) + ).toBe(`2017-09-${day}T${expectedHour.toString().padStart(2, '0')}:14:01.048`, 'No timezone'); + expect( + PushController.formatPushTime({ + date: new Date('2017-09-06'), + isLocalTime: true, + }) + ).toBe('2017-09-06T00:00:00.000', 'YY-MM-DD'); + }); + }); + + describe('Scheduling pushes in local time', () => { + it('should preserve the push time', async () => { + const auth = { isMaster: true }; + const pushAdapter = { + send(body, installations) { + return successfulTransmissions(body, installations); + }, + getValidPushTypes() { + return ['ios']; + }, + }; + const pushTime = '2017-09-06T17:14:01.048'; + let expectedHour = 17 + new Date(pushTime).getTimezoneOffset() / 60; + let day = '06'; + if (expectedHour >= 24) { + expectedHour = expectedHour - 24; + day = '07'; + } + const payload = { + data: { + alert: 'Hello World!', + badge: 'Increment', + }, + push_time: pushTime, + }; + await reconfigureServer({ + push: { adapter: pushAdapter }, + scheduledPush: true, + }); + const config = Config.get(Parse.applicationId); + const pushStatusId = await sendPush(payload, {}, config, auth); + const pushStatus = await Parse.Push.getPushStatus(pushStatusId); + expect(pushStatus.get('status')).toBe('scheduled'); + expect(pushStatus.get('pushTime')).toBe( + `2017-09-${day}T${expectedHour.toString().padStart(2, '0')}:14:01.048` + ); + }); + }); + + describe('With expiration defined', () => { + const auth = { isMaster: true }; + const pushController = new PushController(); + + let config; + + const pushes = []; + const pushAdapter = { + send(body, installations) { + pushes.push(body); + return successfulTransmissions(body, installations); + }, + getValidPushTypes() { + return ['ios']; + }, + }; + + beforeEach(async () => { + await reconfigureServer({ + push: { adapter: pushAdapter }, + }); + config = Config.get(Parse.applicationId); + }); + + it('should throw if both expiration_time and expiration_interval are set', () => { + expect(() => + pushController.sendPush( + { + expiration_time: '2017-09-25T13:21:20.841Z', + expiration_interval: 1000, + }, + {}, + config, + auth + ) + ).toThrow(); + }); + + it('should throw on invalid expiration_interval', () => { + expect(() => + pushController.sendPush( + { + expiration_interval: -1, + }, + {}, + config, + auth + ) + ).toThrow(); + expect(() => + pushController.sendPush( + { + expiration_interval: '', + }, + {}, + config, + auth + ) + ).toThrow(); + expect(() => + pushController.sendPush( + { + expiration_time: {}, + }, + {}, + config, + auth + ) + ).toThrow(); + }); + + describe('For immediate pushes', () => { + it('should transform the expiration_interval into an absolute time', async () => { + const now = new Date('2017-09-25T13:30:10.452Z'); + const payload = { + data: { + alert: 'immediate push', + }, + expiration_interval: 20 * 60, // twenty minutes + }; + await reconfigureServer({ + push: { adapter: pushAdapter }, + }); + const pushStatusId = await sendPush( + payload, + {}, + Config.get(Parse.applicationId), + auth, + now + ); + const pushStatus = await Parse.Push.getPushStatus(pushStatusId); + expect(pushStatus.get('expiry')).toBeDefined('expiry must be set'); + expect(pushStatus.get('expiry')).toEqual(new Date('2017-09-25T13:50:10.452Z').valueOf()); + + expect(pushStatus.get('expiration_interval')).toBeDefined( + 'expiration_interval must be defined' + ); + expect(pushStatus.get('expiration_interval')).toBe(20 * 60); + }); + }); + }); }); diff --git a/spec/PushQueue.spec.js b/spec/PushQueue.spec.js new file mode 100644 index 0000000000..db851ba22e --- /dev/null +++ b/spec/PushQueue.spec.js @@ -0,0 +1,61 @@ +const Config = require('../lib/Config'); +const { PushQueue } = require('../lib/Push/PushQueue'); + +describe('PushQueue', () => { + describe('With a defined channel', () => { + it('should be propagated to the PushWorker and PushQueue', done => { + reconfigureServer({ + push: { + queueOptions: { + disablePushWorker: false, + channel: 'my-specific-channel', + }, + adapter: { + send() { + return Promise.resolve(); + }, + getValidPushTypes() { + return []; + }, + }, + }, + }) + .then(() => { + const config = Config.get(Parse.applicationId); + expect(config.pushWorker.channel).toEqual('my-specific-channel', 'pushWorker.channel'); + expect(config.pushControllerQueue.channel).toEqual( + 'my-specific-channel', + 'pushWorker.channel' + ); + }) + .then(done, done.fail); + }); + }); + + describe('Default channel', () => { + it('should be prefixed with the applicationId', done => { + reconfigureServer({ + push: { + queueOptions: { + disablePushWorker: false, + }, + adapter: { + send() { + return Promise.resolve(); + }, + getValidPushTypes() { + return []; + }, + }, + }, + }) + .then(() => { + const config = Config.get(Parse.applicationId); + expect(PushQueue.defaultPushChannel()).toEqual('test-parse-server-push'); + expect(config.pushWorker.channel).toEqual('test-parse-server-push'); + expect(config.pushControllerQueue.channel).toEqual('test-parse-server-push'); + }) + .then(done, done.fail); + }); + }); +}); diff --git a/spec/PushRouter.spec.js b/spec/PushRouter.spec.js index 7b30ecfd45..99ff17d243 100644 --- a/spec/PushRouter.spec.js +++ b/spec/PushRouter.spec.js @@ -1,91 +1,90 @@ -var PushRouter = require('../src/Routers/PushRouter').PushRouter; -var request = require('request'); +const PushRouter = require('../lib/Routers/PushRouter').PushRouter; +const request = require('../lib/request'); describe('PushRouter', () => { - it('can get query condition when channels is set', (done) => { + it('can get query condition when channels is set', done => { // Make mock request - var request = { + const request = { body: { - channels: ['Giants', 'Mets'] - } - } + channels: ['Giants', 'Mets'], + }, + }; - var where = PushRouter.getQueryCondition(request); + const where = PushRouter.getQueryCondition(request); expect(where).toEqual({ - 'channels': { - '$in': ['Giants', 'Mets'] - } + channels: { + $in: ['Giants', 'Mets'], + }, }); done(); }); - it('can get query condition when where is set', (done) => { + it('can get query condition when where is set', done => { // Make mock request - var request = { + const request = { body: { - 'where': { - 'injuryReports': true - } - } - } + where: { + injuryReports: true, + }, + }, + }; - var where = PushRouter.getQueryCondition(request); + const where = PushRouter.getQueryCondition(request); expect(where).toEqual({ - 'injuryReports': true + injuryReports: true, }); done(); }); - it('can get query condition when nothing is set', (done) => { + it('can get query condition when nothing is set', done => { // Make mock request - var request = { - body: { - } - } + const request = { + body: {}, + }; - expect(function() { + expect(function () { PushRouter.getQueryCondition(request); }).toThrow(); done(); }); - it('can throw on getQueryCondition when channels and where are set', (done) => { + it('can throw on getQueryCondition when channels and where are set', done => { // Make mock request - var request = { + const request = { body: { - 'channels': { - '$in': ['Giants', 'Mets'] + channels: { + $in: ['Giants', 'Mets'], }, - 'where': { - 'injuryReports': true - } - } - } + where: { + injuryReports: true, + }, + }, + }; - expect(function() { + expect(function () { PushRouter.getQueryCondition(request); }).toThrow(); done(); }); - it_exclude_dbs(['postgres'])('sends a push through REST', (done) => { - request.post({ - url: Parse.serverURL+"/push", - json: true, + it('sends a push through REST', done => { + request({ + method: 'POST', + url: Parse.serverURL + '/push', body: { - 'channels': { - '$in': ['Giants', 'Mets'] - } + channels: { + $in: ['Giants', 'Mets'], + }, }, headers: { 'X-Parse-Application-Id': Parse.applicationId, - 'X-Parse-Master-Key': Parse.masterKey - } - }, function(err, res, body){ + 'X-Parse-Master-Key': Parse.masterKey, + 'Content-Type': 'application/json', + }, + }).then(res => { expect(res.headers['x-parse-push-status-id']).not.toBe(undefined); expect(res.headers['x-parse-push-status-id'].length).toBe(10); - expect(res.headers['']) - expect(body.result).toBe(true); + expect(res.data.result).toBe(true); done(); }); }); diff --git a/spec/PushWorker.spec.js b/spec/PushWorker.spec.js new file mode 100644 index 0000000000..6299962d52 --- /dev/null +++ b/spec/PushWorker.spec.js @@ -0,0 +1,419 @@ +const PushWorker = require('../lib').PushWorker; +const PushUtils = require('../lib/Push/utils'); +const Config = require('../lib/Config'); +const { pushStatusHandler } = require('../lib/StatusHandler'); +const rest = require('../lib/rest'); + +describe('PushWorker', () => { + it('should run with small batch', done => { + const batchSize = 3; + let sendCount = 0; + reconfigureServer({ + push: { + queueOptions: { + disablePushWorker: true, + batchSize, + }, + }, + }) + .then(() => { + expect(Config.get('test').pushWorker).toBeUndefined(); + new PushWorker({ + send: (body, installations) => { + expect(installations.length <= batchSize).toBe(true); + sendCount += installations.length; + return Promise.resolve(); + }, + getValidPushTypes: function () { + return ['ios', 'android']; + }, + }); + const installations = []; + while (installations.length != 10) { + const installation = new Parse.Object('_Installation'); + installation.set('installationId', 'installation_' + installations.length); + installation.set('deviceToken', 'device_token_' + installations.length); + installation.set('badge', 1); + installation.set('deviceType', 'ios'); + installations.push(installation); + } + return Parse.Object.saveAll(installations); + }) + .then(() => { + return Parse.Push.send( + { + where: { + deviceType: 'ios', + }, + data: { + alert: 'Hello world!', + }, + }, + { useMasterKey: true } + ); + }) + .then(() => { + return new Promise(resolve => { + setTimeout(resolve, 500); + }); + }) + .then(() => { + expect(sendCount).toBe(10); + done(); + }) + .catch(err => { + jfail(err); + }); + }); + + describe('localized push', () => { + it('should return locales', () => { + const locales = PushUtils.getLocalesFromPush({ + data: { + 'alert-fr': 'french', + alert: 'Yo!', + 'alert-en-US': 'English', + }, + }); + expect(locales).toEqual(['fr', 'en-US']); + }); + + it('should return and empty array if no locale is set', () => { + const locales = PushUtils.getLocalesFromPush({ + data: { + alert: 'Yo!', + }, + }); + expect(locales).toEqual([]); + }); + + it('should deduplicate locales', () => { + const locales = PushUtils.getLocalesFromPush({ + data: { + alert: 'Yo!', + 'alert-fr': 'french', + 'title-fr': 'french', + }, + }); + expect(locales).toEqual(['fr']); + }); + + it('should handle empty body data', () => { + expect(PushUtils.getLocalesFromPush({})).toEqual([]); + }); + + it('transforms body appropriately', () => { + const cleanBody = PushUtils.transformPushBodyForLocale( + { + data: { + alert: 'Yo!', + 'alert-fr': 'frenchy!', + 'alert-en': 'english', + }, + }, + 'fr' + ); + expect(cleanBody).toEqual({ + data: { + alert: 'frenchy!', + }, + }); + }); + + it('transforms body appropriately with title locale', () => { + const cleanBody = PushUtils.transformPushBodyForLocale( + { + data: { + alert: 'Yo!', + 'alert-fr': 'frenchy!', + 'alert-en': 'english', + 'title-fr': 'french title', + }, + }, + 'fr' + ); + expect(cleanBody).toEqual({ + data: { + alert: 'frenchy!', + title: 'french title', + }, + }); + }); + + it('maps body on all provided locales', () => { + const bodies = PushUtils.bodiesPerLocales( + { + data: { + alert: 'Yo!', + 'alert-fr': 'frenchy!', + 'alert-en': 'english', + 'title-fr': 'french title', + }, + }, + ['fr', 'en'] + ); + expect(bodies).toEqual({ + fr: { + data: { + alert: 'frenchy!', + title: 'french title', + }, + }, + en: { + data: { + alert: 'english', + }, + }, + default: { + data: { + alert: 'Yo!', + }, + }, + }); + }); + + it('should properly handle default cases', () => { + expect(PushUtils.transformPushBodyForLocale({})).toEqual({}); + expect(PushUtils.stripLocalesFromBody({})).toEqual({}); + expect(PushUtils.bodiesPerLocales({ where: {} })).toEqual({ + default: { where: {} }, + }); + expect(PushUtils.groupByLocaleIdentifier([])).toEqual({ default: [] }); + }); + }); + + describe('pushStatus', () => { + it('should remove invalid installations', done => { + const config = Config.get('test'); + const handler = pushStatusHandler(config); + const spy = spyOn(config.database, 'update').and.callFake(() => { + return Promise.resolve({}); + }); + const toAwait = handler.trackSent( + [ + { + transmitted: false, + device: { + deviceToken: 1, + deviceType: 'ios', + }, + response: { error: 'Unregistered' }, + }, + { + transmitted: true, + device: { + deviceToken: 10, + deviceType: 'ios', + }, + }, + { + transmitted: false, + device: { + deviceToken: 2, + deviceType: 'ios', + }, + response: { error: 'NotRegistered' }, + }, + { + transmitted: false, + device: { + deviceToken: 3, + deviceType: 'ios', + }, + response: { error: 'InvalidRegistration' }, + }, + { + transmitted: true, + device: { + deviceToken: 11, + deviceType: 'ios', + }, + }, + { + transmitted: false, + device: { + deviceToken: 4, + deviceType: 'ios', + }, + response: { error: 'InvalidRegistration' }, + }, + { + transmitted: false, + device: { + deviceToken: 5, + deviceType: 'ios', + }, + response: { error: 'InvalidRegistration' }, + }, + { + // should not be deleted + transmitted: false, + device: { + deviceToken: Parse.Error.OBJECT_NOT_FOUND, + deviceType: 'ios', + }, + response: { error: 'invalid error...' }, + }, + ], + undefined, + true + ); + expect(spy).toHaveBeenCalled(); + expect(spy.calls.count()).toBe(1); + const lastCall = spy.calls.mostRecent(); + expect(lastCall.args[0]).toBe('_Installation'); + expect(lastCall.args[1]).toEqual({ + deviceToken: { $in: [1, 2, 3, 4, 5] }, + }); + expect(lastCall.args[2]).toEqual({ + deviceToken: { __op: 'Delete' }, + }); + toAwait.then(done).catch(done); + }); + + it_id('764d28ab-241b-4b96-8ce9-e03541850e3f')(it)('tracks push status per UTC offsets', done => { + const config = Config.get('test'); + const handler = pushStatusHandler(config); + const spy = spyOn(rest, 'update').and.callThrough(); + const UTCOffset = 1; + handler + .setInitial() + .then(() => { + return handler.trackSent( + [ + { + transmitted: false, + device: { + deviceToken: 1, + deviceType: 'ios', + }, + }, + { + transmitted: true, + device: { + deviceToken: 1, + deviceType: 'ios', + }, + }, + ], + UTCOffset + ); + }) + .then(() => { + expect(spy).toHaveBeenCalled(); + const lastCall = spy.calls.mostRecent(); + expect(lastCall.args[2]).toBe(`_PushStatus`); + expect(lastCall.args[4]).toEqual({ + numSent: { __op: 'Increment', amount: 1 }, + numFailed: { __op: 'Increment', amount: 1 }, + 'sentPerType.ios': { __op: 'Increment', amount: 1 }, + 'failedPerType.ios': { __op: 'Increment', amount: 1 }, + [`sentPerUTCOffset.${UTCOffset}`]: { __op: 'Increment', amount: 1 }, + [`failedPerUTCOffset.${UTCOffset}`]: { + __op: 'Increment', + amount: 1, + }, + count: { __op: 'Increment', amount: -1 }, + status: 'running', + }); + const query = new Parse.Query('_PushStatus'); + return query.get(handler.objectId, { useMasterKey: true }); + }) + .then(pushStatus => { + const sentPerUTCOffset = pushStatus.get('sentPerUTCOffset'); + expect(sentPerUTCOffset['1']).toBe(1); + const failedPerUTCOffset = pushStatus.get('failedPerUTCOffset'); + expect(failedPerUTCOffset['1']).toBe(1); + return handler.trackSent( + [ + { + transmitted: false, + device: { + deviceToken: 1, + deviceType: 'ios', + }, + }, + { + transmitted: true, + device: { + deviceToken: 1, + deviceType: 'ios', + }, + }, + { + transmitted: true, + device: { + deviceToken: 1, + deviceType: 'ios', + }, + }, + ], + UTCOffset + ); + }) + .then(() => { + const query = new Parse.Query('_PushStatus'); + return query.get(handler.objectId, { useMasterKey: true }); + }) + .then(pushStatus => { + const sentPerUTCOffset = pushStatus.get('sentPerUTCOffset'); + expect(sentPerUTCOffset['1']).toBe(3); + const failedPerUTCOffset = pushStatus.get('failedPerUTCOffset'); + expect(failedPerUTCOffset['1']).toBe(2); + }) + .then(done) + .catch(done.fail); + }); + + it('tracks push status per UTC offsets with negative offsets', done => { + const config = Config.get('test'); + const handler = pushStatusHandler(config); + const spy = spyOn(rest, 'update').and.callThrough(); + const UTCOffset = -6; + handler + .setInitial() + .then(() => { + return handler.trackSent( + [ + { + transmitted: false, + device: { + deviceToken: 1, + deviceType: 'ios', + }, + response: { error: 'Unregistered' }, + }, + { + transmitted: true, + device: { + deviceToken: 1, + deviceType: 'ios', + }, + response: { error: 'Unregistered' }, + }, + ], + UTCOffset + ); + }) + .then(() => { + expect(spy).toHaveBeenCalled(); + const lastCall = spy.calls.mostRecent(); + expect(lastCall.args[2]).toBe('_PushStatus'); + expect(lastCall.args[4]).toEqual({ + numSent: { __op: 'Increment', amount: 1 }, + numFailed: { __op: 'Increment', amount: 1 }, + 'sentPerType.ios': { __op: 'Increment', amount: 1 }, + 'failedPerType.ios': { __op: 'Increment', amount: 1 }, + [`sentPerUTCOffset.${UTCOffset}`]: { __op: 'Increment', amount: 1 }, + [`failedPerUTCOffset.${UTCOffset}`]: { + __op: 'Increment', + amount: 1, + }, + count: { __op: 'Increment', amount: -1 }, + status: 'running', + }); + done(); + }); + }); + }); +}); diff --git a/spec/QueryTools.spec.js b/spec/QueryTools.spec.js index 50433f5805..109c2a209b 100644 --- a/spec/QueryTools.spec.js +++ b/spec/QueryTools.spec.js @@ -1,27 +1,26 @@ -var Parse = require('parse/node'); +const Parse = require('parse/node'); -var Id = require('../src/LiveQuery/Id'); -var QueryTools = require('../src/LiveQuery/QueryTools'); -var queryHash = QueryTools.queryHash; -var matchesQuery = QueryTools.matchesQuery; +const Id = require('../lib/LiveQuery/Id'); +const QueryTools = require('../lib/LiveQuery/QueryTools'); +const queryHash = QueryTools.queryHash; +const matchesQuery = QueryTools.matchesQuery; -var Item = Parse.Object.extend('Item'); +const Item = Parse.Object.extend('Item'); -describe('queryHash', function() { - - it('should always hash a query to the same string', function() { - var q = new Parse.Query(Item); +describe('queryHash', function () { + it('should always hash a query to the same string', function () { + const q = new Parse.Query(Item); q.equalTo('field', 'value'); q.exists('name'); q.ascending('createdAt'); q.limit(10); - var firstHash = queryHash(q); - var secondHash = queryHash(q); + const firstHash = queryHash(q); + const secondHash = queryHash(q); expect(firstHash).toBe(secondHash); }); - it('should return equivalent hashes for equivalent queries', function() { - var q1 = new Parse.Query(Item); + it('should return equivalent hashes for equivalent queries', function () { + let q1 = new Parse.Query(Item); q1.equalTo('field', 'value'); q1.exists('name'); q1.lessThan('age', 30); @@ -30,7 +29,7 @@ describe('queryHash', function() { q1.include(['name', 'age']); q1.limit(10); - var q2 = new Parse.Query(Item); + let q2 = new Parse.Query(Item); q2.limit(10); q2.greaterThan('age', 3); q2.lessThan('age', 30); @@ -39,8 +38,8 @@ describe('queryHash', function() { q2.exists('name'); q2.equalTo('field', 'value'); - var firstHash = queryHash(q1); - var secondHash = queryHash(q2); + let firstHash = queryHash(q1); + let secondHash = queryHash(q2); expect(firstHash).toBe(secondHash); q1.containedIn('fruit', ['apple', 'banana', 'cherry']); @@ -69,11 +68,11 @@ describe('queryHash', function() { expect(firstHash).toBe(secondHash); }); - it('should not let fields of different types appear similar', function() { - var q1 = new Parse.Query(Item); + it('should not let fields of different types appear similar', function () { + let q1 = new Parse.Query(Item); q1.lessThan('age', 30); - var q2 = new Parse.Query(Item); + const q2 = new Parse.Query(Item); q2.equalTo('age', '{$lt:30}'); expect(queryHash(q1)).not.toBe(queryHash(q2)); @@ -87,37 +86,37 @@ describe('queryHash', function() { }); }); -describe('matchesQuery', function() { - it('matches blanket queries', function() { - var obj = { +describe('matchesQuery', function () { + it('matches blanket queries', function () { + const obj = { id: new Id('Klass', 'O1'), - value: 12 + value: 12, }; - var q = new Parse.Query('Klass'); + const q = new Parse.Query('Klass'); expect(matchesQuery(obj, q)).toBe(true); obj.id = new Id('Other', 'O1'); expect(matchesQuery(obj, q)).toBe(false); }); - it('matches existence queries', function() { - var obj = { + it('matches existence queries', function () { + const obj = { id: new Id('Item', 'O1'), - count: 15 + count: 15, }; - var q = new Parse.Query('Item'); + const q = new Parse.Query('Item'); q.exists('count'); expect(matchesQuery(obj, q)).toBe(true); q.exists('name'); expect(matchesQuery(obj, q)).toBe(false); }); - it('matches queries with doesNotExist constraint', function() { - var obj = { + it('matches queries with doesNotExist constraint', function () { + const obj = { id: new Id('Item', 'O1'), - count: 15 + count: 15, }; - var q = new Parse.Query('Item'); + let q = new Parse.Query('Item'); q.doesNotExist('name'); expect(matchesQuery(obj, q)).toBe(true); @@ -126,21 +125,50 @@ describe('matchesQuery', function() { expect(matchesQuery(obj, q)).toBe(false); }); - it('matches on equality queries', function() { - var day = new Date(); - var location = new Parse.GeoPoint({ + it('matches queries with eq constraint', function () { + const obj = { + objectId: 'Person2', + score: 12, + name: 'Tom', + }; + + const q1 = { + objectId: { + $eq: 'Person2', + }, + }; + + const q2 = { + score: { + $eq: 12, + }, + }; + + const q3 = { + name: { + $eq: 'Tom', + }, + }; + expect(matchesQuery(obj, q1)).toBe(true); + expect(matchesQuery(obj, q2)).toBe(true); + expect(matchesQuery(obj, q3)).toBe(true); + }); + + it('matches on equality queries', function () { + const day = new Date(); + const location = new Parse.GeoPoint({ latitude: 37.484815, - longitude: -122.148377 + longitude: -122.148377, }); - var obj = { + const obj = { id: new Id('Person', 'O1'), score: 12, name: 'Bill', birthday: day, - lastLocation: location + lastLocation: location, }; - var q = new Parse.Query('Person'); + let q = new Parse.Query('Person'); q.equalTo('score', 12); expect(matchesQuery(obj, q)).toBe(true); @@ -169,21 +197,30 @@ describe('matchesQuery', function() { expect(matchesQuery(obj, q)).toBe(false); q = new Parse.Query('Person'); - q.equalTo('lastLocation', new Parse.GeoPoint({ - latitude: 37.484815, - longitude: -122.148377 - })); + q.equalTo( + 'lastLocation', + new Parse.GeoPoint({ + latitude: 37.484815, + longitude: -122.148377, + }) + ); expect(matchesQuery(obj, q)).toBe(true); - q.equalTo('lastLocation', new Parse.GeoPoint({ - latitude: 37.4848, - longitude: -122.1483 - })); + q.equalTo( + 'lastLocation', + new Parse.GeoPoint({ + latitude: 37.4848, + longitude: -122.1483, + }) + ); expect(matchesQuery(obj, q)).toBe(false); - q.equalTo('lastLocation', new Parse.GeoPoint({ - latitude: 37.484815, - longitude: -122.148377 - })); + q.equalTo( + 'lastLocation', + new Parse.GeoPoint({ + latitude: 37.484815, + longitude: -122.148377, + }) + ); q.equalTo('score', 12); q.equalTo('name', 'Bill'); q.equalTo('birthday', day); @@ -192,9 +229,9 @@ describe('matchesQuery', function() { q.equalTo('name', 'bill'); expect(matchesQuery(obj, q)).toBe(false); - var img = { + let img = { id: new Id('Image', 'I1'), - tags: ['nofilter', 'latergram', 'tbt'] + tags: ['nofilter', 'latergram', 'tbt'], }; q = new Parse.Query('Image'); @@ -203,13 +240,13 @@ describe('matchesQuery', function() { q.equalTo('tags', 'tbt'); expect(matchesQuery(img, q)).toBe(true); - var q2 = new Parse.Query('Image'); + const q2 = new Parse.Query('Image'); q2.containsAll('tags', ['latergram', 'nofilter']); expect(matchesQuery(img, q2)).toBe(true); q2.containsAll('tags', ['latergram', 'selfie']); expect(matchesQuery(img, q2)).toBe(false); - var u = new Parse.User(); + const u = new Parse.User(); u.id = 'U2'; q = new Parse.Query('Image'); q.equalTo('owner', u); @@ -219,23 +256,42 @@ describe('matchesQuery', function() { objectId: 'I1', owner: { className: '_User', - objectId: 'U2' - } + objectId: 'U2', + }, }; expect(matchesQuery(img, q)).toBe(true); img.owner.objectId = 'U3'; expect(matchesQuery(img, q)).toBe(false); + + // pointers in arrays + q = new Parse.Query('Image'); + q.equalTo('owners', u); + + img = { + className: 'Image', + objectId: 'I1', + owners: [ + { + className: '_User', + objectId: 'U2', + }, + ], + }; + expect(matchesQuery(img, q)).toBe(true); + + img.owners[0].objectId = 'U3'; + expect(matchesQuery(img, q)).toBe(false); }); - it('matches on inequalities', function() { - var player = { + it('matches on inequalities', function () { + const player = { id: new Id('Person', 'O1'), score: 12, name: 'Bill', birthday: new Date(1980, 2, 4), }; - var q = new Parse.Query('Person'); + let q = new Parse.Query('Person'); q.lessThan('score', 15); expect(matchesQuery(player, q)).toBe(true); q.lessThan('score', 10); @@ -270,30 +326,84 @@ describe('matchesQuery', function() { expect(matchesQuery(player, q)).toBe(true); }); - it('matches an $or query', function() { - var player = { + it('matches an $or query', function () { + const player = { id: new Id('Player', 'P1'), name: 'Player 1', - score: 12 + score: 12, }; - var q = new Parse.Query('Player'); + const q = new Parse.Query('Player'); q.equalTo('name', 'Player 1'); - var q2 = new Parse.Query('Player'); + const q2 = new Parse.Query('Player'); q2.equalTo('name', 'Player 2'); - var orQuery = Parse.Query.or(q, q2); + const orQuery = Parse.Query.or(q, q2); expect(matchesQuery(player, q)).toBe(true); expect(matchesQuery(player, q2)).toBe(false); expect(matchesQuery(player, orQuery)).toBe(true); }); - it('matches $regex queries', function() { - var player = { + it('does not match $all query when value is missing', () => { + const player = { + id: new Id('Player', 'P1'), + name: 'Player 1', + score: 12, + }; + const q = { missing: { $all: [1, 2, 3] } }; + expect(matchesQuery(player, q)).toBe(false); + }); + + it('matches an $and query', () => { + const player = { + id: new Id('Player', 'P1'), + name: 'Player 1', + score: 12, + }; + + const q = new Parse.Query('Player'); + q.equalTo('name', 'Player 1'); + const q2 = new Parse.Query('Player'); + q2.equalTo('score', 12); + const q3 = new Parse.Query('Player'); + q3.equalTo('score', 100); + const andQuery1 = Parse.Query.and(q, q2); + const andQuery2 = Parse.Query.and(q, q3); + expect(matchesQuery(player, q)).toBe(true); + expect(matchesQuery(player, q2)).toBe(true); + expect(matchesQuery(player, andQuery1)).toBe(true); + expect(matchesQuery(player, andQuery2)).toBe(false); + }); + + it('matches an $nor query', () => { + const player = { + id: new Id('Player', 'P1'), + name: 'Player 1', + score: 12, + }; + + const q = new Parse.Query('Player'); + q.equalTo('name', 'Player 1'); + const q2 = new Parse.Query('Player'); + q2.equalTo('name', 'Player 2'); + const q3 = new Parse.Query('Player'); + q3.equalTo('name', 'Player 3'); + + const norQuery1 = Parse.Query.nor(q, q2); + const norQuery2 = Parse.Query.nor(q2, q3); + expect(matchesQuery(player, q)).toBe(true); + expect(matchesQuery(player, q2)).toBe(false); + expect(matchesQuery(player, q3)).toBe(false); + expect(matchesQuery(player, norQuery1)).toBe(false); + expect(matchesQuery(player, norQuery2)).toBe(true); + }); + + it('matches $regex queries', function () { + const player = { id: new Id('Player', 'P1'), name: 'Player 1', - score: 12 + score: 12, }; - var q = new Parse.Query('Player'); + let q = new Parse.Query('Player'); q.startsWith('name', 'Play'); expect(matchesQuery(player, q)).toBe(true); q.startsWith('name', 'Ploy'); @@ -335,15 +445,189 @@ describe('matchesQuery', function() { expect(matchesQuery(player, q)).toBe(false); }); - it('matches $nearSphere queries', function() { - var q = new Parse.Query('Checkin'); + it('rejects $regex with catastrophic backtracking pattern (string)', function () { + const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools'); + setRegexTimeout(100); + try { + const player = { + id: new Id('Player', 'P1'), + name: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaac', + }; + + // (a+)+b - classic catastrophic backtracking pattern + let q = new Parse.Query('Player'); + q._addCondition('name', '$regex', '(a+)+b'); + expect(matchesQuery(player, q)).toBe(false); + + // (a|a)+b - exponential alternation + q = new Parse.Query('Player'); + q._addCondition('name', '$regex', '(a|a)+b'); + expect(matchesQuery(player, q)).toBe(false); + + // (a+){2,}b - nested quantifiers + q = new Parse.Query('Player'); + q._addCondition('name', '$regex', '(a+){2,}b'); + expect(matchesQuery(player, q)).toBe(false); + } finally { + setRegexTimeout(0); + } + }); + + it('rejects $regex with catastrophic backtracking pattern (RegExp object)', function () { + const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools'); + setRegexTimeout(100); + try { + const player = { + id: new Id('Player', 'P1'), + name: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaac', + }; + + const q = new Parse.Query('Player'); + q.matches('name', /(a+)+b/); + expect(matchesQuery(player, q)).toBe(false); + } finally { + setRegexTimeout(0); + } + }); + + it('still matches safe $regex patterns with regexTimeout enabled', function () { + const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools'); + setRegexTimeout(100); + try { + const player = { + id: new Id('Player', 'P1'), + name: 'Player 1', + }; + + // Safe string regex + let q = new Parse.Query('Player'); + q.startsWith('name', 'Play'); + expect(matchesQuery(player, q)).toBe(true); + + q = new Parse.Query('Player'); + q.endsWith('name', ' 1'); + expect(matchesQuery(player, q)).toBe(true); + + q = new Parse.Query('Player'); + q.contains('name', 'ayer'); + expect(matchesQuery(player, q)).toBe(true); + + // Safe RegExp object + q = new Parse.Query('Player'); + q.matches('name', /Play.*/); + expect(matchesQuery(player, q)).toBe(true); + + // Case-insensitive + q = new Parse.Query('Player'); + q._addCondition('name', '$regex', 'player'); + q._addCondition('name', '$options', 'i'); + expect(matchesQuery(player, q)).toBe(true); + } finally { + setRegexTimeout(0); + } + }); + + it('matches $regex with backreferences when regexTimeout is enabled', function () { + const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools'); + setRegexTimeout(100); + try { + const player = { + id: new Id('Player', 'P1'), + name: 'aa', + }; + + const q = new Parse.Query('Player'); + q._addCondition('name', '$regex', '(a)\\1'); + expect(matchesQuery(player, q)).toBe(true); + } finally { + setRegexTimeout(0); + } + }); + + it('uses native RegExp when regexTimeout is 0 (disabled)', function () { + const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools'); + setRegexTimeout(0); + const player = { + id: new Id('Player', 'P1'), + name: 'Player 1', + }; + + const q = new Parse.Query('Player'); + q.startsWith('name', 'Play'); + expect(matchesQuery(player, q)).toBe(true); + }); + + it('applies default regexTimeout when liveQuery is configured without explicit regexTimeout', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['Player'] }, + }); + // Verify the default value is applied by checking the config + const Config = require('../lib/Config'); + const config = Config.get('test'); + expect(config.liveQuery.regexTimeout).toBe(100); + }); + + it('does not throw on invalid $regex pattern', function () { + const player = { + id: new Id('Player', 'P1'), + name: 'Player 1', + }; + + // Invalid regex syntax should not throw, just return false + const q = new Parse.Query('Player'); + q._where = { name: { $regex: '[invalid' } }; + expect(() => matchesQuery(player, q)).not.toThrow(); + expect(matchesQuery(player, q)).toBe(false); + }); + + it('does not throw on invalid $regex pattern with regexTimeout enabled', function () { + const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools'); + setRegexTimeout(100); + try { + const player = { + id: new Id('Player', 'P1'), + name: 'Player 1', + }; + + const q = new Parse.Query('Player'); + q._where = { name: { $regex: '[invalid' } }; + expect(() => matchesQuery(player, q)).not.toThrow(); + expect(matchesQuery(player, q)).toBe(false); + } finally { + setRegexTimeout(0); + } + }); + + it('does not throw on invalid $regex flags', function () { + const player = { + id: new Id('Player', 'P1'), + name: 'Player 1', + }; + + const q = new Parse.Query('Player'); + q._where = { name: { $regex: 'valid', $options: 'xyz' } }; + expect(() => matchesQuery(player, q)).not.toThrow(); + expect(matchesQuery(player, q)).toBe(false); + }); + + it('matches $nearSphere queries', function () { + let q = new Parse.Query('Checkin'); q.near('location', new Parse.GeoPoint(20, 20)); // With no max distance, any GeoPoint is 'near' - var pt = { + const pt = { id: new Id('Checkin', 'C1'), - location: new Parse.GeoPoint(40, 40) + location: new Parse.GeoPoint(40, 40), + }; + const ptUndefined = { + id: new Id('Checkin', 'C1'), + }; + const ptNull = { + id: new Id('Checkin', 'C1'), + location: null, }; expect(matchesQuery(pt, q)).toBe(true); + expect(matchesQuery(ptUndefined, q)).toBe(false); + expect(matchesQuery(ptNull, q)).toBe(false); q = new Parse.Query('Checkin'); pt.location = new Parse.GeoPoint(40, 40); @@ -354,20 +638,31 @@ describe('matchesQuery', function() { expect(matchesQuery(pt, q)).toBe(false); }); - it('matches $within queries', function() { - var caltrainStation = { + it('matches $within queries', function () { + const caltrainStation = { id: new Id('Checkin', 'C1'), location: new Parse.GeoPoint(37.776346, -122.394218), - name: 'Caltrain' + name: 'Caltrain', }; - var santaClara = { + const santaClara = { id: new Id('Checkin', 'C2'), location: new Parse.GeoPoint(37.325635, -121.945753), - name: 'Santa Clara' + name: 'Santa Clara', + }; + + const noLocation = { + id: new Id('Checkin', 'C2'), + name: 'Santa Clara', + }; + + const nullLocation = { + id: new Id('Checkin', 'C2'), + location: null, + name: 'Santa Clara', }; - var q = new Parse.Query('Checkin').withinGeoBox( + let q = new Parse.Query('Checkin').withinGeoBox( 'location', new Parse.GeoPoint(37.708813, -122.526398), new Parse.GeoPoint(37.822802, -122.373962) @@ -375,7 +670,8 @@ describe('matchesQuery', function() { expect(matchesQuery(caltrainStation, q)).toBe(true); expect(matchesQuery(santaClara, q)).toBe(false); - + expect(matchesQuery(noLocation, q)).toBe(false); + expect(matchesQuery(nullLocation, q)).toBe(false); // Invalid rectangles q = new Parse.Query('Checkin').withinGeoBox( 'location', @@ -395,4 +691,425 @@ describe('matchesQuery', function() { expect(matchesQuery(caltrainStation, q)).toBe(false); expect(matchesQuery(santaClara, q)).toBe(false); }); + + it('matches on subobjects with dot notation', function () { + const message = { + id: new Id('Message', 'O1'), + text: 'content', + status: { x: 'read', y: 'delivered' }, + }; + + let q = new Parse.Query('Message'); + q.equalTo('status.x', 'read'); + expect(matchesQuery(message, q)).toBe(true); + + q = new Parse.Query('Message'); + q.equalTo('status.z', 'read'); + expect(matchesQuery(message, q)).toBe(false); + + q = new Parse.Query('Message'); + q.equalTo('status.x', 'delivered'); + expect(matchesQuery(message, q)).toBe(false); + + q = new Parse.Query('Message'); + q.notEqualTo('status.x', 'read'); + expect(matchesQuery(message, q)).toBe(false); + + q = new Parse.Query('Message'); + q.notEqualTo('status.z', 'read'); + expect(matchesQuery(message, q)).toBe(true); + + q = new Parse.Query('Message'); + q.notEqualTo('status.x', 'delivered'); + expect(matchesQuery(message, q)).toBe(true); + + q = new Parse.Query('Message'); + q.exists('status.x'); + expect(matchesQuery(message, q)).toBe(true); + + q = new Parse.Query('Message'); + q.exists('status.z'); + expect(matchesQuery(message, q)).toBe(false); + + q = new Parse.Query('Message'); + q.exists('nonexistent.x'); + expect(matchesQuery(message, q)).toBe(false); + + q = new Parse.Query('Message'); + q.doesNotExist('status.x'); + expect(matchesQuery(message, q)).toBe(false); + + q = new Parse.Query('Message'); + q.doesNotExist('status.z'); + expect(matchesQuery(message, q)).toBe(true); + + q = new Parse.Query('Message'); + q.doesNotExist('nonexistent.z'); + expect(matchesQuery(message, q)).toBe(true); + + q = new Parse.Query('Message'); + q.equalTo('status.x', 'read'); + q.doesNotExist('status.y'); + expect(matchesQuery(message, q)).toBe(false); + }); + + function pointer(className, objectId) { + return { __type: 'Pointer', className, objectId }; + } + + it('should support containedIn with pointers', () => { + const message = { + id: new Id('Message', 'O1'), + profile: pointer('Profile', 'abc'), + }; + let q = new Parse.Query('Message'); + q.containedIn('profile', [ + Parse.Object.fromJSON({ className: 'Profile', objectId: 'abc' }), + Parse.Object.fromJSON({ className: 'Profile', objectId: 'def' }), + ]); + expect(matchesQuery(message, q)).toBe(true); + + q = new Parse.Query('Message'); + q.containedIn('profile', [ + Parse.Object.fromJSON({ className: 'Profile', objectId: 'ghi' }), + Parse.Object.fromJSON({ className: 'Profile', objectId: 'def' }), + ]); + expect(matchesQuery(message, q)).toBe(false); + }); + + it('should support containedIn with array of pointers', () => { + const message = { + id: new Id('Message', 'O2'), + profiles: [pointer('Profile', 'yeahaw'), pointer('Profile', 'yes')], + }; + + let q = new Parse.Query('Message'); + q.containedIn('profiles', [ + Parse.Object.fromJSON({ className: 'Profile', objectId: 'no' }), + Parse.Object.fromJSON({ className: 'Profile', objectId: 'yes' }), + ]); + + expect(matchesQuery(message, q)).toBe(true); + + q = new Parse.Query('Message'); + q.containedIn('profiles', [ + Parse.Object.fromJSON({ className: 'Profile', objectId: 'no' }), + Parse.Object.fromJSON({ className: 'Profile', objectId: 'nope' }), + ]); + + expect(matchesQuery(message, q)).toBe(false); + }); + + it('should support notContainedIn with pointers', () => { + let message = { + id: new Id('Message', 'O1'), + profile: pointer('Profile', 'abc'), + }; + let q = new Parse.Query('Message'); + q.notContainedIn('profile', [ + Parse.Object.fromJSON({ className: 'Profile', objectId: 'def' }), + Parse.Object.fromJSON({ className: 'Profile', objectId: 'ghi' }), + ]); + expect(matchesQuery(message, q)).toBe(true); + + message = { + id: new Id('Message', 'O1'), + profile: pointer('Profile', 'def'), + }; + q = new Parse.Query('Message'); + q.notContainedIn('profile', [ + Parse.Object.fromJSON({ className: 'Profile', objectId: 'ghi' }), + Parse.Object.fromJSON({ className: 'Profile', objectId: 'def' }), + ]); + expect(matchesQuery(message, q)).toBe(false); + }); + + it('should support containedIn queries with [objectId]', () => { + let message = { + id: new Id('Message', 'O1'), + profile: pointer('Profile', 'abc'), + }; + let q = new Parse.Query('Message'); + q.containedIn('profile', ['abc', 'def']); + expect(matchesQuery(message, q)).toBe(true); + + message = { + id: new Id('Message', 'O1'), + profile: pointer('Profile', 'ghi'), + }; + q = new Parse.Query('Message'); + q.containedIn('profile', ['abc', 'def']); + expect(matchesQuery(message, q)).toBe(false); + }); + + it('should support notContainedIn queries with [objectId]', () => { + let message = { + id: new Id('Message', 'O1'), + profile: pointer('Profile', 'ghi'), + }; + let q = new Parse.Query('Message'); + q.notContainedIn('profile', ['abc', 'def']); + expect(matchesQuery(message, q)).toBe(true); + message = { + id: new Id('Message', 'O1'), + profile: pointer('Profile', 'ghi'), + }; + q = new Parse.Query('Message'); + q.notContainedIn('profile', ['abc', 'def', 'ghi']); + expect(matchesQuery(message, q)).toBe(false); + }); + + it('matches on Date', () => { + // given + const now = new Date(); + const obj = { + id: new Id('Person', '01'), + dateObject: now, + dateJSON: { + __type: 'Date', + iso: now.toISOString(), + }, + }; + + // when, then: Equal + let q = new Parse.Query('Person'); + q.equalTo('dateObject', now); + q.equalTo('dateJSON', now); + expect(matchesQuery(Object.assign({}, obj), q)).toBe(true); + + // when, then: lessThan + const future = Date(now.getTime() + 1000); + q = new Parse.Query('Person'); + q.lessThan('dateObject', future); + q.lessThan('dateJSON', future); + expect(matchesQuery(Object.assign({}, obj), q)).toBe(true); + + // when, then: lessThanOrEqualTo + q = new Parse.Query('Person'); + q.lessThanOrEqualTo('dateObject', now); + q.lessThanOrEqualTo('dateJSON', now); + expect(matchesQuery(Object.assign({}, obj), q)).toBe(true); + + // when, then: greaterThan + const past = Date(now.getTime() - 1000); + q = new Parse.Query('Person'); + q.greaterThan('dateObject', past); + q.greaterThan('dateJSON', past); + expect(matchesQuery(Object.assign({}, obj), q)).toBe(true); + + // when, then: greaterThanOrEqualTo + q = new Parse.Query('Person'); + q.greaterThanOrEqualTo('dateObject', now); + q.greaterThanOrEqualTo('dateJSON', now); + expect(matchesQuery(Object.assign({}, obj), q)).toBe(true); + }); + + it('should support containedBy query', () => { + const obj1 = { + id: new Id('Numbers', 'N1'), + numbers: [0, 1, 2], + }; + const obj2 = { + id: new Id('Numbers', 'N2'), + numbers: [2, 0], + }; + const obj3 = { + id: new Id('Numbers', 'N3'), + numbers: [1, 2, 3, 4], + }; + + const q = new Parse.Query('Numbers'); + q.containedBy('numbers', [1, 2, 3, 4, 5]); + expect(matchesQuery(obj1, q)).toBe(false); + expect(matchesQuery(obj2, q)).toBe(false); + expect(matchesQuery(obj3, q)).toBe(true); + }); + + it('should support withinPolygon query', () => { + const sacramento = { + id: new Id('Location', 'L1'), + location: new Parse.GeoPoint(38.52, -121.5), + name: 'Sacramento', + }; + const honolulu = { + id: new Id('Location', 'L2'), + location: new Parse.GeoPoint(21.35, -157.93), + name: 'Honolulu', + }; + const sf = { + id: new Id('Location', 'L3'), + location: new Parse.GeoPoint(37.75, -122.68), + name: 'San Francisco', + }; + + const points = [ + new Parse.GeoPoint(37.85, -122.33), + new Parse.GeoPoint(37.85, -122.9), + new Parse.GeoPoint(37.68, -122.9), + new Parse.GeoPoint(37.68, -122.33), + ]; + const q = new Parse.Query('Location'); + q.withinPolygon('location', points); + + expect(matchesQuery(sacramento, q)).toBe(false); + expect(matchesQuery(honolulu, q)).toBe(false); + expect(matchesQuery(sf, q)).toBe(true); + }); + + it('should support polygonContains query', () => { + const p1 = [ + [0, 0], + [0, 1], + [1, 1], + [1, 0], + ]; + const p2 = [ + [0, 0], + [0, 2], + [2, 2], + [2, 0], + ]; + const p3 = [ + [10, 10], + [10, 15], + [15, 15], + [15, 10], + [10, 10], + ]; + + const obj1 = { + id: new Id('Bounds', 'B1'), + polygon: new Parse.Polygon(p1), + }; + const obj2 = { + id: new Id('Bounds', 'B2'), + polygon: new Parse.Polygon(p2), + }; + const obj3 = { + id: new Id('Bounds', 'B3'), + polygon: new Parse.Polygon(p3), + }; + + const point = new Parse.GeoPoint(0.5, 0.5); + const q = new Parse.Query('Bounds'); + q.polygonContains('polygon', point); + + expect(matchesQuery(obj1, q)).toBe(true); + expect(matchesQuery(obj2, q)).toBe(true); + expect(matchesQuery(obj3, q)).toBe(false); + }); + + it('terminates catastrophic backtracking regex within regexTimeout (GHSA-qxh4-6wmx-rhg9)', function () { + const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools'); + setRegexTimeout(100); + try { + const object = { + id: new Id('Post', 'P1'), + title: 'aaaaaaaaaaaaaaaaaaaaaaaaaab', + }; + + // (a+)+$ is a classic catastrophic backtracking pattern + const q = new Parse.Query('Post'); + q._where = { title: { $regex: '(a+)+$' } }; + + const start = Date.now(); + // With timeout protection, the regex should be terminated and return false + const result = matchesQuery(object, q); + const elapsed = Date.now() - start; + + expect(result).toBe(false); + // Should complete within a reasonable time (timeout + overhead), not hang + expect(elapsed).toBeLessThan(5000); + } finally { + setRegexTimeout(0); + } + }); + + it('applies default regexTimeout of 100ms protecting against ReDoS (GHSA-qxh4-6wmx-rhg9)', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['Post'] }, + }); + const Config = require('../lib/Config'); + const config = Config.get('test'); + // Default regexTimeout is 100ms, providing ReDoS protection out-of-the-box + expect(config.liveQuery.regexTimeout).toBe(100); + expect(config.liveQuery.regexTimeout).toBeGreaterThan(0); + }); + + it('does not leak regex context between sequential evaluations with shared vmContext (GHSA-v88r-ghm9-267f)', function () { + const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools'); + setRegexTimeout(100); + try { + // Simulate the scenario from the advisory: + // Client A subscribes to { secretField: { $regex: "^admin" } } + // Client B subscribes to { publicField: { $regex: ".*" } } + + // Object with a secretField that should only match Client A's subscription + const object = { + id: new Id('Data', 'D1'), + secretField: 'admin_secret_data', + publicField: 'public_data', + }; + + // Client A's query: should match because secretField starts with "admin" + const queryA = new Parse.Query('Data'); + queryA._where = { secretField: { $regex: '^admin' } }; + + // Client B's query: should match because publicField matches .* + const queryB = new Parse.Query('Data'); + queryB._where = { publicField: { $regex: '.*' } }; + + // Evaluate both queries sequentially (as the LiveQuery server does) + const resultA = matchesQuery(object, queryA); + const resultB = matchesQuery(object, queryB); + + // Both should match correctly — no cross-contamination + expect(resultA).toBe(true); + expect(resultB).toBe(true); + + // Now test the inverse: object that should NOT match Client A + const object2 = { + id: new Id('Data', 'D2'), + secretField: 'user_regular_data', + publicField: 'public_data', + }; + + const resultA2 = matchesQuery(object2, queryA); + const resultB2 = matchesQuery(object2, queryB); + + // Client A should NOT match (secretField doesn't start with "admin") + // Client B should still match + expect(resultA2).toBe(false); + expect(resultB2).toBe(true); + } finally { + setRegexTimeout(0); + } + }); + + it('does not cross-contaminate regex results across different field evaluations with regexTimeout (GHSA-v88r-ghm9-267f)', function () { + const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools'); + setRegexTimeout(100); + try { + // Multiple subscriptions with different regex patterns evaluated against + // different objects in rapid succession — the advisory claims the shared + // vmContext causes pattern/input from one call to leak into another + const subscriptions = [ + { where: { field: { $regex: '^secret' } }, object: { id: new Id('X', '1'), field: 'secret_value' }, expected: true }, + { where: { field: { $regex: '^public' } }, object: { id: new Id('X', '2'), field: 'public_value' }, expected: true }, + { where: { field: { $regex: '^secret' } }, object: { id: new Id('X', '3'), field: 'public_value' }, expected: false }, + { where: { field: { $regex: '^public' } }, object: { id: new Id('X', '4'), field: 'secret_value' }, expected: false }, + { where: { field: { $regex: '^admin' } }, object: { id: new Id('X', '5'), field: 'admin_panel' }, expected: true }, + { where: { field: { $regex: '^admin' } }, object: { id: new Id('X', '6'), field: 'user_panel' }, expected: false }, + ]; + + for (const sub of subscriptions) { + const q = new Parse.Query('X'); + q._where = sub.where; + const result = matchesQuery(sub.object, q); + expect(result).toBe(sub.expected); + } + } finally { + setRegexTimeout(0); + } + }); }); diff --git a/spec/RateLimit.spec.js b/spec/RateLimit.spec.js new file mode 100644 index 0000000000..07e45dfa65 --- /dev/null +++ b/spec/RateLimit.spec.js @@ -0,0 +1,1204 @@ +const RedisCacheAdapter = require('../lib/Adapters/Cache/RedisCacheAdapter').default; +const request = require('../lib/request'); + +const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', +}; + +describe('rate limit', () => { + it('can limit cloud functions', async () => { + Parse.Cloud.define('test', () => 'Abc'); + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/functions/*path', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const response1 = await Parse.Cloud.run('test'); + expect(response1).toBe('Abc'); + await expectAsync(Parse.Cloud.run('test')).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can limit cloud functions with user session token', async () => { + await Parse.User.signUp('myUser', 'password'); + Parse.Cloud.define('test', () => 'Abc'); + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/functions/*path', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const response1 = await Parse.Cloud.run('test'); + expect(response1).toBe('Abc'); + await expectAsync(Parse.Cloud.run('test')).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can add global limit', async () => { + Parse.Cloud.define('test', () => 'Abc'); + await reconfigureServer({ + rateLimit: { + requestPath: '/*path', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + }); + const response1 = await Parse.Cloud.run('test'); + expect(response1).toBe('Abc'); + await expectAsync(Parse.Cloud.run('test')).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + await expectAsync(new Parse.Object('Test').save()).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can limit cloud with validator', async () => { + Parse.Cloud.define('test', () => 'Abc', { + rateLimit: { + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + }); + const response1 = await Parse.Cloud.run('test'); + expect(response1).toBe('Abc'); + await expectAsync(Parse.Cloud.run('test')).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can skip with masterKey', async () => { + Parse.Cloud.define('test', () => 'Abc'); + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/functions/*path', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const response1 = await Parse.Cloud.run('test', null, { useMasterKey: true }); + expect(response1).toBe('Abc'); + const response2 = await Parse.Cloud.run('test', null, { useMasterKey: true }); + expect(response2).toBe('Abc'); + }); + + it('should run with masterKey', async () => { + Parse.Cloud.define('test', () => 'Abc'); + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/functions/*path', + requestTimeWindow: 10000, + requestCount: 1, + includeMasterKey: true, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const response1 = await Parse.Cloud.run('test', null, { useMasterKey: true }); + expect(response1).toBe('Abc'); + await expectAsync(Parse.Cloud.run('test')).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can limit saving objects', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const obj = new Parse.Object('Test'); + await obj.save(); + await expectAsync(obj.save()).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can set method to post', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 1, + requestMethods: 'POST', + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const obj = new Parse.Object('Test'); + await obj.save(); + await obj.save(); + const obj2 = new Parse.Object('Test'); + await expectAsync(obj2.save()).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can use a validator for post', async () => { + Parse.Cloud.beforeSave('Test', () => {}, { + rateLimit: { + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + }); + const obj = new Parse.Object('Test'); + await obj.save(); + await expectAsync(obj.save()).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can use a validator for file', async () => { + Parse.Cloud.beforeSave(Parse.File, () => {}, { + rateLimit: { + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + }); + const file = new Parse.File('yolo.txt', [1, 2, 3], 'text/plain'); + await file.save(); + const file2 = new Parse.File('yolo.txt', [1, 2, 3], 'text/plain'); + await expectAsync(file2.save()).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can set method to get', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/Test', + requestTimeWindow: 10000, + requestCount: 1, + requestMethods: 'GET', + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const obj = new Parse.Object('Test'); + await obj.save(); + await obj.save(); + await new Parse.Query('Test').first(); + await expectAsync(new Parse.Query('Test').first()).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can use a validator', async () => { + await reconfigureServer({ silent: false }); + Parse.Cloud.beforeFind('TestObject', () => {}, { + rateLimit: { + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + }); + const obj = new Parse.Object('TestObject'); + await obj.save(); + await obj.save(); + await new Parse.Query('TestObject').first(); + await expectAsync(new Parse.Query('TestObject').first()).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + await expectAsync(new Parse.Query('TestObject').get('abc')).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can set method to delete', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/Test/*path', + requestTimeWindow: 10000, + requestCount: 1, + requestMethods: 'DELETE', + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const obj = new Parse.Object('Test'); + await obj.save(); + await obj.destroy(); + await expectAsync(obj.destroy()).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can set beforeDelete', async () => { + const obj = new Parse.Object('TestDelete'); + await obj.save(); + Parse.Cloud.beforeDelete('TestDelete', () => {}, { + rateLimit: { + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + }); + await obj.destroy(); + await expectAsync(obj.destroy()).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can set beforeLogin', async () => { + Parse.Cloud.beforeLogin(() => {}, { + rateLimit: { + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + }); + await Parse.User.signUp('myUser', 'password'); + await Parse.User.logIn('myUser', 'password'); + await expectAsync(Parse.User.logIn('myUser', 'password')).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can define limits via rateLimit and define', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/functions/*path', + requestTimeWindow: 10000, + requestCount: 100, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + Parse.Cloud.define('test', () => 'Abc', { + rateLimit: { + requestTimeWindow: 10000, + requestCount: 1, + includeInternalRequests: true, + }, + }); + const response1 = await Parse.Cloud.run('test'); + expect(response1).toBe('Abc'); + await expectAsync(Parse.Cloud.run('test')).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests.') + ); + }); + + it('does not limit internal calls', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/functions/*path', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + }, + ], + }); + Parse.Cloud.define('test1', () => 'Abc'); + Parse.Cloud.define('test2', async () => { + await Parse.Cloud.run('test1'); + await Parse.Cloud.run('test1'); + }); + await Parse.Cloud.run('test2'); + }); + + describe('zone', () => { + const middlewares = require('../lib/middlewares'); + it('can use global zone', async () => { + await reconfigureServer({ + rateLimit: { + requestPath: '*path', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + zone: Parse.Server.RateLimitZone.global, + }, + }); + const fakeReq = { + originalUrl: 'http://example.com/parse/', + url: 'http://example.com/', + body: { + _ApplicationId: 'test', + }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + get: key => { + return fakeReq.headers[key]; + }, + }; + fakeReq.ip = '127.0.0.1'; + let fakeRes = jasmine.createSpyObj('fakeRes', ['end', 'status', 'setHeader', 'json']); + await new Promise(resolve => middlewares.handleParseHeaders(fakeReq, fakeRes, resolve)); + fakeReq.ip = '127.0.0.2'; + fakeRes = jasmine.createSpyObj('fakeRes', ['end', 'status', 'setHeader']); + let resolvingPromise; + const promise = new Promise(resolve => { + resolvingPromise = resolve; + }); + fakeRes.json = jasmine.createSpy('json').and.callFake(resolvingPromise); + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { + throw new Error('Should not call next'); + }); + await promise; + expect(fakeRes.status).toHaveBeenCalledWith(429); + expect(fakeRes.json).toHaveBeenCalledWith({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('can use session zone', async () => { + await reconfigureServer({ + rateLimit: { + requestPath: '/functions/*path', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + zone: Parse.Server.RateLimitZone.session, + }, + }); + Parse.Cloud.define('test', () => 'Abc'); + await Parse.User.signUp('username', 'password'); + await Parse.Cloud.run('test'); + await expectAsync(Parse.Cloud.run('test')).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + await Parse.User.logIn('username', 'password'); + await Parse.Cloud.run('test'); + }); + + it('can use user zone', async () => { + await reconfigureServer({ + rateLimit: { + requestPath: '/functions/*path', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + zone: Parse.Server.RateLimitZone.user, + }, + }); + Parse.Cloud.define('test', () => 'Abc'); + await Parse.User.signUp('username', 'password'); + await Parse.Cloud.run('test'); + await expectAsync(Parse.Cloud.run('test')).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + await Parse.User.logIn('username', 'password'); + await expectAsync(Parse.Cloud.run('test')).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('should rate limit per user independently with user zone', async () => { + await reconfigureServer({ + rateLimit: { + requestPath: '/functions/*path', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + zone: Parse.Server.RateLimitZone.user, + }, + }); + Parse.Cloud.define('test', () => 'Abc'); + // Sign up two different users using REST API to avoid destroying sessions + const res1 = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/users', + body: JSON.stringify({ username: 'user1', password: 'password' }), + }); + const sessionToken1 = res1.data.sessionToken; + const res2 = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/users', + body: JSON.stringify({ username: 'user2', password: 'password' }), + }); + const sessionToken2 = res2.data.sessionToken; + // User 1 makes a request — should succeed + const result1 = await request({ + method: 'POST', + headers: { ...headers, 'X-Parse-Session-Token': sessionToken1 }, + url: 'http://localhost:8378/1/functions/test', + body: JSON.stringify({}), + }); + expect(result1.data.result).toBe('Abc'); + // User 2 makes a request — should also succeed (independent rate limit per user) + const result2 = await request({ + method: 'POST', + headers: { ...headers, 'X-Parse-Session-Token': sessionToken2 }, + url: 'http://localhost:8378/1/functions/test', + body: JSON.stringify({}), + }); + expect(result2.data.result).toBe('Abc'); + // User 1 makes another request — should be rate limited + const result3 = await request({ + method: 'POST', + headers: { ...headers, 'X-Parse-Session-Token': sessionToken1 }, + url: 'http://localhost:8378/1/functions/test', + body: JSON.stringify({}), + }).catch(e => e); + expect(result3.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + // User 2 makes another request — should also be rate limited + const result4 = await request({ + method: 'POST', + headers: { ...headers, 'X-Parse-Session-Token': sessionToken2 }, + url: 'http://localhost:8378/1/functions/test', + body: JSON.stringify({}), + }).catch(e => e); + expect(result4.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + }); + + it('can validate rateLimit', async () => { + const Config = require('../lib/Config'); + const validateRateLimit = ({ rateLimit }) => Config.validateRateLimit(rateLimit); + expect(() => + validateRateLimit({ rateLimit: 'a', requestTimeWindow: 1000, requestCount: 3 }) + ).toThrow('rateLimit must be an array or object'); + expect(() => validateRateLimit({ rateLimit: ['a'] })).toThrow( + 'rateLimit must be an array of objects' + ); + expect(() => validateRateLimit({ rateLimit: [{ requestPath: [] }] })).toThrow( + 'rateLimit.requestPath must be a string' + ); + expect(() => + validateRateLimit({ rateLimit: [{ requestTimeWindow: [], requestPath: 'a' }] }) + ).toThrow('rateLimit.requestTimeWindow must be a number'); + expect(() => + validateRateLimit({ + rateLimit: [{ requestPath: 'a', requestTimeWindow: 1000, requestCount: 3, zone: 'abc' }], + }) + ).toThrow('rateLimit.zone must be one of global, session, user, or ip'); + expect(() => + validateRateLimit({ + rateLimit: [ + { + includeInternalRequests: [], + requestTimeWindow: 1000, + requestCount: 3, + requestPath: 'a', + }, + ], + }) + ).toThrow('rateLimit.includeInternalRequests must be a boolean'); + expect(() => + validateRateLimit({ + rateLimit: [{ requestCount: [], requestTimeWindow: 1000, requestPath: 'a' }], + }) + ).toThrow('rateLimit.requestCount must be a number'); + expect(() => + validateRateLimit({ + rateLimit: [ + { errorResponseMessage: [], requestTimeWindow: 1000, requestCount: 3, requestPath: 'a' }, + ], + }) + ).toThrow('rateLimit.errorResponseMessage must be a string'); + expect(() => + validateRateLimit({ rateLimit: [{ requestCount: 3, requestPath: 'abc' }] }) + ).toThrow('rateLimit.requestTimeWindow must be defined'); + expect(() => + validateRateLimit({ rateLimit: [{ requestTimeWindow: 3, requestPath: 'abc' }] }) + ).toThrow('rateLimit.requestCount must be defined'); + expect(() => + validateRateLimit({ rateLimit: [{ requestTimeWindow: 3, requestCount: 'abc' }] }) + ).toThrow('rateLimit.requestPath must be defined'); + await expectAsync( + reconfigureServer({ + rateLimit: [{ requestTimeWindow: 3, requestCount: 1, path: 'abc', requestPath: 'a' }], + }) + ).toBeRejectedWith(`Invalid rate limit option "path"`); + }); + describe('batch', () => { + it('should reject batch request when sub-requests exceed rate limit for a path', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 2, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } }, + ], + }), + }).catch(e => e); + expect(response.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('should allow batch request when sub-requests are within rate limit', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 5, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } }, + ], + }), + }); + expect(response.data.length).toBe(3); + expect(response.data[0].success).toBeDefined(); + }); + + it('should reject batch when sub-requests for one rate-limited path exceed limit among mixed paths', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/login', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many login requests', + includeInternalRequests: true, + }, + ], + }); + await Parse.User.signUp('testuser', 'password'); + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } }, + { method: 'POST', path: '/1/login', body: { username: 'testuser', password: 'password' } }, + { method: 'POST', path: '/1/login', body: { username: 'testuser', password: 'wrong' } }, + ], + }), + }).catch(e => e); + expect(response.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many login requests', + }); + }); + + it('should not count sub-requests whose method does not match requestMethods', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 1, + requestMethods: 'GET', + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + // 3 POST sub-requests should NOT be counted against a GET-only rate limit + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } }, + ], + }), + }); + expect(response.data.length).toBe(3); + expect(response.data[0].success).toBeDefined(); + }); + + it('should skip batch rate limit check for master key requests when includeMasterKey is false', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + // Master key requests should bypass rate limit (includeMasterKey defaults to false) + const masterHeaders = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }; + const response = await request({ + method: 'POST', + headers: masterHeaders, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } }, + ], + }), + }); + expect(response.data.length).toBe(3); + expect(response.data[0].success).toBeDefined(); + }); + + it('should use configured errorResponseMessage when rejecting batch', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Custom rate limit message', + includeInternalRequests: true, + }, + ], + }); + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } }, + ], + }), + }).catch(e => e); + expect(response.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Custom rate limit message', + }); + }); + + it('should enforce rate limit across direct requests and batch sub-requests', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 2, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + // First direct request — should succeed (count: 1) + const obj = new Parse.Object('MyObject'); + await obj.save(); + // Batch with 1 sub-request — should succeed (count: 2) + const response1 = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } }, + ], + }), + }); + expect(response1.data.length).toBe(1); + expect(response1.data[0].success).toBeDefined(); + // Another batch with 1 sub-request — should be rate limited (count would be 3) + const response2 = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } }, + ], + }), + }).catch(e => e); + expect(response2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('should enforce rate limit for multiple batch requests in same window', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 2, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + // First batch with 2 sub-requests — should succeed (count: 2) + const response1 = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } }, + ], + }), + }); + expect(response1.data.length).toBe(2); + expect(response1.data[0].success).toBeDefined(); + // Second batch with 1 sub-request — should be rate limited (count would be 3) + const response2 = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } }, + ], + }), + }).catch(e => e); + expect(response2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('should not reject batch when sub-requests target non-rate-limited paths', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/login', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many login requests', + includeInternalRequests: true, + }, + ], + }); + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } }, + ], + }), + }); + expect(response.data.length).toBe(3); + expect(response.data[0].success).toBeDefined(); + }); + }); + + describe('method override bypass', () => { + it('should enforce rate limit when _method override attempts to change POST to GET', async () => { + Parse.Cloud.beforeLogin(() => {}, { + rateLimit: { + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + }); + await Parse.User.signUp('testuser', 'password'); + // First login via POST — should succeed + const res1 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ username: 'testuser', password: 'password' }), + }); + expect(res1.data.username).toBe('testuser'); + // Second login via POST with _method:GET — should still be rate limited + const res2 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ _method: 'GET', username: 'testuser', password: 'password' }), + }).catch(e => e); + expect(res2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('should allow _method override with PUT', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/Test/*path', + requestTimeWindow: 10000, + requestCount: 1, + requestMethods: 'PUT', + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const obj = new Parse.Object('Test'); + await obj.save(); + // Update via POST with _method:PUT — should succeed and count toward rate limit + await request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/classes/Test/${obj.id}`, + body: JSON.stringify({ _method: 'PUT', key: 'value1' }), + }); + // Second update via POST with _method:PUT — should be rate limited + const res = await request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/classes/Test/${obj.id}`, + body: JSON.stringify({ _method: 'PUT', key: 'value2' }), + }).catch(e => e); + expect(res.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('should allow _method override with DELETE', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/Test/*path', + requestTimeWindow: 10000, + requestCount: 1, + requestMethods: 'DELETE', + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const obj1 = new Parse.Object('Test'); + await obj1.save(); + const obj2 = new Parse.Object('Test'); + await obj2.save(); + // Delete via POST with _method:DELETE — should succeed + await request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/classes/Test/${obj1.id}`, + body: JSON.stringify({ _method: 'DELETE' }), + }); + // Second delete via POST with _method:DELETE — should be rate limited + const res = await request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/classes/Test/${obj2.id}`, + body: JSON.stringify({ _method: 'DELETE' }), + }).catch(e => e); + expect(res.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('should enforce rate limit when _method override uses non-standard casing', async () => { + Parse.Cloud.beforeLogin(() => {}, { + rateLimit: { + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + }); + await Parse.User.signUp('testuser', 'password'); + const res1 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ username: 'testuser', password: 'password' }), + }); + expect(res1.data.username).toBe('testuser'); + // Second login via POST with _method:'get' (lowercase) — should still be rate limited + const res2 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ _method: 'get', username: 'testuser', password: 'password' }), + }).catch(e => e); + expect(res2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('should ignore _method override with non-string type', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 1, + requestMethods: 'POST', + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + // POST with _method as number — should be ignored and treated as POST + const obj = new Parse.Object('Test'); + await obj.save(); + const res = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/classes/Test', + body: JSON.stringify({ _method: 123, key: 'value' }), + }).catch(e => e); + expect(res.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + }); + + describe('batch method bypass', () => { + it('should use IP-based keying for batch login sub-requests with session zone', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/login', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + zone: Parse.Server.RateLimitZone.session, + }, + ], + }); + // Create two users and get their session tokens + const res1 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/users', + body: JSON.stringify({ username: 'user1', password: 'password1' }), + }); + const sessionToken1 = res1.data.sessionToken; + const res2 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/users', + body: JSON.stringify({ username: 'user2', password: 'password2' }), + }); + const sessionToken2 = res2.data.sessionToken; + // First batch login with TOKEN1 — should succeed + const batch1 = await request({ + method: 'POST', + headers: { ...headers, 'X-Parse-Session-Token': sessionToken1 }, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/login', body: { username: 'user1', password: 'password1' } }, + ], + }), + }); + expect(batch1.status).toBe(200); + // Second batch login with TOKEN2 — should be rate limited because + // login rate limit must use IP-based keying, not session-token keying; + // rotating session tokens must not create independent rate limit counters + const batch2 = await request({ + method: 'POST', + headers: { ...headers, 'X-Parse-Session-Token': sessionToken2 }, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/login', body: { username: 'user1', password: 'password1' } }, + ], + }), + }).catch(e => e); + expect(batch2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('should use IP-based keying for batch login sub-requests with user zone', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/login', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + zone: Parse.Server.RateLimitZone.user, + }, + ], + }); + // Create two users and get their session tokens + const res1 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/users', + body: JSON.stringify({ username: 'user1', password: 'password1' }), + }); + const sessionToken1 = res1.data.sessionToken; + const res2 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/users', + body: JSON.stringify({ username: 'user2', password: 'password2' }), + }); + const sessionToken2 = res2.data.sessionToken; + // First batch login with TOKEN1 — should succeed + const batch1 = await request({ + method: 'POST', + headers: { ...headers, 'X-Parse-Session-Token': sessionToken1 }, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/login', body: { username: 'user1', password: 'password1' } }, + ], + }), + }); + expect(batch1.status).toBe(200); + // Second batch login with TOKEN2 — should be rate limited + const batch2 = await request({ + method: 'POST', + headers: { ...headers, 'X-Parse-Session-Token': sessionToken2 }, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/login', body: { username: 'user1', password: 'password1' } }, + ], + }), + }).catch(e => e); + expect(batch2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('should enforce POST rate limit on batch sub-requests using GET method for login', async () => { + Parse.Cloud.beforeLogin(() => {}, { + rateLimit: { + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + }); + await Parse.User.signUp('testuser', 'password'); + // Batch with 2 login sub-requests using GET — should be rate limited + const res = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'GET', path: '/1/login', body: { username: 'testuser', password: 'password' } }, + { method: 'GET', path: '/1/login', body: { username: 'testuser', password: 'password' } }, + ], + }), + }).catch(e => e); + expect(res.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + }); + + describe_only(() => { + return process.env.PARSE_SERVER_TEST_CACHE === 'redis'; + })('with RedisCache', function () { + it('does work with cache', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + redisUrl: 'redis://localhost:6379', + }, + ], + }); + const obj = new Parse.Object('Test'); + await obj.save(); + await expectAsync(obj.save()).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + const cache = new RedisCacheAdapter(); + await cache.connect(); + const value = await cache.get('rl:127.0.0.1'); + expect(value).toEqual(2); + const ttl = await cache.client.ttl('rl:127.0.0.1'); + expect(ttl).toEqual(10); + }); + }); +}); diff --git a/spec/ReadPreferenceOption.spec.js b/spec/ReadPreferenceOption.spec.js new file mode 100644 index 0000000000..67b976674b --- /dev/null +++ b/spec/ReadPreferenceOption.spec.js @@ -0,0 +1,1176 @@ +'use strict'; + +const Parse = require('parse/node'); +const { ReadPreference, Collection } = require('mongodb'); +const request = require('../lib/request'); + +function waitForReplication() { + return new Promise(function (resolve) { + setTimeout(resolve, 1000); + }); +} + +describe_only_db('mongo')('Read preference option', () => { + it('should find in primary by default', done => { + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); + + Parse.Object.saveAll([obj0, obj1]) + .then(() => { + spyOn(Collection.prototype, 'find').and.callThrough(); + + const query = new Parse.Query('MyObject'); + query.equalTo('boolKey', false); + + return query.find().then(results => { + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(false); + let myObjectReadPreference = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = true; + expect(call.object.s.readPreference.mode).toBe(ReadPreference.PRIMARY); + } + }); + + expect(myObjectReadPreference).toBe(true); + + done(); + }); + }) + .catch(done.fail); + }); + + xit('should preserve the read preference set (#4831)', async () => { + const { MongoStorageAdapter } = require('../lib/Adapters/Storage/Mongo/MongoStorageAdapter'); + const adapterOptions = { + uri: 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase', + mongoOptions: { + readPreference: ReadPreference.NEAREST, + }, + }; + await reconfigureServer({ + databaseAdapter: new MongoStorageAdapter(adapterOptions), + }); + + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); + + await Parse.Object.saveAll([obj0, obj1]); + spyOn(Collection.prototype, 'find').and.callThrough(); + + const query = new Parse.Query('MyObject'); + query.equalTo('boolKey', false); + + const results = await query.find(); + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(false); + + let myObjectReadPreference = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = true; + expect(call.args[1].readPreference).toBe(ReadPreference.NEAREST); + } + }); + + expect(myObjectReadPreference).toBe(true); + }); + + it('should change read preference in the beforeFind trigger', async () => { + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); + + await Parse.Object.saveAll([obj0, obj1]); + spyOn(Collection.prototype, 'find').and.callThrough(); + + Parse.Cloud.beforeFind('MyObject', req => { + req.readPreference = 'SECONDARY'; + }); + await waitForReplication(); + + const query = new Parse.Query('MyObject'); + query.equalTo('boolKey', false); + + const results = await query.find(); + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(false); + + let myObjectReadPreference = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + }); + + it('should check read preference as case insensitive', async () => { + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); + + await Parse.Object.saveAll([obj0, obj1]); + spyOn(Collection.prototype, 'find').and.callThrough(); + + Parse.Cloud.beforeFind('MyObject', req => { + req.readPreference = 'sEcOnDarY'; + }); + + await waitForReplication(); + + const query = new Parse.Query('MyObject'); + query.equalTo('boolKey', false); + + const results = await query.find(); + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(false); + + let myObjectReadPreference = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + }); + + it('should change read preference in the beforeFind trigger even changing query', async () => { + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); + + await Parse.Object.saveAll([obj0, obj1]); + spyOn(Collection.prototype, 'find').and.callThrough(); + + Parse.Cloud.beforeFind('MyObject', req => { + req.query.equalTo('boolKey', true); + req.readPreference = 'SECONDARY'; + }); + await waitForReplication(); + + const query = new Parse.Query('MyObject'); + query.equalTo('boolKey', false); + + const results = await query.find(); + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(true); + + let myObjectReadPreference = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + }); + + it('should change read preference in the beforeFind trigger even returning query', async () => { + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); + + await Parse.Object.saveAll([obj0, obj1]); + spyOn(Collection.prototype, 'find').and.callThrough(); + + Parse.Cloud.beforeFind('MyObject', req => { + req.readPreference = 'SECONDARY'; + + const otherQuery = new Parse.Query('MyObject'); + otherQuery.equalTo('boolKey', true); + return otherQuery; + }); + + await waitForReplication(); + + const query = new Parse.Query('MyObject'); + query.equalTo('boolKey', false); + + const results = await query.find(); + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(true); + + let myObjectReadPreference = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + }); + + it('should change read preference in the beforeFind trigger even returning promise', async () => { + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); + + await Parse.Object.saveAll([obj0, obj1]); + spyOn(Collection.prototype, 'find').and.callThrough(); + + Parse.Cloud.beforeFind('MyObject', req => { + req.readPreference = 'SECONDARY'; + + const otherQuery = new Parse.Query('MyObject'); + otherQuery.equalTo('boolKey', true); + return Promise.resolve(otherQuery); + }); + await waitForReplication(); + + const query = new Parse.Query('MyObject'); + query.equalTo('boolKey', false); + + const results = await query.find(); + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(true); + + let myObjectReadPreference = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + }); + + it('should change read preference to PRIMARY_PREFERRED', async () => { + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); + + await Parse.Object.saveAll([obj0, obj1]); + spyOn(Collection.prototype, 'find').and.callThrough(); + + Parse.Cloud.beforeFind('MyObject', req => { + req.readPreference = 'PRIMARY_PREFERRED'; + }); + await waitForReplication(); + + const query = new Parse.Query('MyObject'); + query.equalTo('boolKey', false); + + const results = await query.find(); + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(false); + + let myObjectReadPreference = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.PRIMARY_PREFERRED); + }); + + it('should change read preference to SECONDARY_PREFERRED', async () => { + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); + + await Parse.Object.saveAll([obj0, obj1]); + spyOn(Collection.prototype, 'find').and.callThrough(); + + Parse.Cloud.beforeFind('MyObject', req => { + req.readPreference = 'SECONDARY_PREFERRED'; + }); + await waitForReplication(); + + const query = new Parse.Query('MyObject'); + query.equalTo('boolKey', false); + + const results = await query.find(); + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(false); + + let myObjectReadPreference = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY_PREFERRED); + }); + + it('should change read preference to NEAREST', async () => { + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); + + await Parse.Object.saveAll([obj0, obj1]); + spyOn(Collection.prototype, 'find').and.callThrough(); + + Parse.Cloud.beforeFind('MyObject', req => { + req.readPreference = 'NEAREST'; + }); + await waitForReplication(); + + const query = new Parse.Query('MyObject'); + query.equalTo('boolKey', false); + + const results = await query.find(); + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(false); + + let myObjectReadPreference = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.NEAREST); + }); + + it('should change read preference for GET', async () => { + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); + + await Parse.Object.saveAll([obj0, obj1]); + spyOn(Collection.prototype, 'find').and.callThrough(); + + Parse.Cloud.beforeFind('MyObject', req => { + req.readPreference = 'SECONDARY'; + }); + await waitForReplication(); + + const query = new Parse.Query('MyObject'); + + const result = await query.get(obj0.id); + expect(result.get('boolKey')).toBe(false); + + let myObjectReadPreference = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + }); + + it('should change read preference for GET using API', async () => { + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); + + await Parse.Object.saveAll([obj0, obj1]); + spyOn(Collection.prototype, 'find').and.callThrough(); + + Parse.Cloud.beforeFind('MyObject', req => { + req.readPreference = 'SECONDARY'; + }); + await waitForReplication(); + + const response = await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/MyObject/' + obj0.id, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + }); + const body = response.data; + expect(body.boolKey).toBe(false); + + let myObjectReadPreference = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + }); + + it('should change read preference for GET directly from API', async () => { + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); + + await Parse.Object.saveAll([obj0, obj1]); + spyOn(Collection.prototype, 'find').and.callThrough(); + await waitForReplication(); + + const response = await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/MyObject/' + obj0.id + '?readPreference=SECONDARY', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + }); + expect(response.data.boolKey).toBe(false); + + let myObjectReadPreference = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + }); + + it('should change read preference for GET using API through the beforeFind overriding API option', async () => { + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); + + await Parse.Object.saveAll([obj0, obj1]); + spyOn(Collection.prototype, 'find').and.callThrough(); + + Parse.Cloud.beforeFind('MyObject', req => { + req.readPreference = 'SECONDARY_PREFERRED'; + }); + await waitForReplication(); + + const response = await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/MyObject/' + obj0.id + '?readPreference=SECONDARY', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + }); + expect(response.data.boolKey).toBe(false); + + let myObjectReadPreference = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY_PREFERRED); + }); + + it('should change read preference for FIND using API through beforeFind trigger', async () => { + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); + + await Parse.Object.saveAll([obj0, obj1]); + spyOn(Collection.prototype, 'find').and.callThrough(); + + Parse.Cloud.beforeFind('MyObject', req => { + req.readPreference = 'SECONDARY'; + }); + await waitForReplication(); + + const response = await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/MyObject/', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + }); + expect(response.data.results.length).toEqual(2); + + let myObjectReadPreference = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + }); + + it('should change read preference for FIND directly from API', async () => { + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); + + await Parse.Object.saveAll([obj0, obj1]); + spyOn(Collection.prototype, 'find').and.callThrough(); + await waitForReplication(); + + const response = await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/MyObject?readPreference=SECONDARY', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + }); + expect(response.data.results.length).toEqual(2); + + let myObjectReadPreference = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + }); + + it('should change read preference for FIND using API through the beforeFind overriding API option', async () => { + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); + + await Parse.Object.saveAll([obj0, obj1]); + spyOn(Collection.prototype, 'find').and.callThrough(); + + Parse.Cloud.beforeFind('MyObject', req => { + req.readPreference = 'SECONDARY_PREFERRED'; + }); + await waitForReplication(); + + const response = await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/MyObject/?readPreference=SECONDARY', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + }); + expect(response.data.results.length).toEqual(2); + + let myObjectReadPreference = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY_PREFERRED); + }); + + xit('should change read preference for count', done => { + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); + + Parse.Object.saveAll([obj0, obj1]).then(() => { + spyOn(Collection.prototype, 'find').and.callThrough(); + + Parse.Cloud.beforeFind('MyObject', req => { + req.readPreference = 'SECONDARY'; + }); + + const query = new Parse.Query('MyObject'); + query.equalTo('boolKey', false); + + query + .count() + .then(result => { + expect(result).toBe(1); + + let myObjectReadPreference = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + + done(); + }) + .catch(done.fail); + }); + }); + + it('should change read preference for `aggregate` using `beforeFind`', async () => { + // Save objects + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); + await Parse.Object.saveAll([obj0, obj1]); + // Add trigger + Parse.Cloud.beforeFind('MyObject', req => { + req.readPreference = 'SECONDARY'; + }); + await waitForReplication(); + + // Spy on DB adapter + spyOn(Collection.prototype, 'aggregate').and.callThrough(); + // Query + const query = new Parse.Query('MyObject'); + const results = await query.aggregate([{ $match: { boolKey: false } }]); + // Validate + expect(results.length).toBe(1); + let readPreference = null; + Collection.prototype.aggregate.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject') > -1) { + readPreference = call.args[1].readPreference; + } + }); + expect(readPreference).toEqual(ReadPreference.SECONDARY); + }); + + it('should change read preference for `find` using query option', async () => { + // Save objects + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); + await Parse.Object.saveAll([obj0, obj1]); + await waitForReplication(); + + // Spy on DB adapter + spyOn(Collection.prototype, 'find').and.callThrough(); + // Query + const query = new Parse.Query('MyObject'); + query.equalTo('boolKey', false); + query.readPreference('SECONDARY'); + const results = await query.find(); + // Validate + expect(results.length).toBe(1); + let myObjectReadPreference = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject') >= 0) { + myObjectReadPreference = call.args[1].readPreference; + } + }); + expect(myObjectReadPreference).toEqual(ReadPreference.SECONDARY); + }); + + it('should change read preference for `aggregate` using query option', async () => { + // Save objects + const obj0 = new Parse.Object('MyObject'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject'); + obj1.set('boolKey', true); + await Parse.Object.saveAll([obj0, obj1]); + await waitForReplication(); + + // Spy on DB adapter + spyOn(Collection.prototype, 'aggregate').and.callThrough(); + // Query + const query = new Parse.Query('MyObject'); + query.readPreference('SECONDARY'); + const results = await query.aggregate([{ $match: { boolKey: false } }]); + // Validate + expect(results.length).toBe(1); + let readPreference = null; + Collection.prototype.aggregate.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject') > -1) { + readPreference = call.args[1].readPreference; + } + }); + expect(readPreference).toEqual(ReadPreference.SECONDARY); + }); + + it('should find includes in same replica of readPreference by default', async () => { + const obj0 = new Parse.Object('MyObject0'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject1'); + obj1.set('boolKey', true); + obj1.set('myObject0', obj0); + const obj2 = new Parse.Object('MyObject2'); + obj2.set('boolKey', false); + obj2.set('myObject1', obj1); + + await Parse.Object.saveAll([obj0, obj1, obj2]); + spyOn(Collection.prototype, 'find').and.callThrough(); + + Parse.Cloud.beforeFind('MyObject2', req => { + req.readPreference = 'SECONDARY'; + }); + await waitForReplication(); + + const query = new Parse.Query('MyObject2'); + query.equalTo('boolKey', false); + query.include('myObject1'); + query.include('myObject1.myObject0'); + + const results = await query.find(); + expect(results.length).toBe(1); + const firstResult = results[0]; + expect(firstResult.get('boolKey')).toBe(false); + expect(firstResult.get('myObject1').get('boolKey')).toBe(true); + expect(firstResult.get('myObject1').get('myObject0').get('boolKey')).toBe(false); + + let myObjectReadPreference0 = null; + let myObjectReadPreference1 = null; + let myObjectReadPreference2 = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject0') >= 0) { + myObjectReadPreference0 = call.args[1].readPreference; + } + if (call.object.s.namespace.collection.indexOf('MyObject1') >= 0) { + myObjectReadPreference1 = call.args[1].readPreference; + } + if (call.object.s.namespace.collection.indexOf('MyObject2') >= 0) { + myObjectReadPreference2 = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference0).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference1).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference2).toEqual(ReadPreference.SECONDARY); + }); + + it('should change includes read preference', async () => { + const obj0 = new Parse.Object('MyObject0'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject1'); + obj1.set('boolKey', true); + obj1.set('myObject0', obj0); + const obj2 = new Parse.Object('MyObject2'); + obj2.set('boolKey', false); + obj2.set('myObject1', obj1); + + await Parse.Object.saveAll([obj0, obj1, obj2]); + spyOn(Collection.prototype, 'find').and.callThrough(); + + Parse.Cloud.beforeFind('MyObject2', req => { + req.readPreference = 'SECONDARY_PREFERRED'; + req.includeReadPreference = 'SECONDARY'; + }); + await waitForReplication(); + + const query = new Parse.Query('MyObject2'); + query.equalTo('boolKey', false); + query.include('myObject1'); + query.include('myObject1.myObject0'); + + const results = await query.find(); + expect(results.length).toBe(1); + const firstResult = results[0]; + expect(firstResult.get('boolKey')).toBe(false); + expect(firstResult.get('myObject1').get('boolKey')).toBe(true); + expect(firstResult.get('myObject1').get('myObject0').get('boolKey')).toBe(false); + + let myObjectReadPreference0 = null; + let myObjectReadPreference1 = null; + let myObjectReadPreference2 = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject0') >= 0) { + myObjectReadPreference0 = call.args[1].readPreference; + } + if (call.object.s.namespace.collection.indexOf('MyObject1') >= 0) { + myObjectReadPreference1 = call.args[1].readPreference; + } + if (call.object.s.namespace.collection.indexOf('MyObject2') >= 0) { + myObjectReadPreference2 = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference0).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference1).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference2).toEqual(ReadPreference.SECONDARY_PREFERRED); + }); + + it('should change includes read preference when finding through API', async () => { + const obj0 = new Parse.Object('MyObject0'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject1'); + obj1.set('boolKey', true); + obj1.set('myObject0', obj0); + const obj2 = new Parse.Object('MyObject2'); + obj2.set('boolKey', false); + obj2.set('myObject1', obj1); + + await Parse.Object.saveAll([obj0, obj1, obj2]); + spyOn(Collection.prototype, 'find').and.callThrough(); + await waitForReplication(); + + const response = await request({ + method: 'GET', + url: + 'http://localhost:8378/1/classes/MyObject2/' + + obj2.id + + '?include=' + + JSON.stringify(['myObject1', 'myObject1.myObject0']) + + '&readPreference=SECONDARY_PREFERRED&includeReadPreference=SECONDARY', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + }); + const firstResult = response.data; + expect(firstResult.boolKey).toBe(false); + expect(firstResult.myObject1.boolKey).toBe(true); + expect(firstResult.myObject1.myObject0.boolKey).toBe(false); + + let myObjectReadPreference0 = null; + let myObjectReadPreference1 = null; + let myObjectReadPreference2 = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject0') >= 0) { + myObjectReadPreference0 = call.args[1].readPreference; + } + if (call.object.s.namespace.collection.indexOf('MyObject1') >= 0) { + myObjectReadPreference1 = call.args[1].readPreference; + } + if (call.object.s.namespace.collection.indexOf('MyObject2') >= 0) { + myObjectReadPreference2 = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference0).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference1).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference2).toEqual(ReadPreference.SECONDARY_PREFERRED); + }); + + it('should change includes read preference when getting through API', async () => { + const obj0 = new Parse.Object('MyObject0'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject1'); + obj1.set('boolKey', true); + obj1.set('myObject0', obj0); + const obj2 = new Parse.Object('MyObject2'); + obj2.set('boolKey', false); + obj2.set('myObject1', obj1); + + await Parse.Object.saveAll([obj0, obj1, obj2]); + spyOn(Collection.prototype, 'find').and.callThrough(); + await waitForReplication(); + + const response = await request({ + method: 'GET', + url: + 'http://localhost:8378/1/classes/MyObject2?where=' + + JSON.stringify({ boolKey: false }) + + '&include=' + + JSON.stringify(['myObject1', 'myObject1.myObject0']) + + '&readPreference=SECONDARY_PREFERRED&includeReadPreference=SECONDARY', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + }); + expect(response.data.results.length).toBe(1); + const firstResult = response.data.results[0]; + expect(firstResult.boolKey).toBe(false); + expect(firstResult.myObject1.boolKey).toBe(true); + expect(firstResult.myObject1.myObject0.boolKey).toBe(false); + + let myObjectReadPreference0 = null; + let myObjectReadPreference1 = null; + let myObjectReadPreference2 = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject0') >= 0) { + myObjectReadPreference0 = call.args[1].readPreference; + } + if (call.object.s.namespace.collection.indexOf('MyObject1') >= 0) { + myObjectReadPreference1 = call.args[1].readPreference; + } + if (call.object.s.namespace.collection.indexOf('MyObject2') >= 0) { + myObjectReadPreference2 = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference0).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference1).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference2).toEqual(ReadPreference.SECONDARY_PREFERRED); + }); + + it('should find subqueries in same replica of readPreference by default', async () => { + const obj0 = new Parse.Object('MyObject0'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject1'); + obj1.set('boolKey', true); + obj1.set('myObject0', obj0); + const obj2 = new Parse.Object('MyObject2'); + obj2.set('boolKey', false); + obj2.set('myObject1', obj1); + + await Parse.Object.saveAll([obj0, obj1, obj2]); + spyOn(Collection.prototype, 'find').and.callThrough(); + + Parse.Cloud.beforeFind('MyObject2', req => { + req.readPreference = 'SECONDARY'; + }); + await waitForReplication(); + + const query0 = new Parse.Query('MyObject0'); + query0.equalTo('boolKey', false); + + const query1 = new Parse.Query('MyObject1'); + query1.matchesQuery('myObject0', query0); + + const query2 = new Parse.Query('MyObject2'); + query2.matchesQuery('myObject1', query1); + + const results = await query2.find(); + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(false); + + let myObjectReadPreference0 = null; + let myObjectReadPreference1 = null; + let myObjectReadPreference2 = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject0') >= 0) { + myObjectReadPreference0 = call.args[1].readPreference; + } + if (call.object.s.namespace.collection.indexOf('MyObject1') >= 0) { + myObjectReadPreference1 = call.args[1].readPreference; + } + if (call.object.s.namespace.collection.indexOf('MyObject2') >= 0) { + myObjectReadPreference2 = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference0).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference1).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference2).toEqual(ReadPreference.SECONDARY); + }); + + it('should change subqueries read preference when using matchesQuery', async () => { + const obj0 = new Parse.Object('MyObject0'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject1'); + obj1.set('boolKey', true); + obj1.set('myObject0', obj0); + const obj2 = new Parse.Object('MyObject2'); + obj2.set('boolKey', false); + obj2.set('myObject1', obj1); + + await Parse.Object.saveAll([obj0, obj1, obj2]); + spyOn(Collection.prototype, 'find').and.callThrough(); + + Parse.Cloud.beforeFind('MyObject2', req => { + req.readPreference = 'SECONDARY_PREFERRED'; + req.subqueryReadPreference = 'SECONDARY'; + }); + await waitForReplication(); + + const query0 = new Parse.Query('MyObject0'); + query0.equalTo('boolKey', false); + + const query1 = new Parse.Query('MyObject1'); + query1.matchesQuery('myObject0', query0); + + const query2 = new Parse.Query('MyObject2'); + query2.matchesQuery('myObject1', query1); + + const results = await query2.find(); + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(false); + + let myObjectReadPreference0 = null; + let myObjectReadPreference1 = null; + let myObjectReadPreference2 = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject0') >= 0) { + myObjectReadPreference0 = call.args[1].readPreference; + } + if (call.object.s.namespace.collection.indexOf('MyObject1') >= 0) { + myObjectReadPreference1 = call.args[1].readPreference; + } + if (call.object.s.namespace.collection.indexOf('MyObject2') >= 0) { + myObjectReadPreference2 = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference0).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference1).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference2).toEqual(ReadPreference.SECONDARY_PREFERRED); + }); + + it('should change subqueries read preference when using doesNotMatchQuery', async () => { + const obj0 = new Parse.Object('MyObject0'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject1'); + obj1.set('boolKey', true); + obj1.set('myObject0', obj0); + const obj2 = new Parse.Object('MyObject2'); + obj2.set('boolKey', false); + obj2.set('myObject1', obj1); + + await Parse.Object.saveAll([obj0, obj1, obj2]); + spyOn(Collection.prototype, 'find').and.callThrough(); + + Parse.Cloud.beforeFind('MyObject2', req => { + req.readPreference = 'SECONDARY_PREFERRED'; + req.subqueryReadPreference = 'SECONDARY'; + }); + await waitForReplication(); + + const query0 = new Parse.Query('MyObject0'); + query0.equalTo('boolKey', false); + + const query1 = new Parse.Query('MyObject1'); + query1.doesNotMatchQuery('myObject0', query0); + + const query2 = new Parse.Query('MyObject2'); + query2.doesNotMatchQuery('myObject1', query1); + + const results = await query2.find(); + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(false); + + let myObjectReadPreference0 = null; + let myObjectReadPreference1 = null; + let myObjectReadPreference2 = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject0') >= 0) { + myObjectReadPreference0 = call.args[1].readPreference; + } + if (call.object.s.namespace.collection.indexOf('MyObject1') >= 0) { + myObjectReadPreference1 = call.args[1].readPreference; + } + if (call.object.s.namespace.collection.indexOf('MyObject2') >= 0) { + myObjectReadPreference2 = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference0).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference1).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference2).toEqual(ReadPreference.SECONDARY_PREFERRED); + }); + + it('should change subqueries read preference when using matchesKeyInQuery and doesNotMatchKeyInQuery', async () => { + const obj0 = new Parse.Object('MyObject0'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject1'); + obj1.set('boolKey', true); + obj1.set('myObject0', obj0); + const obj2 = new Parse.Object('MyObject2'); + obj2.set('boolKey', false); + obj2.set('myObject1', obj1); + + await Parse.Object.saveAll([obj0, obj1, obj2]); + spyOn(Collection.prototype, 'find').and.callThrough(); + + Parse.Cloud.beforeFind('MyObject2', req => { + req.readPreference = 'SECONDARY_PREFERRED'; + req.subqueryReadPreference = 'SECONDARY'; + }); + await waitForReplication(); + + const query0 = new Parse.Query('MyObject0'); + query0.equalTo('boolKey', false); + + const query1 = new Parse.Query('MyObject1'); + query1.equalTo('boolKey', true); + + const query2 = new Parse.Query('MyObject2'); + query2.matchesKeyInQuery('boolKey', 'boolKey', query0); + query2.doesNotMatchKeyInQuery('boolKey', 'boolKey', query1); + + const results = await query2.find(); + expect(results.length).toBe(1); + expect(results[0].get('boolKey')).toBe(false); + + let myObjectReadPreference0 = null; + let myObjectReadPreference1 = null; + let myObjectReadPreference2 = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject0') >= 0) { + myObjectReadPreference0 = call.args[1].readPreference; + } + if (call.object.s.namespace.collection.indexOf('MyObject1') >= 0) { + myObjectReadPreference1 = call.args[1].readPreference; + } + if (call.object.s.namespace.collection.indexOf('MyObject2') >= 0) { + myObjectReadPreference2 = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference0).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference1).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference2).toEqual(ReadPreference.SECONDARY_PREFERRED); + }); + + it('should change subqueries read preference when using matchesKeyInQuery and doesNotMatchKeyInQuery to find through API', async () => { + const obj0 = new Parse.Object('MyObject0'); + obj0.set('boolKey', false); + const obj1 = new Parse.Object('MyObject1'); + obj1.set('boolKey', true); + obj1.set('myObject0', obj0); + const obj2 = new Parse.Object('MyObject2'); + obj2.set('boolKey', false); + obj2.set('myObject1', obj1); + + await Parse.Object.saveAll([obj0, obj1, obj2]); + spyOn(Collection.prototype, 'find').and.callThrough(); + await waitForReplication(); + + const whereString = JSON.stringify({ + boolKey: { + $select: { + query: { + className: 'MyObject0', + where: { boolKey: false }, + }, + key: 'boolKey', + }, + $dontSelect: { + query: { + className: 'MyObject1', + where: { boolKey: true }, + }, + key: 'boolKey', + }, + }, + }); + + const response = await request({ + method: 'GET', + url: + 'http://localhost:8378/1/classes/MyObject2/?where=' + + whereString + + '&readPreference=SECONDARY_PREFERRED&subqueryReadPreference=SECONDARY', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + }); + expect(response.data.results.length).toBe(1); + expect(response.data.results[0].boolKey).toBe(false); + + let myObjectReadPreference0 = null; + let myObjectReadPreference1 = null; + let myObjectReadPreference2 = null; + Collection.prototype.find.calls.all().forEach(call => { + if (call.object.s.namespace.collection.indexOf('MyObject0') >= 0) { + myObjectReadPreference0 = call.args[1].readPreference; + } + if (call.object.s.namespace.collection.indexOf('MyObject1') >= 0) { + myObjectReadPreference1 = call.args[1].readPreference; + } + if (call.object.s.namespace.collection.indexOf('MyObject2') >= 0) { + myObjectReadPreference2 = call.args[1].readPreference; + } + }); + + expect(myObjectReadPreference0).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference1).toEqual(ReadPreference.SECONDARY); + expect(myObjectReadPreference2).toEqual(ReadPreference.SECONDARY_PREFERRED); + }); +}); diff --git a/spec/RedisCacheAdapter.spec.js b/spec/RedisCacheAdapter.spec.js new file mode 100644 index 0000000000..9b88e857c4 --- /dev/null +++ b/spec/RedisCacheAdapter.spec.js @@ -0,0 +1,184 @@ +const RedisCacheAdapter = require('../lib/Adapters/Cache/RedisCacheAdapter').default; + +function wait(sleep) { + return new Promise(function (resolve) { + setTimeout(resolve, sleep); + }); +} +/* +To run this test part of the complete suite +set PARSE_SERVER_TEST_CACHE='redis' +and make sure a redis server is available on the default port + */ +describe_only(() => { + return process.env.PARSE_SERVER_TEST_CACHE === 'redis'; +})('RedisCacheAdapter', function () { + const KEY = 'hello'; + const VALUE = 'world'; + let cache; + + beforeEach(async () => { + cache = new RedisCacheAdapter(null, 100); + await cache.connect(); + await cache.clear(); + }); + + it('should get/set/clear', async () => { + const cacheNaN = new RedisCacheAdapter({ + ttl: NaN, + }); + await cacheNaN.connect(); + await cacheNaN.put(KEY, VALUE); + let value = await cacheNaN.get(KEY); + expect(value).toEqual(VALUE); + await cacheNaN.clear(); + value = await cacheNaN.get(KEY); + expect(value).toEqual(null); + await cacheNaN.clear(); + }); + + it('should expire after ttl', done => { + cache + .put(KEY, VALUE) + .then(() => cache.get(KEY)) + .then(value => expect(value).toEqual(VALUE)) + .then(wait.bind(null, 102)) + .then(() => cache.get(KEY)) + .then(value => expect(value).toEqual(null)) + .then(done); + }); + + it('should not store value for ttl=0', done => { + cache + .put(KEY, VALUE, 0) + .then(() => cache.get(KEY)) + .then(value => expect(value).toEqual(null)) + .then(done); + }); + + it('should not expire when ttl=Infinity', done => { + cache + .put(KEY, VALUE, Infinity) + .then(() => cache.get(KEY)) + .then(value => expect(value).toEqual(VALUE)) + .then(wait.bind(null, 102)) + .then(() => cache.get(KEY)) + .then(value => expect(value).toEqual(VALUE)) + .then(done); + }); + + it('should fallback to default ttl', done => { + let promise = Promise.resolve(); + + [-100, null, undefined, 'not number', true].forEach(ttl => { + promise = promise.then(() => + cache + .put(KEY, VALUE, ttl) + .then(() => cache.get(KEY)) + .then(value => expect(value).toEqual(VALUE)) + .then(wait.bind(null, 102)) + .then(() => cache.get(KEY)) + .then(value => expect(value).toEqual(null)) + ); + }); + + promise.then(done); + }); + + it('should find un-expired records', done => { + cache + .put(KEY, VALUE) + .then(() => cache.get(KEY)) + .then(value => expect(value).toEqual(VALUE)) + .then(wait.bind(null, 1)) + .then(() => cache.get(KEY)) + .then(value => expect(value).not.toEqual(null)) + .then(done); + }); + + it('handleShutdown, close connection', async () => { + await cache.handleShutdown(); + setTimeout(() => { + expect(cache.client.isOpen).toBe(false); + }, 0); + }); +}); + +describe_only(() => { + return process.env.PARSE_SERVER_TEST_CACHE === 'redis'; +})('RedisCacheAdapter/KeyPromiseQueue', function () { + const KEY1 = 'key1'; + const KEY2 = 'key2'; + const VALUE = 'hello'; + + // number of chained ops on a single key + function getQueueCountForKey(cache, key) { + return cache.queue.queue[key][0]; + } + + // total number of queued keys + function getQueueCount(cache) { + return Object.keys(cache.queue.queue).length; + } + + it('it should clear completed operations from queue', async done => { + const cache = new RedisCacheAdapter({ ttl: NaN }); + await cache.connect(); + + // execute a bunch of operations in sequence + let promise = Promise.resolve(); + for (let index = 1; index < 100; index++) { + promise = promise.then(() => { + const key = `${index}`; + return cache + .put(key, VALUE) + .then(() => expect(getQueueCount(cache)).toEqual(0)) + .then(() => cache.get(key)) + .then(() => expect(getQueueCount(cache)).toEqual(0)) + .then(() => cache.clear()) + .then(() => expect(getQueueCount(cache)).toEqual(0)); + }); + } + + // at the end the queue should be empty + promise.then(() => expect(getQueueCount(cache)).toEqual(0)).then(done); + }); + + it('it should count per key chained operations correctly', async done => { + const cache = new RedisCacheAdapter({ ttl: NaN }); + await cache.connect(); + + let key1Promise = Promise.resolve(); + let key2Promise = Promise.resolve(); + for (let index = 1; index < 100; index++) { + key1Promise = cache.put(KEY1, VALUE); + key2Promise = cache.put(KEY2, VALUE); + // per key chain should be equal to index, which is the + // total number of operations on that key + expect(getQueueCountForKey(cache, KEY1)).toEqual(index); + expect(getQueueCountForKey(cache, KEY2)).toEqual(index); + // the total keys counts should be equal to the different keys + // we have currently being processed. + expect(getQueueCount(cache)).toEqual(2); + } + + // at the end the queue should be empty + Promise.all([key1Promise, key2Promise]) + .then(() => expect(getQueueCount(cache)).toEqual(0)) + .then(done); + }); + + it('should start and connect cache adapter', async () => { + const server = await reconfigureServer({ + cacheAdapter: { + module: `${__dirname.replace('/spec', '')}/lib/Adapters/Cache/RedisCacheAdapter`, + options: { + url: 'redis://127.0.0.1:6379/1', + }, + }, + }); + const symbol = Object.getOwnPropertySymbols(server.config.cacheController); + const client = server.config.cacheController[symbol[0]].client; + expect(client.isOpen).toBeTrue(); + }); +}); diff --git a/spec/RedisPubSub.spec.js b/spec/RedisPubSub.spec.js index 097a678d67..1ff4de27dc 100644 --- a/spec/RedisPubSub.spec.js +++ b/spec/RedisPubSub.spec.js @@ -1,29 +1,43 @@ -var RedisPubSub = require('../src/LiveQuery/RedisPubSub').RedisPubSub; +const RedisPubSub = require('../lib/Adapters/PubSub/RedisPubSub').RedisPubSub; -describe('RedisPubSub', function() { - - beforeEach(function(done) { +describe('RedisPubSub', function () { + beforeEach(function (done) { // Mock redis - var createClient = jasmine.createSpy('createClient'); + const createClient = jasmine.createSpy('createClient').and.returnValue({ + connect: jasmine.createSpy('connect').and.resolveTo(), + on: jasmine.createSpy('on'), + }); jasmine.mockLibrary('redis', 'createClient', createClient); done(); }); - it('can create publisher', function() { - var publisher = RedisPubSub.createPublisher('redisAddress'); + it('can create publisher', function () { + RedisPubSub.createPublisher({ + redisURL: 'redisAddress', + redisOptions: { socket_keepalive: true }, + }); - var redis = require('redis'); - expect(redis.createClient).toHaveBeenCalledWith('redisAddress', { no_ready_check: true }); + const redis = require('redis'); + expect(redis.createClient).toHaveBeenCalledWith({ + url: 'redisAddress', + socket_keepalive: true, + }); }); - it('can create subscriber', function() { - var subscriber = RedisPubSub.createSubscriber('redisAddress'); + it('can create subscriber', function () { + RedisPubSub.createSubscriber({ + redisURL: 'redisAddress', + redisOptions: { socket_keepalive: true }, + }); - var redis = require('redis'); - expect(redis.createClient).toHaveBeenCalledWith('redisAddress', { no_ready_check: true }); + const redis = require('redis'); + expect(redis.createClient).toHaveBeenCalledWith({ + url: 'redisAddress', + socket_keepalive: true, + }); }); - afterEach(function() { + afterEach(function () { jasmine.restoreLibrary('redis', 'createClient'); }); }); diff --git a/spec/RegexVulnerabilities.spec.js b/spec/RegexVulnerabilities.spec.js new file mode 100644 index 0000000000..297be93599 --- /dev/null +++ b/spec/RegexVulnerabilities.spec.js @@ -0,0 +1,518 @@ +const request = require('../lib/request'); + +const serverURL = 'http://localhost:8378/1'; +const headers = { + 'Content-Type': 'application/json', +}; +const keys = { + _ApplicationId: 'test', + _JavaScriptKey: 'test', +}; +const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, +}; +const appName = 'test'; +const publicServerURL = 'http://localhost:8378/1'; + +describe('Regex Vulnerabilities', () => { + let objectId; + let sessionToken; + let partialSessionToken; + let user; + + beforeEach(async () => { + await reconfigureServer({ + maintenanceKey: 'test2', + verifyUserEmails: true, + emailAdapter, + appName, + publicServerURL, + }); + + const signUpResponse = await request({ + url: `${serverURL}/users`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + username: 'someemail@somedomain.com', + password: 'somepassword', + email: 'someemail@somedomain.com', + }), + }); + objectId = signUpResponse.data.objectId; + sessionToken = signUpResponse.data.sessionToken; + partialSessionToken = sessionToken.slice(0, 3); + }); + + describe('on session token', () => { + it('should not work with regex', async () => { + try { + await request({ + url: `${serverURL}/users/me`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _SessionToken: { + $regex: partialSessionToken, + }, + _method: 'GET', + }), + }); + fail('should not work'); + } catch (e) { + expect(e.data.error).toEqual('unauthorized'); + } + }); + + it('should work with plain token', async () => { + const meResponse = await request({ + url: `${serverURL}/users/me`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _SessionToken: sessionToken, + _method: 'GET', + }), + }); + expect(meResponse.data.objectId).toEqual(objectId); + expect(meResponse.data.sessionToken).toEqual(sessionToken); + }); + }); + + describe('on verify e-mail', () => { + beforeEach(async function () { + const userQuery = new Parse.Query(Parse.User); + user = await userQuery.get(objectId, { useMasterKey: true }); + }); + + it('should not work with regex', async () => { + expect(user.get('emailVerified')).toEqual(false); + await request({ + url: `${serverURL}/apps/test/verify_email?token[$regex]=`, + method: 'GET', + }); + await user.fetch({ useMasterKey: true }); + expect(user.get('emailVerified')).toEqual(false); + }); + + it_id('92bbb86d-bcda-49fa-8d79-aa0501078044')(it)('should work with plain token', async () => { + expect(user.get('emailVerified')).toEqual(false); + const current = await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Rest-API-Key': 'test', + 'X-Parse-Maintenance-Key': 'test2', + 'Content-Type': 'application/json', + }, + }).then(res => res.data); + // It should work + await request({ + url: `${serverURL}/apps/test/verify_email?token=${current._email_verify_token}`, + method: 'GET', + }); + await user.fetch({ useMasterKey: true }); + expect(user.get('emailVerified')).toEqual(true); + }); + }); + + describe('on password reset request via token (handleResetRequest)', () => { + beforeEach(async () => { + user = await Parse.User.logIn('someemail@somedomain.com', 'somepassword'); + // Trigger a password reset to generate a _perishable_token + await request({ + url: `${serverURL}/requestPasswordReset`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + email: 'someemail@somedomain.com', + }), + }); + // Expire the token so the handleResetRequest token-lookup branch matches + await Parse.Server.database.update( + '_User', + { objectId: user.id }, + { + _perishable_token_expires_at: new Date(Date.now() - 10000), + } + ); + }); + + it('should not allow $ne operator to match user via token injection', async () => { + // Without the fix, {$ne: null} matches any user with a non-null expired token, + // causing a password reset email to be sent — a boolean oracle for token extraction. + try { + await request({ + url: `${serverURL}/requestPasswordReset`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + token: { $ne: null }, + }), + }); + fail('should not succeed with $ne token'); + } catch (e) { + expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE); + } + }); + + it('should not allow $regex operator to extract token via injection', async () => { + try { + await request({ + url: `${serverURL}/requestPasswordReset`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + token: { $regex: '^.' }, + }), + }); + fail('should not succeed with $regex token'); + } catch (e) { + expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE); + } + }); + + it('should not allow $exists operator for token injection', async () => { + try { + await request({ + url: `${serverURL}/requestPasswordReset`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + token: { $exists: true }, + }), + }); + fail('should not succeed with $exists token'); + } catch (e) { + expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE); + } + }); + + it('should not allow $gt operator for token injection', async () => { + try { + await request({ + url: `${serverURL}/requestPasswordReset`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + token: { $gt: '' }, + }), + }); + fail('should not succeed with $gt token'); + } catch (e) { + expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE); + } + }); + }); + + describe('on authData id operator injection', () => { + it('should reject $regex operator in anonymous authData id on login', async () => { + // Create a victim anonymous user with a known ID prefix + const victimId = 'victim_' + Date.now(); + const signupRes = await request({ + url: `${serverURL}/users`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + authData: { anonymous: { id: victimId } }, + }), + }); + expect(signupRes.data.objectId).toBeDefined(); + + // Attacker tries to login with $regex to match the victim + try { + await request({ + url: `${serverURL}/users`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + authData: { anonymous: { id: { $regex: '^victim_' } } }, + }), + }); + fail('should not allow $regex in authData id'); + } catch (e) { + expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE); + } + }); + + it('should reject $ne operator in anonymous authData id on login', async () => { + const victimId = 'victim_ne_' + Date.now(); + await request({ + url: `${serverURL}/users`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + authData: { anonymous: { id: victimId } }, + }), + }); + + try { + await request({ + url: `${serverURL}/users`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + authData: { anonymous: { id: { $ne: 'nonexistent' } } }, + }), + }); + fail('should not allow $ne in authData id'); + } catch (e) { + expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE); + } + }); + + it('should reject $exists operator in anonymous authData id on login', async () => { + const victimId = 'victim_exists_' + Date.now(); + await request({ + url: `${serverURL}/users`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + authData: { anonymous: { id: victimId } }, + }), + }); + + try { + await request({ + url: `${serverURL}/users`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + authData: { anonymous: { id: { $exists: true } } }, + }), + }); + fail('should not allow $exists in authData id'); + } catch (e) { + expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE); + } + }); + + it('should allow valid string authData id for anonymous login', async () => { + const userId = 'valid_anon_' + Date.now(); + const signupRes = await request({ + url: `${serverURL}/users`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + authData: { anonymous: { id: userId } }, + }), + }); + expect(signupRes.data.objectId).toBeDefined(); + + // Same ID should successfully log in + const loginRes = await request({ + url: `${serverURL}/users`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + authData: { anonymous: { id: userId } }, + }), + }); + expect(loginRes.data.objectId).toEqual(signupRes.data.objectId); + }); + }); + + describe('on resend verification email', () => { + // The PagesRouter uses express.urlencoded({ extended: false }) which does not parse + // nested objects (e.g. token[$regex]=^.), so the HTTP layer already blocks object injection. + // Non-string tokens are rejected (treated as undefined) to prevent both NoSQL injection + // and type confusion errors. These tests verify the guard works correctly + // by directly testing the PagesRouter method. + it('should reject non-string token as undefined', async () => { + const { PagesRouter } = require('../lib/Routers/PagesRouter'); + const router = new PagesRouter(); + const goToPage = spyOn(router, 'goToPage').and.returnValue(Promise.resolve()); + const resendSpy = jasmine.createSpy('resendVerificationEmail').and.returnValue(Promise.resolve()); + const req = { + config: { + userController: { resendVerificationEmail: resendSpy }, + }, + body: { + username: 'testuser', + token: { $regex: '^.' }, + }, + }; + await router.resendVerificationEmail(req); + // Non-string token should be treated as undefined + const passedToken = resendSpy.calls.first().args[2]; + expect(passedToken).toBeUndefined(); + }); + + it('should pass through valid string token unchanged', async () => { + const { PagesRouter } = require('../lib/Routers/PagesRouter'); + const router = new PagesRouter(); + const goToPage = spyOn(router, 'goToPage').and.returnValue(Promise.resolve()); + const resendSpy = jasmine.createSpy('resendVerificationEmail').and.returnValue(Promise.resolve()); + const req = { + config: { + userController: { resendVerificationEmail: resendSpy }, + }, + body: { + username: 'testuser', + token: 'validtoken123', + }, + }; + await router.resendVerificationEmail(req); + const passedToken = resendSpy.calls.first().args[2]; + expect(typeof passedToken).toEqual('string'); + expect(passedToken).toEqual('validtoken123'); + }); + }); + + describe('on password reset', () => { + beforeEach(async () => { + user = await Parse.User.logIn('someemail@somedomain.com', 'somepassword'); + }); + + it('should not work with regex', async () => { + expect(user.id).toEqual(objectId); + await request({ + url: `${serverURL}/requestPasswordReset`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + email: 'someemail@somedomain.com', + }), + }); + await user.fetch({ useMasterKey: true }); + const passwordResetResponse = await request({ + url: `${serverURL}/apps/test/request_password_reset?token[$regex]=`, + method: 'GET', + }); + expect(passwordResetResponse.status).toEqual(200); + expect(passwordResetResponse.text).toContain('Invalid password reset link!'); + await request({ + url: `${serverURL}/apps/test/request_password_reset`, + method: 'POST', + body: { + token: { $regex: '' }, + username: 'someemail@somedomain.com', + new_password: 'newpassword', + }, + }); + try { + await Parse.User.logIn('someemail@somedomain.com', 'newpassword'); + fail('should not work'); + } catch (e) { + expect(e.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); + expect(e.message).toEqual('Invalid username/password.'); + } + }); + + it('should work with plain token', async () => { + expect(user.id).toEqual(objectId); + await request({ + url: `${serverURL}/requestPasswordReset`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + email: 'someemail@somedomain.com', + }), + }); + const current = await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Rest-API-Key': 'test', + 'X-Parse-Maintenance-Key': 'test2', + 'Content-Type': 'application/json', + }, + }).then(res => res.data); + const token = current._perishable_token; + const passwordResetResponse = await request({ + url: `${serverURL}/apps/test/request_password_reset?token=${token}`, + method: 'GET', + }); + expect(passwordResetResponse.status).toEqual(200); + expect(passwordResetResponse.text).toContain('Reset Your Password'); + await request({ + url: `${serverURL}/apps/test/request_password_reset`, + method: 'POST', + body: { + token, + username: 'someemail@somedomain.com', + new_password: 'newpassword', + }, + }); + const userAgain = await Parse.User.logIn('someemail@somedomain.com', 'newpassword'); + expect(userAgain.id).toEqual(objectId); + }); + }); +}); + +describe('Regex Vulnerabilities - authData operator injection with custom adapter', () => { + it('should reject non-string authData id for custom auth adapter on login', async () => { + await reconfigureServer({ + auth: { + myAdapter: { + validateAuthData: () => Promise.resolve(), + validateAppId: () => Promise.resolve(), + }, + }, + }); + + const victimId = 'adapter_victim_' + Date.now(); + await request({ + url: `${serverURL}/users`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + authData: { myAdapter: { id: victimId, token: 'valid' } }, + }), + }); + + try { + await request({ + url: `${serverURL}/users`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + authData: { myAdapter: { id: { $regex: '^adapter_victim_' }, token: 'valid' } }, + }), + }); + fail('should not allow $regex in custom adapter authData id'); + } catch (e) { + expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE); + } + }); +}); diff --git a/spec/RequestComplexity.spec.js b/spec/RequestComplexity.spec.js new file mode 100644 index 0000000000..4f1a712218 --- /dev/null +++ b/spec/RequestComplexity.spec.js @@ -0,0 +1,855 @@ +'use strict'; + +const Config = require('../lib/Config'); +const auth = require('../lib/Auth'); +const rest = require('../lib/rest'); + +describe('request complexity', () => { + function buildNestedInQuery(depth, className = '_User') { + let where = {}; + for (let i = 0; i < depth; i++) { + where = { username: { $inQuery: { className, where } } }; + } + return where; + } + + function buildNestedNotInQuery(depth, className = '_User') { + let where = {}; + for (let i = 0; i < depth; i++) { + where = { username: { $notInQuery: { className, where } } }; + } + return where; + } + + function buildNestedSelect(depth, className = '_User') { + let where = {}; + for (let i = 0; i < depth; i++) { + where = { username: { $select: { query: { className, where }, key: 'username' } } }; + } + return where; + } + + function buildNestedDontSelect(depth, className = '_User') { + let where = {}; + for (let i = 0; i < depth; i++) { + where = { username: { $dontSelect: { query: { className, where }, key: 'username' } } }; + } + return where; + } + + function buildNestedOrQuery(depth) { + let where = { username: 'test' }; + for (let i = 0; i < depth; i++) { + where = { $or: [where, { username: 'test' }] }; + } + return where; + } + + function buildNestedAndQuery(depth) { + let where = { username: 'test' }; + for (let i = 0; i < depth; i++) { + where = { $and: [where, { username: 'test' }] }; + } + return where; + } + + function buildNestedNorQuery(depth) { + let where = { username: 'test' }; + for (let i = 0; i < depth; i++) { + where = { $nor: [where, { username: 'test' }] }; + } + return where; + } + + describe('config validation', () => { + it('should accept valid requestComplexity config', async () => { + await expectAsync( + reconfigureServer({ + requestComplexity: { + includeDepth: 10, + includeCount: 100, + subqueryDepth: 5, + queryDepth: 10, + graphQLDepth: 15, + graphQLFields: 300, + }, + }) + ).toBeResolved(); + }); + + it('should accept -1 to disable a specific limit', async () => { + await expectAsync( + reconfigureServer({ + requestComplexity: { + includeDepth: -1, + includeCount: -1, + subqueryDepth: -1, + queryDepth: -1, + graphQLDepth: -1, + graphQLFields: -1, + }, + }) + ).toBeResolved(); + }); + + it('should reject value of 0', async () => { + await expectAsync( + reconfigureServer({ + requestComplexity: { includeDepth: 0 }, + }) + ).toBeRejectedWith( + new Error('requestComplexity.includeDepth must be a positive integer or -1 to disable.') + ); + }); + + it('should reject non-integer values', async () => { + await expectAsync( + reconfigureServer({ + requestComplexity: { includeDepth: 3.5 }, + }) + ).toBeRejectedWith( + new Error('requestComplexity.includeDepth must be a positive integer or -1 to disable.') + ); + }); + + it('should reject non-boolean value for allowRegex', async () => { + await expectAsync( + reconfigureServer({ + requestComplexity: { allowRegex: 'yes' }, + }) + ).toBeRejectedWith( + new Error('requestComplexity.allowRegex must be a boolean.') + ); + }); + + it('should reject unknown properties', async () => { + await expectAsync( + reconfigureServer({ + requestComplexity: { unknownProp: 5 }, + }) + ).toBeRejectedWith( + new Error("requestComplexity contains unknown property 'unknownProp'.") + ); + }); + + it('should reject non-object values', async () => { + await expectAsync( + reconfigureServer({ + requestComplexity: 'invalid', + }) + ).toBeRejectedWith(new Error('requestComplexity must be an object.')); + }); + + it('should apply defaults for missing properties', async () => { + await reconfigureServer({ + requestComplexity: { includeDepth: 3 }, + }); + const config = Config.get('test'); + expect(config.requestComplexity.includeDepth).toBe(3); + expect(config.requestComplexity.includeCount).toBe(-1); + expect(config.requestComplexity.subqueryDepth).toBe(-1); + expect(config.requestComplexity.queryDepth).toBe(-1); + expect(config.requestComplexity.graphQLDepth).toBe(-1); + expect(config.requestComplexity.graphQLFields).toBe(-1); + }); + + it('should apply full defaults when not configured', async () => { + await reconfigureServer({}); + const config = Config.get('test'); + expect(config.requestComplexity).toEqual({ + allowRegex: true, + batchRequestLimit: -1, + includeDepth: -1, + includeCount: -1, + subqueryDepth: -1, + subqueryLimit: -1, + queryDepth: -1, + graphQLDepth: -1, + graphQLFields: -1, + }); + }); + }); + + describe('subquery depth', () => { + let config; + + beforeEach(async () => { + await reconfigureServer({ + requestComplexity: { subqueryDepth: 3 }, + }); + config = Config.get('test'); + }); + + it('should allow $inQuery within depth limit', async () => { + const where = buildNestedInQuery(3); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); + + it('should reject $inQuery exceeding depth limit', async () => { + const where = buildNestedInQuery(4); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should reject $notInQuery exceeding depth limit', async () => { + const where = buildNestedNotInQuery(4); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should reject $select exceeding depth limit', async () => { + const where = buildNestedSelect(4); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should reject $dontSelect exceeding depth limit', async () => { + const where = buildNestedDontSelect(4); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should allow subqueries with master key even when exceeding limit', async () => { + const where = buildNestedInQuery(4); + await expectAsync( + rest.find(config, auth.master(config), '_User', where) + ).toBeResolved(); + }); + + it('should allow subqueries with maintenance key even when exceeding limit', async () => { + const where = buildNestedInQuery(4); + await expectAsync( + rest.find(config, auth.maintenance(config), '_User', where) + ).toBeResolved(); + }); + + it('should allow unlimited subqueries when subqueryDepth is -1', async () => { + await reconfigureServer({ + requestComplexity: { subqueryDepth: -1 }, + }); + config = Config.get('test'); + const where = buildNestedInQuery(15); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); + + it('should allow multiple sibling $inQuery at same depth within limit', async () => { + await reconfigureServer({ + requestComplexity: { subqueryDepth: 1 }, + }); + config = Config.get('test'); + // Multiple sibling $inQuery operators in $or, each at depth 1 — within the limit + const where = { + $or: [ + { username: { $inQuery: { className: '_User', where: { username: 'a' } } } }, + { username: { $inQuery: { className: '_User', where: { username: 'b' } } } }, + { username: { $inQuery: { className: '_User', where: { username: 'c' } } } }, + ], + }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); + + it('should reject sibling $inQuery when nested beyond depth limit', async () => { + await reconfigureServer({ + requestComplexity: { subqueryDepth: 1 }, + }); + config = Config.get('test'); + // Each sibling contains a nested $inQuery at depth 2 — exceeds limit + const where = { + $or: [ + { + username: { + $inQuery: { + className: '_User', + where: { username: { $inQuery: { className: '_User', where: {} } } }, + }, + }, + }, + { + username: { + $inQuery: { + className: '_User', + where: { username: { $inQuery: { className: '_User', where: {} } } }, + }, + }, + }, + ], + }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 1/), + }) + ); + }); + + it('should allow multiple sibling $notInQuery at same depth within limit', async () => { + await reconfigureServer({ + requestComplexity: { subqueryDepth: 1 }, + }); + config = Config.get('test'); + const where = { + $or: [ + { username: { $notInQuery: { className: '_User', where: { username: 'a' } } } }, + { username: { $notInQuery: { className: '_User', where: { username: 'b' } } } }, + { username: { $notInQuery: { className: '_User', where: { username: 'c' } } } }, + ], + }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); + + it('should allow mixed sibling $inQuery and $notInQuery at same depth within limit', async () => { + await reconfigureServer({ + requestComplexity: { subqueryDepth: 1 }, + }); + config = Config.get('test'); + const where = { + $or: [ + { username: { $inQuery: { className: '_User', where: { username: 'a' } } } }, + { username: { $notInQuery: { className: '_User', where: { username: 'b' } } } }, + { username: { $inQuery: { className: '_User', where: { username: 'c' } } } }, + ], + }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); + }); + + describe('query depth', () => { + let config; + + beforeEach(async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 3 }, + }); + config = Config.get('test'); + }); + + it('should allow $or within depth limit', async () => { + const where = buildNestedOrQuery(3); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); + + it('should reject $or exceeding depth limit', async () => { + const where = buildNestedOrQuery(4); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should reject $and exceeding depth limit', async () => { + const where = buildNestedAndQuery(4); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should reject $nor exceeding depth limit', async () => { + const where = buildNestedNorQuery(4); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should reject mixed nested operators exceeding depth limit', async () => { + // $or > $and > $nor > $or = depth 4 + const where = { + $or: [ + { + $and: [ + { + $nor: [ + { $or: [{ username: 'a' }, { username: 'b' }] }, + ], + }, + ], + }, + ], + }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should allow with master key even when exceeding limit', async () => { + const where = buildNestedOrQuery(4); + await expectAsync( + rest.find(config, auth.master(config), '_User', where) + ).toBeResolved(); + }); + + it('should allow with maintenance key even when exceeding limit', async () => { + const where = buildNestedOrQuery(4); + await expectAsync( + rest.find(config, auth.maintenance(config), '_User', where) + ).toBeResolved(); + }); + + it('should allow unlimited when queryDepth is -1', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: -1 }, + }); + config = Config.get('test'); + const where = buildNestedOrQuery(15); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); + }); + + describe('include limits', () => { + let config; + + beforeEach(async () => { + await reconfigureServer({ + requestComplexity: { includeDepth: 3, includeCount: 5 }, + }); + config = Config.get('test'); + }); + + it('should allow include within depth limit', async () => { + await expectAsync( + rest.find(config, auth.nobody(config), '_User', {}, { include: 'a.b.c' }) + ).toBeResolved(); + }); + + it('should reject include exceeding depth limit', async () => { + await expectAsync( + rest.find(config, auth.nobody(config), '_User', {}, { include: 'a.b.c.d' }) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Include depth of 4 exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should allow include count within limit', async () => { + await expectAsync( + rest.find(config, auth.nobody(config), '_User', {}, { include: 'a,b,c,d,e' }) + ).toBeResolved(); + }); + + it('should reject include count exceeding limit', async () => { + await expectAsync( + rest.find(config, auth.nobody(config), '_User', {}, { include: 'a,b,c,d,e,f' }) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Number of include fields \(\d+\) exceeds maximum allowed \(5\)/), + }) + ); + }); + + it('should allow includeAll when within count limit', async () => { + const schema = new Parse.Schema('IncludeTestClass'); + schema.addPointer('ptr1', '_User'); + schema.addPointer('ptr2', '_User'); + schema.addPointer('ptr3', '_User'); + await schema.save(); + + const obj = new Parse.Object('IncludeTestClass'); + await obj.save(); + + await expectAsync( + rest.find(config, auth.nobody(config), 'IncludeTestClass', {}, { includeAll: true }) + ).toBeResolved(); + }); + + it('should reject includeAll when exceeding count limit', async () => { + await reconfigureServer({ + requestComplexity: { includeDepth: 3, includeCount: 2 }, + }); + config = Config.get('test'); + + const schema = new Parse.Schema('IncludeTestClass2'); + schema.addPointer('ptr1', '_User'); + schema.addPointer('ptr2', '_User'); + schema.addPointer('ptr3', '_User'); + await schema.save(); + + const obj = new Parse.Object('IncludeTestClass2'); + await obj.save(); + + await expectAsync( + rest.find(config, auth.nobody(config), 'IncludeTestClass2', {}, { includeAll: true }) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Number of include fields .* exceeds maximum allowed/), + }) + ); + }); + + it('should allow includes with master key even when exceeding limits', async () => { + await expectAsync( + rest.find(config, auth.master(config), '_User', {}, { include: 'a.b.c.d' }) + ).toBeResolved(); + }); + + it('should allow unlimited depth when includeDepth is -1', async () => { + await reconfigureServer({ + requestComplexity: { includeDepth: -1 }, + }); + config = Config.get('test'); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', {}, { include: 'a.b.c.d.e.f.g' }) + ).toBeResolved(); + }); + + it('should allow unlimited count when includeCount is -1', async () => { + await reconfigureServer({ + requestComplexity: { includeCount: -1 }, + }); + config = Config.get('test'); + const includes = Array.from({ length: 100 }, (_, i) => `field${i}`).join(','); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', {}, { include: includes }) + ).toBeResolved(); + }); + }); + + describe('allowRegex', () => { + let config; + + beforeEach(async () => { + await reconfigureServer({ + requestComplexity: { allowRegex: false }, + }); + config = Config.get('test'); + }); + + it('should reject $regex query when allowRegex is false (unauthenticated)', async () => { + const where = { username: { $regex: 'test' } }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: '$regex operator is not allowed', + }) + ); + }); + + it('should reject $regex query when allowRegex is false (authenticated user)', async () => { + const user = new Parse.User(); + user.setUsername('testuser'); + user.setPassword('testpass'); + await user.signUp(); + const userAuth = new auth.Auth({ + config, + isMaster: false, + user, + }); + const where = { username: { $regex: 'test' } }; + await expectAsync( + rest.find(config, userAuth, '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: '$regex operator is not allowed', + }) + ); + }); + + it('should allow $regex query when allowRegex is false with master key', async () => { + const where = { username: { $regex: 'test' } }; + await expectAsync( + rest.find(config, auth.master(config), '_User', where) + ).toBeResolved(); + }); + + it('should allow $regex query when allowRegex is true (default)', async () => { + await reconfigureServer({ + requestComplexity: { allowRegex: true }, + }); + config = Config.get('test'); + const where = { username: { $regex: 'test' } }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); + + it('should reject $regex inside $or when allowRegex is false', async () => { + const where = { + $or: [ + { username: { $regex: 'test' } }, + { username: 'exact' }, + ], + }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: '$regex operator is not allowed', + }) + ); + }); + + it('should reject $regex inside $and when allowRegex is false', async () => { + const where = { + $and: [ + { username: { $regex: 'test' } }, + { username: 'exact' }, + ], + }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: '$regex operator is not allowed', + }) + ); + }); + + it('should reject $regex inside $nor when allowRegex is false', async () => { + const where = { + $nor: [ + { username: { $regex: 'test' } }, + ], + }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: '$regex operator is not allowed', + }) + ); + }); + + it('should allow $regex by default when allowRegex is not configured', async () => { + await reconfigureServer({}); + config = Config.get('test'); + const where = { username: { $regex: 'test' } }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); + + it('should reject empty-string $regex when allowRegex is false', async () => { + const where = { username: { $regex: '' } }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: '$regex operator is not allowed', + }) + ); + }); + + it('should allow $regex with maintenance key when allowRegex is false', async () => { + const where = { username: { $regex: 'test' } }; + await expectAsync( + rest.find(config, auth.maintenance(config), '_User', where) + ).toBeResolved(); + }); + + describe('LiveQuery', () => { + beforeEach(async () => { + await reconfigureServer({ + requestComplexity: { allowRegex: false }, + liveQuery: { classNames: ['TestObject'] }, + startLiveQueryServer: true, + }); + config = Config.get('test'); + }); + + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + }); + + it('should reject LiveQuery subscription with $regex when allowRegex is false', async () => { + const query = new Parse.Query('TestObject'); + query.matches('field', /test/); + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY }) + ); + }); + + it('should reject LiveQuery subscription with $regex inside $or when allowRegex is false', async () => { + const query = new Parse.Query('TestObject'); + query._where = { + $or: [ + { field: { $regex: 'test' } }, + { field: 'exact' }, + ], + }; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY }) + ); + }); + + it('should allow LiveQuery subscription without $regex when allowRegex is false', async () => { + const query = new Parse.Query('TestObject'); + query.equalTo('field', 'test'); + const subscription = await query.subscribe(); + expect(subscription).toBeDefined(); + subscription.unsubscribe(); + }); + }); + }); + + describe('subquery result limit', () => { + let config; + const totalObjects = 5; + const resultLimit = 3; + + beforeEach(async () => { + await reconfigureServer({ + requestComplexity: { subqueryLimit: resultLimit }, + }); + config = Config.get('test'); + // Create target objects + const targets = []; + for (let i = 0; i < totalObjects; i++) { + const obj = new Parse.Object('Target'); + obj.set('value', `v${i}`); + targets.push(obj); + } + await Parse.Object.saveAll(targets); + // Create source objects, each pointing to a target + const sources = []; + for (let i = 0; i < totalObjects; i++) { + const obj = new Parse.Object('Source'); + obj.set('ref', targets[i]); + obj.set('value', targets[i].get('value')); + sources.push(obj); + } + await Parse.Object.saveAll(sources); + }); + + it('should limit $inQuery subquery results', async () => { + const where = { + ref: { + $inQuery: { className: 'Target', where: {} }, + }, + }; + const result = await rest.find(config, auth.nobody(config), 'Source', where); + expect(result.results.length).toBe(resultLimit); + }); + + it('should limit $notInQuery subquery results', async () => { + const where = { + ref: { + $notInQuery: { className: 'Target', where: {} }, + }, + }; + const result = await rest.find(config, auth.nobody(config), 'Source', where); + // With limit, only `resultLimit` targets are excluded, so (totalObjects - resultLimit) sources remain + expect(result.results.length).toBe(totalObjects - resultLimit); + }); + + it('should limit $select subquery results', async () => { + const where = { + value: { + $select: { query: { className: 'Target', where: {} }, key: 'value' }, + }, + }; + const result = await rest.find(config, auth.nobody(config), 'Source', where); + expect(result.results.length).toBe(resultLimit); + }); + + it('should limit $dontSelect subquery results', async () => { + const where = { + value: { + $dontSelect: { query: { className: 'Target', where: {} }, key: 'value' }, + }, + }; + const result = await rest.find(config, auth.nobody(config), 'Source', where); + expect(result.results.length).toBe(totalObjects - resultLimit); + }); + + it('should allow unlimited subquery results with master key', async () => { + const where = { + ref: { + $inQuery: { className: 'Target', where: {} }, + }, + }; + const result = await rest.find(config, auth.master(config), 'Source', where); + expect(result.results.length).toBe(totalObjects); + }); + + it('should allow unlimited subquery results with maintenance key', async () => { + const where = { + ref: { + $inQuery: { className: 'Target', where: {} }, + }, + }; + const result = await rest.find(config, auth.maintenance(config), 'Source', where); + expect(result.results.length).toBe(totalObjects); + }); + + it('should allow unlimited subquery results when subqueryLimit is -1', async () => { + await reconfigureServer({ + requestComplexity: { subqueryLimit: -1 }, + }); + config = Config.get('test'); + const where = { + ref: { + $inQuery: { className: 'Target', where: {} }, + }, + }; + const result = await rest.find(config, auth.nobody(config), 'Source', where); + expect(result.results.length).toBe(totalObjects); + }); + + it('should include subqueryLimit in config defaults', async () => { + await reconfigureServer({}); + config = Config.get('test'); + expect(config.requestComplexity.subqueryLimit).toBe(-1); + }); + + it('should accept subqueryLimit in config validation', async () => { + await expectAsync( + reconfigureServer({ + requestComplexity: { subqueryLimit: 100 }, + }) + ).toBeResolved(); + }); + }); +}); diff --git a/spec/RestCreate.spec.js b/spec/RestCreate.spec.js deleted file mode 100644 index 155dd11bb0..0000000000 --- a/spec/RestCreate.spec.js +++ /dev/null @@ -1,409 +0,0 @@ -"use strict"; -// These tests check the "create" / "update" functionality of the REST API. -var auth = require('../src/Auth'); -var cache = require('../src/cache'); -var Config = require('../src/Config'); -var Parse = require('parse/node').Parse; -var rest = require('../src/rest'); -var request = require('request'); - -var config = new Config('test'); -let database = config.database; - -describe('rest create', () => { - it_exclude_dbs(['postgres'])('handles _id', done => { - rest.create(config, auth.nobody(config), 'Foo', {}) - .then(() => database.adapter.find('Foo', { fields: {} }, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - var obj = results[0]; - expect(typeof obj.objectId).toEqual('string'); - expect(obj._id).toBeUndefined(); - done(); - }); - }); - - it_exclude_dbs(['postgres'])('handles array, object, date', (done) => { - let now = new Date(); - var obj = { - array: [1, 2, 3], - object: {foo: 'bar'}, - date: Parse._encode(now), - }; - rest.create(config, auth.nobody(config), 'MyClass', obj) - .then(() => database.adapter.find('MyClass', { fields: { - array: { type: 'Array' }, - object: { type: 'Object' }, - date: { type: 'Date' }, - } }, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - var mob = results[0]; - expect(mob.array instanceof Array).toBe(true); - expect(typeof mob.object).toBe('object'); - expect(mob.date.__type).toBe('Date'); - expect(new Date(mob.date.iso).getTime()).toBe(now.getTime()); - done(); - }); - }); - - it_exclude_dbs(['postgres'])('handles object and subdocument', done => { - let obj = { subdoc: {foo: 'bar', wu: 'tan'} }; - rest.create(config, auth.nobody(config), 'MyClass', obj) - .then(() => database.adapter.find('MyClass', { fields: {} }, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - let mob = results[0]; - expect(typeof mob.subdoc).toBe('object'); - expect(mob.subdoc.foo).toBe('bar'); - expect(mob.subdoc.wu).toBe('tan'); - expect(typeof mob.objectId).toEqual('string'); - let obj = { 'subdoc.wu': 'clan' }; - return rest.update(config, auth.nobody(config), 'MyClass', mob.objectId, obj) - }) - .then(() => database.adapter.find('MyClass', { fields: {} }, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - let mob = results[0]; - expect(typeof mob.subdoc).toBe('object'); - expect(mob.subdoc.foo).toBe('bar'); - expect(mob.subdoc.wu).toBe('clan'); - done(); - }) - .catch(error => { - console.log(error); - fail(); - done(); - }); - }); - - it_exclude_dbs(['postgres'])('handles create on non-existent class when disabled client class creation', (done) => { - var customConfig = Object.assign({}, config, {allowClientClassCreation: false}); - rest.create(customConfig, auth.nobody(customConfig), 'ClientClassCreation', {}) - .then(() => { - fail('Should throw an error'); - done(); - }, (err) => { - expect(err.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); - expect(err.message).toEqual('This user is not allowed to access ' + - 'non-existent class: ClientClassCreation'); - done(); - }); - }); - - it_exclude_dbs(['postgres'])('handles create on existent class when disabled client class creation', (done) => { - var customConfig = Object.assign({}, config, {allowClientClassCreation: false}); - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('ClientClassCreation', {})) - .then(actualSchema => { - expect(actualSchema.className).toEqual('ClientClassCreation'); - return rest.create(customConfig, auth.nobody(customConfig), 'ClientClassCreation', {}); - }) - .then(() => { - done(); - }, err => { - fail('Should not throw error') - }); - }); - - it('handles user signup', (done) => { - var user = { - username: 'asdf', - password: 'zxcv', - foo: 'bar', - }; - rest.create(config, auth.nobody(config), '_User', user) - .then((r) => { - expect(Object.keys(r.response).length).toEqual(3); - expect(typeof r.response.objectId).toEqual('string'); - expect(typeof r.response.createdAt).toEqual('string'); - expect(typeof r.response.sessionToken).toEqual('string'); - done(); - }); - }); - - it_exclude_dbs(['postgres'])('handles anonymous user signup', (done) => { - var data1 = { - authData: { - anonymous: { - id: '00000000-0000-0000-0000-000000000001' - } - } - }; - var data2 = { - authData: { - anonymous: { - id: '00000000-0000-0000-0000-000000000002' - } - } - }; - var username1; - rest.create(config, auth.nobody(config), '_User', data1) - .then((r) => { - expect(typeof r.response.objectId).toEqual('string'); - expect(typeof r.response.createdAt).toEqual('string'); - expect(typeof r.response.sessionToken).toEqual('string'); - expect(typeof r.response.username).toEqual('string'); - return rest.create(config, auth.nobody(config), '_User', data1); - }).then((r) => { - expect(typeof r.response.objectId).toEqual('string'); - expect(typeof r.response.createdAt).toEqual('string'); - expect(typeof r.response.username).toEqual('string'); - expect(typeof r.response.updatedAt).toEqual('string'); - username1 = r.response.username; - return rest.create(config, auth.nobody(config), '_User', data2); - }).then((r) => { - expect(typeof r.response.objectId).toEqual('string'); - expect(typeof r.response.createdAt).toEqual('string'); - expect(typeof r.response.sessionToken).toEqual('string'); - return rest.create(config, auth.nobody(config), '_User', data2); - }).then((r) => { - expect(typeof r.response.objectId).toEqual('string'); - expect(typeof r.response.createdAt).toEqual('string'); - expect(typeof r.response.username).toEqual('string'); - expect(typeof r.response.updatedAt).toEqual('string'); - expect(r.response.username).not.toEqual(username1); - done(); - }); - }); - - it_exclude_dbs(['postgres'])('handles anonymous user signup and upgrade to new user', (done) => { - var data1 = { - authData: { - anonymous: { - id: '00000000-0000-0000-0000-000000000001' - } - } - }; - - var updatedData = { - authData: { anonymous: null }, - username: 'hello', - password: 'world' - } - var username1; - var objectId; - rest.create(config, auth.nobody(config), '_User', data1) - .then((r) => { - expect(typeof r.response.objectId).toEqual('string'); - expect(typeof r.response.createdAt).toEqual('string'); - expect(typeof r.response.sessionToken).toEqual('string'); - objectId = r.response.objectId; - return auth.getAuthForSessionToken({config, sessionToken: r.response.sessionToken }) - }).then((sessionAuth) => { - return rest.update(config, sessionAuth, '_User', objectId, updatedData); - }).then((r) => { - return Parse.User.logOut().then(() => { - return Parse.User.logIn('hello', 'world'); - }) - }).then((r) => { - expect(r.id).toEqual(objectId); - expect(r.get('username')).toEqual('hello'); - done(); - }).catch((err) => { - fail('should not fail') - done(); - }) - }); - - it('handles no anonymous users config', (done) => { - var NoAnnonConfig = Object.assign({}, config); - NoAnnonConfig.authDataManager.setEnableAnonymousUsers(false); - var data1 = { - authData: { - anonymous: { - id: '00000000-0000-0000-0000-000000000001' - } - } - }; - rest.create(NoAnnonConfig, auth.nobody(NoAnnonConfig), '_User', data1).then(() => { - fail("Should throw an error"); - done(); - }, (err) => { - expect(err.code).toEqual(Parse.Error.UNSUPPORTED_SERVICE); - expect(err.message).toEqual('This authentication method is unsupported.'); - NoAnnonConfig.authDataManager.setEnableAnonymousUsers(true); - done(); - }) - }); - - it_exclude_dbs(['postgres'])('test facebook signup and login', (done) => { - var data = { - authData: { - facebook: { - id: '8675309', - access_token: 'jenny' - } - } - }; - var newUserSignedUpByFacebookObjectId; - rest.create(config, auth.nobody(config), '_User', data) - .then((r) => { - expect(typeof r.response.objectId).toEqual('string'); - expect(typeof r.response.createdAt).toEqual('string'); - expect(typeof r.response.sessionToken).toEqual('string'); - newUserSignedUpByFacebookObjectId = r.response.objectId; - return rest.create(config, auth.nobody(config), '_User', data); - }).then((r) => { - expect(typeof r.response.objectId).toEqual('string'); - expect(typeof r.response.createdAt).toEqual('string'); - expect(typeof r.response.username).toEqual('string'); - expect(typeof r.response.updatedAt).toEqual('string'); - expect(r.response.objectId).toEqual(newUserSignedUpByFacebookObjectId); - return rest.find(config, auth.master(config), - '_Session', {sessionToken: r.response.sessionToken}); - }).then((response) => { - expect(response.results.length).toEqual(1); - var output = response.results[0]; - expect(output.user.objectId).toEqual(newUserSignedUpByFacebookObjectId); - done(); - }); - }); - - it_exclude_dbs(['postgres'])('stores pointers', done => { - let obj = { - foo: 'bar', - aPointer: { - __type: 'Pointer', - className: 'JustThePointer', - objectId: 'qwerty' - } - }; - rest.create(config, auth.nobody(config), 'APointerDarkly', obj) - .then(() => database.adapter.find('APointerDarkly', { fields: { - foo: { type: 'String' }, - aPointer: { type: 'Pointer', targetClass: 'JustThePointer' }, - }}, {}, {})) - .then(results => { - expect(results.length).toEqual(1); - let output = results[0]; - expect(typeof output.foo).toEqual('string'); - expect(typeof output._p_aPointer).toEqual('undefined'); - expect(output._p_aPointer).toBeUndefined(); - expect(output.aPointer).toEqual({ - __type: 'Pointer', - className: 'JustThePointer', - objectId: 'qwerty' - }); - done(); - }); - }); - - it("cannot set objectId", (done) => { - var headers = { - 'Content-Type': 'application/octet-stream', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - request.post({ - headers: headers, - url: 'http://localhost:8378/1/classes/TestObject', - body: JSON.stringify({ - 'foo': 'bar', - 'objectId': 'hello' - }) - }, (error, response, body) => { - var b = JSON.parse(body); - expect(b.code).toEqual(105); - expect(b.error).toEqual('objectId is an invalid field name.'); - done(); - }); - }); - - it_exclude_dbs(['postgres'])("test default session length", (done) => { - var user = { - username: 'asdf', - password: 'zxcv', - foo: 'bar', - }; - var now = new Date(); - - rest.create(config, auth.nobody(config), '_User', user) - .then((r) => { - expect(Object.keys(r.response).length).toEqual(3); - expect(typeof r.response.objectId).toEqual('string'); - expect(typeof r.response.createdAt).toEqual('string'); - expect(typeof r.response.sessionToken).toEqual('string'); - return rest.find(config, auth.master(config), - '_Session', {sessionToken: r.response.sessionToken}); - }) - .then((r) => { - expect(r.results.length).toEqual(1); - - var session = r.results[0]; - var actual = new Date(session.expiresAt.iso); - var expected = new Date(now.getTime() + (1000 * 3600 * 24 * 365)); - - expect(actual.getFullYear()).toEqual(expected.getFullYear()); - expect(actual.getMonth()).toEqual(expected.getMonth()); - expect(actual.getDate()).toEqual(expected.getDate()); - // less than a minute, if test happen at the wrong time :/ - expect(actual.getMinutes() - expected.getMinutes() <= 1).toBe(true); - - done(); - }); - }); - - it_exclude_dbs(['postgres'])("test specified session length", (done) => { - var user = { - username: 'asdf', - password: 'zxcv', - foo: 'bar', - }; - var sessionLength = 3600, // 1 Hour ahead - now = new Date(); // For reference later - config.sessionLength = sessionLength; - - rest.create(config, auth.nobody(config), '_User', user) - .then((r) => { - expect(Object.keys(r.response).length).toEqual(3); - expect(typeof r.response.objectId).toEqual('string'); - expect(typeof r.response.createdAt).toEqual('string'); - expect(typeof r.response.sessionToken).toEqual('string'); - return rest.find(config, auth.master(config), - '_Session', {sessionToken: r.response.sessionToken}); - }) - .then((r) => { - expect(r.results.length).toEqual(1); - - var session = r.results[0]; - var actual = new Date(session.expiresAt.iso); - var expected = new Date(now.getTime() + (sessionLength*1000)); - - expect(actual.getFullYear()).toEqual(expected.getFullYear()); - expect(actual.getMonth()).toEqual(expected.getMonth()); - expect(actual.getDate()).toEqual(expected.getDate()); - expect(actual.getHours()).toEqual(expected.getHours()); - expect(actual.getMinutes()).toEqual(expected.getMinutes()); - - done(); - }); - }); - - it_exclude_dbs(['postgres'])("can create a session with no expiration", (done) => { - var user = { - username: 'asdf', - password: 'zxcv', - foo: 'bar' - }; - config.expireInactiveSessions = false; - - rest.create(config, auth.nobody(config), '_User', user) - .then((r) => { - expect(Object.keys(r.response).length).toEqual(3); - expect(typeof r.response.objectId).toEqual('string'); - expect(typeof r.response.createdAt).toEqual('string'); - expect(typeof r.response.sessionToken).toEqual('string'); - return rest.find(config, auth.master(config), - '_Session', {sessionToken: r.response.sessionToken}); - }) - .then((r) => { - expect(r.results.length).toEqual(1); - - var session = r.results[0]; - expect(session.expiresAt).toBeUndefined(); - - done(); - }); - }); -}); diff --git a/spec/RestQuery.spec.js b/spec/RestQuery.spec.js index a6c8d9e7e1..3438febf76 100644 --- a/spec/RestQuery.spec.js +++ b/spec/RestQuery.spec.js @@ -1,238 +1,738 @@ -'use strict' +'use strict'; // These tests check the "find" functionality of the REST API. -var auth = require('../src/Auth'); -var cache = require('../src/cache'); -var Config = require('../src/Config'); -var rest = require('../src/rest'); +const auth = require('../lib/Auth'); +const Config = require('../lib/Config'); +const rest = require('../lib/rest'); +const RestQuery = require('../lib/RestQuery'); +const request = require('../lib/request'); +const querystring = require('querystring'); -var querystring = require('querystring'); -var request = require('request'); -var rp = require('request-promise'); - -var config; +let config; let database; -var nobody = auth.nobody(config); +const nobody = auth.nobody(config); describe('rest query', () => { - - beforeEach(() => { - config = new Config('test'); + beforeEach(() => { + config = Config.get('test'); database = config.database; }); - it('basic query', (done) => { - rest.create(config, nobody, 'TestObject', {}).then(() => { - return rest.find(config, nobody, 'TestObject', {}); - }).then((response) => { - expect(response.results.length).toEqual(1); - done(); - }); + it('basic query', done => { + rest + .create(config, nobody, 'TestObject', {}) + .then(() => { + return rest.find(config, nobody, 'TestObject', {}); + }) + .then(response => { + expect(response.results.length).toEqual(1); + done(); + }); }); - it('query with limit', (done) => { - rest.create(config, nobody, 'TestObject', {foo: 'baz'} - ).then(() => { - return rest.create(config, nobody, - 'TestObject', {foo: 'qux'}); - }).then(() => { - return rest.find(config, nobody, - 'TestObject', {}, {limit: 1}); - }).then((response) => { - expect(response.results.length).toEqual(1); - expect(response.results[0].foo).toBeTruthy(); - done(); - }); + it('query with limit', done => { + rest + .create(config, nobody, 'TestObject', { foo: 'baz' }) + .then(() => { + return rest.create(config, nobody, 'TestObject', { foo: 'qux' }); + }) + .then(() => { + return rest.find(config, nobody, 'TestObject', {}, { limit: 1 }); + }) + .then(response => { + expect(response.results.length).toEqual(1); + expect(response.results[0].foo).toBeTruthy(); + done(); + }); }); - var data = { + const data = { username: 'blah', password: 'pass', sessionToken: 'abc123', - } - - it_exclude_dbs(['postgres'])('query for user w/ legacy credentials without masterKey has them stripped from results', done => { - database.create('_User', data).then(() => { - return rest.find(config, nobody, '_User') - }).then((result) => { - var user = result.results[0]; - expect(user.username).toEqual('blah'); - expect(user.sessionToken).toBeUndefined(); - expect(user.password).toBeUndefined(); - done(); - }); - }); + }; - it_exclude_dbs(['postgres'])('query for user w/ legacy credentials with masterKey has them stripped from results', done => { - database.create('_User', data).then(() => { - return rest.find(config, {isMaster: true}, '_User') - }).then((result) => { - var user = result.results[0]; - expect(user.username).toEqual('blah'); - expect(user.sessionToken).toBeUndefined(); - expect(user.password).toBeUndefined(); - done(); - }); - }); + it_exclude_dbs(['postgres'])( + 'query for user w/ legacy credentials without masterKey has them stripped from results', + done => { + database + .create('_User', data) + .then(() => { + return rest.find(config, nobody, '_User'); + }) + .then(result => { + const user = result.results[0]; + expect(user.username).toEqual('blah'); + expect(user.sessionToken).toBeUndefined(); + expect(user.password).toBeUndefined(); + done(); + }); + } + ); + + it_exclude_dbs(['postgres'])( + 'query for user w/ legacy credentials with masterKey has them stripped from results', + done => { + database + .create('_User', data) + .then(() => { + return rest.find(config, { isMaster: true }, '_User'); + }) + .then(result => { + const user = result.results[0]; + expect(user.username).toEqual('blah'); + expect(user.sessionToken).toBeUndefined(); + expect(user.password).toBeUndefined(); + done(); + }); + } + ); // Created to test a scenario in AnyPic - it_exclude_dbs(['postgres'])('query with include', (done) => { - var photo = { - foo: 'bar' + it_exclude_dbs(['postgres'])('query with include', done => { + let photo = { + foo: 'bar', }; - var user = { + let user = { username: 'aUsername', - password: 'aPassword' + password: 'aPassword', + ACL: { '*': { read: true } }, }; - var activity = { + const activity = { type: 'comment', photo: { __type: 'Pointer', className: 'TestPhoto', - objectId: '' + objectId: '', }, fromUser: { __type: 'Pointer', className: '_User', - objectId: '' - } + objectId: '', + }, }; - var queryWhere = { + const queryWhere = { photo: { __type: 'Pointer', className: 'TestPhoto', - objectId: '' + objectId: '', }, - type: 'comment' + type: 'comment', }; - var queryOptions = { + const queryOptions = { include: 'fromUser', order: 'createdAt', - limit: 30 + limit: 30, }; - rest.create(config, nobody, 'TestPhoto', photo - ).then((p) => { - photo = p; - return rest.create(config, nobody, '_User', user); - }).then((u) => { - user = u.response; - activity.photo.objectId = photo.objectId; - activity.fromUser.objectId = user.objectId; - return rest.create(config, nobody, - 'TestActivity', activity); - }).then(() => { - queryWhere.photo.objectId = photo.objectId; - return rest.find(config, nobody, - 'TestActivity', queryWhere, queryOptions); - }).then((response) => { - var results = response.results; - expect(results.length).toEqual(1); - expect(typeof results[0].objectId).toEqual('string'); - expect(typeof results[0].photo).toEqual('object'); - expect(typeof results[0].fromUser).toEqual('object'); - expect(typeof results[0].fromUser.username).toEqual('string'); - done(); - }).catch((error) => { console.log(error); }); + rest + .create(config, nobody, 'TestPhoto', photo) + .then(p => { + photo = p; + return rest.create(config, nobody, '_User', user); + }) + .then(u => { + user = u.response; + activity.photo.objectId = photo.objectId; + activity.fromUser.objectId = user.objectId; + return rest.create(config, nobody, 'TestActivity', activity); + }) + .then(() => { + queryWhere.photo.objectId = photo.objectId; + return rest.find(config, nobody, 'TestActivity', queryWhere, queryOptions); + }) + .then(response => { + const results = response.results; + expect(results.length).toEqual(1); + expect(typeof results[0].objectId).toEqual('string'); + expect(typeof results[0].photo).toEqual('object'); + expect(typeof results[0].fromUser).toEqual('object'); + expect(typeof results[0].fromUser.username).toEqual('string'); + done(); + }) + .catch(error => { + console.log(error); + }); }); - it_exclude_dbs(['postgres'])('query non-existent class when disabled client class creation', (done) => { - var customConfig = Object.assign({}, config, {allowClientClassCreation: false}); - rest.find(customConfig, auth.nobody(customConfig), 'ClientClassCreation', {}) - .then(() => { + it('query non-existent class when disabled client class creation', done => { + const logger = require('../lib/logger').default; + const loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + + const customConfig = Object.assign({}, config, { + allowClientClassCreation: false, + }); + loggerErrorSpy.calls.reset(); + rest.find(customConfig, auth.nobody(customConfig), 'ClientClassCreation', {}).then( + () => { fail('Should throw an error'); done(); - }, (err) => { + }, + err => { expect(err.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); - expect(err.message).toEqual('This user is not allowed to access ' + - 'non-existent class: ClientClassCreation'); + expect(err.message).toEqual('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('This user is not allowed to access ' + 'non-existent class: ClientClassCreation')); done(); + } + ); + }); + + it('query existent class when disabled client class creation', async () => { + const customConfig = Object.assign({}, config, { + allowClientClassCreation: false, }); + const schema = await config.database.loadSchema(); + const actualSchema = await schema.addClassIfNotExists('ClientClassCreation', {}); + expect(actualSchema.className).toEqual('ClientClassCreation'); + + await schema.reloadData({ clearCache: true }); + // Should not throw + const result = await rest.find( + customConfig, + auth.nobody(customConfig), + 'ClientClassCreation', + {} + ); + expect(result.results.length).toEqual(0); }); - it_exclude_dbs(['postgres'])('query existent class when disabled client class creation', (done) => { - var customConfig = Object.assign({}, config, {allowClientClassCreation: false}); - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('ClientClassCreation', {})) - .then(actualSchema => { - expect(actualSchema.className).toEqual('ClientClassCreation'); - return rest.find(customConfig, auth.nobody(customConfig), 'ClientClassCreation', {}); - }) - .then((result) => { - expect(result.results.length).toEqual(0); - done(); - }, err => { - fail('Should not throw error') - }); - }); - - it('query with wrongly encoded parameter', (done) => { - rest.create(config, nobody, 'TestParameterEncode', {foo: 'bar'} - ).then(() => { - return rest.create(config, nobody, - 'TestParameterEncode', {foo: 'baz'}); - }).then(() => { - var headers = { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - - let p0 = rp.get({ - headers: headers, - url: 'http://localhost:8378/1/classes/TestParameterEncode?' - + querystring.stringify({ - where: '{"foo":{"$ne": "baz"}}', - limit: 1 - }).replace('=', '%3D'), - }).then(fail, (response) => { - let error = response.error; - var b = JSON.parse(error); - expect(b.code).toEqual(Parse.Error.INVALID_QUERY); + it('query internal field', async () => { + const internalFields = [ + '_email_verify_token', + '_perishable_token', + '_tombstone', + '_email_verify_token_expires_at', + '_failed_login_count', + '_account_lockout_expires_at', + '_password_changed_at', + '_password_history', + ]; + // Run rejection and success queries sequentially to avoid orphaned promises + // that can cause unhandled rejections when Promise.all short-circuits + for (const field of internalFields) { + await expectAsync(new Parse.Query(Parse.User).exists(field).find()).toBeRejectedWith( + new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid key name: ${field}`) + ); + } + for (const field of internalFields) { + await new Parse.Query(Parse.User).exists(field).find({ useMasterKey: true }); + } + }); + + it('query internal field that has no database column', async () => { + const user = new Parse.User(); + user.setUsername('user1'); + user.setPassword('password'); + await user.signUp(); + // _tombstone is registered as an internal field but has no column in the + // Postgres _User table. Querying it with master key should return empty + // results (consistent with MongoDB behavior), not throw an error. + const results = await new Parse.Query(Parse.User).exists('_tombstone').find({ useMasterKey: true }); + expect(results.length).toBe(0); + }); + + it('count internal field that has no database column', async () => { + const user = new Parse.User(); + user.setUsername('user1'); + user.setPassword('password'); + await user.signUp(); + const count = await new Parse.Query(Parse.User).exists('_tombstone').count({ useMasterKey: true }); + expect(count).toBe(0); + }); + + it('query protected field', async () => { + const user = new Parse.User(); + user.setUsername('username1'); + user.setPassword('password'); + await user.signUp(); + const config = Config.get(Parse.applicationId); + const obj = new Parse.Object('Test'); + + obj.set('owner', user); + obj.set('test', 'test'); + obj.set('zip', 1234); + await obj.save(); + + const schema = await config.database.loadSchema(); + await schema.updateClass( + 'Test', + {}, + { + get: { '*': true }, + find: { '*': true }, + protectedFields: { [user.id]: ['zip'] }, + } + ); + await Promise.all([ + new Parse.Query('Test').exists('test').find(), + expectAsync(new Parse.Query('Test').exists('zip').find()).toBeRejectedWith( + new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + 'Permission denied' + ) + ), + ]); + }); + + it('query protected field with matchesQuery', async () => { + const user = new Parse.User(); + user.setUsername('username1'); + user.setPassword('password'); + await user.signUp(); + const test = new Parse.Object('TestObject', { user }); + await test.save(); + const subQuery = new Parse.Query(Parse.User); + subQuery.exists('_perishable_token'); + await expectAsync( + new Parse.Query('TestObject').matchesQuery('user', subQuery).find() + ).toBeRejectedWith( + new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'Invalid key name: _perishable_token') + ); + }); + + it('query with wrongly encoded parameter', done => { + rest + .create(config, nobody, 'TestParameterEncode', { foo: 'bar' }) + .then(() => { + return rest.create(config, nobody, 'TestParameterEncode', { + foo: 'baz', + }); + }) + .then(() => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + const p0 = request({ + headers: headers, + url: + 'http://localhost:8378/1/classes/TestParameterEncode?' + + querystring + .stringify({ + where: '{"foo":{"$ne": "baz"}}', + limit: 1, + }) + .replace('=', '%3D'), + }).then(fail, response => { + const error = response.data; + expect(error.code).toEqual(Parse.Error.INVALID_QUERY); + }); + + const p1 = request({ + headers: headers, + url: + 'http://localhost:8378/1/classes/TestParameterEncode?' + + querystring + .stringify({ + limit: 1, + }) + .replace('=', '%3D'), + }).then(fail, response => { + const error = response.data; + expect(error.code).toEqual(Parse.Error.INVALID_QUERY); + }); + return Promise.all([p0, p1]); + }) + .then(done) + .catch(err => { + jfail(err); + fail('should not fail'); + done(); }); + }); - let p1 = rp.get({ - headers: headers, - url: 'http://localhost:8378/1/classes/TestParameterEncode?' - + querystring.stringify({ - limit: 1 - }).replace('=', '%3D'), - }).then(fail, (response) => { - let error = response.error; - var b = JSON.parse(error); - expect(b.code).toEqual(Parse.Error.INVALID_QUERY); + it('query with limit = 0', done => { + rest + .create(config, nobody, 'TestObject', { foo: 'baz' }) + .then(() => { + return rest.create(config, nobody, 'TestObject', { foo: 'qux' }); + }) + .then(() => { + return rest.find(config, nobody, 'TestObject', {}, { limit: 0 }); + }) + .then(response => { + expect(response.results.length).toEqual(0); + done(); }); - return Promise.all([p0, p1]); - }).then(done).catch((err) => { - console.error(err); - fail('should not fail'); - done(); - }) - }); - - it('query with limit = 0', (done) => { - rest.create(config, nobody, 'TestObject', {foo: 'baz'} - ).then(() => { - return rest.create(config, nobody, - 'TestObject', {foo: 'qux'}); - }).then(() => { - return rest.find(config, nobody, - 'TestObject', {}, {limit: 0}); - }).then((response) => { - expect(response.results.length).toEqual(0); - done(); + }); + + it('query with limit = 0 and count = 1', done => { + rest + .create(config, nobody, 'TestObject', { foo: 'baz' }) + .then(() => { + return rest.create(config, nobody, 'TestObject', { foo: 'qux' }); + }) + .then(() => { + return rest.find(config, nobody, 'TestObject', {}, { limit: 0, count: 1 }); + }) + .then(response => { + expect(response.results.length).toEqual(0); + expect(response.count).toEqual(2); + done(); + }); + }); + + it('makes sure null pointers are handed correctly #2189', done => { + const object = new Parse.Object('AnObject'); + const anotherObject = new Parse.Object('AnotherObject'); + anotherObject + .save() + .then(() => { + object.set('values', [null, null, anotherObject]); + return object.save(); + }) + .then(() => { + const query = new Parse.Query('AnObject'); + query.include('values'); + return query.first(); + }) + .then( + result => { + const values = result.get('values'); + expect(values.length).toBe(3); + let anotherObjectFound = false; + let nullCounts = 0; + for (const value of values) { + if (value === null) { + nullCounts++; + } else if (value instanceof Parse.Object) { + anotherObjectFound = true; + } + } + expect(nullCounts).toBe(2); + expect(anotherObjectFound).toBeTruthy(); + done(); + }, + err => { + console.error(err); + fail(err); + done(); + } + ); + }); + + it('battle test parallel include with 100 nested includes', async () => { + await reconfigureServer({ requestComplexity: { includeCount: 200 } }); + const RootObject = Parse.Object.extend('RootObject'); + const Level1Object = Parse.Object.extend('Level1Object'); + const Level2Object = Parse.Object.extend('Level2Object'); + + // Create 100 level2 objects (10 per level1 object) + const level2Objects = []; + for (let i = 0; i < 100; i++) { + const level2 = new Level2Object({ + index: i, + value: `level2_${i}`, + }); + level2Objects.push(level2); + } + await Parse.Object.saveAll(level2Objects); + + // Create 10 level1 objects, each with 10 pointers to level2 objects + const level1Objects = []; + for (let i = 0; i < 10; i++) { + const level1 = new Level1Object({ + index: i, + value: `level1_${i}`, + }); + // Set 10 pointer fields (level2_0 through level2_9) + for (let j = 0; j < 10; j++) { + level1.set(`level2_${j}`, level2Objects[i * 10 + j]); + } + level1Objects.push(level1); + } + await Parse.Object.saveAll(level1Objects); + + // Create 1 root object with 10 pointers to level1 objects + const rootObject = new RootObject({ + value: 'root', + }); + for (let i = 0; i < 10; i++) { + rootObject.set(`level1_${i}`, level1Objects[i]); + } + await rootObject.save(); + + // Build include paths: level1_0 through level1_9, and level1_0.level2_0 through level1_9.level2_9 + const includePaths = []; + for (let i = 0; i < 10; i++) { + includePaths.push(`level1_${i}`); + for (let j = 0; j < 10; j++) { + includePaths.push(`level1_${i}.level2_${j}`); + } + } + + // Query with all includes + const query = new Parse.Query(RootObject); + query.equalTo('objectId', rootObject.id); + for (const path of includePaths) { + query.include(path); + } + console.time('query.find'); + const results = await query.find(); + console.timeEnd('query.find'); + expect(results.length).toBe(1); + + const result = results[0]; + expect(result.id).toBe(rootObject.id); + + // Verify all 10 level1 objects are included + for (let i = 0; i < 10; i++) { + const level1Field = result.get(`level1_${i}`); + expect(level1Field).toBeDefined(); + expect(level1Field instanceof Parse.Object).toBe(true); + expect(level1Field.get('index')).toBe(i); + expect(level1Field.get('value')).toBe(`level1_${i}`); + + // Verify all 10 level2 objects are included for each level1 object + for (let j = 0; j < 10; j++) { + const level2Field = level1Field.get(`level2_${j}`); + expect(level2Field).toBeDefined(); + expect(level2Field instanceof Parse.Object).toBe(true); + expect(level2Field.get('index')).toBe(i * 10 + j); + expect(level2Field.get('value')).toBe(`level2_${i * 10 + j}`); + } + } + }); +}); + +describe('RestQuery.each', () => { + beforeEach(() => { + config = Config.get('test'); + }); + it_id('3416c90b-ee2e-4bb5-9231-46cd181cd0a2')(it)('should run each', async () => { + const objects = []; + while (objects.length != 10) { + objects.push(new Parse.Object('Object', { value: objects.length })); + } + const config = Config.get('test'); + await Parse.Object.saveAll(objects); + const query = await RestQuery({ + method: RestQuery.Method.find, + config, + auth: auth.master(config), + className: 'Object', + restWhere: { value: { $gt: 2 } }, + restOptions: { limit: 2 }, + }); + const spy = spyOn(query, 'execute').and.callThrough(); + const classSpy = spyOn(RestQuery._UnsafeRestQuery.prototype, 'execute').and.callThrough(); + const results = []; + await query.each(result => { + expect(result.value).toBeGreaterThan(2); + results.push(result); + }); + expect(spy.calls.count()).toBe(0); + expect(classSpy.calls.count()).toBe(4); + expect(results.length).toBe(7); + }); + + it_id('0fe22501-4b18-461e-b87d-82ceac4a496e')(it)('should work with query on relations', async () => { + const objectA = new Parse.Object('Letter', { value: 'A' }); + const objectB = new Parse.Object('Letter', { value: 'B' }); + + const object1 = new Parse.Object('Number', { value: '1' }); + const object2 = new Parse.Object('Number', { value: '2' }); + const object3 = new Parse.Object('Number', { value: '3' }); + const object4 = new Parse.Object('Number', { value: '4' }); + await Parse.Object.saveAll([object1, object2, object3, object4]); + + objectA.relation('numbers').add(object1); + objectB.relation('numbers').add(object2); + await Parse.Object.saveAll([objectA, objectB]); + + const config = Config.get('test'); + + /** + * Two queries needed since objectId are sorted and we can't know which one + * going to be the first and then skip by the $gt added by each + */ + const queryOne = await RestQuery({ + method: RestQuery.Method.get, + config, + auth: auth.master(config), + className: 'Letter', + restWhere: { + numbers: { + __type: 'Pointer', + className: 'Number', + objectId: object1.id, + }, + }, + restOptions: { limit: 1 }, + }); + + const queryTwo = await RestQuery({ + method: RestQuery.Method.get, + config, + auth: auth.master(config), + className: 'Letter', + restWhere: { + numbers: { + __type: 'Pointer', + className: 'Number', + objectId: object2.id, + }, + }, + restOptions: { limit: 1 }, + }); + + const classSpy = spyOn(RestQuery._UnsafeRestQuery.prototype, 'execute').and.callThrough(); + const resultsOne = []; + const resultsTwo = []; + await queryOne.each(result => { + resultsOne.push(result); + }); + await queryTwo.each(result => { + resultsTwo.push(result); }); + expect(classSpy.calls.count()).toBe(4); + expect(resultsOne.length).toBe(1); + expect(resultsTwo.length).toBe(1); }); - it_exclude_dbs(['postgres'])('query with limit = 0 and count = 1', (done) => { - rest.create(config, nobody, 'TestObject', {foo: 'baz'} - ).then(() => { - return rest.create(config, nobody, - 'TestObject', {foo: 'qux'}); - }).then(() => { - return rest.find(config, nobody, - 'TestObject', {}, {limit: 0, count: 1}); - }).then((response) => { - expect(response.results.length).toEqual(0); - expect(response.count).toEqual(2); + it('test afterSave response object is return', done => { + Parse.Cloud.beforeSave('TestObject2', function (req) { + req.object.set('tobeaddbefore', true); + req.object.set('tobeaddbeforeandremoveafter', true); + }); + + Parse.Cloud.afterSave('TestObject2', function (req) { + const jsonObject = req.object.toJSON(); + delete jsonObject.todelete; + delete jsonObject.tobeaddbeforeandremoveafter; + jsonObject.toadd = true; + + return jsonObject; + }); + + rest.create(config, nobody, 'TestObject2', { todelete: true, tokeep: true }).then(response => { + expect(response.response.toadd).toBeTruthy(); + expect(response.response.tokeep).toBeTruthy(); + expect(response.response.tobeaddbefore).toBeTruthy(); + expect(response.response.tobeaddbeforeandremoveafter).toBeUndefined(); + expect(response.response.todelete).toBeUndefined(); done(); }); }); + + it('test afterSave should not affect save response', async () => { + Parse.Cloud.beforeSave('TestObject2', ({ object }) => { + object.set('addedBeforeSave', true); + }); + Parse.Cloud.afterSave('TestObject2', ({ object }) => { + object.set('addedAfterSave', true); + object.unset('initialToRemove'); + }); + const { response } = await rest.create(config, nobody, 'TestObject2', { + initialSave: true, + initialToRemove: true, + }); + expect(Object.keys(response).sort()).toEqual([ + 'addedAfterSave', + 'addedBeforeSave', + 'createdAt', + 'initialToRemove', + 'objectId', + ]); + }); +}); + +describe('redirectClassNameForKey security', () => { + let config; + + beforeEach(() => { + config = Config.get('test'); + }); + + it('should scope _Session results to the current user when redirected via redirectClassNameForKey', async () => { + // Create two users with sessions (without logging out, to preserve sessions) + const user1 = await Parse.User.signUp('user1', 'password1'); + const sessionToken1 = user1.getSessionToken(); + + // Sign up user2 via REST to avoid logging out user1 + await request({ + method: 'POST', + url: Parse.serverURL + '/users', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { username: 'user2', password: 'password2' }, + }); + + // Create a public class with a relation field pointing to _Session + // (using masterKey to create the object and relation schema) + const obj = new Parse.Object('PublicData'); + const relation = obj.relation('pivot'); + // Add a fake pointer to _Session to establish the relation schema + relation.add(Parse.Object.fromJSON({ className: '_Session', objectId: 'fakeId' })); + await obj.save(null, { useMasterKey: true }); + + // Authenticated user queries with redirectClassNameForKey + const userAuth = await auth.getAuthForSessionToken({ + config, + sessionToken: sessionToken1, + }); + const result = await rest.find(config, userAuth, 'PublicData', {}, { redirectClassNameForKey: 'pivot' }); + + // Should only see user1's own session, not user2's + expect(result.results.length).toBe(1); + expect(result.results[0].user.objectId).toBe(user1.id); + }); + + it('should reject unauthenticated access to _Session via redirectClassNameForKey', async () => { + // Create a user so a session exists + await Parse.User.signUp('victim', 'password123'); + await Parse.User.logOut(); + + // Create a public class with a relation to _Session + const obj = new Parse.Object('PublicData'); + const relation = obj.relation('pivot'); + relation.add(Parse.Object.fromJSON({ className: '_Session', objectId: 'fakeId' })); + await obj.save(null, { useMasterKey: true }); + + // Unauthenticated query with redirectClassNameForKey + await expectAsync( + rest.find(config, auth.nobody(config), 'PublicData', {}, { redirectClassNameForKey: 'pivot' }) + ).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.INVALID_SESSION_TOKEN }) + ); + }); + + it('should block redirectClassNameForKey to master-only classes', async () => { + // Create a public class with a relation to _JobStatus (master-only) + const obj = new Parse.Object('PublicData'); + const relation = obj.relation('jobPivot'); + relation.add(Parse.Object.fromJSON({ className: '_JobStatus', objectId: 'fakeId' })); + await obj.save(null, { useMasterKey: true }); + + // Create a user for authenticated access + const user = await Parse.User.signUp('attacker', 'password123'); + const sessionToken = user.getSessionToken(); + const userAuth = await auth.getAuthForSessionToken({ config, sessionToken }); + + // Authenticated query should be blocked + await expectAsync( + rest.find(config, userAuth, 'PublicData', {}, { redirectClassNameForKey: 'jobPivot' }) + ).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }) + ); + }); + + it('should allow redirectClassNameForKey between regular classes', async () => { + // Create target class objects + const wheel1 = new Parse.Object('Wheel'); + await wheel1.save(); + + // Create source class with relation to Wheel + const car = new Parse.Object('Car'); + const relation = car.relation('wheels'); + relation.add(wheel1); + await car.save(); + + // Query with redirectClassNameForKey should work normally + const result = await rest.find(config, auth.nobody(config), 'Car', {}, { redirectClassNameForKey: 'wheels' }); + expect(result.results.length).toBe(1); + expect(result.results[0].objectId).toBe(wheel1.id); + }); }); diff --git a/spec/RevocableSessionsUpgrade.spec.js b/spec/RevocableSessionsUpgrade.spec.js new file mode 100644 index 0000000000..27b6c1a3f6 --- /dev/null +++ b/spec/RevocableSessionsUpgrade.spec.js @@ -0,0 +1,171 @@ +const Config = require('../lib/Config'); +const sessionToken = 'legacySessionToken'; +const request = require('../lib/request'); +const Parse = require('parse/node'); + +function createUser() { + const config = Config.get(Parse.applicationId); + const user = { + objectId: '1234567890', + username: 'hello', + password: 'pass', + _session_token: sessionToken, + }; + return config.database.create('_User', user); +} + +describe_only_db('mongo')('revocable sessions', () => { + beforeEach(async () => { + // Create 1 user with the legacy + await createUser(); + }); + + it('should upgrade legacy session token', done => { + const user = Parse.Object.fromJSON({ + className: '_User', + objectId: '1234567890', + sessionToken: sessionToken, + }); + user + ._upgradeToRevocableSession() + .then(res => { + expect(res.getSessionToken().indexOf('r:')).toBe(0); + const config = Config.get(Parse.applicationId); + // use direct access to the DB to make sure we're not + // getting the session token stripped + return config.database + .loadSchema() + .then(schemaController => { + return schemaController.getOneSchema('_User', true); + }) + .then(schema => { + return config.database.adapter.find('_User', schema, { objectId: '1234567890' }, {}); + }) + .then(results => { + expect(results.length).toBe(1); + expect(results[0].sessionToken).toBeUndefined(); + }); + }) + .then( + () => { + done(); + }, + err => { + jfail(err); + done(); + } + ); + }); + + it('should be able to become with revocable session token', done => { + const user = Parse.Object.fromJSON({ + className: '_User', + objectId: '1234567890', + sessionToken: sessionToken, + }); + user + ._upgradeToRevocableSession() + .then(res => { + expect(res.getSessionToken().indexOf('r:')).toBe(0); + return Parse.User.logOut() + .then(() => { + return Parse.User.become(res.getSessionToken()); + }) + .then(user => { + expect(user.id).toEqual('1234567890'); + }); + }) + .then( + () => { + done(); + }, + err => { + jfail(err); + done(); + } + ); + }); + + it('should not upgrade bad legacy session token', done => { + request({ + method: 'POST', + url: Parse.serverURL + '/upgradeToRevocableSession', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Rest-API-Key': 'rest', + 'X-Parse-Session-Token': 'badSessionToken', + }, + }) + .then( + () => { + fail('should not be able to upgrade a bad token'); + }, + response => { + expect(response.status).toBe(400); + expect(response.data).not.toBeUndefined(); + expect(response.data.code).toBe(Parse.Error.INVALID_SESSION_TOKEN); + expect(response.data.error).toEqual('invalid legacy session token'); + } + ) + .then(() => { + done(); + }); + }); + + it('should not crash without session token #2720', done => { + request({ + method: 'POST', + url: Parse.serverURL + '/upgradeToRevocableSession', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Rest-API-Key': 'rest', + }, + }) + .then( + () => { + fail('should not be able to upgrade a bad token'); + }, + response => { + expect(response.status).toBe(404); + expect(response.data).not.toBeUndefined(); + expect(response.data.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + expect(response.data.error).toEqual('invalid session'); + } + ) + .then(() => { + done(); + }); + }); + + it('should strip protected fields from upgrade response when protectedFieldsSaveResponseExempt is false', async () => { + await reconfigureServer({ + protectedFields: { + _Session: { '*': ['createdWith', 'installationId'] }, + }, + protectedFieldsSaveResponseExempt: false, + }); + const config = Config.get(Parse.applicationId); + const user = { + objectId: 'pfUser123', + username: 'pfuser', + password: 'pass', + _session_token: 'legacySessionTokenPf', + }; + await config.database.create('_User', user); + + const response = await request({ + method: 'POST', + url: Parse.serverURL + '/upgradeToRevocableSession', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Rest-API-Key': 'rest', + 'X-Parse-Session-Token': 'legacySessionTokenPf', + 'X-Parse-Installation-Id': 'test-install-id', + }, + }); + expect(response.data.sessionToken).toBeDefined(); + expect(response.data.sessionToken.indexOf('r:')).toBe(0); + expect(response.data.createdWith).toBeUndefined(); + expect(response.data.installationId).toBeUndefined(); + }); +}); diff --git a/spec/RouteAllowList.spec.js b/spec/RouteAllowList.spec.js new file mode 100644 index 0000000000..5af46d98e2 --- /dev/null +++ b/spec/RouteAllowList.spec.js @@ -0,0 +1,374 @@ +'use strict'; + +const Config = require('../lib/Config'); + +describe('routeAllowList', () => { + describe('config validation', () => { + it_id('da6e6e19-a25a-4a4f-87e9-4179ac470bb4')(it)('should accept undefined (feature inactive)', async () => { + await reconfigureServer({ routeAllowList: undefined }); + expect(Config.get(Parse.applicationId).routeAllowList).toBeUndefined(); + }); + + it_id('ae221b65-c0e5-4564-bed3-08e73c07a872')(it)('should accept an empty array', async () => { + await reconfigureServer({ routeAllowList: [] }); + expect(Config.get(Parse.applicationId).routeAllowList).toEqual([]); + }); + + it_id('4d48aa24-2bc9-48af-9b59-d558c38a1173')(it)('should accept valid regex patterns', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore', 'classes/Chat.*', 'functions/.*'] }); + expect(Config.get(Parse.applicationId).routeAllowList).toEqual(['classes/GameScore', 'classes/Chat.*', 'functions/.*']); + }); + + it_id('136c091e-77e4-4c19-a1dc-a644ce2239eb')(it)('should reject non-array values', async () => { + for (const value of ['string', 123, true, {}]) { + await expectAsync(reconfigureServer({ routeAllowList: value })).toBeRejected(); + } + }); + + it_id('7f30d08d-c9db-4a35-bcc0-11cae45f106b')(it)('should reject arrays with non-string elements', async () => { + await expectAsync(reconfigureServer({ routeAllowList: [123] })).toBeRejected(); + await expectAsync(reconfigureServer({ routeAllowList: [null] })).toBeRejected(); + await expectAsync(reconfigureServer({ routeAllowList: [{}] })).toBeRejected(); + }); + + it_id('528d3457-b0d9-4f3f-8ff7-e3b9a24a6d3a')(it)('should reject invalid regex patterns', async () => { + await expectAsync(reconfigureServer({ routeAllowList: ['classes/[invalid'] })).toBeRejected(); + }); + + it_id('94ba256a-a84c-4b29-8c1e-d65bb5100da3')(it)('should compile regex patterns and cache them', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore', 'users'] }); + const config = Config.get(Parse.applicationId); + expect(config._routeAllowListRegex).toBeDefined(); + expect(config._routeAllowListRegex.length).toBe(2); + expect(config._routeAllowListRegex[0]).toEqual(jasmine.any(RegExp)); + expect(config._routeAllowListRegex[0].test('classes/GameScore')).toBe(true); + expect(config._routeAllowListRegex[0].test('classes/Other')).toBe(false); + expect(config._routeAllowListRegex[1].test('users')).toBe(true); + }); + }); + + describe('middleware', () => { + it_id('d9fb2eea-7508-4f68-bdbe-a0270595b4bf')(it)('should allow all requests when routeAllowList is undefined', async () => { + await reconfigureServer({ routeAllowList: undefined }); + const obj = new Parse.Object('GameScore'); + obj.set('score', 100); + await obj.save(); + const query = new Parse.Query('GameScore'); + const results = await query.find(); + expect(results.length).toBe(1); + }); + + it_id('3dd73684-e7b5-41dc-868b-31a64bdfb307')(it)('should block all external requests when routeAllowList is empty array', async () => { + await reconfigureServer({ routeAllowList: [] }); + const obj = new Parse.Object('GameScore'); + obj.set('score', 100); + await expectAsync(obj.save()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }) + ); + }); + + it_id('be57f97e-8248-44b6-9d03-881a889f0416')(it)('should allow matching class routes', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const obj = new Parse.Object('GameScore'); + obj.set('score', 100); + await obj.save(); + const query = new Parse.Query('GameScore'); + const results = await query.find(); + expect(results.length).toBe(1); + }); + + it_id('425449e4-72b1-4a91-8053-921c477fefd4')(it)('should block non-matching class routes', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const obj = new Parse.Object('Secret'); + obj.set('data', 'hidden'); + await expectAsync(obj.save()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }) + ); + }); + + it_id('bb12a497-1187-4234-bdcc-2457d41823af')(it)('should support regex wildcard patterns', async () => { + await reconfigureServer({ routeAllowList: ['classes/Chat.*'] }); + const obj1 = new Parse.Object('ChatMessage'); + obj1.set('text', 'hello'); + await obj1.save(); + + const obj2 = new Parse.Object('ChatRoom'); + obj2.set('name', 'general'); + await obj2.save(); + + const obj3 = new Parse.Object('Secret'); + obj3.set('data', 'hidden'); + await expectAsync(obj3.save()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }) + ); + }); + + it_id('980472ec-9004-40b7-b6dc-9184292e0bba')(it)('should enforce full-match anchoring', async () => { + await reconfigureServer({ routeAllowList: ['classes/Chat'] }); + const obj = new Parse.Object('ChatRoom'); + obj.set('name', 'general'); + await expectAsync(obj.save()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }) + ); + }); + + it_id('ca6fedeb-f35f-48ab-baf5-b6379b96e864')(it)('should allow master key requests to bypass', async () => { + await reconfigureServer({ routeAllowList: [] }); + const obj = new Parse.Object('GameScore'); + obj.set('score', 100); + await obj.save(null, { useMasterKey: true }); + const query = new Parse.Query('GameScore'); + const results = await query.find({ useMasterKey: true }); + expect(results.length).toBe(1); + }); + + it_id('99bfdf7f-f80e-489d-9880-3d6c81391fd1')(it)('should allow Cloud Code internal calls to bypass', async () => { + await reconfigureServer({ + routeAllowList: ['functions/testInternal'], + cloud: () => { + Parse.Cloud.define('testInternal', async () => { + const obj = new Parse.Object('BlockedClass'); + obj.set('data', 'from-cloud'); + await obj.save(null, { useMasterKey: true }); + const query = new Parse.Query('BlockedClass'); + const results = await query.find({ useMasterKey: true }); + return { count: results.length }; + }); + }, + }); + const result = await Parse.Cloud.run('testInternal'); + expect(result.count).toBe(1); + }); + + it_id('34ea792f-1dcc-4399-adcf-d2d6cdfc8c6f')(it)('should allow non-class routes like users when matched', async () => { + await reconfigureServer({ routeAllowList: ['users', 'login'] }); + const user = new Parse.User(); + user.set('username', 'testuser'); + user.set('password', 'testpass'); + await user.signUp(); + expect(user.getSessionToken()).toBeDefined(); + }); + + it_id('c3beed92-edd8-4cf1-be54-331a6dfaf077')(it)('should block non-class routes like users when not matched', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const user = new Parse.User(); + user.set('username', 'testuser'); + user.set('password', 'testpass'); + await expectAsync(user.signUp()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }) + ); + }); + + it_id('618ab39b-84f2-4547-aa27-fe478731c83f')(it)('should return sanitized error message by default', async () => { + await reconfigureServer({ routeAllowList: [] }); + const obj = new Parse.Object('GameScore'); + obj.set('score', 100); + try { + await obj.save(); + fail('should have thrown'); + } catch (e) { + expect(e.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(e.message).toBe('Permission denied'); + } + }); + + it_id('51232d42-5c8a-4633-acc2-e0fbc40ea3da')(it)('should return detailed error message when sanitization is disabled', async () => { + await reconfigureServer({ routeAllowList: [], enableSanitizedErrorResponse: false }); + const obj = new Parse.Object('GameScore'); + obj.set('score', 100); + try { + await obj.save(); + fail('should have thrown'); + } catch (e) { + expect(e.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(e.message).toContain('routeAllowList'); + } + }); + + it_id('7146a4a8-9175-4a5c-b966-287e6121cb3e')(it)('should allow object get by ID when class pattern includes subpaths', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore.*'] }); + const obj = new Parse.Object('GameScore'); + obj.set('score', 100); + await obj.save(); + const query = new Parse.Query('GameScore'); + const result = await query.get(obj.id); + expect(result.get('score')).toBe(100); + }); + + it_id('81156f55-e766-445d-b978-80b92e614696')(it)('should allow queries with where constraints (query string in URL)', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const obj = new Parse.Object('GameScore'); + obj.set('score', 100); + await obj.save(); + const query = new Parse.Query('GameScore'); + query.equalTo('score', 100); + const results = await query.find(); + expect(results.length).toBe(1); + }); + + it_id('1160e6e5-c680-4f18-b1d0-ea5699c97eeb')(it)('should allow maintenance key requests to bypass', async () => { + await reconfigureServer({ routeAllowList: [] }); + const obj = new Parse.Object('GameScore'); + obj.set('score', 100); + await obj.save(null, { useMasterKey: true }); + const request = require('../lib/request'); + const res = await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Maintenance-Key': 'testing', + }, + method: 'GET', + url: 'http://localhost:8378/1/classes/GameScore', + }); + expect(res.data.results.length).toBe(1); + }); + + it_id('9536b2c0-b11e-4f57-92e4-0093a40b6284')(it)('should match multiple patterns independently', async () => { + await reconfigureServer({ + routeAllowList: ['classes/AllowedA', 'classes/AllowedB', 'functions/.*'], + }); + + const objA = new Parse.Object('AllowedA'); + objA.set('data', 'a'); + await objA.save(); + + const objB = new Parse.Object('AllowedB'); + objB.set('data', 'b'); + await objB.save(); + + const objC = new Parse.Object('Blocked'); + objC.set('data', 'c'); + await expectAsync(objC.save()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }) + ); + }); + + it_id('ad700243-ea26-41e7-b237-bd6b6aa99d46')(it)('should block health endpoint when not in allow list', async () => { + await reconfigureServer({ routeAllowList: ['classes/GameScore'] }); + const request = require('../lib/request'); + try { + await request({ + method: 'GET', + url: 'http://localhost:8378/1/health', + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('b59dd736-029d-4769-b69d-ac3aed6e4c3f')(it)('should allow health endpoint when in allow list', async () => { + await reconfigureServer({ routeAllowList: ['health'] }); + const request = require('../lib/request'); + const res = await request({ + method: 'GET', + url: 'http://localhost:8378/1/health', + }); + expect(res.data.status).toBe('ok'); + }); + + it_id('60466f80-27af-456c-a05d-8f5ceaf95451')(it)('should allow read-only master key requests to bypass', async () => { + await reconfigureServer({ routeAllowList: [] }); + const request = require('../lib/request'); + const res = await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'read-only-test', + }, + method: 'GET', + url: 'http://localhost:8378/1/classes/GameScore', + }); + expect(res.data.results).toEqual([]); + }); + + it_id('4fe57cc2-f104-491c-843b-64afc11c6fa3')(it)('should block all routes when routeAllowList is empty array and no key provided', async () => { + await reconfigureServer({ routeAllowList: [] }); + const request = require('../lib/request'); + try { + await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'GET', + url: 'http://localhost:8378/1/classes/GameScore', + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('f3dd5622-036c-45bf-ab76-c31b59028642')(it)('should block health endpoint even when routeAllowList is empty array', async () => { + await reconfigureServer({ routeAllowList: [] }); + const request = require('../lib/request'); + try { + await request({ + method: 'GET', + url: 'http://localhost:8378/1/health', + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it_id('229cab22-dad3-4d08-8de5-64d813658596')(it)('should block all route groups when not in allow list', async () => { + await reconfigureServer({ + routeAllowList: ['classes/GameScore'], + cloud: () => { + Parse.Cloud.define('blockedFn', () => 'should not run'); + }, + }); + const request = require('../lib/request'); + const routes = [ + { method: 'GET', path: 'sessions' }, + { method: 'GET', path: 'roles' }, + { method: 'GET', path: 'installations' }, + { method: 'POST', path: 'push' }, + { method: 'GET', path: 'schemas' }, + { method: 'GET', path: 'config' }, + { method: 'POST', path: 'jobs' }, + { method: 'POST', path: 'batch' }, + { method: 'POST', path: 'events/AppOpened' }, + { method: 'GET', path: 'serverInfo' }, + { method: 'GET', path: 'aggregate/GameScore' }, + { method: 'GET', path: 'push_audiences' }, + { method: 'GET', path: 'security' }, + { method: 'GET', path: 'hooks/functions' }, + { method: 'GET', path: 'cloud_code/jobs' }, + { method: 'GET', path: 'scriptlog' }, + { method: 'DELETE', path: 'purge/GameScore' }, + { method: 'GET', path: 'graphql-config' }, + { method: 'POST', path: 'validate_purchase' }, + { method: 'POST', path: 'logout' }, + { method: 'POST', path: 'loginAs' }, + { method: 'POST', path: 'upgradeToRevocableSession' }, + { method: 'POST', path: 'verificationEmailRequest' }, + { method: 'POST', path: 'verifyPassword' }, + { method: 'POST', path: 'requestPasswordReset' }, + { method: 'POST', path: 'challenge' }, + { method: 'GET', path: 'health' }, + { method: 'POST', path: 'functions/blockedFn' }, + ]; + for (const route of routes) { + try { + await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: route.method, + url: `http://localhost:8378/1/${route.path}`, + body: route.method === 'POST' ? JSON.stringify({}) : undefined, + }); + fail(`should have blocked ${route.method} ${route.path}`); + } catch (e) { + expect(e.data.code).withContext(`${route.method} ${route.path}`).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + } + }); + }); +}); diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index 2c6dc242b8..03c68276f8 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -1,225 +1,503 @@ 'use strict'; -var Config = require('../src/Config'); -var SchemaController = require('../src/Controllers/SchemaController'); -var dd = require('deep-diff'); +const Config = require('../lib/Config'); +const SchemaController = require('../lib/Controllers/SchemaController'); +const dd = require('deep-diff'); -var config; +let config; -var hasAllPODobject = () => { - var obj = new Parse.Object('HasAllPOD'); +const hasAllPODobject = () => { + const obj = new Parse.Object('HasAllPOD'); obj.set('aNumber', 5); obj.set('aString', 'string'); obj.set('aBool', true); obj.set('aDate', new Date()); - obj.set('aObject', {k1: 'value', k2: true, k3: 5}); + obj.set('aObject', { k1: 'value', k2: true, k3: 5 }); obj.set('aArray', ['contents', true, 5]); - obj.set('aGeoPoint', new Parse.GeoPoint({latitude: 0, longitude: 0})); + obj.set('aGeoPoint', new Parse.GeoPoint({ latitude: 0, longitude: 0 })); obj.set('aFile', new Parse.File('f.txt', { base64: 'V29ya2luZyBhdCBQYXJzZSBpcyBncmVhdCE=' })); return obj; }; describe('SchemaController', () => { - beforeEach(() => { - config = new Config('test'); - }); - - it('can validate one object', (done) => { - config.database.loadSchema().then((schema) => { - return schema.validateObject('TestObject', {a: 1, b: 'yo', c: false}); - }).then((schema) => { - done(); - }, (error) => { - fail(error); - done(); - }); + let loggerErrorSpy; + + beforeEach(() => { + config = Config.get('test'); + const logger = require('../lib/logger').default; + loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); }); - it('can validate one object with dot notation', (done) => { - config.database.loadSchema().then((schema) => { - return schema.validateObject('TestObjectWithSubDoc', {x: false, y: 'YY', z: 1, 'aObject.k1': 'newValue'}); - }).then((schema) => { - done(); - }, (error) => { - fail(error); - done(); - }); + it('can validate one object', done => { + config.database + .loadSchema() + .then(schema => { + return schema.validateObject('TestObject', { a: 1, b: 'yo', c: false }); + }) + .then( + () => { + done(); + }, + error => { + jfail(error); + done(); + } + ); }); - it('can validate two objects in a row', (done) => { - config.database.loadSchema().then((schema) => { - return schema.validateObject('Foo', {x: true, y: 'yyy', z: 0}); - }).then((schema) => { - return schema.validateObject('Foo', {x: false, y: 'YY', z: 1}); - }).then((schema) => { - done(); - }); + it('can validate one object with dot notation', done => { + config.database + .loadSchema() + .then(schema => { + return schema.validateObject('TestObjectWithSubDoc', { + x: false, + y: 'YY', + z: 1, + 'aObject.k1': 'newValue', + }); + }) + .then( + () => { + done(); + }, + error => { + jfail(error); + done(); + } + ); }); - it('rejects inconsistent types', (done) => { - config.database.loadSchema().then((schema) => { - return schema.validateObject('Stuff', {bacon: 7}); - }).then((schema) => { - return schema.validateObject('Stuff', {bacon: 'z'}); - }).then(() => { - fail('expected invalidity'); - done(); - }, done); - }); - - it('updates when new fields are added', (done) => { - config.database.loadSchema().then((schema) => { - return schema.validateObject('Stuff', {bacon: 7}); - }).then((schema) => { - return schema.validateObject('Stuff', {sausage: 8}); - }).then((schema) => { - return schema.validateObject('Stuff', {sausage: 'ate'}); - }).then(() => { - fail('expected invalidity'); - done(); - }, done); - }); - - it('class-level permissions test find', (done) => { - config.database.loadSchema().then((schema) => { - // Just to create a valid class - return schema.validateObject('Stuff', {foo: 'bar'}); - }).then((schema) => { - return schema.setPermissions('Stuff', { - 'find': {} + it('can validate two objects in a row', done => { + config.database + .loadSchema() + .then(schema => { + return schema.validateObject('Foo', { x: true, y: 'yyy', z: 0 }); + }) + .then(schema => { + return schema.validateObject('Foo', { x: false, y: 'YY', z: 1 }); + }) + .then(() => { + done(); }); - }).then((schema) => { - var query = new Parse.Query('Stuff'); - return query.find(); - }).then((results) => { - fail('Class permissions should have rejected this query.'); - done(); - }, (e) => { - done(); - }); }); - it_exclude_dbs(['postgres'])('class-level permissions test user', (done) => { - var user; - createTestUser().then((u) => { - user = u; - return config.database.loadSchema(); - }).then((schema) => { - // Just to create a valid class - return schema.validateObject('Stuff', {foo: 'bar'}); - }).then((schema) => { - var find = {}; - find[user.id] = true; - return schema.setPermissions('Stuff', { - 'find': find - }); - }).then((schema) => { - var query = new Parse.Query('Stuff'); - return query.find(); - }).then((results) => { - done(); - }, (e) => { - fail('Class permissions should have allowed this query.'); - done(); - }); + it('can validate Relation object', done => { + config.database + .loadSchema() + .then(schema => { + return schema.validateObject('Stuff', { + aRelation: { __type: 'Relation', className: 'Stuff' }, + }); + }) + .then(schema => { + return schema + .validateObject('Stuff', { + aRelation: { __type: 'Pointer', className: 'Stuff' }, + }) + .then( + () => { + done.fail('expected invalidity'); + }, + () => done() + ); + }, done.fail); }); - it_exclude_dbs(['postgres'])('class-level permissions test get', (done) => { - var obj; - createTestUser() - .then(user => { - return config.database.loadSchema() - // Create a valid class - .then(schema => schema.validateObject('Stuff', {foo: 'bar'})) - .then(schema => { - var find = {}; - var get = {}; - get[user.id] = true; + it('rejects inconsistent types', done => { + config.database + .loadSchema() + .then(schema => { + return schema.validateObject('Stuff', { bacon: 7 }); + }) + .then(schema => { + return schema.validateObject('Stuff', { bacon: 'z' }); + }) + .then( + () => { + fail('expected invalidity'); + done(); + }, + () => done() + ); + }); + + it('updates when new fields are added', done => { + config.database + .loadSchema() + .then(schema => { + return schema.validateObject('Stuff', { bacon: 7 }); + }) + .then(schema => { + return schema.validateObject('Stuff', { sausage: 8 }); + }) + .then(schema => { + return schema.validateObject('Stuff', { sausage: 'ate' }); + }) + .then( + () => { + fail('expected invalidity'); + done(); + }, + () => done() + ); + }); + + it('class-level permissions test find', done => { + config.database + .loadSchema() + .then(schema => { + // Just to create a valid class + return schema.validateObject('Stuff', { foo: 'bar' }); + }) + .then(schema => { return schema.setPermissions('Stuff', { - 'create': {'*': true}, - 'find': find, - 'get': get + find: {}, }); - }).then((schema) => { - obj = new Parse.Object('Stuff'); - obj.set('foo', 'bar'); - return obj.save(); - }).then((o) => { - obj = o; - var query = new Parse.Query('Stuff'); + }) + .then(() => { + const query = new Parse.Query('Stuff'); return query.find(); - }).then((results) => { - fail('Class permissions should have rejected this query.'); - done(); - }, (e) => { - var query = new Parse.Query('Stuff'); - return query.get(obj.id).then((o) => { + }) + .then( + () => { + fail('Class permissions should have rejected this query.'); done(); - }, (e) => { - fail('Class permissions should have allowed this get query'); + }, + () => { done(); + } + ); + }); + + it('class-level permissions test user', done => { + let user; + createTestUser() + .then(u => { + user = u; + return config.database.loadSchema(); + }) + .then(schema => { + // Just to create a valid class + return schema.validateObject('Stuff', { foo: 'bar' }); + }) + .then(schema => { + const find = {}; + find[user.id] = true; + return schema.setPermissions('Stuff', { + find: find, }); - }); + }) + .then(() => { + const query = new Parse.Query('Stuff'); + return query.find(); + }) + .then( + () => { + done(); + }, + () => { + fail('Class permissions should have allowed this query.'); + done(); + } + ); + }); + + it('class-level permissions test get', done => { + let obj; + createTestUser().then(user => { + return ( + config.database + .loadSchema() + // Create a valid class + .then(schema => schema.validateObject('Stuff', { foo: 'bar' })) + .then(schema => { + const find = {}; + const get = {}; + get[user.id] = true; + return schema.setPermissions('Stuff', { + create: { '*': true }, + find: find, + get: get, + }); + }) + .then(() => { + obj = new Parse.Object('Stuff'); + obj.set('foo', 'bar'); + return obj.save(); + }) + .then(o => { + obj = o; + const query = new Parse.Query('Stuff'); + return query.find(); + }) + .then( + () => { + fail('Class permissions should have rejected this query.'); + done(); + }, + () => { + const query = new Parse.Query('Stuff'); + return query.get(obj.id).then( + () => { + done(); + }, + () => { + fail('Class permissions should have allowed this get query'); + done(); + } + ); + } + ) + ); }); }); - it_exclude_dbs(['postgres'])('can add classes without needing an object', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', { - foo: {type: 'String'} - })) - .then(actualSchema => { - const expectedSchema = { - className: 'NewClass', - fields: { - objectId: { type: 'String' }, - updatedAt: { type: 'Date' }, - createdAt: { type: 'Date' }, - ACL: { type: 'ACL' }, + it('class-level permissions test count', done => { + let obj; + return ( + config.database + .loadSchema() + // Create a valid class + .then(schema => schema.validateObject('Stuff', { foo: 'bar' })) + .then(schema => { + const count = {}; + return schema.setPermissions('Stuff', { + create: { '*': true }, + find: { '*': true }, + count: count, + }); + }) + .then(() => { + obj = new Parse.Object('Stuff'); + obj.set('foo', 'bar'); + return obj.save(); + }) + .then(o => { + obj = o; + const query = new Parse.Query('Stuff'); + return query.find(); + }) + .then(results => { + expect(results.length).toBe(1); + loggerErrorSpy.calls.reset(); + const query = new Parse.Query('Stuff'); + return query.count(); + }) + .then( + () => { + fail('Class permissions should have rejected this query.'); + }, + err => { + expect(err.message).toEqual('Permission denied'); + expect(err.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('Permission denied for action count on class Stuff')); + done(); + } + ) + ); + }); + + it('can add classes without needing an object', done => { + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { foo: { type: 'String' }, + }) + ) + .then(actualSchema => { + const expectedSchema = { + className: 'NewClass', + fields: { + objectId: { type: 'String' }, + updatedAt: { type: 'Date' }, + createdAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + foo: { type: 'String' }, + }, + classLevelPermissions: { + ACL: { + '*': { + read: true, + write: true, + }, + }, + find: { '*': true }, + get: { '*': true }, + count: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': [] }, + }, + }; + expect(dd(actualSchema, expectedSchema)).toEqual(undefined); + done(); + }) + .catch(error => { + fail('Error creating class: ' + JSON.stringify(error)); + }); + }); + + it('can update classes without needing an object', done => { + const levelPermissions = { + ACL: { + '*': { + read: true, + write: true, }, - classLevelPermissions: { - find: { '*': true }, - get: { '*': true }, - create: { '*': true }, - update: { '*': true }, - delete: { '*': true }, - addField: { '*': true }, - }, - } - expect(dd(actualSchema, expectedSchema)).toEqual(undefined); - done(); - }) - .catch(error => { - fail('Error creating class: ' + JSON.stringify(error)); + }, + find: { '*': true }, + get: { '*': true }, + count: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': [] }, + }; + config.database.loadSchema().then(schema => { + schema + .validateObject('NewClass', { foo: 2 }) + .then(() => schema.reloadData()) + .then(() => + schema.updateClass( + 'NewClass', + { + fooOne: { type: 'Number' }, + fooTwo: { type: 'Array' }, + fooThree: { type: 'Date' }, + fooFour: { type: 'Object' }, + fooFive: { type: 'Relation', targetClass: '_User' }, + fooSix: { type: 'String' }, + fooSeven: { type: 'Object' }, + fooEight: { type: 'String' }, + fooNine: { type: 'String' }, + fooTeen: { type: 'Number' }, + fooEleven: { type: 'String' }, + fooTwelve: { type: 'String' }, + fooThirteen: { type: 'String' }, + fooFourteen: { type: 'String' }, + fooFifteen: { type: 'String' }, + fooSixteen: { type: 'String' }, + fooEighteen: { type: 'String' }, + fooNineteen: { type: 'String' }, + }, + levelPermissions, + {}, + config.database + ) + ) + .then(actualSchema => { + const expectedSchema = { + className: 'NewClass', + fields: { + objectId: { type: 'String' }, + updatedAt: { type: 'Date' }, + createdAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + foo: { type: 'Number' }, + fooOne: { type: 'Number' }, + fooTwo: { type: 'Array' }, + fooThree: { type: 'Date' }, + fooFour: { type: 'Object' }, + fooFive: { type: 'Relation', targetClass: '_User' }, + fooSix: { type: 'String' }, + fooSeven: { type: 'Object' }, + fooEight: { type: 'String' }, + fooNine: { type: 'String' }, + fooTeen: { type: 'Number' }, + fooEleven: { type: 'String' }, + fooTwelve: { type: 'String' }, + fooThirteen: { type: 'String' }, + fooFourteen: { type: 'String' }, + fooFifteen: { type: 'String' }, + fooSixteen: { type: 'String' }, + fooEighteen: { type: 'String' }, + fooNineteen: { type: 'String' }, + }, + classLevelPermissions: { ...levelPermissions }, + indexes: { + _id_: { _id: 1 }, + }, + }; + + expect(dd(actualSchema, expectedSchema)).toEqual(undefined); + done(); + }) + .catch(error => { + console.trace(error); + done(); + fail('Error creating class: ' + JSON.stringify(error)); + }); + }); + }); + + it('can update class level permission', done => { + const newLevelPermissions = { + find: {}, + get: { '*': true }, + count: {}, + create: { '*': true }, + update: {}, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': [] }, + }; + config.database.loadSchema().then(schema => { + schema + .validateObject('NewClass', { foo: 2 }) + .then(() => schema.reloadData()) + .then(() => schema.updateClass('NewClass', {}, newLevelPermissions, {}, config.database)) + .then(actualSchema => { + expect(dd(actualSchema.classLevelPermissions, newLevelPermissions)).toEqual(undefined); + done(); + }) + .catch(error => { + console.trace(error); + done(); + fail('Error creating class: ' + JSON.stringify(error)); + }); }); }); it('will fail to create a class if that class was already created by an object', done => { - config.database.loadSchema() - .then(schema => { - schema.validateObject('NewClass', { foo: 7 }) - .then(() => schema.reloadData()) - .then(() => schema.addClassIfNotExists('NewClass', { - foo: { type: 'String' } - })) - .catch(error => { - expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(error.message).toEqual('Class NewClass already exists.'); - done(); - }); - }); + config.database.loadSchema().then(schema => { + schema + .validateObject('NewClass', { foo: 7 }) + .then(() => schema.reloadData()) + .then(() => + schema.addClassIfNotExists('NewClass', { + foo: { type: 'String' }, + }) + ) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(error.message).toEqual('Class NewClass already exists.'); + done(); + }); + }); }); - it_exclude_dbs(['postgres'])('will resolve class creation races appropriately', done => { + it('will resolve class creation races appropriately', done => { // If two callers race to create the same schema, the response to the // race loser should be the same as if they hadn't been racing. - config.database.loadSchema() - .then(schema => { - var p1 = schema.addClassIfNotExists('NewClass', {foo: {type: 'String'}}); - var p2 = schema.addClassIfNotExists('NewClass', {foo: {type: 'String'}}); - Promise.race([p1, p2]) - .then(actualSchema => { + config.database.loadSchema().then(schema => { + const p1 = schema + .addClassIfNotExists('NewClass', { + foo: { type: 'String' }, + }) + .then(validateSchema) + .catch(validateError); + const p2 = schema + .addClassIfNotExists('NewClass', { + foo: { type: 'String' }, + }) + .then(validateSchema) + .catch(validateError); + let schemaValidated = false; + function validateSchema(actualSchema) { const expectedSchema = { className: 'NewClass', fields: { @@ -230,31 +508,43 @@ describe('SchemaController', () => { foo: { type: 'String' }, }, classLevelPermissions: { + ACL: { + '*': { + read: true, + write: true, + }, + }, find: { '*': true }, get: { '*': true }, + count: { '*': true }, create: { '*': true }, update: { '*': true }, delete: { '*': true }, addField: { '*': true }, + protectedFields: { '*': [] }, }, - } + }; expect(dd(actualSchema, expectedSchema)).toEqual(undefined); - }); - Promise.all([p1,p2]) - .catch(error => { + schemaValidated = true; + } + let errorValidated = false; + function validateError(error) { expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); expect(error.message).toEqual('Class NewClass already exists.'); + errorValidated = true; + } + Promise.all([p1, p2]).then(() => { + expect(schemaValidated).toEqual(true); + expect(errorValidated).toEqual(true); done(); }); }); }); it('refuses to create classes with invalid names', done => { - config.database.loadSchema() - .then(schema => { - schema.addClassIfNotExists('_InvalidName', {foo: {type: 'String'}}) - .catch(error => { - expect(error.error).toEqual( + config.database.loadSchema().then(schema => { + schema.addClassIfNotExists('_InvalidName', { foo: { type: 'String' } }).catch(error => { + expect(error.message).toEqual( 'Invalid classname: _InvalidName, classnames can only have alphanumeric characters and _, and must start with an alpha character ' ); done(); @@ -263,154 +553,233 @@ describe('SchemaController', () => { }); it('refuses to add fields with invalid names', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', {'0InvalidName': {type: 'String'}})) - .catch(error => { - expect(error.code).toEqual(Parse.Error.INVALID_KEY_NAME); - expect(error.error).toEqual('invalid field name: 0InvalidName'); - done(); - }); + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { + '0InvalidName': { type: 'String' }, + }) + ) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_KEY_NAME); + expect(error.message).toEqual('invalid field name: 0InvalidName'); + done(); + }); }); it('refuses to explicitly create the default fields for custom classes', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', {objectId: {type: 'String'}})) - .catch(error => { - expect(error.code).toEqual(136); - expect(error.error).toEqual('field objectId cannot be added'); - done(); - }); + config.database + .loadSchema() + .then(schema => schema.addClassIfNotExists('NewClass', { objectId: { type: 'String' } })) + .catch(error => { + expect(error.code).toEqual(136); + expect(error.message).toEqual('field objectId cannot be added'); + done(); + }); }); it('refuses to explicitly create the default fields for non-custom classes', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('_Installation', {localeIdentifier: {type: 'String'}})) - .catch(error => { - expect(error.code).toEqual(136); - expect(error.error).toEqual('field localeIdentifier cannot be added'); - done(); - }); + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('_Installation', { + localeIdentifier: { type: 'String' }, + }) + ) + .catch(error => { + expect(error.code).toEqual(136); + expect(error.message).toEqual('field localeIdentifier cannot be added'); + done(); + }); }); it('refuses to add fields with invalid types', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', { - foo: {type: 7} - })) - .catch(error => { - expect(error.code).toEqual(Parse.Error.INVALID_JSON); - expect(error.error).toEqual('invalid JSON'); - done(); - }); + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { + foo: { type: 7 }, + }) + ) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_JSON); + expect(error.message).toEqual('invalid JSON'); + done(); + }); }); it('refuses to add fields with invalid pointer types', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', { - foo: {type: 'Pointer'} - })) - .catch(error => { - expect(error.code).toEqual(135); - expect(error.error).toEqual('type Pointer needs a class name'); - done(); - }); + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { + foo: { type: 'Pointer' }, + }) + ) + .catch(error => { + expect(error.code).toEqual(135); + expect(error.message).toEqual('type Pointer needs a class name'); + done(); + }); }); it('refuses to add fields with invalid pointer target', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', { - foo: {type: 'Pointer', targetClass: 7}, - })) - .catch(error => { - expect(error.code).toEqual(Parse.Error.INVALID_JSON); - expect(error.error).toEqual('invalid JSON'); - done(); - }); + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { + foo: { type: 'Pointer', targetClass: 7 }, + }) + ) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_JSON); + expect(error.message).toEqual('invalid JSON'); + done(); + }); }); it('refuses to add fields with invalid Relation type', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', { - foo: {type: 'Relation', uselessKey: 7}, - })) - .catch(error => { - expect(error.code).toEqual(135); - expect(error.error).toEqual('type Relation needs a class name'); - done(); - }); + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { + foo: { type: 'Relation', uselessKey: 7 }, + }) + ) + .catch(error => { + expect(error.code).toEqual(135); + expect(error.message).toEqual('type Relation needs a class name'); + done(); + }); }); it('refuses to add fields with invalid relation target', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', { - foo: {type: 'Relation', targetClass: 7}, - })) - .catch(error => { - expect(error.code).toEqual(Parse.Error.INVALID_JSON); - expect(error.error).toEqual('invalid JSON'); - done(); - }); + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { + foo: { type: 'Relation', targetClass: 7 }, + }) + ) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_JSON); + expect(error.message).toEqual('invalid JSON'); + done(); + }); }); it('refuses to add fields with uncreatable pointer target class', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', { - foo: {type: 'Pointer', targetClass: 'not a valid class name'}, - })) - .catch(error => { - expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(error.error).toEqual('Invalid classname: not a valid class name, classnames can only have alphanumeric characters and _, and must start with an alpha character '); - done(); - }); + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { + foo: { type: 'Pointer', targetClass: 'not a valid class name' }, + }) + ) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(error.message).toEqual( + 'Invalid classname: not a valid class name, classnames can only have alphanumeric characters and _, and must start with an alpha character ' + ); + done(); + }); }); it('refuses to add fields with uncreatable relation target class', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', { - foo: {type: 'Relation', targetClass: 'not a valid class name'}, - })) - .catch(error => { - expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(error.error).toEqual('Invalid classname: not a valid class name, classnames can only have alphanumeric characters and _, and must start with an alpha character '); - done(); - }); + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { + foo: { type: 'Relation', targetClass: 'not a valid class name' }, + }) + ) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(error.message).toEqual( + 'Invalid classname: not a valid class name, classnames can only have alphanumeric characters and _, and must start with an alpha character ' + ); + done(); + }); }); it('refuses to add fields with unknown types', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', { - foo: {type: 'Unknown'}, - })) - .catch(error => { - expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); - expect(error.error).toEqual('invalid field type: Unknown'); - done(); + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { + foo: { type: 'Unknown' }, + }) + ) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); + expect(error.message).toEqual('invalid field type: Unknown'); + done(); + }); + }); + + it('refuses to add CLP with incorrect find', done => { + const levelPermissions = { + ACL: { + '*': { + read: true, + write: true, + }, + }, + find: { '*': false }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': ['email'] }, + }; + config.database.loadSchema().then(schema => { + schema + .validateObject('NewClass', {}) + .then(() => schema.reloadData()) + .then(() => schema.updateClass('NewClass', {}, levelPermissions, {}, config.database)) + .then(done.fail) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_JSON); + done(); + }); }); }); - it_exclude_dbs(['postgres'])('will create classes', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', { - aNumber: {type: 'Number'}, - aString: {type: 'String'}, - aBool: {type: 'Boolean'}, - aDate: {type: 'Date'}, - aObject: {type: 'Object'}, - aArray: {type: 'Array'}, - aGeoPoint: {type: 'GeoPoint'}, - aFile: {type: 'File'}, - aPointer: {type: 'Pointer', targetClass: 'ThisClassDoesNotExistYet'}, - aRelation: {type: 'Relation', targetClass: 'NewClass'}, - })) - .then(actualSchema => { - const expectedSchema = { - className: 'NewClass', - fields: { - objectId: { type: 'String' }, - updatedAt: { type: 'Date' }, - createdAt: { type: 'Date' }, - ACL: { type: 'ACL' }, - aString: { type: 'String' }, + it('refuses to add CLP when incorrectly sending a string to protectedFields object value instead of an array', done => { + const levelPermissions = { + ACL: { + '*': { + read: true, + write: true, + }, + }, + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': 'email' }, + }; + config.database.loadSchema().then(schema => { + schema + .validateObject('NewClass', {}) + .then(() => schema.reloadData()) + .then(() => schema.updateClass('NewClass', {}, levelPermissions, {}, config.database)) + .then(done.fail) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_JSON); + done(); + }); + }); + }); + + it('will create classes', done => { + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { aNumber: { type: 'Number' }, aString: { type: 'String' }, aBool: { type: 'Boolean' }, @@ -419,387 +788,1010 @@ describe('SchemaController', () => { aArray: { type: 'Array' }, aGeoPoint: { type: 'GeoPoint' }, aFile: { type: 'File' }, - aPointer: { type: 'Pointer', targetClass: 'ThisClassDoesNotExistYet' }, + aPointer: { + type: 'Pointer', + targetClass: 'ThisClassDoesNotExistYet', + }, aRelation: { type: 'Relation', targetClass: 'NewClass' }, - }, - classLevelPermissions: { - find: { '*': true }, - get: { '*': true }, - create: { '*': true }, - update: { '*': true }, - delete: { '*': true }, - addField: { '*': true }, - }, - } - expect(dd(actualSchema, expectedSchema)).toEqual(undefined); - done(); - }); + aBytes: { type: 'Bytes' }, + aPolygon: { type: 'Polygon' }, + }) + ) + .then(actualSchema => { + const expectedSchema = { + className: 'NewClass', + fields: { + objectId: { type: 'String' }, + updatedAt: { type: 'Date' }, + createdAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + aString: { type: 'String' }, + aNumber: { type: 'Number' }, + aBool: { type: 'Boolean' }, + aDate: { type: 'Date' }, + aObject: { type: 'Object' }, + aArray: { type: 'Array' }, + aGeoPoint: { type: 'GeoPoint' }, + aFile: { type: 'File' }, + aPointer: { + type: 'Pointer', + targetClass: 'ThisClassDoesNotExistYet', + }, + aRelation: { type: 'Relation', targetClass: 'NewClass' }, + aBytes: { type: 'Bytes' }, + aPolygon: { type: 'Polygon' }, + }, + classLevelPermissions: { + ACL: { + '*': { + read: true, + write: true, + }, + }, + find: { '*': true }, + get: { '*': true }, + count: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': [] }, + }, + }; + expect(dd(actualSchema, expectedSchema)).toEqual(undefined); + done(); + }); }); - it_exclude_dbs(['postgres'])('creates the default fields for non-custom classes', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('_Installation', { - foo: {type: 'Number'}, - })) - .then(actualSchema => { - const expectedSchema = { - className: '_Installation', - fields: { - objectId: { type: 'String' }, - updatedAt: { type: 'Date' }, - createdAt: { type: 'Date' }, - ACL: { type: 'ACL' }, + it('creates the default fields for non-custom classes', done => { + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('_Installation', { foo: { type: 'Number' }, - installationId: { type: 'String' }, - deviceToken: { type: 'String' }, - channels: { type: 'Array' }, - deviceType: { type: 'String' }, - pushType: { type: 'String' }, - GCMSenderId: { type: 'String' }, - timeZone: { type: 'String' }, - localeIdentifier: { type: 'String' }, - badge: { type: 'Number' }, - appVersion: { type: 'String' }, - appName: { type: 'String' }, - appIdentifier: { type: 'String' }, - parseVersion: { type: 'String' }, - }, - classLevelPermissions: { - find: { '*': true }, - get: { '*': true }, - create: { '*': true }, - update: { '*': true }, - delete: { '*': true }, - addField: { '*': true }, - }, - } - expect(dd(actualSchema, expectedSchema)).toEqual(undefined); - done(); - }); - }); - - it_exclude_dbs(['postgres'])('creates non-custom classes which include relation field', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('_Role', {})) - .then(actualSchema => { - const expectedSchema = { - className: '_Role', - fields: { - objectId: { type: 'String' }, - updatedAt: { type: 'Date' }, - createdAt: { type: 'Date' }, - ACL: { type: 'ACL' }, - name: { type: 'String' }, - users: { type: 'Relation', targetClass: '_User' }, - roles: { type: 'Relation', targetClass: '_Role' }, - }, - classLevelPermissions: { - find: { '*': true }, - get: { '*': true }, - create: { '*': true }, - update: { '*': true }, - delete: { '*': true }, - addField: { '*': true }, - }, - }; - expect(dd(actualSchema, expectedSchema)).toEqual(undefined); - done(); - }); + }) + ) + .then(actualSchema => { + const expectedSchema = { + className: '_Installation', + fields: { + objectId: { type: 'String' }, + updatedAt: { type: 'Date' }, + createdAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + foo: { type: 'Number' }, + installationId: { type: 'String' }, + deviceToken: { type: 'String' }, + channels: { type: 'Array' }, + deviceType: { type: 'String' }, + pushType: { type: 'String' }, + GCMSenderId: { type: 'String' }, + timeZone: { type: 'String' }, + localeIdentifier: { type: 'String' }, + badge: { type: 'Number' }, + appVersion: { type: 'String' }, + appName: { type: 'String' }, + appIdentifier: { type: 'String' }, + parseVersion: { type: 'String' }, + }, + classLevelPermissions: { + ACL: { + '*': { + read: true, + write: true, + }, + }, + find: { '*': true }, + get: { '*': true }, + count: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': [] }, + }, + }; + expect(dd(actualSchema, expectedSchema)).toEqual(undefined); + done(); + }); }); - it_exclude_dbs(['postgres'])('creates non-custom classes which include pointer field', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('_Session', {})) - .then(actualSchema => { - const expectedSchema = { - className: '_Session', - fields: { - objectId: { type: 'String' }, - updatedAt: { type: 'Date' }, - createdAt: { type: 'Date' }, - restricted: { type: 'Boolean' }, - user: { type: 'Pointer', targetClass: '_User' }, - installationId: { type: 'String' }, - sessionToken: { type: 'String' }, - expiresAt: { type: 'Date' }, - createdWith: { type: 'Object' }, - ACL: { type: 'ACL' }, - }, - classLevelPermissions: { - find: { '*': true }, - get: { '*': true }, - create: { '*': true }, - update: { '*': true }, - delete: { '*': true }, - addField: { '*': true }, - }, - }; - expect(dd(actualSchema, expectedSchema)).toEqual(undefined); - done(); - }); + it('creates non-custom classes which include relation field', async done => { + await reconfigureServer(); + config.database + .loadSchema() + //as `_Role` is always created by default, we only get it here + .then(schema => schema.getOneSchema('_Role')) + .then(actualSchema => { + const expectedSchema = { + className: '_Role', + fields: { + objectId: { type: 'String' }, + updatedAt: { type: 'Date' }, + createdAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + name: { type: 'String' }, + users: { type: 'Relation', targetClass: '_User' }, + roles: { type: 'Relation', targetClass: '_Role' }, + }, + classLevelPermissions: { + ACL: { + '*': { + read: true, + write: true, + }, + }, + find: { '*': true }, + get: { '*': true }, + count: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': [] }, + }, + }; + expect(dd(actualSchema, expectedSchema)).toEqual(undefined); + done(); + }); }); - it('refuses to create two geopoints', done => { - config.database.loadSchema() - .then(schema => schema.addClassIfNotExists('NewClass', { - geo1: {type: 'GeoPoint'}, - geo2: {type: 'GeoPoint'} - })) - .catch(error => { - expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); - expect(error.error).toEqual('currently, only one GeoPoint field may exist in an object. Adding geo2 when geo1 already exists.'); - done(); - }); + it('creates non-custom classes which include pointer field', done => { + config.database + .loadSchema() + .then(schema => schema.addClassIfNotExists('_Session', {})) + .then(actualSchema => { + const expectedSchema = { + className: '_Session', + fields: { + objectId: { type: 'String' }, + updatedAt: { type: 'Date' }, + createdAt: { type: 'Date' }, + user: { type: 'Pointer', targetClass: '_User' }, + installationId: { type: 'String' }, + sessionToken: { type: 'String' }, + expiresAt: { type: 'Date' }, + createdWith: { type: 'Object' }, + ACL: { type: 'ACL' }, + }, + classLevelPermissions: { + ACL: { + '*': { + read: true, + write: true, + }, + }, + find: { '*': true }, + get: { '*': true }, + count: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': [] }, + }, + }; + expect(dd(actualSchema, expectedSchema)).toEqual(undefined); + done(); + }); }); - it_exclude_dbs(['postgres'])('can check if a class exists', done => { - config.database.loadSchema() - .then(schema => { - return schema.addClassIfNotExists('NewClass', {}) - .then(() => { - schema.hasClass('NewClass') - .then(hasClass => { - expect(hasClass).toEqual(true); - done(); - }) - .catch(fail); - - schema.hasClass('NonexistantClass') - .then(hasClass => { - expect(hasClass).toEqual(false); - done(); + it('refuses to create two geopoints', done => { + config.database + .loadSchema() + .then(schema => + schema.addClassIfNotExists('NewClass', { + geo1: { type: 'GeoPoint' }, + geo2: { type: 'GeoPoint' }, }) - .catch(fail); - }) + ) .catch(error => { - fail('Couldn\'t create class'); - fail(error); + expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); + expect(error.message).toEqual( + 'currently, only one GeoPoint field may exist in an object. Adding geo2 when geo1 already exists.' + ); + done(); }); - }) - .catch(error => fail('Couldn\'t load schema')); + }); + + it('can check if a class exists', done => { + config.database + .loadSchema() + .then(schema => { + return schema + .addClassIfNotExists('NewClass', {}) + .then(() => schema.reloadData({ clearCache: true })) + .then(() => { + schema + .hasClass('NewClass') + .then(hasClass => { + expect(hasClass).toEqual(true); + done(); + }) + .catch(fail); + + schema + .hasClass('NonexistantClass') + .then(hasClass => { + expect(hasClass).toEqual(false); + done(); + }) + .catch(fail); + }) + .catch(error => { + fail("Couldn't create class"); + jfail(error); + }); + }) + .catch(() => fail("Couldn't load schema")); }); it('refuses to delete fields from invalid class names', done => { - config.database.loadSchema() - .then(schema => schema.deleteField('fieldName', 'invalid class name')) - .catch(error => { - expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - done(); - }); + config.database + .loadSchema() + .then(schema => schema.deleteField('fieldName', 'invalid class name')) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + done(); + }); }); it('refuses to delete invalid fields', done => { - config.database.loadSchema() - .then(schema => schema.deleteField('invalid field name', 'ValidClassName')) - .catch(error => { - expect(error.code).toEqual(Parse.Error.INVALID_KEY_NAME); - done(); - }); + config.database + .loadSchema() + .then(schema => schema.deleteField('invalid field name', 'ValidClassName')) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_KEY_NAME); + done(); + }); }); it('refuses to delete the default fields', done => { - config.database.loadSchema() - .then(schema => schema.deleteField('installationId', '_Installation')) - .catch(error => { - expect(error.code).toEqual(136); - expect(error.message).toEqual('field installationId cannot be changed'); - done(); - }); + config.database + .loadSchema() + .then(schema => schema.deleteField('installationId', '_Installation')) + .catch(error => { + expect(error.code).toEqual(136); + expect(error.message).toEqual('field installationId cannot be changed'); + done(); + }); }); it('refuses to delete fields from nonexistant classes', done => { - config.database.loadSchema() - .then(schema => schema.deleteField('field', 'NoClass')) - .catch(error => { - expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(error.message).toEqual('Class NoClass does not exist.'); - done(); - }); + config.database + .loadSchema() + .then(schema => schema.deleteField('field', 'NoClass')) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(error.message).toEqual('Class NoClass does not exist.'); + done(); + }); }); - it_exclude_dbs(['postgres'])('refuses to delete fields that dont exist', done => { - hasAllPODobject().save() - .then(() => config.database.loadSchema()) - .then(schema => schema.deleteField('missingField', 'HasAllPOD')) - .fail(error => { - expect(error.code).toEqual(255); - expect(error.message).toEqual('Field missingField does not exist, cannot delete.'); - done(); - }); + it('refuses to delete fields that dont exist', done => { + hasAllPODobject() + .save() + .then(() => config.database.loadSchema()) + .then(schema => schema.deleteField('missingField', 'HasAllPOD')) + .catch(error => { + expect(error.code).toEqual(255); + expect(error.message).toEqual('Field missingField does not exist, cannot delete.'); + done(); + }); }); - it_exclude_dbs(['postgres'])('drops related collection when deleting relation field', done => { - var obj1 = hasAllPODobject(); - obj1.save() + it('drops related collection when deleting relation field', done => { + const obj1 = hasAllPODobject(); + obj1 + .save() .then(savedObj1 => { - var obj2 = new Parse.Object('HasPointersAndRelations'); + const obj2 = new Parse.Object('HasPointersAndRelations'); obj2.set('aPointer', savedObj1); - var relation = obj2.relation('aRelation'); + const relation = obj2.relation('aRelation'); relation.add(obj1); return obj2.save(); }) .then(() => config.database.collectionExists('_Join:aRelation:HasPointersAndRelations')) .then(exists => { if (!exists) { - fail('Relation collection ' + - 'should exist after save.'); + fail('Relation collection ' + 'should exist after save.'); } }) .then(() => config.database.loadSchema()) .then(schema => schema.deleteField('aRelation', 'HasPointersAndRelations', config.database)) .then(() => config.database.collectionExists('_Join:aRelation:HasPointersAndRelations')) - .then(exists => { - if (exists) { - fail('Relation collection should not exist after deleting relation field.'); + .then( + exists => { + if (exists) { + fail('Relation collection should not exist after deleting relation field.'); + } + done(); + }, + error => { + jfail(error); + done(); } - done(); - }, error => { - fail(error); - done(); - }); + ); }); - it_exclude_dbs(['postgres'])('can delete relation field when related _Join collection not exist', done => { - config.database.loadSchema() - .then(schema => { - schema.addClassIfNotExists('NewClass', { - relationField: {type: 'Relation', targetClass: '_User'} - }) - .then(actualSchema => { - const expectedSchema = { - className: 'NewClass', - fields: { + it('can delete relation field when related _Join collection not exist', done => { + config.database.loadSchema().then(schema => { + schema + .addClassIfNotExists('NewClass', { + relationField: { type: 'Relation', targetClass: '_User' }, + }) + .then(actualSchema => { + const expectedSchema = { + className: 'NewClass', + fields: { + objectId: { type: 'String' }, + updatedAt: { type: 'Date' }, + createdAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + relationField: { type: 'Relation', targetClass: '_User' }, + }, + classLevelPermissions: { + ACL: { + '*': { + read: true, + write: true, + }, + }, + find: { '*': true }, + get: { '*': true }, + count: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': [] }, + }, + }; + expect(dd(actualSchema, expectedSchema)).toEqual(undefined); + }) + .then(() => config.database.collectionExists('_Join:relationField:NewClass')) + .then(exist => { + on_db( + 'postgres', + () => { + // We create the table when creating the column + expect(exist).toEqual(true); + }, + () => { + expect(exist).toEqual(false); + } + ); + }) + .then(() => schema.deleteField('relationField', 'NewClass', config.database)) + .then(() => schema.reloadData()) + .then(() => { + const expectedSchema = { objectId: { type: 'String' }, updatedAt: { type: 'Date' }, createdAt: { type: 'Date' }, ACL: { type: 'ACL' }, - relationField: { type: 'Relation', targetClass: '_User' }, - }, - classLevelPermissions: { - find: { '*': true }, - get: { '*': true }, - create: { '*': true }, - update: { '*': true }, - delete: { '*': true }, - addField: { '*': true }, - }, - }; - expect(dd(actualSchema, expectedSchema)).toEqual(undefined); - }) - .then(() => config.database.collectionExists('_Join:relationField:NewClass')) - .then(exist => { - expect(exist).toEqual(false); - }) - .then(() => schema.deleteField('relationField', 'NewClass', config.database)) - .then(() => schema.reloadData()) - .then(() => { - const expectedSchema = { - objectId: { type: 'String' }, - updatedAt: { type: 'Date' }, - createdAt: { type: 'Date' }, - ACL: { type: 'ACL' }, - }; - expect(dd(schema.data.NewClass, expectedSchema)).toEqual(undefined); - done(); - }); + }; + expect(dd(schema.schemaData.NewClass.fields, expectedSchema)).toEqual(undefined); + }) + .then(done) + .catch(done.fail); }); }); - it_exclude_dbs(['postgres'])('can delete string fields and resave as number field', done => { + it('can delete string fields and resave as number field', done => { Parse.Object.disableSingleInstance(); - var obj1 = hasAllPODobject(); - var obj2 = hasAllPODobject(); - var p = Parse.Object.saveAll([obj1, obj2]) - .then(() => config.database.loadSchema()) - .then(schema => schema.deleteField('aString', 'HasAllPOD', config.database)) - .then(() => new Parse.Query('HasAllPOD').get(obj1.id)) - .then(obj1Reloaded => { - expect(obj1Reloaded.get('aString')).toEqual(undefined); - obj1Reloaded.set('aString', ['not a string', 'this time']); - obj1Reloaded.save() - .then(obj1reloadedAgain => { - expect(obj1reloadedAgain.get('aString')).toEqual(['not a string', 'this time']); - return new Parse.Query('HasAllPOD').get(obj2.id); - }) - .then(obj2reloaded => { - expect(obj2reloaded.get('aString')).toEqual(undefined); + const obj1 = hasAllPODobject(); + const obj2 = hasAllPODobject(); + Parse.Object.saveAll([obj1, obj2]) + .then(() => config.database.loadSchema()) + .then(schema => schema.deleteField('aString', 'HasAllPOD', config.database)) + .then(() => new Parse.Query('HasAllPOD').get(obj1.id)) + .then(obj1Reloaded => { + expect(obj1Reloaded.get('aString')).toEqual(undefined); + obj1Reloaded.set('aString', ['not a string', 'this time']); + obj1Reloaded + .save() + .then(obj1reloadedAgain => { + expect(obj1reloadedAgain.get('aString')).toEqual(['not a string', 'this time']); + return new Parse.Query('HasAllPOD').get(obj2.id); + }) + .then(obj2reloaded => { + expect(obj2reloaded.get('aString')).toEqual(undefined); + done(); + }); + }) + .catch(error => { + jfail(error); done(); - Parse.Object.enableSingleInstance(); }); - }) - .catch(error => { - fail(error); - done(); - }); }); - it_exclude_dbs(['postgres'])('can delete pointer fields and resave as string', done => { + it('can delete pointer fields and resave as string', done => { Parse.Object.disableSingleInstance(); - var obj1 = new Parse.Object('NewClass'); - obj1.save() - .then(() => { - obj1.set('aPointer', obj1); - return obj1.save(); - }) - .then(obj1 => { - expect(obj1.get('aPointer').id).toEqual(obj1.id); - }) - .then(() => config.database.loadSchema()) - .then(schema => schema.deleteField('aPointer', 'NewClass', config.database)) - .then(() => new Parse.Query('NewClass').get(obj1.id)) - .then(obj1 => { - expect(obj1.get('aPointer')).toEqual(undefined); - obj1.set('aPointer', 'Now a string'); - return obj1.save(); - }) - .then(obj1 => { - expect(obj1.get('aPointer')).toEqual('Now a string'); - done(); - Parse.Object.enableSingleInstance(); - }); + const obj1 = new Parse.Object('NewClass'); + obj1 + .save() + .then(() => { + obj1.set('aPointer', obj1); + return obj1.save(); + }) + .then(obj1 => { + expect(obj1.get('aPointer').id).toEqual(obj1.id); + }) + .then(() => config.database.loadSchema()) + .then(schema => schema.deleteField('aPointer', 'NewClass', config.database)) + .then(() => new Parse.Query('NewClass').get(obj1.id)) + .then(obj1 => { + expect(obj1.get('aPointer')).toEqual(undefined); + obj1.set('aPointer', 'Now a string'); + return obj1.save(); + }) + .then(obj1 => { + expect(obj1.get('aPointer')).toEqual('Now a string'); + done(); + }); }); it('can merge schemas', done => { - expect(SchemaController.buildMergedSchemaObject({ - _id: 'SomeClass', - someType: { type: 'Number' } - }, { - newType: {type: 'Number'} - })).toEqual({ - someType: {type: 'Number'}, - newType: {type: 'Number'}, + expect( + SchemaController.buildMergedSchemaObject( + { + _id: 'SomeClass', + someType: { type: 'Number' }, + }, + { + newType: { type: 'Number' }, + } + ) + ).toEqual({ + someType: { type: 'Number' }, + newType: { type: 'Number' }, }); done(); }); it('can merge deletions', done => { - expect(SchemaController.buildMergedSchemaObject({ - _id: 'SomeClass', + expect( + SchemaController.buildMergedSchemaObject( + { + _id: 'SomeClass', + someType: { type: 'Number' }, + outDatedType: { type: 'String' }, + }, + { + newType: { type: 'GeoPoint' }, + outDatedType: { __op: 'Delete' }, + } + ) + ).toEqual({ someType: { type: 'Number' }, - outDatedType: { type: 'String' }, - },{ - newType: {type: 'GeoPoint'}, - outDatedType: {__op: 'Delete'}, - })).toEqual({ - someType: {type: 'Number'}, - newType: {type: 'GeoPoint'}, + newType: { type: 'GeoPoint' }, }); done(); }); it('ignore default field when merge with system class', done => { - expect(SchemaController.buildMergedSchemaObject({ - _id: '_User', - username: { type: 'String' }, - password: { type: 'String' }, - email: { type: 'String' }, - emailVerified: { type: 'Boolean' }, - },{ - emailVerified: { type: 'String' }, + expect( + SchemaController.buildMergedSchemaObject( + { + _id: '_User', + username: { type: 'String' }, + password: { type: 'String' }, + email: { type: 'String' }, + emailVerified: { type: 'Boolean' }, + }, + { + emailVerified: { type: 'String' }, + customField: { type: 'String' }, + } + ) + ).toEqual({ customField: { type: 'String' }, - })).toEqual({ - customField: { type: 'String' } }); done(); }); + + it('yields a proper schema mismatch error (#2661)', done => { + const anObject = new Parse.Object('AnObject'); + const anotherObject = new Parse.Object('AnotherObject'); + const someObject = new Parse.Object('SomeObject'); + Parse.Object.saveAll([anObject, anotherObject, someObject]) + .then(() => { + anObject.set('pointer', anotherObject); + return anObject.save(); + }) + .then(() => { + anObject.set('pointer', someObject); + return anObject.save(); + }) + .then( + () => { + fail('shoud not save correctly'); + done(); + }, + err => { + expect(err instanceof Parse.Error).toBeTruthy(); + expect(err.message).toEqual( + 'schema mismatch for AnObject.pointer; expected Pointer but got Pointer' + ); + done(); + } + ); + }); + + it('yields a proper schema mismatch error bis (#2661)', done => { + const anObject = new Parse.Object('AnObject'); + const someObject = new Parse.Object('SomeObject'); + Parse.Object.saveAll([anObject, someObject]) + .then(() => { + anObject.set('number', 1); + return anObject.save(); + }) + .then(() => { + anObject.set('number', someObject); + return anObject.save(); + }) + .then( + () => { + fail('shoud not save correctly'); + done(); + }, + err => { + expect(err instanceof Parse.Error).toBeTruthy(); + expect(err.message).toEqual( + 'schema mismatch for AnObject.number; expected Number but got Pointer' + ); + done(); + } + ); + }); + + it('yields a proper schema mismatch error ter (#2661)', done => { + const anObject = new Parse.Object('AnObject'); + const someObject = new Parse.Object('SomeObject'); + Parse.Object.saveAll([anObject, someObject]) + .then(() => { + anObject.set('pointer', someObject); + return anObject.save(); + }) + .then(() => { + anObject.set('pointer', 1); + return anObject.save(); + }) + .then( + () => { + fail('shoud not save correctly'); + done(); + }, + err => { + expect(err instanceof Parse.Error).toBeTruthy(); + expect(err.message).toEqual( + 'schema mismatch for AnObject.pointer; expected Pointer but got Number' + ); + done(); + } + ); + }); + + it('properly handles volatile _Schemas', async done => { + await reconfigureServer(); + function validateSchemaStructure(schema) { + expect(Object.prototype.hasOwnProperty.call(schema, 'className')).toBe(true); + expect(Object.prototype.hasOwnProperty.call(schema, 'fields')).toBe(true); + expect(Object.prototype.hasOwnProperty.call(schema, 'classLevelPermissions')).toBe(true); + } + function validateSchemaDataStructure(schemaData) { + Object.keys(schemaData).forEach(className => { + const schema = schemaData[className]; + // Hooks has className... + if (className != '_Hooks') { + expect(Object.prototype.hasOwnProperty.call(schema, 'className')).toBe(false); + } + expect(Object.prototype.hasOwnProperty.call(schema, 'fields')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(schema, 'classLevelPermissions')).toBe(false); + }); + } + let schema; + config.database + .loadSchema() + .then(s => { + schema = s; + return schema.getOneSchema('_User', false); + }) + .then(userSchema => { + validateSchemaStructure(userSchema); + validateSchemaDataStructure(schema.schemaData); + return schema.getOneSchema('_PushStatus', true); + }) + .then(pushStatusSchema => { + validateSchemaStructure(pushStatusSchema); + validateSchemaDataStructure(schema.schemaData); + }) + .then(done) + .catch(done.fail); + }); + + it('should not throw on null field types', async () => { + const schema = await config.database.loadSchema(); + const result = await schema.enforceFieldExists('NewClass', 'fieldName', null); + expect(result).toBeUndefined(); + }); + + it('ensureFields should throw when schema is not set', async () => { + const schema = await config.database.loadSchema(); + try { + schema.ensureFields([ + { + className: 'NewClass', + fieldName: 'fieldName', + type: 'String', + }, + ]); + } catch (e) { + expect(e.message).toBe('Could not add field fieldName'); + } + }); +}); + +describe('Class Level Permissions for requiredAuth', () => { + let loggerErrorSpy; + + beforeEach(() => { + config = Config.get('test'); + const logger = require('../lib/logger').default; + loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + }); + + function createUser() { + const user = new Parse.User(); + user.set('username', 'hello'); + user.set('password', 'world'); + return user.signUp(null); + } + + it('required auth test find', done => { + config.database + .loadSchema() + .then(schema => { + // Just to create a valid class + return schema.validateObject('Stuff', { foo: 'bar' }); + }) + .then(schema => { + return schema.setPermissions('Stuff', { + find: { + requiresAuthentication: true, + }, + }); + }) + .then(() => { + loggerErrorSpy.calls.reset(); + const query = new Parse.Query('Stuff'); + return query.find(); + }) + .then( + () => { + fail('Class permissions should have rejected this query.'); + done(); + }, + e => { + expect(e.message).toEqual('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('Permission denied, user needs to be authenticated.')); + done(); + } + ); + }); + + it('required auth test find authenticated', done => { + config.database + .loadSchema() + .then(schema => { + // Just to create a valid class + return schema.validateObject('Stuff', { foo: 'bar' }); + }) + .then(schema => { + return schema.setPermissions('Stuff', { + find: { + requiresAuthentication: true, + }, + }); + }) + .then(() => { + return createUser(); + }) + .then(() => { + const query = new Parse.Query('Stuff'); + return query.find(); + }) + .then( + results => { + expect(results.length).toEqual(0); + done(); + }, + e => { + console.error(e); + fail('Should not have failed'); + done(); + } + ); + }); + + it('required auth should allow create authenticated', done => { + config.database + .loadSchema() + .then(schema => { + // Just to create a valid class + return schema.validateObject('Stuff', { foo: 'bar' }); + }) + .then(schema => { + return schema.setPermissions('Stuff', { + create: { + requiresAuthentication: true, + }, + }); + }) + .then(() => { + return createUser(); + }) + .then(() => { + const stuff = new Parse.Object('Stuff'); + stuff.set('foo', 'bar'); + return stuff.save(); + }) + .then( + () => { + done(); + }, + e => { + console.error(e); + fail('Should not have failed'); + done(); + } + ); + }); + + it('required auth should reject create when not authenticated', done => { + config.database + .loadSchema() + .then(schema => { + // Just to create a valid class + return schema.validateObject('Stuff', { foo: 'bar' }); + }) + .then(schema => { + return schema.setPermissions('Stuff', { + create: { + requiresAuthentication: true, + }, + }); + }) + .then(() => { + loggerErrorSpy.calls.reset(); + const stuff = new Parse.Object('Stuff'); + stuff.set('foo', 'bar'); + return stuff.save(); + }) + .then( + () => { + fail('Class permissions should have rejected this query.'); + done(); + }, + e => { + expect(e.message).toEqual('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('Permission denied, user needs to be authenticated.')); + done(); + } + ); + }); + + it('required auth test create/get/update/delete authenticated', done => { + config.database + .loadSchema() + .then(schema => { + // Just to create a valid class + return schema.validateObject('Stuff', { foo: 'bar' }); + }) + .then(schema => { + return schema.setPermissions('Stuff', { + create: { + requiresAuthentication: true, + }, + get: { + requiresAuthentication: true, + }, + delete: { + requiresAuthentication: true, + }, + update: { + requiresAuthentication: true, + }, + }); + }) + .then(() => { + return createUser(); + }) + .then(() => { + const stuff = new Parse.Object('Stuff'); + stuff.set('foo', 'bar'); + return stuff.save().then(() => { + const query = new Parse.Query('Stuff'); + return query.get(stuff.id); + }); + }) + .then(gotStuff => { + return gotStuff.save({ foo: 'baz' }).then(() => { + return gotStuff.destroy(); + }); + }) + .then( + () => { + done(); + }, + e => { + console.error(e); + fail('Should not have failed'); + done(); + } + ); + }); + + it('required auth test get not authenticated', done => { + config.database + .loadSchema() + .then(schema => { + // Just to create a valid class + return schema.validateObject('Stuff', { foo: 'bar' }); + }) + .then(schema => { + return schema.setPermissions('Stuff', { + get: { + requiresAuthentication: true, + }, + create: { + '*': true, + }, + }); + }) + .then(() => { + const stuff = new Parse.Object('Stuff'); + stuff.set('foo', 'bar'); + return stuff.save().then(() => { + loggerErrorSpy.calls.reset(); + const query = new Parse.Query('Stuff'); + return query.get(stuff.id); + }); + }) + .then( + () => { + fail('Should not succeed!'); + done(); + }, + e => { + expect(e.message).toEqual('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('Permission denied, user needs to be authenticated.')); + done(); + } + ); + }); + + it('required auth test find not authenticated', done => { + config.database + .loadSchema() + .then(schema => { + // Just to create a valid class + return schema.validateObject('Stuff', { foo: 'bar' }); + }) + .then(schema => { + return schema.setPermissions('Stuff', { + find: { + requiresAuthentication: true, + }, + create: { + '*': true, + }, + get: { + '*': true, + }, + }); + }) + .then(() => { + const stuff = new Parse.Object('Stuff'); + stuff.set('foo', 'bar'); + return stuff.save().then(() => { + const query = new Parse.Query('Stuff'); + return query.get(stuff.id); + }); + }) + .then(result => { + expect(result.get('foo')).toEqual('bar'); + loggerErrorSpy.calls.reset(); + const query = new Parse.Query('Stuff'); + return query.find(); + }) + .then( + () => { + fail('Should not succeed!'); + done(); + }, + e => { + expect(e.message).toEqual('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('Permission denied, user needs to be authenticated.')); + done(); + } + ); + }); + + it('required auth test create/get/update/delete with roles (#3753)', done => { + let user; + config.database + .loadSchema() + .then(schema => { + // Just to create a valid class + return schema.validateObject('Stuff', { foo: 'bar' }); + }) + .then(schema => { + return schema.setPermissions('Stuff', { + find: { + requiresAuthentication: true, + 'role:admin': true, + }, + create: { 'role:admin': true }, + update: { 'role:admin': true }, + delete: { 'role:admin': true }, + get: { + requiresAuthentication: true, + 'role:admin': true, + }, + }); + }) + .then(() => { + const stuff = new Parse.Object('Stuff'); + stuff.set('foo', 'bar'); + return stuff + .save(null, { useMasterKey: true }) + .then(() => { + const query = new Parse.Query('Stuff'); + return query + .get(stuff.id) + .then( + () => { + done.fail('should not succeed'); + }, + () => { + return new Parse.Query('Stuff').find(); + } + ) + .then( + () => { + done.fail('should not succeed'); + }, + () => { + return Promise.resolve(); + } + ); + }) + .then(() => { + return Parse.User.signUp('user', 'password').then(signedUpUser => { + user = signedUpUser; + const query = new Parse.Query('Stuff'); + return query.get(stuff.id, { + sessionToken: user.getSessionToken(), + }); + }); + }); + }) + .then(result => { + expect(result.get('foo')).toEqual('bar'); + const query = new Parse.Query('Stuff'); + return query.find({ sessionToken: user.getSessionToken() }); + }) + .then( + results => { + expect(results.length).toBe(1); + done(); + }, + e => { + console.error(e); + done.fail(e); + } + ); + }); }); diff --git a/spec/SchemaPerformance.spec.js b/spec/SchemaPerformance.spec.js new file mode 100644 index 0000000000..415f71e2e5 --- /dev/null +++ b/spec/SchemaPerformance.spec.js @@ -0,0 +1,265 @@ +const Config = require('../lib/Config'); + +describe('Schema Performance', function () { + let getAllSpy; + let config; + + beforeEach(async () => { + await reconfigureServer(); + config = Config.get('test'); + getAllSpy = spyOn(databaseAdapter, 'getAllClasses').and.callThrough(); + }); + + it('test new object', async () => { + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + expect(getAllSpy.calls.count()).toBe(2); + }); + + it('test new object multiple fields', async () => { + const container = new Container({ + dateField: new Date(), + arrayField: [], + numberField: 1, + stringField: 'hello', + booleanField: true, + }); + await container.save(); + expect(getAllSpy.calls.count()).toBe(2); + }); + + it('test update existing fields', async () => { + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + getAllSpy.calls.reset(); + + object.set('foo', 'barz'); + await object.save(); + expect(getAllSpy.calls.count()).toBe(0); + }); + + xit('test saveAll / destroyAll', async () => { + // This test can be flaky due to the nature of /batch requests + // Used for performance + const object = new TestObject(); + await object.save(); + + getAllSpy.calls.reset(); + + const objects = []; + for (let i = 0; i < 10; i++) { + const object = new TestObject(); + object.set('number', i); + objects.push(object); + } + await Parse.Object.saveAll(objects); + expect(getAllSpy.calls.count()).toBe(0); + + getAllSpy.calls.reset(); + + const query = new Parse.Query(TestObject); + await query.find(); + expect(getAllSpy.calls.count()).toBe(0); + + getAllSpy.calls.reset(); + + await Parse.Object.destroyAll(objects); + expect(getAllSpy.calls.count()).toBe(0); + }); + + it('test add new field to existing object', async () => { + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + getAllSpy.calls.reset(); + + object.set('new', 'barz'); + await object.save(); + expect(getAllSpy.calls.count()).toBe(1); + }); + + it('test add multiple fields to existing object', async () => { + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + getAllSpy.calls.reset(); + + object.set({ + dateField: new Date(), + arrayField: [], + numberField: 1, + stringField: 'hello', + booleanField: true, + }); + await object.save(); + expect(getAllSpy.calls.count()).toBe(1); + }); + + it('test user', async () => { + const user = new Parse.User(); + user.setUsername('testing'); + user.setPassword('testing'); + await user.signUp(); + + expect(getAllSpy.calls.count()).toBe(1); + }); + + it('test query include', async () => { + const child = new TestObject(); + await child.save(); + + const object = new TestObject(); + object.set('child', child); + await object.save(); + + getAllSpy.calls.reset(); + + const query = new Parse.Query(TestObject); + query.include('child'); + await query.get(object.id); + + expect(getAllSpy.calls.count()).toBe(0); + }); + + it('query relation without schema', async () => { + const child = new Parse.Object('ChildObject'); + await child.save(); + + const parent = new Parse.Object('ParentObject'); + const relation = parent.relation('child'); + relation.add(child); + await parent.save(); + + getAllSpy.calls.reset(); + + const objects = await relation.query().find(); + expect(objects.length).toBe(1); + expect(objects[0].id).toBe(child.id); + + expect(getAllSpy.calls.count()).toBe(0); + }); + + it('test delete object', async () => { + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + getAllSpy.calls.reset(); + + await object.destroy(); + expect(getAllSpy.calls.count()).toBe(0); + }); + + it('test schema update class', async () => { + const container = new Container(); + await container.save(); + + getAllSpy.calls.reset(); + + const schema = await config.database.loadSchema(); + await schema.reloadData(); + + const levelPermissions = { + ACL: { + '*': { + read: true, + write: true, + }, + }, + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': [] }, + }; + + await schema.updateClass( + 'Container', + { + fooOne: { type: 'Number' }, + fooTwo: { type: 'Array' }, + fooThree: { type: 'Date' }, + fooFour: { type: 'Object' }, + fooFive: { type: 'Relation', targetClass: '_User' }, + fooSix: { type: 'String' }, + fooSeven: { type: 'Object' }, + fooEight: { type: 'String' }, + fooNine: { type: 'String' }, + fooTeen: { type: 'Number' }, + fooEleven: { type: 'String' }, + fooTwelve: { type: 'String' }, + fooThirteen: { type: 'String' }, + fooFourteen: { type: 'String' }, + fooFifteen: { type: 'String' }, + fooSixteen: { type: 'String' }, + fooEighteen: { type: 'String' }, + fooNineteen: { type: 'String' }, + }, + levelPermissions, + {}, + config.database + ); + expect(getAllSpy.calls.count()).toBe(2); + }); + + it_id('9dd70965-b683-4cb8-b43a-44c1f4def9f4')(it)('does reload with schemaCacheTtl', async () => { + const databaseURI = + process.env.PARSE_SERVER_TEST_DB === 'postgres' + ? process.env.PARSE_SERVER_TEST_DATABASE_URI + : 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase'; + await reconfigureServer({ + databaseAdapter: undefined, + databaseURI, + silent: false, + databaseOptions: { schemaCacheTtl: 1000 }, + }); + const SchemaController = require('../lib/Controllers/SchemaController').SchemaController; + const spy = spyOn(SchemaController.prototype, 'reloadData').and.callThrough(); + Object.defineProperty(spy, 'reloadCalls', { + get: () => spy.calls.all().filter(call => call.args[0].clearCache).length, + }); + + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + spy.calls.reset(); + + object.set('foo', 'bar'); + await object.save(); + + expect(spy.reloadCalls).toBe(0); + + await new Promise(resolve => setTimeout(resolve, 1100)); + + object.set('foo', 'bar'); + await object.save(); + + expect(spy.reloadCalls).toBe(1); + }); + + it_id('b0ae21f2-c947-48ed-a0db-e8900d45a4c8')(it)('cannot set invalid databaseOptions', async () => { + const expectError = async (key, value, expected) => + expectAsync( + reconfigureServer({ databaseAdapter: undefined, databaseOptions: { [key]: value } }) + ).toBeRejectedWith(`databaseOptions.${key} must be a ${expected}`); + for (const databaseOptions of [[], 0, 'string']) { + await expectAsync( + reconfigureServer({ databaseAdapter: undefined, databaseOptions }) + ).toBeRejectedWith(`databaseOptions must be an object`); + } + for (const value of [null, 0, 'string', {}, []]) { + await expectError('enableSchemaHooks', value, 'boolean'); + } + for (const value of [null, false, 'string', {}, []]) { + await expectError('schemaCacheTtl', value, 'number'); + } + }); +}); diff --git a/spec/SecurityCheck.spec.js b/spec/SecurityCheck.spec.js new file mode 100644 index 0000000000..6b752ff972 --- /dev/null +++ b/spec/SecurityCheck.spec.js @@ -0,0 +1,408 @@ +'use strict'; + +const Utils = require('../lib/Utils'); +const Config = require('../lib/Config'); +const request = require('../lib/request'); +const Definitions = require('../lib/Options/Definitions'); +const { Check, CheckState } = require('../lib/Security/Check'); +const CheckGroup = require('../lib/Security/CheckGroup'); +const CheckRunner = require('../lib/Security/CheckRunner'); +const CheckGroups = require('../lib/Security/CheckGroups/CheckGroups'); + +describe('Security Check', () => { + let Group; + let groupName; + let checkSuccess; + let checkFail; + let config; + const publicServerURL = 'http://localhost:8378/1'; + const securityUrl = publicServerURL + '/security'; + + async function reconfigureServerWithSecurityConfig(security) { + config.security = security; + await reconfigureServer(config); + } + + const securityRequest = options => + request( + Object.assign( + { + url: securityUrl, + headers: { + 'X-Parse-Master-Key': Parse.masterKey, + 'X-Parse-Application-Id': Parse.applicationId, + }, + followRedirects: false, + }, + options + ) + ).catch(e => e); + + beforeEach(async () => { + groupName = 'Example Group Name'; + checkSuccess = new Check({ + group: 'TestGroup', + title: 'TestTitleSuccess', + warning: 'TestWarning', + solution: 'TestSolution', + check: () => { + return true; + }, + }); + checkFail = new Check({ + group: 'TestGroup', + title: 'TestTitleFail', + warning: 'TestWarning', + solution: 'TestSolution', + check: () => { + throw 'Fail'; + }, + }); + Group = class Group extends CheckGroup { + setName() { + return groupName; + } + setChecks() { + return [checkSuccess, checkFail]; + } + }; + config = { + appId: 'test', + appName: 'ExampleAppName', + publicServerURL, + security: { + enableCheck: true, + enableCheckLog: true, + }, + }; + await reconfigureServer(config); + }); + + describe('server options', () => { + it('uses default configuration when none is set', async () => { + await reconfigureServerWithSecurityConfig({}); + expect(Config.get(Parse.applicationId).security.enableCheck).toBe( + Definitions.SecurityOptions.enableCheck.default + ); + expect(Config.get(Parse.applicationId).security.enableCheckLog).toBe( + Definitions.SecurityOptions.enableCheckLog.default + ); + }); + + it('throws on invalid configuration', async () => { + const options = [ + [], + 'a', + 0, + true, + { enableCheck: 'a' }, + { enableCheck: 0 }, + { enableCheck: {} }, + { enableCheck: [] }, + { enableCheckLog: 'a' }, + { enableCheckLog: 0 }, + { enableCheckLog: {} }, + { enableCheckLog: [] }, + ]; + for (const option of options) { + await expectAsync(reconfigureServerWithSecurityConfig(option)).toBeRejected(); + } + }); + }); + + describe('auto-run', () => { + it('runs security checks on server start if enabled', async () => { + const runnerSpy = spyOn(CheckRunner.prototype, 'run').and.callThrough(); + await reconfigureServerWithSecurityConfig({ enableCheck: true, enableCheckLog: true }); + expect(runnerSpy).toHaveBeenCalledTimes(1); + }); + + it('does not run security checks on server start if disabled', async () => { + const runnerSpy = spyOn(CheckRunner.prototype, 'run').and.callThrough(); + const configs = [ + { enableCheck: true, enableCheckLog: false }, + { enableCheck: false, enableCheckLog: false }, + { enableCheck: false }, + {}, + ]; + for (const config of configs) { + await reconfigureServerWithSecurityConfig(config); + expect(runnerSpy).not.toHaveBeenCalled(); + } + }); + }); + + describe('security endpoint accessibility', () => { + it('responds with 403 without masterkey', async () => { + const response = await securityRequest({ headers: {} }); + expect(response.status).toBe(403); + }); + + it('responds with 409 with masterkey and security check disabled', async () => { + await reconfigureServerWithSecurityConfig({}); + const response = await securityRequest(); + expect(response.status).toBe(409); + }); + + it('responds with 200 with masterkey and security check enabled', async () => { + const response = await securityRequest(); + expect(response.status).toBe(200); + }); + }); + + describe('check', () => { + const initCheck = config => (() => new Check(config)).bind(null); + + it('instantiates check with valid parameters', async () => { + const configs = [ + { + group: 'string', + title: 'string', + warning: 'string', + solution: 'string', + check: () => {}, + }, + { + group: 'string', + title: 'string', + warning: 'string', + solution: 'string', + check: async () => {}, + }, + ]; + for (const config of configs) { + expect(initCheck(config)).not.toThrow(); + } + }); + + it('throws instantiating check with invalid parameters', async () => { + const configDefinition = { + group: [false, true, 0, 1, [], {}, () => {}], + title: [false, true, 0, 1, [], {}, () => {}], + warning: [false, true, 0, 1, [], {}, () => {}], + solution: [false, true, 0, 1, [], {}, () => {}], + check: [false, true, 0, 1, [], {}, 'string'], + }; + const configs = Utils.getObjectKeyPermutations(configDefinition); + + for (const config of configs) { + expect(initCheck(config)).toThrow(); + } + }); + + it('sets correct states for check success', async () => { + const check = new Check({ + group: 'string', + title: 'string', + warning: 'string', + solution: 'string', + check: () => {}, + }); + expect(check._checkState == CheckState.none); + check.run(); + expect(check._checkState == CheckState.success); + }); + + it('sets correct states for check fail', async () => { + const check = new Check({ + group: 'string', + title: 'string', + warning: 'string', + solution: 'string', + check: () => { + throw 'error'; + }, + }); + expect(check._checkState == CheckState.none); + check.run(); + expect(check._checkState == CheckState.fail); + }); + }); + + describe('check group', () => { + it('returns properties if subclassed correctly', async () => { + const group = new Group(); + expect(group.name()).toBe(groupName); + expect(group.checks().length).toBe(2); + expect(group.checks()[0]).toEqual(checkSuccess); + expect(group.checks()[1]).toEqual(checkFail); + }); + + it('throws if subclassed incorrectly', async () => { + class InvalidGroup1 extends CheckGroup {} + expect((() => new InvalidGroup1()).bind()).toThrow('Check group has no name.'); + class InvalidGroup2 extends CheckGroup { + setName() { + return groupName; + } + } + expect((() => new InvalidGroup2()).bind()).toThrow('Check group has no checks.'); + }); + + it('runs checks', async () => { + const group = new Group(); + expect(group.checks()[0].checkState()).toBe(CheckState.none); + expect(group.checks()[1].checkState()).toBe(CheckState.none); + expect((() => group.run()).bind(null)).not.toThrow(); + expect(group.checks()[0].checkState()).toBe(CheckState.success); + expect(group.checks()[1].checkState()).toBe(CheckState.fail); + }); + }); + + describe('check runner', () => { + const initRunner = config => (() => new CheckRunner(config)).bind(null); + + it('instantiates runner with valid parameters', async () => { + const configDefinition = { + enableCheck: [false, true, undefined], + enableCheckLog: [false, true, undefined], + checkGroups: [[], undefined], + }; + const configs = Utils.getObjectKeyPermutations(configDefinition); + for (const config of configs) { + expect(initRunner(config)).not.toThrow(); + } + }); + + it('throws instantiating runner with invalid parameters', async () => { + const configDefinition = { + enableCheck: [0, 1, [], {}, () => {}], + enableCheckLog: [0, 1, [], {}, () => {}], + checkGroups: [false, true, 0, 1, {}, () => {}], + }; + const configs = Utils.getObjectKeyPermutations(configDefinition); + + for (const config of configs) { + expect(initRunner(config)).toThrow(); + } + }); + + it('instantiates runner with default parameters', async () => { + const runner = new CheckRunner(); + expect(runner.enableCheck).toBeFalse(); + expect(runner.enableCheckLog).toBeFalse(); + expect(runner.checkGroups).toBe(CheckGroups); + }); + + it('runs all checks of all groups', async () => { + const checkGroups = [Group, Group]; + const runner = new CheckRunner({ checkGroups }); + const report = await runner.run(); + expect(report.report.groups[0].checks[0].state).toBe(CheckState.success); + expect(report.report.groups[0].checks[1].state).toBe(CheckState.fail); + expect(report.report.groups[1].checks[0].state).toBe(CheckState.success); + expect(report.report.groups[1].checks[1].state).toBe(CheckState.fail); + }); + + it('reports correct default syntax version 1.0.0', async () => { + const checkGroups = [Group]; + const runner = new CheckRunner({ checkGroups, enableCheckLog: true }); + const report = await runner.run(); + expect(report).toEqual({ + report: { + version: '1.0.0', + state: 'fail', + groups: [ + { + name: 'Example Group Name', + state: 'fail', + checks: [ + { + title: 'TestTitleSuccess', + state: 'success', + }, + { + title: 'TestTitleFail', + state: 'fail', + warning: 'TestWarning', + solution: 'TestSolution', + }, + ], + }, + ], + }, + }); + }); + + it('logs report', async () => { + const logger = require('../lib/logger').logger; + const logSpy = spyOn(logger, 'warn').and.callThrough(); + const checkGroups = [Group]; + const runner = new CheckRunner({ checkGroups, enableCheckLog: true }); + const report = await runner.run(); + const titles = report.report.groups.flatMap(group => group.checks.map(check => check.title)); + expect(titles.length).toBe(2); + + for (const title of titles) { + expect(logSpy.calls.all()[0].args[0]).toContain(title); + } + }); + + it('warns when LiveQuery regex timeout is disabled', async () => { + await reconfigureServer({ + security: { enableCheck: true, enableCheckLog: true }, + liveQuery: { classNames: ['TestObject'], regexTimeout: 0 }, + }); + const runner = new CheckRunner({ enableCheck: true }); + const report = await runner.run(); + const check = report.report.groups + .flatMap(g => g.checks) + .find(c => c.title === 'LiveQuery regex timeout enabled'); + expect(check).toBeDefined(); + expect(check.state).toBe(CheckState.fail); + }); + + it('passes when LiveQuery regex timeout is enabled', async () => { + await reconfigureServer({ + security: { enableCheck: true, enableCheckLog: true }, + liveQuery: { classNames: ['TestObject'], regexTimeout: 100 }, + }); + const runner = new CheckRunner({ enableCheck: true }); + const report = await runner.run(); + const check = report.report.groups + .flatMap(g => g.checks) + .find(c => c.title === 'LiveQuery regex timeout enabled'); + expect(check.state).toBe(CheckState.success); + }); + + it('passes when LiveQuery is not configured', async () => { + await reconfigureServer({ + security: { enableCheck: true, enableCheckLog: true }, + }); + const runner = new CheckRunner({ enableCheck: true }); + const report = await runner.run(); + const check = report.report.groups + .flatMap(g => g.checks) + .find(c => c.title === 'LiveQuery regex timeout enabled'); + expect(check.state).toBe(CheckState.success); + }); + + it('does update featuresRouter', async () => { + let response = await request({ + url: 'http://localhost:8378/1/serverInfo', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + }, + }); + expect(response.data.features.settings.securityCheck).toBeTrue(); + await reconfigureServer({ + security: { + enableCheck: false, + }, + }); + response = await request({ + url: 'http://localhost:8378/1/serverInfo', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + }, + }); + expect(response.data.features.settings.securityCheck).toBeFalse(); + }); + }); +}); diff --git a/spec/SecurityCheckGroups.spec.js b/spec/SecurityCheckGroups.spec.js new file mode 100644 index 0000000000..08357e453f --- /dev/null +++ b/spec/SecurityCheckGroups.spec.js @@ -0,0 +1,150 @@ +'use strict'; + +const Config = require('../lib/Config'); +const { CheckState } = require('../lib/Security/Check'); +const CheckGroupServerConfig = require('../lib/Security/CheckGroups/CheckGroupServerConfig'); +const CheckGroupDatabase = require('../lib/Security/CheckGroups/CheckGroupDatabase'); + +describe('Security Check Groups', () => { + let config; + + beforeEach(async () => { + config = { + appId: 'test', + appName: 'ExampleAppName', + publicServerURL: 'http://localhost:8378/1', + security: { + enableCheck: true, + enableCheckLog: false, + }, + }; + await reconfigureServer(config); + }); + + describe('CheckGroupServerConfig', () => { + it('is subclassed correctly', async () => { + const group = new CheckGroupServerConfig(); + expect(group.name()).toBeDefined(); + expect(group.checks().length).toBeGreaterThan(0); + }); + + it('checks succeed correctly', async () => { + config.masterKey = 'aMoreSecur3Passwor7!'; + config.security.enableCheckLog = false; + config.allowClientClassCreation = false; + config.enableInsecureAuthAdapters = false; + config.graphQLPublicIntrospection = false; + config.mountPlayground = false; + config.readOnlyMasterKey = 'someReadOnlyMasterKey'; + config.readOnlyMasterKeyIps = ['127.0.0.1', '::1']; + config.requestComplexity = { + includeDepth: 5, + includeCount: 50, + subqueryDepth: 5, + queryDepth: 10, + graphQLDepth: 50, + graphQLFields: 200, + batchRequestLimit: 50, + }; + await reconfigureServer(config); + + const group = new CheckGroupServerConfig(); + await group.run(); + expect(group.checks()[0].checkState()).toBe(CheckState.success); + expect(group.checks()[1].checkState()).toBe(CheckState.success); + expect(group.checks()[2].checkState()).toBe(CheckState.success); + expect(group.checks()[4].checkState()).toBe(CheckState.success); + expect(group.checks()[5].checkState()).toBe(CheckState.success); + expect(group.checks()[6].checkState()).toBe(CheckState.success); + expect(group.checks()[8].checkState()).toBe(CheckState.success); + expect(group.checks()[9].checkState()).toBe(CheckState.success); + expect(group.checks()[10].checkState()).toBe(CheckState.success); + expect(group.checks()[11].checkState()).toBe(CheckState.success); + }); + + it('checks fail correctly', async () => { + config.masterKey = 'insecure'; + config.security.enableCheckLog = true; + config.allowClientClassCreation = true; + config.enableInsecureAuthAdapters = true; + config.graphQLPublicIntrospection = true; + config.mountPlayground = true; + config.readOnlyMasterKey = 'someReadOnlyMasterKey'; + config.readOnlyMasterKeyIps = ['0.0.0.0/0']; + config.requestComplexity = { + includeDepth: -1, + includeCount: -1, + subqueryDepth: -1, + queryDepth: -1, + graphQLDepth: -1, + graphQLFields: -1, + }; + config.passwordPolicy = { + resetPasswordSuccessOnInvalidEmail: false, + }; + config.emailVerifySuccessOnInvalidEmail = false; + await reconfigureServer(config); + + const group = new CheckGroupServerConfig(); + await group.run(); + expect(group.checks()[0].checkState()).toBe(CheckState.fail); + expect(group.checks()[1].checkState()).toBe(CheckState.fail); + expect(group.checks()[2].checkState()).toBe(CheckState.fail); + expect(group.checks()[4].checkState()).toBe(CheckState.fail); + expect(group.checks()[5].checkState()).toBe(CheckState.fail); + expect(group.checks()[6].checkState()).toBe(CheckState.fail); + expect(group.checks()[8].checkState()).toBe(CheckState.fail); + expect(group.checks()[9].checkState()).toBe(CheckState.fail); + expect(group.checks()[10].checkState()).toBe(CheckState.fail); + expect(group.checks()[11].checkState()).toBe(CheckState.fail); + }); + + it_only_db('mongo')('checks succeed correctly (MongoDB specific)', async () => { + config.databaseAdapter = undefined; + config.databaseOptions = { allowPublicExplain: false }; + await reconfigureServer(config); + + const group = new CheckGroupServerConfig(); + await group.run(); + expect(group.checks()[7].checkState()).toBe(CheckState.success); + }); + + it_only_db('mongo')('checks fail correctly (MongoDB specific)', async () => { + config.databaseAdapter = undefined; + config.databaseOptions = { allowPublicExplain: true }; + await reconfigureServer(config); + + const group = new CheckGroupServerConfig(); + await group.run(); + expect(group.checks()[7].checkState()).toBe(CheckState.fail); + }); + }); + + describe('CheckGroupDatabase', () => { + it('is subclassed correctly', async () => { + const group = new CheckGroupDatabase(); + expect(group.name()).toBeDefined(); + expect(group.checks().length).toBeGreaterThan(0); + }); + + it('checks succeed correctly', async () => { + const config = Config.get(Parse.applicationId); + const uri = config.database.adapter._uri; + config.database.adapter._uri = 'protocol://user:aMoreSecur3Passwor7!@example.com'; + const group = new CheckGroupDatabase(); + await group.run(); + expect(group.checks()[0].checkState()).toBe(CheckState.success); + config.database.adapter._uri = uri; + }); + + it('checks fail correctly', async () => { + const config = Config.get(Parse.applicationId); + const uri = config.database.adapter._uri; + config.database.adapter._uri = 'protocol://user:insecure@example.com'; + const group = new CheckGroupDatabase(); + await group.run(); + expect(group.checks()[0].checkState()).toBe(CheckState.fail); + config.database.adapter._uri = uri; + }); + }); +}); diff --git a/spec/SessionTokenCache.spec.js b/spec/SessionTokenCache.spec.js index b02a8bf891..6b3c83df62 100644 --- a/spec/SessionTokenCache.spec.js +++ b/spec/SessionTokenCache.spec.js @@ -1,52 +1,54 @@ -var SessionTokenCache = require('../src/LiveQuery/SessionTokenCache').SessionTokenCache; - -describe('SessionTokenCache', function() { - - beforeEach(function(done) { - var Parse = require('parse/node'); - // Mock parse - var mockUser = { - become: jasmine.createSpy('become').and.returnValue(Parse.Promise.as({ - id: 'userId' - })) - } - jasmine.mockLibrary('parse/node', 'User', mockUser); +const SessionTokenCache = require('../lib/LiveQuery/SessionTokenCache').SessionTokenCache; + +describe('SessionTokenCache', function () { + beforeEach(function (done) { + const Parse = require('parse/node'); + + spyOn(Parse, 'Query').and.returnValue({ + first: jasmine.createSpy('first').and.returnValue( + Promise.resolve( + new Parse.Object('_Session', { + user: new Parse.User({ id: 'userId' }), + }) + ) + ), + equalTo: function () {}, + }); + done(); }); - it('can get undefined userId', function(done) { - var sessionTokenCache = new SessionTokenCache(); + it('can get undefined userId', function (done) { + const sessionTokenCache = new SessionTokenCache(); - sessionTokenCache.getUserId(undefined).then((userIdFromCache) => { - }, (error) => { - expect(error).not.toBeNull(); - done(); - }); + sessionTokenCache.getUserId(undefined).then( + () => {}, + error => { + expect(error).not.toBeNull(); + done(); + } + ); }); - it('can get existing userId', function(done) { - var sessionTokenCache = new SessionTokenCache(); - var sessionToken = 'sessionToken'; - var userId = 'userId' + it('can get existing userId', function (done) { + const sessionTokenCache = new SessionTokenCache(); + const sessionToken = 'sessionToken'; + const userId = 'userId'; sessionTokenCache.cache.set(sessionToken, userId); - sessionTokenCache.getUserId(sessionToken).then((userIdFromCache) => { + sessionTokenCache.getUserId(sessionToken).then(userIdFromCache => { expect(userIdFromCache).toBe(userId); done(); }); }); - it('can get new userId', function(done) { - var sessionTokenCache = new SessionTokenCache(); + it('can get new userId', function (done) { + const sessionTokenCache = new SessionTokenCache(); - sessionTokenCache.getUserId('sessionToken').then((userIdFromCache) => { + sessionTokenCache.getUserId('sessionToken').then(userIdFromCache => { expect(userIdFromCache).toBe('userId'); - expect(sessionTokenCache.cache.length).toBe(1); + expect(sessionTokenCache.cache.size).toBe(1); done(); }); }); - - afterEach(function() { - jasmine.restoreLibrary('parse/node', 'User'); - }); }); diff --git a/spec/Subscription.spec.js b/spec/Subscription.spec.js index a9f35020be..9c7aa4c550 100644 --- a/spec/Subscription.spec.js +++ b/spec/Subscription.spec.js @@ -1,44 +1,43 @@ -var Subscription = require('../src/LiveQuery/Subscription').Subscription; - -describe('Subscription', function() { - - beforeEach(function() { - var mockError = jasmine.createSpy('error'); - jasmine.mockLibrary('../src/LiveQuery/PLog', 'error', mockError); +const Subscription = require('../lib/LiveQuery/Subscription').Subscription; +let logger; +describe('Subscription', function () { + beforeEach(function () { + logger = require('../lib/logger').logger; + spyOn(logger, 'error').and.callThrough(); }); - it('can be initialized', function() { - var subscription = new Subscription('className', { key : 'value' }, 'hash'); + it('can be initialized', function () { + const subscription = new Subscription('className', { key: 'value' }, 'hash'); expect(subscription.className).toBe('className'); - expect(subscription.query).toEqual({ key : 'value' }); + expect(subscription.query).toEqual({ key: 'value' }); expect(subscription.hash).toBe('hash'); expect(subscription.clientRequestIds.size).toBe(0); }); - it('can check it has subscribing clients', function() { - var subscription = new Subscription('className', { key : 'value' }, 'hash'); + it('can check it has subscribing clients', function () { + const subscription = new Subscription('className', { key: 'value' }, 'hash'); expect(subscription.hasSubscribingClient()).toBe(false); }); - it('can check it does not have subscribing clients', function() { - var subscription = new Subscription('className', { key : 'value' }, 'hash'); + it('can check it does not have subscribing clients', function () { + const subscription = new Subscription('className', { key: 'value' }, 'hash'); subscription.addClientSubscription(1, 1); expect(subscription.hasSubscribingClient()).toBe(true); }); - it('can add one request for one client', function() { - var subscription = new Subscription('className', { key : 'value' }, 'hash'); + it('can add one request for one client', function () { + const subscription = new Subscription('className', { key: 'value' }, 'hash'); subscription.addClientSubscription(1, 1); expect(subscription.clientRequestIds.size).toBe(1); expect(subscription.clientRequestIds.get(1)).toEqual([1]); }); - it('can add requests for one client', function() { - var subscription = new Subscription('className', { key : 'value' }, 'hash'); + it('can add requests for one client', function () { + const subscription = new Subscription('className', { key: 'value' }, 'hash'); subscription.addClientSubscription(1, 1); subscription.addClientSubscription(1, 2); @@ -46,8 +45,8 @@ describe('Subscription', function() { expect(subscription.clientRequestIds.get(1)).toEqual([1, 2]); }); - it('can add requests for clients', function() { - var subscription = new Subscription('className', { key : 'value' }, 'hash'); + it('can add requests for clients', function () { + const subscription = new Subscription('className', { key: 'value' }, 'hash'); subscription.addClientSubscription(1, 1); subscription.addClientSubscription(1, 2); subscription.addClientSubscription(2, 2); @@ -58,51 +57,47 @@ describe('Subscription', function() { expect(subscription.clientRequestIds.get(2)).toEqual([2, 3]); }); - it('can delete requests for nonexistent client', function() { - var subscription = new Subscription('className', { key : 'value' }, 'hash'); + it('can delete requests for nonexistent client', function () { + const subscription = new Subscription('className', { key: 'value' }, 'hash'); subscription.deleteClientSubscription(1, 1); - var PLog =require('../src/LiveQuery/PLog'); - expect(PLog.error).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); }); - it('can delete nonexistent request for one client', function() { - var subscription = new Subscription('className', { key : 'value' }, 'hash'); + it('can delete nonexistent request for one client', function () { + const subscription = new Subscription('className', { key: 'value' }, 'hash'); subscription.addClientSubscription(1, 1); subscription.deleteClientSubscription(1, 2); - var PLog =require('../src/LiveQuery/PLog'); - expect(PLog.error).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); expect(subscription.clientRequestIds.size).toBe(1); expect(subscription.clientRequestIds.get(1)).toEqual([1]); }); - it('can delete some requests for one client', function() { - var subscription = new Subscription('className', { key : 'value' }, 'hash'); + it('can delete some requests for one client', function () { + const subscription = new Subscription('className', { key: 'value' }, 'hash'); subscription.addClientSubscription(1, 1); subscription.addClientSubscription(1, 2); subscription.deleteClientSubscription(1, 2); - var PLog =require('../src/LiveQuery/PLog'); - expect(PLog.error).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); expect(subscription.clientRequestIds.size).toBe(1); expect(subscription.clientRequestIds.get(1)).toEqual([1]); }); - it('can delete all requests for one client', function() { - var subscription = new Subscription('className', { key : 'value' }, 'hash'); + it('can delete all requests for one client', function () { + const subscription = new Subscription('className', { key: 'value' }, 'hash'); subscription.addClientSubscription(1, 1); subscription.addClientSubscription(1, 2); subscription.deleteClientSubscription(1, 1); subscription.deleteClientSubscription(1, 2); - var PLog =require('../src/LiveQuery/PLog'); - expect(PLog.error).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); expect(subscription.clientRequestIds.size).toBe(0); }); - it('can delete requests for multiple clients', function() { - var subscription = new Subscription('className', { key : 'value' }, 'hash'); + it('can delete requests for multiple clients', function () { + const subscription = new Subscription('className', { key: 'value' }, 'hash'); subscription.addClientSubscription(1, 1); subscription.addClientSubscription(1, 2); subscription.addClientSubscription(2, 1); @@ -111,13 +106,8 @@ describe('Subscription', function() { subscription.deleteClientSubscription(2, 1); subscription.deleteClientSubscription(2, 2); - var PLog =require('../src/LiveQuery/PLog'); - expect(PLog.error).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); expect(subscription.clientRequestIds.size).toBe(1); expect(subscription.clientRequestIds.get(1)).toEqual([1]); }); - - afterEach(function(){ - jasmine.restoreLibrary('../src/LiveQuery/PLog', 'error'); - }); }); diff --git a/spec/TwitterAuth.spec.js b/spec/TwitterAuth.spec.js deleted file mode 100644 index 0c0de56ec9..0000000000 --- a/spec/TwitterAuth.spec.js +++ /dev/null @@ -1,50 +0,0 @@ -let twitter = require('../src/authDataManager/twitter'); - -describe('Twitter Auth', () => { - it('should use the proper configuration', () => { - // Multiple options, consumer_key found - expect(twitter.handleMultipleConfigurations({ - consumer_key: 'hello', - }, [{ - consumer_key: 'hello' - }, { - consumer_key: 'world' - }]).consumer_key).toEqual('hello') - - // Multiple options, consumer_key not found - expect(function(){ - twitter.handleMultipleConfigurations({ - consumer_key: 'some', - }, [{ - consumer_key: 'hello' - }, { - consumer_key: 'world' - }]); - }).toThrow(); - - // Multiple options, consumer_key not found - expect(function(){ - twitter.handleMultipleConfigurations({ - auth_token: 'token', - }, [{ - consumer_key: 'hello' - }, { - consumer_key: 'world' - }]); - }).toThrow(); - - // Single configuration and consumer_key set - expect(twitter.handleMultipleConfigurations({ - consumer_key: 'hello', - }, { - consumer_key: 'hello' - }).consumer_key).toEqual('hello'); - - // General case, only 1 config, no consumer_key set - expect(twitter.handleMultipleConfigurations({ - auth_token: 'token', - }, { - consumer_key: 'hello' - }).consumer_key).toEqual('hello'); - }); -}); \ No newline at end of file diff --git a/spec/Uniqueness.spec.js b/spec/Uniqueness.spec.js index 43f130855e..92ee6ea92c 100644 --- a/spec/Uniqueness.spec.js +++ b/spec/Uniqueness.spec.js @@ -1,103 +1,128 @@ 'use strict'; -var request = require('request'); -const Parse = require("parse/node"); -let Config = require('../src/Config'); +const Parse = require('parse/node'); +const Config = require('../lib/Config'); -describe('Uniqueness', function() { +describe('Uniqueness', function () { it('fail when create duplicate value in unique field', done => { - let obj = new Parse.Object('UniqueField'); + const obj = new Parse.Object('UniqueField'); obj.set('unique', 'value'); - obj.save().then(() => { - expect(obj.id).not.toBeUndefined(); - let config = new Config('test'); - return config.database.adapter.ensureUniqueness('UniqueField', { fields: { unique: { __type: 'String' } } }, ['unique']) - }) - .then(() => { - let obj = new Parse.Object('UniqueField'); - obj.set('unique', 'value'); - return obj.save() - }).then(() => { - fail('Saving duplicate field should have failed'); - done(); - }, error => { - expect(error.code).toEqual(Parse.Error.DUPLICATE_VALUE); - done(); - }); + obj + .save() + .then(() => { + expect(obj.id).not.toBeUndefined(); + const config = Config.get('test'); + return config.database.adapter.ensureUniqueness( + 'UniqueField', + { fields: { unique: { __type: 'String' } } }, + ['unique'] + ); + }) + .then(() => { + const obj = new Parse.Object('UniqueField'); + obj.set('unique', 'value'); + return obj.save(); + }) + .then( + () => { + fail('Saving duplicate field should have failed'); + done(); + }, + error => { + expect(error.code).toEqual(Parse.Error.DUPLICATE_VALUE); + done(); + } + ); }); - it_exclude_dbs(['postgres'])('unique indexing works on pointer fields', done => { - let obj = new Parse.Object('UniquePointer'); - obj.save({ string: 'who cares' }) - .then(() => obj.save({ ptr: obj })) - .then(() => { - let config = new Config('test'); - return config.database.adapter.ensureUniqueness('UniquePointer', { fields: { - string: { __type: 'String' }, - ptr: { __type: 'Pointer', targetClass: 'UniquePointer' } - } }, ['ptr']); - }) - .then(() => { - let newObj = new Parse.Object('UniquePointer') - newObj.set('ptr', obj) - return newObj.save() - }) - .then(() => { - fail('save should have failed due to duplicate value'); - done(); - }) - .catch(error => { - expect(error.code).toEqual(Parse.Error.DUPLICATE_VALUE); - done(); - }); + it('unique indexing works on pointer fields', done => { + const obj = new Parse.Object('UniquePointer'); + obj + .save({ string: 'who cares' }) + .then(() => obj.save({ ptr: obj })) + .then(() => { + const config = Config.get('test'); + return config.database.adapter.ensureUniqueness( + 'UniquePointer', + { + fields: { + string: { __type: 'String' }, + ptr: { __type: 'Pointer', targetClass: 'UniquePointer' }, + }, + }, + ['ptr'] + ); + }) + .then(() => { + const newObj = new Parse.Object('UniquePointer'); + newObj.set('ptr', obj); + return newObj.save(); + }) + .then(() => { + fail('save should have failed due to duplicate value'); + done(); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.DUPLICATE_VALUE); + done(); + }); }); - it_exclude_dbs(['postgres'])('fails when attempting to ensure uniqueness of fields that are not currently unique', done => { - let o1 = new Parse.Object('UniqueFail'); + it_id('802650a9-a6db-447e-88d0-8aae99100088')(it)('fails when attempting to ensure uniqueness of fields that are not currently unique', done => { + const o1 = new Parse.Object('UniqueFail'); o1.set('key', 'val'); - let o2 = new Parse.Object('UniqueFail'); + const o2 = new Parse.Object('UniqueFail'); o2.set('key', 'val'); Parse.Object.saveAll([o1, o2]) - .then(() => { - let config = new Config('test'); - return config.database.adapter.ensureUniqueness('UniqueFail', { fields: { key: { __type: 'String' } } }, ['key']); - }) - .catch(error => { - expect(error.code).toEqual(Parse.Error.DUPLICATE_VALUE); - done(); - }); + .then(() => { + const config = Config.get('test'); + return config.database.adapter.ensureUniqueness( + 'UniqueFail', + { fields: { key: { __type: 'String' } } }, + ['key'] + ); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.DUPLICATE_VALUE); + done(); + }); }); it_exclude_dbs(['postgres'])('can do compound uniqueness', done => { - let config = new Config('test'); - config.database.adapter.ensureUniqueness('CompoundUnique', { fields: { k1: { __type: 'String' }, k2: { __type: 'String' } } }, ['k1', 'k2']) - .then(() => { - let o1 = new Parse.Object('CompoundUnique'); - o1.set('k1', 'v1'); - o1.set('k2', 'v2'); - return o1.save(); - }) - .then(() => { - let o2 = new Parse.Object('CompoundUnique'); - o2.set('k1', 'v1'); - o2.set('k2', 'not a dupe'); - return o2.save(); - }) - .then(() => { - let o3 = new Parse.Object('CompoundUnique'); - o3.set('k1', 'not a dupe'); - o3.set('k2', 'v2'); - return o3.save(); - }) - .then(() => { - let o4 = new Parse.Object('CompoundUnique'); - o4.set('k1', 'v1'); - o4.set('k2', 'v2'); - return o4.save(); - }) - .catch(error => { - expect(error.code).toEqual(Parse.Error.DUPLICATE_VALUE); - done(); - }); + const config = Config.get('test'); + config.database.adapter + .ensureUniqueness( + 'CompoundUnique', + { fields: { k1: { __type: 'String' }, k2: { __type: 'String' } } }, + ['k1', 'k2'] + ) + .then(() => { + const o1 = new Parse.Object('CompoundUnique'); + o1.set('k1', 'v1'); + o1.set('k2', 'v2'); + return o1.save(); + }) + .then(() => { + const o2 = new Parse.Object('CompoundUnique'); + o2.set('k1', 'v1'); + o2.set('k2', 'not a dupe'); + return o2.save(); + }) + .then(() => { + const o3 = new Parse.Object('CompoundUnique'); + o3.set('k1', 'not a dupe'); + o3.set('k2', 'v2'); + return o3.save(); + }) + .then(() => { + const o4 = new Parse.Object('CompoundUnique'); + o4.set('k1', 'v1'); + o4.set('k2', 'v2'); + return o4.save(); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.DUPLICATE_VALUE); + done(); + }); }); }); diff --git a/spec/UserController.spec.js b/spec/UserController.spec.js new file mode 100644 index 0000000000..1993dde079 --- /dev/null +++ b/spec/UserController.spec.js @@ -0,0 +1,84 @@ +const emailAdapter = require('./support/MockEmailAdapter'); +const Config = require('../lib/Config'); +const Auth = require('../lib/Auth'); +const { resolvingPromise } = require('../lib/TestUtils'); + +describe('UserController', () => { + describe('sendVerificationEmail', () => { + describe('parseFrameURL not provided', () => { + it_id('61338330-eca7-4c33-8816-7ff05966f43b')(it)('uses publicServerURL', async () => { + await reconfigureServer({ + publicServerURL: 'http://www.example.com', + customPages: { + parseFrameURL: undefined, + }, + verifyUserEmails: true, + emailAdapter, + appName: 'test', + }); + + let emailOptions; + const sendPromise = resolvingPromise(); + emailAdapter.sendVerificationEmail = options => { + emailOptions = options; + sendPromise.resolve(); + }; + + const username = 'verificationUser'; + const user = new Parse.User(); + user.setUsername(username); + user.setPassword('pass'); + user.setEmail('verification@example.com'); + await user.signUp(); + await sendPromise; + + const config = Config.get('test'); + const rawUser = await config.database.find('_User', { username }, {}, Auth.maintenance(config)); + const rawUsername = rawUser[0].username; + const rawToken = rawUser[0]._email_verify_token; + expect(rawToken).toBeDefined(); + expect(rawUsername).toBe(username); + + expect(emailOptions.link).toEqual(`http://www.example.com/apps/test/verify_email?token=${rawToken}`); + }); + }); + + describe('parseFrameURL provided', () => { + it_id('673c2bb1-049e-4dda-b6be-88c866260036')(it)('uses parseFrameURL and includes the destination in the link parameter', async () => { + await reconfigureServer({ + publicServerURL: 'http://www.example.com', + customPages: { + parseFrameURL: 'http://someother.example.com/handle-parse-iframe', + }, + verifyUserEmails: true, + emailAdapter, + appName: 'test', + }); + + let emailOptions; + const sendPromise = resolvingPromise(); + emailAdapter.sendVerificationEmail = options => { + emailOptions = options; + sendPromise.resolve(); + }; + + const username = 'verificationUser'; + const user = new Parse.User(); + user.setUsername(username); + user.setPassword('pass'); + user.setEmail('verification@example.com'); + await user.signUp(); + await sendPromise; + + const config = Config.get('test'); + const rawUser = await config.database.find('_User', { username }, {}, Auth.maintenance(config)); + const rawUsername = rawUser[0].username; + const rawToken = rawUser[0]._email_verify_token; + expect(rawToken).toBeDefined(); + expect(rawUsername).toBe(username); + + expect(emailOptions.link).toEqual(`http://someother.example.com/handle-parse-iframe?link=%2Fapps%2Ftest%2Fverify_email&token=${rawToken}`); + }); + }); + }); +}); diff --git a/spec/UserPII.spec.js b/spec/UserPII.spec.js new file mode 100644 index 0000000000..a94e3ca469 --- /dev/null +++ b/spec/UserPII.spec.js @@ -0,0 +1,1174 @@ +'use strict'; + +const Parse = require('parse/node'); +const request = require('../lib/request'); + +// const Config = require('../lib/Config'); + +const EMAIL = 'foo@bar.com'; +const ZIP = '10001'; +const SSN = '999-99-9999'; + +describe('Personally Identifiable Information', () => { + let user; + + beforeEach(async done => { + await reconfigureServer(); + user = await Parse.User.signUp('tester', 'abc'); + user = await Parse.User.logIn(user.get('username'), 'abc'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + await user.set('email', EMAIL).set('zip', ZIP).set('ssn', SSN).setACL(acl).save(); + done(); + }); + + it('should be able to get own PII via API with object', done => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + return userObj + .fetch() + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + }) + .then(done) + .catch(done.fail); + }); + + it('should not be able to get PII via API with object', done => { + return Parse.User.logOut().then(() => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch() + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + done(); + }) + .catch(e => { + done.fail(JSON.stringify(e)); + }) + .then(done) + .catch(done.fail); + }); + }); + + it('should be able to get PII via API with object using master key', done => { + return Parse.User.logOut().then(() => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch({ useMasterKey: true }) + .then(fetchedUser => expect(fetchedUser.get('email')).toBe(EMAIL)) + .then(done) + .catch(done.fail); + }); + }); + + it('should be able to get own PII via API with Find', done => { + return new Parse.Query(Parse.User).first().then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }); + }); + + it('should not get PII via API with Find', done => { + return Parse.User.logOut().then(() => + new Parse.Query(Parse.User).first().then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }) + ); + }); + + it('should get PII via API with Find using master key', done => { + return Parse.User.logOut().then(() => + new Parse.Query(Parse.User).first({ useMasterKey: true }).then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }) + ); + }); + + it('should be able to get own PII via API with Get', done => { + return new Parse.Query(Parse.User).get(user.id).then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }); + }); + + it('should not get PII via API with Get', done => { + return Parse.User.logOut().then(() => + new Parse.Query(Parse.User).get(user.id).then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }) + ); + }); + + it('should get PII via API with Get using master key', done => { + return Parse.User.logOut().then(() => + new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }).then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }) + ); + }); + + it('should not get PII via REST', done => { + return request({ + url: 'http://localhost:8378/1/classes/_User', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }, + }) + .then(response => { + const result = response.data; + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(ZIP); + return expect(fetchedUser.email).toBe(undefined); + }) + .then(done) + .catch(done.fail); + }); + + it('should get PII via REST with self credentials', done => { + return request({ + url: 'http://localhost:8378/1/classes/_User', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }) + .then(response => { + const result = response.data; + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(ZIP); + return expect(fetchedUser.email).toBe(EMAIL); + }) + .then(done) + .catch(done.fail); + }); + + it('should get PII via REST using master key', done => { + request({ + url: 'http://localhost:8378/1/classes/_User', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }) + .then(response => { + const result = response.data; + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(ZIP); + return expect(fetchedUser.email).toBe(EMAIL); + }) + .then(done) + .catch(done.fail); + }); + + it('should not get PII via REST by ID', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }, + }) + .then( + response => { + const fetchedUser = response.data; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(undefined); + }, + e => done.fail(e) + ) + .then(() => done()); + }); + + it('should get PII via REST by ID with self credentials', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }) + .then(response => { + const result = response.data; + const fetchedUser = result; + expect(fetchedUser.zip).toBe(ZIP); + return expect(fetchedUser.email).toBe(EMAIL); + }) + .then(done) + .catch(done.fail); + }); + + it('should get PII via REST by ID with master key', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'X-Parse-Master-Key': 'test', + }, + }) + .then(response => { + const result = response.data; + const fetchedUser = result; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + }) + .then(done) + .catch(done.fail); + }); + + describe('with deprecated configured sensitive fields', () => { + beforeEach(async () => { + await reconfigureServer({ userSensitiveFields: ['ssn', 'zip'] }); + }); + + it('should be able to get own PII via API with object', done => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + return userObj + .fetch() + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }) + .catch(done.fail); + }); + + it('should not be able to get PII via API with object', done => { + Parse.User.logOut().then(() => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch() + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + }) + .then(done) + .catch(done.fail); + }); + }); + + it('should be able to get PII via API with object using master key', done => { + Parse.User.logOut().then(() => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch({ useMasterKey: true }) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + }, done.fail) + .then(done) + .catch(done.fail); + }); + }); + + it('should be able to get own PII via API with Find', done => { + new Parse.Query(Parse.User).first().then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }); + }); + + it('should not get PII via API with Find', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User).first().then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + ); + }); + + it('should get PII via API with Find using master key', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User).first({ useMasterKey: true }).then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }) + ); + }); + + it('should be able to get own PII via API with Get', done => { + new Parse.Query(Parse.User).get(user.id).then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }); + }); + + it('should not get PII via API with Get', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User).get(user.id).then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + ); + }); + + it('should get PII via API with Get using master key', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }).then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }) + ); + }); + + it('should not get PII via REST', done => { + request({ + url: 'http://localhost:8378/1/classes/_User', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }, + }) + .then(response => { + const result = response.data; + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(undefined); + expect(fetchedUser.ssn).toBe(undefined); + expect(fetchedUser.email).toBe(undefined); + }, done.fail) + .then(done) + .catch(done.fail); + }); + + it('should get PII via REST with self credentials', done => { + request({ + url: 'http://localhost:8378/1/classes/_User', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }) + .then(response => { + const result = response.data; + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + return expect(fetchedUser.ssn).toBe(SSN); + }) + .then(done) + .catch(done.fail); + }); + + it('should get PII via REST using master key', done => { + request({ + url: 'http://localhost:8378/1/classes/_User', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }) + .then( + response => { + const result = response.data; + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + expect(fetchedUser.ssn).toBe(SSN); + }, + e => done.fail(e.data) + ) + .then(done) + .catch(done.fail); + }); + + it('should not get PII via REST by ID', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }, + }) + .then( + response => { + const fetchedUser = response.data; + expect(fetchedUser.zip).toBe(undefined); + expect(fetchedUser.email).toBe(undefined); + }, + e => done.fail(e.data) + ) + .then(done) + .catch(done.fail); + }); + + it('should get PII via REST by ID with self credentials', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }) + .then( + response => { + const fetchedUser = response.data; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + }, + () => {} + ) + .then(done) + .catch(done.fail); + }); + + it('should get PII via REST by ID with master key', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'X-Parse-Master-Key': 'test', + }, + }) + .then( + response => { + const result = response.data; + const fetchedUser = result; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + }, + e => done.fail(e.data) + ) + .then(done) + .catch(done.fail); + }); + + // Explicit ACL should be able to read sensitive information + describe('with privileged user no CLP', () => { + let adminUser; + + beforeEach(async done => { + const adminRole = await new Parse.Role('Administrator', new Parse.ACL()).save(null, { + useMasterKey: true, + }); + + const managementRole = new Parse.Role('managementOf_user' + user.id, new Parse.ACL(user)); + managementRole.getRoles().add(adminRole); + await managementRole.save(null, { useMasterKey: true }); + + const userACL = new Parse.ACL(); + userACL.setReadAccess(managementRole, true); + await user.setACL(userACL).save(null, { useMasterKey: true }); + + adminUser = await Parse.User.signUp('administrator', 'secure'); + adminUser = await Parse.User.logIn(adminUser.get('username'), 'secure'); + await adminRole.getUsers().add(adminUser).save(null, { useMasterKey: true }); + + done(); + }); + + it('privileged user should not be able to get user PII via API with object', done => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch() + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + }) + .then(done) + .catch(done.fail); + }); + + it('privileged user should not be able to get user PII via API with Find', done => { + new Parse.Query(Parse.User) + .equalTo('objectId', user.id) + .find() + .then(fetchedUser => { + fetchedUser = fetchedUser[0]; + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail); + }); + + it('privileged user should not be able to get user PII via API with Get', done => { + new Parse.Query(Parse.User) + .get(user.id) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail); + }); + + it('privileged user should not get user PII via REST by ID', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'X-Parse-Session-Token': adminUser.getSessionToken(), + }, + }) + .then(response => { + const result = response.data; + const fetchedUser = result; + expect(fetchedUser.zip).toBe(undefined); + expect(fetchedUser.email).toBe(undefined); + }) + .then(() => done()) + .catch(done.fail); + }); + }); + + // Public access ACL should always hide sensitive information + describe('with public read ACL', () => { + beforeEach(async done => { + const userACL = new Parse.ACL(); + userACL.setPublicReadAccess(true); + await user.setACL(userACL).save(null, { useMasterKey: true }); + done(); + }); + + it('should not be able to get user PII via API with object', done => { + Parse.User.logOut().then(() => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch() + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + }) + .then(done) + .catch(done.fail); + }); + }); + + it('should not be able to get user PII via API with Find', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User) + .equalTo('objectId', user.id) + .find() + .then(fetchedUser => { + fetchedUser = fetchedUser[0]; + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail) + ); + }); + + it('should not be able to get user PII via API with Get', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User) + .get(user.id) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail) + ); + }); + + it('should not get user PII via REST by ID', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }, + }) + .then(response => { + const result = response.data; + const fetchedUser = result; + expect(fetchedUser.zip).toBe(undefined); + expect(fetchedUser.email).toBe(undefined); + }) + .then(() => done()) + .catch(done.fail); + }); + + // Even with an authenticated user, Public read ACL should never expose sensitive data. + describe('with another authenticated user', () => { + let anotherUser; + + beforeEach(async done => { + return Parse.User.signUp('another', 'abc') + .then(loggedInUser => (anotherUser = loggedInUser)) + .then(() => Parse.User.logIn(anotherUser.get('username'), 'abc')) + .then(() => done()); + }); + + it('should not be able to get user PII via API with object', done => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch() + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + }) + .then(done) + .catch(done.fail); + }); + + it('should not be able to get user PII via API with Find', done => { + new Parse.Query(Parse.User) + .equalTo('objectId', user.id) + .find() + .then(fetchedUser => { + fetchedUser = fetchedUser[0]; + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail); + }); + + it('should not be able to get user PII via API with Get', done => { + new Parse.Query(Parse.User) + .get(user.id) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail); + }); + }); + }); + }); + + describe('with configured sensitive fields via CLP', () => { + beforeEach(async () => { + await reconfigureServer({ + protectedFields: { + _User: { '*': ['ssn', 'zip'], 'role:Administrator': [] }, + }, + }); + }); + + it('should be able to get own PII via API with object', done => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj.fetch().then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }, done.fail); + }); + + it('should not be able to get PII via API with object', done => { + Parse.User.logOut().then(() => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch() + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + }) + .then(done) + .catch(done.fail); + }); + }); + + it('should be able to get PII via API with object using master key', done => { + Parse.User.logOut().then(() => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch({ useMasterKey: true }) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + }, done.fail) + .then(done) + .catch(done.fail); + }); + }); + + it('should be able to get own PII via API with Find', done => { + new Parse.Query(Parse.User).first().then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }); + }); + + it('should not get PII via API with Find', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User).first().then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + ); + }); + + it('should get PII via API with Find using master key', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User).first({ useMasterKey: true }).then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }) + ); + }); + + it('should be able to get own PII via API with Get', done => { + new Parse.Query(Parse.User).get(user.id).then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }); + }); + + it('should not get PII via API with Get', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User).get(user.id).then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + ); + }); + + it('should get PII via API with Get using master key', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }).then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }) + ); + }); + + it('should not get PII via REST', done => { + request({ + url: 'http://localhost:8378/1/classes/_User', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }, + }) + .then(response => { + const result = response.data; + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(undefined); + expect(fetchedUser.ssn).toBe(undefined); + expect(fetchedUser.email).toBe(undefined); + }, done.fail) + .then(done) + .catch(done.fail); + }); + + it('should get PII via REST with self credentials', done => { + request({ + url: 'http://localhost:8378/1/classes/_User', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }) + .then( + response => { + const result = response.data; + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + expect(fetchedUser.ssn).toBe(SSN); + }, + () => {} + ) + .then(done) + .catch(done.fail); + }); + + it('should get PII via REST using master key', done => { + request({ + url: 'http://localhost:8378/1/classes/_User', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }) + .then( + response => { + const result = response.data; + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + expect(fetchedUser.ssn).toBe(SSN); + }, + e => done.fail(e.data) + ) + .then(done) + .catch(done.fail); + }); + + it('should not get PII via REST by ID', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }, + }) + .then( + response => { + const fetchedUser = response.data; + expect(fetchedUser.zip).toBe(undefined); + expect(fetchedUser.email).toBe(undefined); + }, + e => done.fail(e.data) + ) + .then(done) + .catch(done.fail); + }); + + it('should get PII via REST by ID with self credentials', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }) + .then( + response => { + const fetchedUser = response.data; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + }, + () => {} + ) + .then(done) + .catch(done.fail); + }); + + it('should get PII via REST by ID with master key', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'X-Parse-Master-Key': 'test', + }, + }) + .then( + response => { + const result = response.data; + const fetchedUser = result; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + }, + e => done.fail(e.data) + ) + .then(done) + .catch(done.fail); + }); + + // Explicit ACL should be able to read sensitive information + describe('with privileged user CLP', () => { + let adminUser; + + beforeEach(async done => { + const adminRole = await new Parse.Role('Administrator', new Parse.ACL()).save(null, { + useMasterKey: true, + }); + + const managementRole = new Parse.Role('managementOf_user' + user.id, new Parse.ACL(user)); + managementRole.getRoles().add(adminRole); + await managementRole.save(null, { useMasterKey: true }); + + const userACL = new Parse.ACL(); + userACL.setReadAccess(managementRole, true); + await user.setACL(userACL).save(null, { useMasterKey: true }); + + adminUser = await Parse.User.signUp('administrator', 'secure'); + adminUser = await Parse.User.logIn(adminUser.get('username'), 'secure'); + await adminRole.getUsers().add(adminUser).save(null, { useMasterKey: true }); + + done(); + }); + + it('privileged user should be able to get user PII via API with object', done => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch() + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + }) + .then(done) + .catch(done.fail); + }); + + it('privileged user should be able to get user PII via API with Find', done => { + new Parse.Query(Parse.User) + .equalTo('objectId', user.id) + .find() + .then(fetchedUser => { + fetchedUser = fetchedUser[0]; + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }) + .catch(done.fail); + }); + + it('privileged user should be able to get user PII via API with Get', done => { + new Parse.Query(Parse.User) + .get(user.id) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }) + .catch(done.fail); + }); + + it('privileged user should get user PII via REST by ID', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'X-Parse-Session-Token': adminUser.getSessionToken(), + }, + }) + .then(response => { + const result = response.data; + const fetchedUser = result; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + }) + .then(done) + .catch(done.fail); + }); + }); + + // Public access ACL should always hide sensitive information + describe('with public read ACL', () => { + beforeEach(async done => { + const userACL = new Parse.ACL(); + userACL.setPublicReadAccess(true); + await user.setACL(userACL).save(null, { useMasterKey: true }); + done(); + }); + + it('should not be able to get user PII via API with object', done => { + Parse.User.logOut().then(() => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch() + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + }) + .then(done) + .catch(done.fail); + }); + }); + + it('should not be able to get user PII via API with Find', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User) + .equalTo('objectId', user.id) + .find() + .then(fetchedUser => { + fetchedUser = fetchedUser[0]; + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail) + ); + }); + + it('should not be able to get user PII via API with Get', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User) + .get(user.id) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail) + ); + }); + + it('should not get user PII via REST by ID', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }, + }) + .then(response => { + const result = response.data; + const fetchedUser = result; + expect(fetchedUser.zip).toBe(undefined); + expect(fetchedUser.email).toBe(undefined); + }) + .then(() => done()) + .catch(done.fail); + }); + + // Even with an authenticated user, Public read ACL should never expose sensitive data. + describe('with another authenticated user', () => { + let anotherUser; + const ANOTHER_EMAIL = 'another@bar.com'; + + beforeEach(async done => { + return Parse.User.signUp('another', 'abc') + .then(loggedInUser => (anotherUser = loggedInUser)) + .then(() => Parse.User.logIn(anotherUser.get('username'), 'abc')) + .then(() => + anotherUser.set('email', ANOTHER_EMAIL).set('zip', ZIP).set('ssn', SSN).save() + ) + .then(() => done()); + }); + + it('should not be able to get user PII via API with object', done => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch() + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + }) + .then(done) + .catch(done.fail); + }); + + it('should not be able to get user PII via API with Find', done => { + new Parse.Query(Parse.User) + .equalTo('objectId', user.id) + .find() + .then(fetchedUser => { + fetchedUser = fetchedUser[0]; + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail); + }); + + it('should not be able to get user PII via API with Find without constraints', done => { + new Parse.Query(Parse.User) + .find() + .then(fetchedUsers => { + const notCurrentUser = fetchedUsers.find(u => u.id !== anotherUser.id); + expect(notCurrentUser.get('email')).toBe(undefined); + expect(notCurrentUser.get('zip')).toBe(undefined); + expect(notCurrentUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail); + }); + + it('should be able to get own PII via API with Find without constraints', done => { + new Parse.Query(Parse.User) + .find() + .then(fetchedUsers => { + const currentUser = fetchedUsers.find(u => u.id === anotherUser.id); + expect(currentUser.get('email')).toBe(ANOTHER_EMAIL); + expect(currentUser.get('zip')).toBe(ZIP); + expect(currentUser.get('ssn')).toBe(SSN); + done(); + }) + .catch(done.fail); + }); + + it('should not be able to get user PII via API with Get', done => { + new Parse.Query(Parse.User) + .get(user.id) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail); + }); + }); + }); + }); +}); diff --git a/spec/Utils.spec.js b/spec/Utils.spec.js new file mode 100644 index 0000000000..f6d24b55f9 --- /dev/null +++ b/spec/Utils.spec.js @@ -0,0 +1,450 @@ +const Utils = require('../lib/Utils'); +const { createSanitizedError, createSanitizedHttpError } = require("../lib/Error") +const vm = require('vm'); + +describe('Utils', () => { + describe('encodeForUrl', () => { + it('should properly escape email with all special ASCII characters for use in URLs', async () => { + const values = [ + { input: `!\"'),.:;<>?]^}`, output: '%21%22%27%29%2C%2E%3A%3B%3C%3E%3F%5D%5E%7D' }, + ] + for (const value of values) { + expect(Utils.encodeForUrl(value.input)).toBe(value.output); + } + }); + }); + + describe('addNestedKeysToRoot', () => { + it('should move the nested keys to root of object', async () => { + const obj = { + a: 1, + b: { + c: 2, + d: 3 + }, + e: 4 + }; + Utils.addNestedKeysToRoot(obj, 'b'); + expect(obj).toEqual({ + a: 1, + c: 2, + d: 3, + e: 4 + }); + }); + + it('should not modify the object if the key does not exist', async () => { + const obj = { + a: 1, + e: 4 + }; + Utils.addNestedKeysToRoot(obj, 'b'); + expect(obj).toEqual({ + a: 1, + e: 4 + }); + }); + + it('should not modify the object if the key is not an object', () => { + const obj = { + a: 1, + b: 2, + e: 4 + }; + Utils.addNestedKeysToRoot(obj, 'b'); + expect(obj).toEqual({ + a: 1, + b: 2, + e: 4 + }); + }); + }); + + describe('getCircularReplacer', () => { + it('should handle Map instances', () => { + const obj = { + name: 'test', + mapData: new Map([ + ['key1', 'value1'], + ['key2', 'value2'] + ]) + }; + const result = JSON.stringify(obj, Utils.getCircularReplacer()); + expect(result).toBe('{"name":"test","mapData":{"key1":"value1","key2":"value2"}}'); + }); + + it('should handle Set instances', () => { + const obj = { + name: 'test', + setData: new Set([1, 2, 3]) + }; + const result = JSON.stringify(obj, Utils.getCircularReplacer()); + expect(result).toBe('{"name":"test","setData":[1,2,3]}'); + }); + + it('should handle circular references', () => { + const obj = { name: 'test', value: 123 }; + obj.self = obj; + const result = JSON.stringify(obj, Utils.getCircularReplacer()); + expect(result).toBe('{"name":"test","value":123,"self":"[Circular]"}'); + }); + + it('should handle nested circular references', () => { + const obj = { + name: 'parent', + child: { + name: 'child' + } + }; + obj.child.parent = obj; + const result = JSON.stringify(obj, Utils.getCircularReplacer()); + expect(result).toBe('{"name":"parent","child":{"name":"child","parent":"[Circular]"}}'); + }); + + it('should handle mixed Map, Set, and circular references', () => { + const obj = { + mapData: new Map([['key', 'value']]), + setData: new Set([1, 2]), + regular: 'data' + }; + obj.circular = obj; + const result = JSON.stringify(obj, Utils.getCircularReplacer()); + expect(result).toBe('{"mapData":{"key":"value"},"setData":[1,2],"regular":"data","circular":"[Circular]"}'); + }); + + it('should handle normal objects without modification', () => { + const obj = { + name: 'test', + number: 42, + nested: { + key: 'value' + } + }; + const result = JSON.stringify(obj, Utils.getCircularReplacer()); + expect(result).toBe('{"name":"test","number":42,"nested":{"key":"value"}}'); + }); + }); + + describe('getNestedProperty', () => { + it('should get top-level property', () => { + const obj = { foo: 'bar' }; + expect(Utils.getNestedProperty(obj, 'foo')).toBe('bar'); + }); + + it('should get nested property with dot notation', () => { + const obj = { database: { options: { enabled: true } } }; + expect(Utils.getNestedProperty(obj, 'database.options.enabled')).toBe(true); + }); + + it('should return undefined for non-existent property', () => { + const obj = { foo: 'bar' }; + expect(Utils.getNestedProperty(obj, 'baz')).toBeUndefined(); + }); + + it('should return undefined for non-existent nested property', () => { + const obj = { database: { options: {} } }; + expect(Utils.getNestedProperty(obj, 'database.options.enabled')).toBeUndefined(); + }); + + it('should return undefined when path traverses non-object', () => { + const obj = { database: 'string' }; + expect(Utils.getNestedProperty(obj, 'database.options.enabled')).toBeUndefined(); + }); + + it('should return undefined for null object', () => { + expect(Utils.getNestedProperty(null, 'foo')).toBeUndefined(); + }); + + it('should return undefined for empty path', () => { + const obj = { foo: 'bar' }; + expect(Utils.getNestedProperty(obj, '')).toBeUndefined(); + }); + + it('should handle value of 0', () => { + const obj = { database: { timeout: 0 } }; + expect(Utils.getNestedProperty(obj, 'database.timeout')).toBe(0); + }); + + it('should handle value of false', () => { + const obj = { database: { enabled: false } }; + expect(Utils.getNestedProperty(obj, 'database.enabled')).toBe(false); + }); + + it('should handle value of empty string', () => { + const obj = { database: { name: '' } }; + expect(Utils.getNestedProperty(obj, 'database.name')).toBe(''); + }); + }); + + describe('parseSizeToBytes', () => { + it('parses megabyte string', () => { + expect(Utils.parseSizeToBytes('20mb')).toBe(20 * 1024 * 1024); + }); + + it('parses Mb string (case-insensitive)', () => { + expect(Utils.parseSizeToBytes('20Mb')).toBe(20 * 1024 * 1024); + }); + + it('parses kilobyte string', () => { + expect(Utils.parseSizeToBytes('512kb')).toBe(512 * 1024); + }); + + it('parses gigabyte string', () => { + expect(Utils.parseSizeToBytes('1gb')).toBe(1 * 1024 * 1024 * 1024); + }); + + it('parses bytes suffix', () => { + expect(Utils.parseSizeToBytes('100b')).toBe(100); + }); + + it('parses plain number as bytes', () => { + expect(Utils.parseSizeToBytes(1048576)).toBe(1048576); + }); + + it('parses numeric string as bytes', () => { + expect(Utils.parseSizeToBytes('1048576')).toBe(1048576); + }); + + it('parses decimal value and floors result', () => { + expect(Utils.parseSizeToBytes('1.5mb')).toBe(Math.floor(1.5 * 1024 * 1024)); + }); + + it('trims whitespace around value', () => { + expect(Utils.parseSizeToBytes(' 20mb ')).toBe(20 * 1024 * 1024); + }); + + it('allows whitespace between number and unit', () => { + expect(Utils.parseSizeToBytes('20 mb')).toBe(20 * 1024 * 1024); + }); + + it('parses zero', () => { + expect(Utils.parseSizeToBytes('0')).toBe(0); + expect(Utils.parseSizeToBytes(0)).toBe(0); + }); + + it('throws on invalid string', () => { + expect(() => Utils.parseSizeToBytes('abc')).toThrow(); + }); + + it('throws on negative value', () => { + expect(() => Utils.parseSizeToBytes('-5mb')).toThrow(); + }); + + it('throws on empty string', () => { + expect(() => Utils.parseSizeToBytes('')).toThrow(); + }); + + it('throws on unsupported unit', () => { + expect(() => Utils.parseSizeToBytes('10tb')).toThrow(); + }); + + it('throws on NaN', () => { + expect(() => Utils.parseSizeToBytes(NaN)).toThrow(); + }); + + it('throws on Infinity', () => { + expect(() => Utils.parseSizeToBytes(Infinity)).toThrow(); + }); + + it('throws on negative number', () => { + expect(() => Utils.parseSizeToBytes(-1)).toThrow(); + }); + }); + + describe('createSanitizedError', () => { + it('should return "Permission denied" when enableSanitizedErrorResponse is true', () => { + const config = { enableSanitizedErrorResponse: true }; + const error = createSanitizedError(Parse.Error.OPERATION_FORBIDDEN, 'Detailed error message', config); + expect(error.message).toBe('Permission denied'); + }); + + it('should not crash with config undefined', () => { + const error = createSanitizedError(Parse.Error.OPERATION_FORBIDDEN, 'Detailed error message', undefined); + expect(error.message).toBe('Permission denied'); + }); + + it('should return the detailed message when enableSanitizedErrorResponse is false', () => { + const config = { enableSanitizedErrorResponse: false }; + const error = createSanitizedError(Parse.Error.OPERATION_FORBIDDEN, 'Detailed error message', config); + expect(error.message).toBe('Detailed error message'); + }); + }); + + describe('createSanitizedHttpError', () => { + it('should return "Permission denied" when enableSanitizedErrorResponse is true', () => { + const config = { enableSanitizedErrorResponse: true }; + const error = createSanitizedHttpError(403, 'Detailed error message', config); + expect(error.message).toBe('Permission denied'); + }); + + it('should not crash with config undefined', () => { + const error = createSanitizedHttpError(403, 'Detailed error message', undefined); + expect(error.message).toBe('Permission denied'); + }); + + it('should return the detailed message when enableSanitizedErrorResponse is false', () => { + const config = { enableSanitizedErrorResponse: false }; + const error = createSanitizedHttpError(403, 'Detailed error message', config); + expect(error.message).toBe('Detailed error message'); + }); + }); + + describe('isDate', () => { + it('should return true for a Date', () => { + expect(Utils.isDate(new Date())).toBe(true); + }); + it('should return true for a cross-realm Date', () => { + const crossRealmDate = vm.runInNewContext('new Date()'); + // eslint-disable-next-line no-restricted-syntax -- intentional: proving instanceof fails cross-realm + expect(crossRealmDate instanceof Date).toBe(false); + expect(Utils.isDate(crossRealmDate)).toBe(true); + }); + it('should return false for non-Date values', () => { + expect(Utils.isDate(null)).toBe(false); + expect(Utils.isDate(undefined)).toBe(false); + expect(Utils.isDate('2021-01-01')).toBe(false); + expect(Utils.isDate(123)).toBe(false); + expect(Utils.isDate({})).toBe(false); + }); + }); + + describe('isRegExp', () => { + it('should return true for a RegExp', () => { + expect(Utils.isRegExp(/test/)).toBe(true); + expect(Utils.isRegExp(new RegExp('test'))).toBe(true); + }); + it('should return true for a cross-realm RegExp', () => { + const crossRealmRegExp = vm.runInNewContext('/test/'); + // eslint-disable-next-line no-restricted-syntax -- intentional: proving instanceof fails cross-realm + expect(crossRealmRegExp instanceof RegExp).toBe(false); + expect(Utils.isRegExp(crossRealmRegExp)).toBe(true); + }); + it('should return false for non-RegExp values', () => { + expect(Utils.isRegExp(null)).toBe(false); + expect(Utils.isRegExp(undefined)).toBe(false); + expect(Utils.isRegExp('/test/')).toBe(false); + expect(Utils.isRegExp({})).toBe(false); + }); + }); + + describe('isMap', () => { + it('should return true for a Map', () => { + expect(Utils.isMap(new Map())).toBe(true); + }); + it('should return true for a cross-realm Map', () => { + const crossRealmMap = vm.runInNewContext('new Map()'); + // eslint-disable-next-line no-restricted-syntax -- intentional: proving instanceof fails cross-realm + expect(crossRealmMap instanceof Map).toBe(false); + expect(Utils.isMap(crossRealmMap)).toBe(true); + }); + it('should return false for non-Map values', () => { + expect(Utils.isMap(null)).toBe(false); + expect(Utils.isMap(undefined)).toBe(false); + expect(Utils.isMap({})).toBe(false); + expect(Utils.isMap(new Set())).toBe(false); + }); + }); + + describe('isSet', () => { + it('should return true for a Set', () => { + expect(Utils.isSet(new Set())).toBe(true); + }); + it('should return true for a cross-realm Set', () => { + const crossRealmSet = vm.runInNewContext('new Set()'); + // eslint-disable-next-line no-restricted-syntax -- intentional: proving instanceof fails cross-realm + expect(crossRealmSet instanceof Set).toBe(false); + expect(Utils.isSet(crossRealmSet)).toBe(true); + }); + it('should return false for non-Set values', () => { + expect(Utils.isSet(null)).toBe(false); + expect(Utils.isSet(undefined)).toBe(false); + expect(Utils.isSet({})).toBe(false); + expect(Utils.isSet(new Map())).toBe(false); + }); + }); + + describe('isNativeError', () => { + it('should return true for an Error', () => { + expect(Utils.isNativeError(new Error('test'))).toBe(true); + }); + it('should return true for Error subclasses', () => { + expect(Utils.isNativeError(new TypeError('test'))).toBe(true); + expect(Utils.isNativeError(new RangeError('test'))).toBe(true); + }); + it('should return true for a cross-realm Error', () => { + const crossRealmError = vm.runInNewContext('new Error("test")'); + // eslint-disable-next-line no-restricted-syntax -- intentional: proving instanceof fails cross-realm + expect(crossRealmError instanceof Error).toBe(false); + expect(Utils.isNativeError(crossRealmError)).toBe(true); + }); + it('should return false for non-Error values', () => { + expect(Utils.isNativeError(null)).toBe(false); + expect(Utils.isNativeError(undefined)).toBe(false); + expect(Utils.isNativeError({ message: 'fake' })).toBe(false); + expect(Utils.isNativeError('error')).toBe(false); + }); + }); + + describe('isPromise', () => { + it('should return true for a Promise', () => { + expect(Utils.isPromise(Promise.resolve())).toBe(true); + }); + it('should return true for a cross-realm Promise', () => { + const crossRealmPromise = vm.runInNewContext('Promise.resolve()'); + // eslint-disable-next-line no-restricted-syntax -- intentional: proving instanceof fails cross-realm + expect(crossRealmPromise instanceof Promise).toBe(false); + expect(Utils.isPromise(crossRealmPromise)).toBe(true); + }); + it('should return true for a thenable', () => { + expect(Utils.isPromise({ then: () => {} })).toBe(true); + }); + it('should return false for non-Promise values', () => { + expect(Utils.isPromise(null)).toBe(false); + expect(Utils.isPromise(undefined)).toBe(false); + expect(Utils.isPromise({})).toBe(false); + expect(Utils.isPromise(42)).toBe(false); + }); + it('should return false for plain objects when Object.prototype.then is polluted', () => { + Object.prototype.then = () => {}; + try { + expect(Utils.isPromise({})).toBe(false); + expect(Utils.isPromise({ a: 1 })).toBe(false); + } finally { + delete Object.prototype.then; + } + }); + it('should return true for real thenables even when Object.prototype.then is polluted', () => { + Object.prototype.then = () => {}; + try { + expect(Utils.isPromise({ then: () => {} })).toBe(true); + expect(Utils.isPromise(Promise.resolve())).toBe(true); + } finally { + delete Object.prototype.then; + } + }); + }); + + describe('isObject', () => { + it('should return true for plain objects', () => { + expect(Utils.isObject({})).toBe(true); + expect(Utils.isObject({ a: 1 })).toBe(true); + }); + it('should return true for a cross-realm object', () => { + const crossRealmObj = vm.runInNewContext('({ a: 1 })'); + // eslint-disable-next-line no-restricted-syntax -- intentional: proving instanceof fails cross-realm + expect(crossRealmObj instanceof Object).toBe(false); + expect(Utils.isObject(crossRealmObj)).toBe(true); + }); + it('should return true for arrays and other objects', () => { + expect(Utils.isObject([])).toBe(true); + expect(Utils.isObject(new Date())).toBe(true); + }); + it('should return false for non-object values', () => { + expect(Utils.isObject(null)).toBe(false); + expect(Utils.isObject(undefined)).toBe(false); + expect(Utils.isObject(42)).toBe(false); + expect(Utils.isObject('string')).toBe(false); + expect(Utils.isObject(true)).toBe(false); + }); + }); +}); diff --git a/spec/ValidationAndPasswordsReset.spec.js b/spec/ValidationAndPasswordsReset.spec.js index afd9ae7fab..851013c1b7 100644 --- a/spec/ValidationAndPasswordsReset.spec.js +++ b/spec/ValidationAndPasswordsReset.spec.js @@ -1,196 +1,168 @@ -"use strict"; +'use strict'; -let MockEmailAdapterWithOptions = require('./MockEmailAdapterWithOptions'); -let request = require('request'); -let Config = require("../src/Config"); +const MockEmailAdapterWithOptions = require('./support/MockEmailAdapterWithOptions'); +const request = require('../lib/request'); +const Config = require('../lib/Config'); +const Auth = require('../lib/Auth'); -describe("Custom Pages, Email Verification, Password Reset", () => { - it("should set the custom pages", (done) => { +describe('Custom Pages, Email Verification, Password Reset', () => { + it('should set the custom pages', done => { reconfigureServer({ appName: 'unused', customPages: { - invalidLink: "myInvalidLink", - verifyEmailSuccess: "myVerifyEmailSuccess", - choosePassword: "myChoosePassword", - passwordResetSuccess: "myPasswordResetSuccess" + invalidLink: 'myInvalidLink', + verifyEmailSuccess: 'myVerifyEmailSuccess', + choosePassword: 'myChoosePassword', + passwordResetSuccess: 'myPasswordResetSuccess', + parseFrameURL: 'http://example.com/handle-parse-iframe', }, - publicServerURL: "https://my.public.server.com/1" - }) - .then(() => { - var config = new Config("test"); - expect(config.invalidLinkURL).toEqual("myInvalidLink"); - expect(config.verifyEmailSuccessURL).toEqual("myVerifyEmailSuccess"); - expect(config.choosePasswordURL).toEqual("myChoosePassword"); - expect(config.passwordResetSuccessURL).toEqual("myPasswordResetSuccess"); - expect(config.verifyEmailURL).toEqual("https://my.public.server.com/1/apps/test/verify_email"); - expect(config.requestResetPasswordURL).toEqual("https://my.public.server.com/1/apps/test/request_password_reset"); + publicServerURL: 'https://my.public.server.com/1', + }).then(() => { + const config = Config.get('test'); + expect(config.invalidLinkURL).toEqual('myInvalidLink'); + expect(config.verifyEmailSuccessURL).toEqual('myVerifyEmailSuccess'); + expect(config.choosePasswordURL).toEqual('myChoosePassword'); + expect(config.passwordResetSuccessURL).toEqual('myPasswordResetSuccess'); + expect(config.parseFrameURL).toEqual('http://example.com/handle-parse-iframe'); + expect(config.verifyEmailURL).toEqual( + 'https://my.public.server.com/1/apps/test/verify_email' + ); + expect(config.requestResetPasswordURL).toEqual( + 'https://my.public.server.com/1/apps/test/request_password_reset' + ); done(); }); }); - it_exclude_dbs(['postgres'])('sends verification email if email verification is enabled', done => { - var emailAdapter = { + it_id('5e558687-40f3-496c-9e4f-af6100bd1b2f')(it)('sends verification email if email verification is enabled', done => { + const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => Promise.resolve() - } + sendMail: () => Promise.resolve(), + }; reconfigureServer({ appName: 'unused', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { + publicServerURL: 'http://localhost:8378/1', + }).then(async () => { spyOn(emailAdapter, 'sendVerificationEmail'); - var user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); user.setEmail('testIfEnabled@parse.com'); - user.signUp(null, { - success: function(user) { - expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); - user.fetch() - .then(() => { - expect(user.get('emailVerified')).toEqual(false); - done(); - }); - }, - error: function(userAgain, error) { - fail('Failed to save user'); - done(); - } + await user.signUp(); + await jasmine.timeout(); + expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); + user.fetch().then(() => { + expect(user.get('emailVerified')).toEqual(false); + done(); }); }); }); it('does not send verification email when verification is enabled and email is not set', done => { - var emailAdapter = { + const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => Promise.resolve() - } + sendMail: () => Promise.resolve(), + }; reconfigureServer({ appName: 'unused', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { + publicServerURL: 'http://localhost:8378/1', + }).then(async () => { spyOn(emailAdapter, 'sendVerificationEmail'); - var user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.signUp(null, { - success: function(user) { - expect(emailAdapter.sendVerificationEmail).not.toHaveBeenCalled(); - user.fetch() - .then(() => { - expect(user.get('emailVerified')).toEqual(undefined); - done(); - }); - }, - error: function(userAgain, error) { - fail('Failed to save user'); - done(); - } + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + await user.signUp(); + expect(emailAdapter.sendVerificationEmail).not.toHaveBeenCalled(); + user.fetch().then(() => { + expect(user.get('emailVerified')).toEqual(undefined); + done(); }); }); }); - it_exclude_dbs(['postgres'])('does send a validation email when updating the email', done => { - var emailAdapter = { + it('does send a validation email when updating the email', done => { + const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => Promise.resolve() - } + sendMail: () => Promise.resolve(), + }; reconfigureServer({ appName: 'unused', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { + publicServerURL: 'http://localhost:8378/1', + }).then(async () => { spyOn(emailAdapter, 'sendVerificationEmail'); - var user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.signUp(null, { - success: function(user) { - expect(emailAdapter.sendVerificationEmail).not.toHaveBeenCalled(); - user.fetch() - .then((user) => { - user.set("email", "testWhenUpdating@parse.com"); - return user.save(); - }).then((user) => { - return user.fetch(); - }).then(() => { - expect(user.get('emailVerified')).toEqual(false); - // Wait as on update email, we need to fetch the username - setTimeout(function(){ - expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); - done(); - }, 200); - }); - }, - error: function(userAgain, error) { - fail('Failed to save user'); - done(); - } - }); + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + await user.signUp(); + expect(emailAdapter.sendVerificationEmail).not.toHaveBeenCalled(); + user + .fetch() + .then(user => { + user.set('email', 'testWhenUpdating@parse.com'); + return user.save(); + }) + .then(user => { + return user.fetch(); + }) + .then(() => { + expect(user.get('emailVerified')).toEqual(false); + // Wait as on update email, we need to fetch the username + setTimeout(function () { + expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); + done(); + }, 200); + }); }); }); - it_exclude_dbs(['postgres'])('does send a validation email with valid verification link when updating the email', done => { - var emailAdapter = { + it('does send a validation email with valid verification link when updating the email', async done => { + const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => Promise.resolve() - } - reconfigureServer({ + sendMail: () => Promise.resolve(), + }; + await reconfigureServer({ appName: 'unused', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - spyOn(emailAdapter, 'sendVerificationEmail').and.callFake((options) => { - expect(options.link).not.toBeNull(); - expect(options.link).not.toMatch(/token=undefined/); - Promise.resolve(); - }); - var user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.signUp(null, { - success: function(user) { - expect(emailAdapter.sendVerificationEmail).not.toHaveBeenCalled(); - user.fetch() - .then((user) => { - user.set("email", "testValidLinkWhenUpdating@parse.com"); - return user.save(); - }).then((user) => { - return user.fetch(); - }).then(() => { - expect(user.get('emailVerified')).toEqual(false); - // Wait as on update email, we need to fetch the username - setTimeout(function(){ - expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); - done(); - }, 200); - }); - }, - error: function(userAgain, error) { - fail('Failed to save user'); - done(); - } - }); + publicServerURL: 'http://localhost:8378/1', + }); + spyOn(emailAdapter, 'sendVerificationEmail').and.callFake(options => { + expect(options.link).not.toBeNull(); + expect(options.link).not.toMatch(/token=undefined/); + expect(options.link).not.toMatch(/username=undefined/); + Promise.resolve(); }); + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + await user.signUp(); + expect(emailAdapter.sendVerificationEmail).not.toHaveBeenCalled(); + await user.fetch(); + user.set('email', 'testValidLinkWhenUpdating@parse.com'); + await user.save(); + await user.fetch(); + expect(user.get('emailVerified')).toEqual(false); + // Wait as on update email, we need to fetch the username + setTimeout(function () { + expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); + done(); + }, 200); }); - it_exclude_dbs(['postgres'])('does send with a simple adapter', done => { - var calls = 0; - var emailAdapter = { - sendMail: function(options){ + it_id('33d31119-c724-4f5d-83ec-f56815d23df3')(it)('does send with a simple adapter', done => { + let calls = 0; + const emailAdapter = { + sendMail: function (options) { expect(options.to).toBe('testSendSimpleAdapter@parse.com'); if (calls == 0) { expect(options.subject).toEqual('Please verify your e-mail for My Cool App'); @@ -201,44 +173,40 @@ describe("Custom Pages, Email Verification, Password Reset", () => { } calls++; return Promise.resolve(); - } - } + }, + }; reconfigureServer({ appName: 'My Cool App', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - var user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.set("email", "testSendSimpleAdapter@parse.com"); - user.signUp(null, { - success: function(user) { - expect(calls).toBe(1); - user.fetch() - .then((user) => { - return user.save(); - }).then((user) => { - return Parse.User.requestPasswordReset("testSendSimpleAdapter@parse.com").catch((err) => { - fail('Should not fail requesting a password'); - done(); - }) - }).then(() => { - expect(calls).toBe(2); + publicServerURL: 'http://localhost:8378/1', + }).then(async () => { + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + user.set('email', 'testSendSimpleAdapter@parse.com'); + await user.signUp(); + await jasmine.timeout(); + expect(calls).toBe(1); + user + .fetch() + .then(user => { + return user.save(); + }) + .then(() => { + return Parse.User.requestPasswordReset('testSendSimpleAdapter@parse.com').catch(() => { + fail('Should not fail requesting a password'); done(); }); - }, - error: function(userAgain, error) { - fail('Failed to save user'); + }) + .then(() => { + expect(calls).toBe(2); done(); - } - }); + }); }); }); - it_exclude_dbs(['postgres'])('prevents user from login if email is not verified but preventLoginWithUnverifiedEmail is set to true', done => { + it('prevents user from login if email is not verified but preventLoginWithUnverifiedEmail is set to true', done => { reconfigureServer({ appName: 'test', publicServerURL: 'http://localhost:1337/1', @@ -250,84 +218,132 @@ describe("Custom Pages, Email Verification, Password Reset", () => { domain: 'd', }), }) - .then(() => { - let user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.set("email", "testInvalidConfig@parse.com"); - user.signUp(null) - .then(user => Parse.User.logIn("zxcv", "asdf")) - .then(result => { - fail('login should have failed'); - done(); - }, error => { - expect(error.message).toEqual('User email is not verified.') + .then(() => { + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + user.set('email', 'testInvalidConfig@parse.com'); + user + .signUp(null) + .then(user => { + expect(user.getSessionToken()).toBe(undefined); + return Parse.User.logIn('zxcv', 'asdf'); + }) + .then( + () => { + fail('login should have failed'); + done(); + }, + error => { + expect(error.message).toEqual('User email is not verified.'); + done(); + } + ); + }) + .catch(error => { + fail(JSON.stringify(error)); done(); }); - }) - .catch(error => { - fail(JSON.stringify(error)); - done(); + }); + + it('prevents user from signup and login if email is not verified and preventLoginWithUnverifiedEmail is set to function returning true', async () => { + await reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:1337/1', + verifyUserEmails: async () => true, + preventLoginWithUnverifiedEmail: async () => true, + preventSignupWithUnverifiedEmail: true, + emailAdapter: MockEmailAdapterWithOptions({ + fromAddress: 'parse@example.com', + apiKey: 'k', + domain: 'd', + }), + }); + + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + user.set('email', 'testInvalidConfig@parse.com'); + const signupRes = await user.signUp(null).catch(e => e); + expect(signupRes.message).toEqual('User email is not verified.'); + + const loginRes = await Parse.User.logIn('zxcv', 'asdf').catch(e => e); + expect(loginRes.message).toEqual('User email is not verified.'); + }); + + it('provides function arguments in verifyUserEmails on login', async () => { + const user = new Parse.User(); + user.setUsername('user'); + user.setPassword('pass'); + user.set('email', 'test@example.com'); + await user.signUp(); + + const verifyUserEmails = { + method: async (params) => { + expect(params.object).toBeInstanceOf(Parse.User); + expect(params.ip).toBeDefined(); + expect(params.master).toBeDefined(); + expect(params.installationId).toBeDefined(); + expect(params.createdWith).toEqual({ action: 'login', authProvider: 'password' }); + return true; + }, + }; + const verifyUserEmailsSpy = spyOn(verifyUserEmails, 'method').and.callThrough(); + await reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:1337/1', + verifyUserEmails: verifyUserEmails.method, + preventLoginWithUnverifiedEmail: verifyUserEmails.method, + preventSignupWithUnverifiedEmail: true, + emailAdapter: MockEmailAdapterWithOptions({ + fromAddress: 'parse@example.com', + apiKey: 'k', + domain: 'd', + }), }); + + const res = await Parse.User.logIn('user', 'pass').catch(e => e); + expect(res.code).toBe(205); + expect(verifyUserEmailsSpy).toHaveBeenCalledTimes(2); }); - it_exclude_dbs(['postgres'])('allows user to login only after user clicks on the link to confirm email address if preventLoginWithUnverifiedEmail is set to true', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { + it_id('2a5d24be-2ca5-4385-b580-1423bd392e43')(it)('allows user to login only after user clicks on the link to confirm email address if preventLoginWithUnverifiedEmail is set to true', async () => { + let sendEmailOptions; + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } - reconfigureServer({ + sendMail: () => {}, + }; + await reconfigureServer({ appName: 'emailing app', verifyUserEmails: true, preventLoginWithUnverifiedEmail: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - user.setPassword("other-password"); - user.setUsername("user"); - user.set('email', 'user@parse.com'); - return user.signUp(); - }).then(() => { - expect(sendEmailOptions).not.toBeUndefined(); - request.get(sendEmailOptions.link, { - followRedirect: false, - }, (error, response, body) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html?username=user'); - user.fetch() - .then(() => { - expect(user.get('emailVerified')).toEqual(true); - - Parse.User.logIn("user", "other-password") - .then(user => { - expect(typeof user).toBe('object'); - expect(user.get('emailVerified')).toBe(true); - done(); - }, error => { - fail('login should have succeeded'); - done(); - }); - }, (err) => { - console.error(err); - fail("this should not fail"); - done(); - }).catch((err) => - { - console.error(err); - fail(err); - done(); - }) - }); + publicServerURL: 'http://localhost:8378/1', + }); + let user = new Parse.User(); + user.setPassword('other-password'); + user.setUsername('user'); + user.set('email', 'user@example.com'); + await user.signUp(); + await jasmine.timeout(); + expect(sendEmailOptions).not.toBeUndefined(); + const response = await request({ + url: sendEmailOptions.link, + followRedirects: false, }); + expect(response.status).toEqual(200); + expect(response.text).toContain('Email verified!'); + user = await new Parse.Query(Parse.User).first({ useMasterKey: true }); + expect(user.get('emailVerified')).toEqual(true); + user = await Parse.User.logIn('user', 'other-password'); + expect(typeof user).toBe('object'); + expect(user.get('emailVerified')).toBe(true); }); - it_exclude_dbs(['postgres'])('allows user to login if email is not verified but preventLoginWithUnverifiedEmail is set to false', done => { + it('allows user to login if email is not verified but preventLoginWithUnverifiedEmail is set to false', done => { reconfigureServer({ appName: 'test', publicServerURL: 'http://localhost:1337/1', @@ -339,29 +355,62 @@ describe("Custom Pages, Email Verification, Password Reset", () => { domain: 'd', }), }) - .then(() => { - let user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.set("email", "testInvalidConfig@parse.com"); - user.signUp(null) - .then(user => Parse.User.logIn("zxcv", "asdf")) - .then(user => { - expect(typeof user).toBe('object'); - expect(user.get('emailVerified')).toBe(false); - done(); - }, error => { - fail('login should have succeeded'); + .then(() => { + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + user.set('email', 'testInvalidConfig@parse.com'); + user + .signUp(null) + .then(() => Parse.User.logIn('zxcv', 'asdf')) + .then( + user => { + expect(typeof user).toBe('object'); + expect(user.get('emailVerified')).toBe(false); + done(); + }, + () => { + fail('login should have succeeded'); + done(); + } + ); + }) + .catch(error => { + fail(JSON.stringify(error)); done(); }); - }) - .catch(error => { - fail(JSON.stringify(error)); - done(); + }); + + it_id('a18a07af-0319-4f15-8237-28070c5948fa')(it)('does not allow signup with preventSignupWithUnverified', async () => { + let sendEmailOptions; + const emailAdapter = { + sendVerificationEmail: options => { + sendEmailOptions = options; + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + await reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:1337/1', + verifyUserEmails: true, + preventLoginWithUnverifiedEmail: true, + preventSignupWithUnverifiedEmail: true, + emailAdapter, }); + const newUser = new Parse.User(); + newUser.setPassword('asdf'); + newUser.setUsername('zxcv'); + newUser.set('email', 'test@example.com'); + await expectAsync(newUser.signUp()).toBeRejectedWith( + new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User email is not verified.') + ); + const user = await new Parse.Query(Parse.User).first({ useMasterKey: true }); + expect(user).toBeDefined(); + expect(sendEmailOptions).toBeDefined(); }); - it_exclude_dbs(['postgres'])('fails if you include an emailAdapter, set a publicServerURL, but have no appName and send a password reset email', done => { + it('fails if you include an emailAdapter, set a publicServerURL, but have no appName and send a password reset email', done => { reconfigureServer({ appName: undefined, publicServerURL: 'http://localhost:1337/1', @@ -371,29 +420,34 @@ describe("Custom Pages, Email Verification, Password Reset", () => { domain: 'd', }), }) - .then(() => { - let user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.set("email", "testInvalidConfig@parse.com"); - user.signUp(null) - .then(user => Parse.User.requestPasswordReset("testInvalidConfig@parse.com")) - .then(result => { - console.log(result); - fail('sending password reset email should not have succeeded'); - done(); - }, error => { - expect(error.message).toEqual('An appName, publicServerURL, and emailAdapter are required for password reset functionality.') + .then(() => { + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + user.set('email', 'testInvalidConfig@parse.com'); + user + .signUp(null) + .then(() => Parse.User.requestPasswordReset('testInvalidConfig@parse.com')) + .then( + () => { + fail('sending password reset email should not have succeeded'); + done(); + }, + error => { + expect(error.message).toEqual( + 'An appName, publicServerURL, and emailAdapter are required for password reset and email verification functionality.' + ); + done(); + } + ); + }) + .catch(error => { + fail(JSON.stringify(error)); done(); }); - }) - .catch(error => { - fail(JSON.stringify(error)); - done(); - }); }); - it_exclude_dbs(['postgres'])('fails if you include an emailAdapter, have an appName, but have no publicServerURL and send a password reset email', done => { + it('fails if you include an emailAdapter, have an appName, but have no publicServerURL and send a password reset email', done => { reconfigureServer({ appName: undefined, emailAdapter: MockEmailAdapterWithOptions({ @@ -402,57 +456,67 @@ describe("Custom Pages, Email Verification, Password Reset", () => { domain: 'd', }), }) - .then(() => { - let user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.set("email", "testInvalidConfig@parse.com"); - user.signUp(null) - .then(user => Parse.User.requestPasswordReset("testInvalidConfig@parse.com")) - .then(result => { - console.log(result); - fail('sending password reset email should not have succeeded'); - done(); - }, error => { - expect(error.message).toEqual('An appName, publicServerURL, and emailAdapter are required for password reset functionality.') + .then(() => { + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + user.set('email', 'testInvalidConfig@parse.com'); + user + .signUp(null) + .then(() => Parse.User.requestPasswordReset('testInvalidConfig@parse.com')) + .then( + () => { + fail('sending password reset email should not have succeeded'); + done(); + }, + error => { + expect(error.message).toEqual( + 'An appName, publicServerURL, and emailAdapter are required for password reset and email verification functionality.' + ); + done(); + } + ); + }) + .catch(error => { + fail(JSON.stringify(error)); done(); }); - }) - .catch(error => { - fail(JSON.stringify(error)); - done(); - }); }); - it_exclude_dbs(['postgres'])('fails if you set a publicServerURL, have an appName, but no emailAdapter and send a password reset email', done => { + it('fails if you set a publicServerURL, have an appName, but no emailAdapter and send a password reset email', done => { reconfigureServer({ appName: 'unused', publicServerURL: 'http://localhost:1337/1', emailAdapter: undefined, }) - .then(() => { - let user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.set("email", "testInvalidConfig@parse.com"); - user.signUp(null) - .then(user => Parse.User.requestPasswordReset("testInvalidConfig@parse.com")) - .then(result => { - console.log(result); - fail('sending password reset email should not have succeeded'); - done(); - }, error => { - expect(error.message).toEqual('An appName, publicServerURL, and emailAdapter are required for password reset functionality.') + .then(() => { + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + user.set('email', 'testInvalidConfig@parse.com'); + user + .signUp(null) + .then(() => Parse.User.requestPasswordReset('testInvalidConfig@parse.com')) + .then( + () => { + fail('sending password reset email should not have succeeded'); + done(); + }, + error => { + expect(error.message).toEqual( + 'An appName, publicServerURL, and emailAdapter are required for password reset and email verification functionality.' + ); + done(); + } + ); + }) + .catch(error => { + fail(JSON.stringify(error)); done(); }); - }) - .catch(error => { - fail(JSON.stringify(error)); - done(); - }); }); - it_exclude_dbs(['postgres'])('succeeds sending a password reset email if appName, publicServerURL, and email adapter are prodvided', done => { + it('succeeds sending a password reset email if appName, publicServerURL, and email adapter are provided', done => { reconfigureServer({ appName: 'coolapp', publicServerURL: 'http://localhost:1337/1', @@ -462,261 +526,346 @@ describe("Custom Pages, Email Verification, Password Reset", () => { domain: 'd', }), }) - .then(() => { - let user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.set("email", "testInvalidConfig@parse.com"); - user.signUp(null) - .then(user => Parse.User.requestPasswordReset("testInvalidConfig@parse.com")) - .then(result => { + .then(() => { + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + user.set('email', 'testInvalidConfig@parse.com'); + user + .signUp(null) + .then(() => Parse.User.requestPasswordReset('testInvalidConfig@parse.com')) + .then( + () => { + done(); + }, + error => { + done(error); + } + ); + }) + .catch(error => { + fail(JSON.stringify(error)); done(); - }, error => { - done(error); }); - }) - .catch(error => { - fail(JSON.stringify(error)); - done(); + }); + + it('succeeds sending a password reset username if appName, publicServerURL, and email adapter are provided', done => { + const adapter = MockEmailAdapterWithOptions({ + fromAddress: 'parse@example.com', + apiKey: 'k', + domain: 'd', + sendMail: function (options) { + expect(options.to).toEqual('testValidConfig@parse.com'); + return Promise.resolve(); + }, }); + + // delete that handler to force using the default + delete adapter.sendPasswordResetEmail; + + spyOn(adapter, 'sendMail').and.callThrough(); + reconfigureServer({ + appName: 'coolapp', + publicServerURL: 'http://localhost:1337/1', + emailAdapter: adapter, + }) + .then(() => { + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('testValidConfig@parse.com'); + user + .signUp(null) + .then(() => Parse.User.requestPasswordReset('testValidConfig@parse.com')) + .then( + () => { + expect(adapter.sendMail).toHaveBeenCalled(); + done(); + }, + error => { + done(error); + } + ); + }) + .catch(error => { + fail(JSON.stringify(error)); + done(); + }); }); it('does not send verification email if email verification is disabled', done => { - var emailAdapter = { + const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => Promise.resolve() - } + sendMail: () => Promise.resolve(), + }; reconfigureServer({ appName: 'unused', publicServerURL: 'http://localhost:1337/1', verifyUserEmails: false, emailAdapter: emailAdapter, - }) - .then(() => { + }).then(async () => { spyOn(emailAdapter, 'sendVerificationEmail'); - var user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.signUp(null, { - success: function(user) { - user.fetch() - .then(() => { - expect(emailAdapter.sendVerificationEmail.calls.count()).toEqual(0); - expect(user.get('emailVerified')).toEqual(undefined); - done(); - }); - }, - error: function(userAgain, error) { - fail('Failed to save user'); - done(); - } - }); + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + await user.signUp(); + await user.fetch(); + expect(emailAdapter.sendVerificationEmail.calls.count()).toEqual(0); + expect(user.get('emailVerified')).toEqual(undefined); + done(); }); }); - it_exclude_dbs(['postgres'])('receives the app name and user in the adapter', done => { - var emailSent = false; - var emailAdapter = { + it_id('45f550a2-a2b2-4b2b-b533-ccbf96139cc9')(it)('receives the app name and user in the adapter', done => { + let emailSent = false; + const emailAdapter = { sendVerificationEmail: options => { expect(options.appName).toEqual('emailing app'); expect(options.user.get('email')).toEqual('user@parse.com'); emailSent = true; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailing app', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - var user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); + publicServerURL: 'http://localhost:8378/1', + }).then(async () => { + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); user.set('email', 'user@parse.com'); - user.signUp(null, { - success: () => { - expect(emailSent).toBe(true); - done(); - }, - error: function(userAgain, error) { - fail('Failed to save user'); - done(); - } - }); + await user.signUp(); + await jasmine.timeout(); + expect(emailSent).toBe(true); + done(); }); - }) + }); - it_exclude_dbs(['postgres'])('when you click the link in the email it sets emailVerified to true and redirects you', done => { - var user = new Parse.User(); - var sendEmailOptions; - var emailAdapter = { + it_id('ea37ef62-aad8-4a17-8dfe-35e5b2986f0f')(it)('when you click the link in the email it sets emailVerified to true and redirects you', done => { + const user = new Parse.User(); + let sendEmailOptions; + const emailAdapter = { sendVerificationEmail: options => { sendEmailOptions = options; }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailing app', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" + publicServerURL: 'http://localhost:8378/1', }) - .then(() => { - user.setPassword("other-password"); - user.setUsername("user"); - user.set('email', 'user@parse.com'); - return user.signUp(); + .then(() => { + user.setPassword('other-password'); + user.setUsername('user'); + user.set('email', 'user@parse.com'); + return user.signUp(); + }) + .then(() => jasmine.timeout()) + .then(() => { + expect(sendEmailOptions).not.toBeUndefined(); + request({ + url: sendEmailOptions.link, + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(200); + expect(response.text).toContain('Email verified!'); + user + .fetch() + .then( + () => { + expect(user.get('emailVerified')).toEqual(true); + done(); + }, + err => { + jfail(err); + fail('this should not fail'); + done(); + } + ) + .catch(err => { + jfail(err); + done(); + }); + }); + }); + }); + + it('redirects you to invalid link if you try to verify email incorrectly', done => { + reconfigureServer({ + appName: 'emailing app', + verifyUserEmails: true, + emailAdapter: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + publicServerURL: 'http://localhost:8378/1', }).then(() => { - expect(sendEmailOptions).not.toBeUndefined(); - request.get(sendEmailOptions.link, { - followRedirect: false, - }, (error, response, body) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html?username=user'); - user.fetch() - .then(() => { - expect(user.get('emailVerified')).toEqual(true); - done(); - }, (err) => { - console.error(err); - fail("this should not fail"); - done(); - }).catch((err) => - { - console.error(err); - fail(err); - done(); - }) + request({ + url: 'http://localhost:8378/1/apps/test/verify_email', + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(200); + expect(response.text).toContain('Invalid verification link!'); + done(); }); }); }); - it('redirects you to invalid link if you try to verify email incorrecly', done => { + it('redirects you to invalid verification link page if you try to validate a nonexistant users email', done => { reconfigureServer({ appName: 'emailing app', verifyUserEmails: true, emailAdapter: { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} + sendMail: () => {}, }, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - request.get('http://localhost:8378/1/apps/test/verify_email', { - followRedirect: false, - }, (error, response, body) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); - done() + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + request({ + url: 'http://localhost:8378/1/apps/test/verify_email?token=asdfasdf', + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(200); + expect(response.text).toContain('Invalid verification link!'); + done(); }); }); }); - it('redirects you to invalid link if you try to validate a nonexistant users email', done => { + it('redirects you to link send success page if you try to resend a link for a nonexistent user', done => { reconfigureServer({ appName: 'emailing app', verifyUserEmails: true, emailAdapter: { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} + sendMail: () => {}, }, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - request.get('http://localhost:8378/1/apps/test/verify_email?token=asdfasdf&username=sadfasga', { - followRedirect: false, - }, (error, response, body) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + request({ + url: 'http://localhost:8378/1/apps/test/resend_verification_email', + method: 'POST', + followRedirects: false, + body: { + username: 'sadfasga', + }, + }).then(response => { + expect(response.status).toEqual(303); + // With emailVerifySuccessOnInvalidEmail: true (default), the resend + // page redirects to success to prevent user enumeration + expect(response.text).toContain('email_verification_send_success.html'); done(); }); }); }); - it_exclude_dbs(['postgres'])('does not update email verified if you use an invalid token', done => { - var user = new Parse.User(); - var emailAdapter = { - sendVerificationEmail: options => { - request.get('http://localhost:8378/1/apps/test/verify_email?token=invalid&username=zxcv', { - followRedirect: false, - }, (error, response, body) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); - user.fetch() - .then(() => { + it('redirects you to link send fail page if you try to resend a link for a nonexistent user with emailVerifySuccessOnInvalidEmail disabled', done => { + reconfigureServer({ + appName: 'emailing app', + verifyUserEmails: true, + emailVerifySuccessOnInvalidEmail: false, + emailAdapter: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + request({ + url: 'http://localhost:8378/1/apps/test/resend_verification_email', + method: 'POST', + followRedirects: false, + body: { + username: 'sadfasga', + }, + }).then(response => { + expect(response.status).toEqual(303); + expect(response.text).toContain('email_verification_send_fail.html'); + done(); + }); + }); + }); + + it('does not update email verified if you use an invalid token', done => { + const user = new Parse.User(); + const emailAdapter = { + sendVerificationEmail: () => { + request({ + url: 'http://localhost:8378/1/apps/test/verify_email?token=invalid', + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(200); + expect(response.text).toContain('Invalid verification link!'); + user.fetch().then(() => { expect(user.get('emailVerified')).toEqual(false); done(); }); }); }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailing app', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - user.setPassword("asdf"); - user.setUsername("zxcv"); + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setPassword('asdf'); + user.setUsername('zxcv'); user.set('email', 'user@parse.com'); user.signUp(null, { success: () => {}, - error: function(userAgain, error) { + error: function () { fail('Failed to save user'); done(); - } + }, }); }); }); - it_exclude_dbs(['postgres'])('should send a password reset link', done => { - var user = new Parse.User(); - var emailAdapter = { + it('should send a password reset link', done => { + const user = new Parse.User(); + const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: options => { - request.get(options.link, { - followRedirect: false, - }, (error, response, body) => { - if (error) { - console.error(error); - fail("Failed to get the reset link"); - return; - } - expect(response.statusCode).toEqual(302); - var re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=[a-zA-Z0-9]+\&id=test\&username=zxcv%2Bzxcv/; - expect(response.body.match(re)).not.toBe(null); + request({ + url: options.link, + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(200); + const re = /name="token"[^>]*value="([^"]+)"/; + expect(response.text.match(re)).not.toBe(null); done(); }); }, - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailing app', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - user.setPassword("asdf"); - user.setUsername("zxcv+zxcv"); + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setPassword('asdf'); + user.setUsername('zxcv+zxcv'); user.set('email', 'user@parse.com'); user.signUp().then(() => { Parse.User.requestPasswordReset('user@parse.com', { - error: (err) => { - console.error(err); - fail("Should not fail requesting a password"); + error: err => { + jfail(err); + fail('Should not fail requesting a password'); done(); - } + }, }); }); }); @@ -729,99 +878,398 @@ describe("Custom Pages, Email Verification, Password Reset", () => { emailAdapter: { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} + sendMail: () => {}, }, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - request.get('http://localhost:8378/1/apps/test/request_password_reset?token=asdfasdf&username=sadfasga', { - followRedirect: false, - }, (error, response, body) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + request({ + url: 'http://localhost:8378/1/apps/test/request_password_reset?token=asdfasdf', + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(200); + expect(response.text).toContain('Invalid password reset link!'); done(); }); }); }); - it_exclude_dbs(['postgres'])('should programatically reset password', done => { - var user = new Parse.User(); - var emailAdapter = { + it('should programmatically reset password', done => { + const user = new Parse.User(); + const emailAdapter = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: options => { - request.get(options.link, { - followRedirect: false, - }, (error, response, body) => { - if (error) { - console.error(error); - fail("Failed to get the reset link"); - return; - } - expect(response.statusCode).toEqual(302); - var re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=zxcv/; - var match = response.body.match(re); + request({ + url: options.link, + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(200); + const re = /name="token"[^>]*value="([^"]+)"/; + const match = response.text.match(re); if (!match) { - fail("should have a token"); + fail('should have a token'); done(); return; } - var token = match[1]; + const token = match[1]; - request.post({ - url: "http://localhost:8378/1/apps/test/request_password_reset" , - body: `new_password=hello&token=${token}&username=zxcv`, + request({ + url: 'http://localhost:8378/1/apps/test/request_password_reset', + method: 'POST', + body: { new_password: 'hello', token, username: 'zxcv' }, headers: { - 'Content-Type': 'application/x-www-form-urlencoded' + 'Content-Type': 'application/x-www-form-urlencoded', }, - followRedirect: false, - }, (error, response, body) => { - if (error) { - console.error(error); - fail("Failed to POST request password reset"); - return; - } - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html'); - - Parse.User.logIn("zxcv", "hello").then(function(user){ - let config = new Config('test'); - config.database.adapter.find('_User', { fields: {} }, { 'username': 'zxcv' }, { limit: 1 }) - .then(results => { - // _perishable_token should be unset after reset password - expect(results.length).toEqual(1); - expect(results[0]['_perishable_token']).toEqual(undefined); - done(); - }); - }, (err) => { - console.error(err); - fail("should login with new password"); - done(); - }); + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(200); + expect(response.text).toContain('Success!'); + Parse.User.logIn('zxcv', 'hello').then( + function () { + const config = Config.get('test'); + config.database.adapter + .find('_User', { fields: {} }, { username: 'zxcv' }, { limit: 1 }) + .then(results => { + // _perishable_token should be unset after reset password + expect(results.length).toEqual(1); + expect(results[0]['_perishable_token']).toEqual(undefined); + done(); + }); + }, + err => { + jfail(err); + fail('should login with new password'); + done(); + } + ); }); }); }, - sendMail: () => {} - } + sendMail: () => {}, + }; reconfigureServer({ appName: 'emailing app', verifyUserEmails: true, emailAdapter: emailAdapter, - publicServerURL: "http://localhost:8378/1" - }) - .then(() => { - user.setPassword("asdf"); - user.setUsername("zxcv"); + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setPassword('asdf'); + user.setUsername('zxcv'); user.set('email', 'user@parse.com'); user.signUp().then(() => { Parse.User.requestPasswordReset('user@parse.com', { - error: (err) => { - console.error(err); - fail("Should not fail"); + error: err => { + jfail(err); + fail('Should not fail'); done(); + }, + }); + }); + }); + }); + + it('should redirect with username encoded on success page', done => { + const user = new Parse.User(); + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + request({ + url: options.link, + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(200); + const re = /name="token"[^>]*value="([^"]+)"/; + const match = response.text.match(re); + if (!match) { + fail('should have a token'); + done(); + return; } + const token = match[1]; + + request({ + url: 'http://localhost:8378/1/apps/test/request_password_reset', + method: 'POST', + body: { new_password: 'hello', token, username: 'zxcv+1' }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + followRedirects: false, + }).then(response => { + expect(response.status).toEqual(200); + expect(response.text).toContain('Success!'); + done(); + }); + }); + }, + sendMail: () => {}, + }; + reconfigureServer({ + appName: 'emailing app', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }).then(() => { + user.setPassword('asdf'); + user.setUsername('zxcv+1'); + user.set('email', 'user@parse.com'); + user.signUp().then(() => { + Parse.User.requestPasswordReset('user@parse.com', { + error: err => { + jfail(err); + fail('Should not fail'); + done(); + }, }); }); }); }); -}) + + it('should programmatically reset password on ajax request', async done => { + const user = new Parse.User(); + const emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: async options => { + const response = await request({ + url: options.link, + followRedirects: false, + }); + expect(response.status).toEqual(200); + const re = /name="token"[^>]*value="([^"]+)"/; + const match = response.text.match(re); + if (!match) { + fail('should have a token'); + return; + } + const token = match[1]; + + const resetResponse = await request({ + url: 'http://localhost:8378/1/apps/test/request_password_reset', + method: 'POST', + body: { new_password: 'hello', token, username: 'zxcv' }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest', + }, + followRedirects: false, + }); + expect(resetResponse.status).toEqual(200); + expect(resetResponse.text).toEqual('"Password successfully reset"'); + + await Parse.User.logIn('zxcv', 'hello'); + const config = Config.get('test'); + const results = await config.database.adapter.find( + '_User', + { fields: {} }, + { username: 'zxcv' }, + { limit: 1 } + ); + // _perishable_token should be unset after reset password + expect(results.length).toEqual(1); + expect(results[0]['_perishable_token']).toEqual(undefined); + done(); + }, + sendMail: () => {}, + }; + await reconfigureServer({ + appName: 'emailing app', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: 'http://localhost:8378/1', + }); + user.setPassword('asdf'); + user.setUsername('zxcv'); + user.set('email', 'user@parse.com'); + await user.signUp(); + await Parse.User.requestPasswordReset('user@parse.com'); + }); + + it('should return ajax failure error on ajax request with wrong data provided', async () => { + await reconfigureServer({ + publicServerURL: 'http://localhost:8378/1', + }); + + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=user1&token=12345`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest', + }, + followRedirects: false, + }); + } catch (error) { + expect(error.status).not.toBe(302); + expect(error.text).toEqual( + '{"code":-1,"error":"Failed to reset password: username / email / token is invalid"}' + ); + } + }); + + it('deletes password reset token on email address change', done => { + reconfigureServer({ + appName: 'coolapp', + publicServerURL: 'http://localhost:1337/1', + emailAdapter: MockEmailAdapterWithOptions({ + fromAddress: 'parse@example.com', + apiKey: 'k', + domain: 'd', + }), + }) + .then(() => { + const config = Config.get('test'); + const user = new Parse.User(); + user.setPassword('asdf'); + user.setUsername('zxcv'); + user.set('email', 'test@parse.com'); + return user + .signUp(null) + .then(() => Parse.User.requestPasswordReset('test@parse.com')) + .then(() => + config.database.adapter.find( + '_User', + { fields: {} }, + { username: 'zxcv' }, + { limit: 1 } + ) + ) + .then(results => { + // validate that there is a token + expect(results.length).toEqual(1); + expect(results[0]['_perishable_token']).not.toBeNull(); + user.set('email', 'test2@parse.com'); + return user.save(); + }) + .then(() => + config.database.adapter.find( + '_User', + { fields: {} }, + { username: 'zxcv' }, + { limit: 1 } + ) + ) + .then(results => { + expect(results.length).toEqual(1); + expect(results[0]['_perishable_token']).toBeUndefined(); + done(); + }); + }) + .catch(error => { + fail(JSON.stringify(error)); + done(); + }); + }); + + it('can resend email using an expired reset password token', async () => { + const user = new Parse.User(); + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }; + await reconfigureServer({ + appName: 'emailVerifyToken', + verifyUserEmails: true, + emailAdapter: emailAdapter, + emailVerifyTokenValidityDuration: 5, // 5 seconds + publicServerURL: 'http://localhost:8378/1', + passwordPolicy: { + resetTokenValidityDuration: 5 * 60, // 5 minutes + }, + silent: false, + }); + user.setUsername('test'); + user.setPassword('password'); + user.set('email', 'user@example.com'); + await user.signUp(); + await Parse.User.requestPasswordReset('user@example.com'); + + await Parse.Server.database.update( + '_User', + { objectId: user.id }, + { + _perishable_token_expires_at: Parse._encode(new Date('2000')), + } + ); + + let obj = await Parse.Server.database.find( + '_User', + { objectId: user.id }, + {}, + Auth.maintenance(Parse.Server) + ); + const token = obj[0]._perishable_token; + const res = await request({ + url: `http://localhost:8378/1/apps/test/request_password_reset`, + method: 'POST', + body: { + token, + new_password: 'newpassword', + }, + }); + expect(res.text).toContain('The password reset link has expired'); + + await request({ + url: `http://localhost:8378/1/requestPasswordReset`, + method: 'POST', + body: { + token: token, + }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + + obj = await Parse.Server.database.find( + '_User', + { objectId: user.id }, + {}, + Auth.maintenance(Parse.Server) + ); + + expect(obj._perishable_token).not.toBe(token); + }); + + it('should throw on an invalid reset password', async () => { + await reconfigureServer({ + appName: 'coolapp', + publicServerURL: 'http://localhost:1337/1', + emailAdapter: MockEmailAdapterWithOptions({ + fromAddress: 'parse@example.com', + apiKey: 'k', + domain: 'd', + }), + passwordPolicy: { + resetPasswordSuccessOnInvalidEmail: false, + }, + }); + + await expectAsync(Parse.User.requestPasswordReset('test@example.com')).toBeRejectedWith( + new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'A user with that email does not exist.') + ); + }); + + it('validate resetPasswordSuccessonInvalidEmail', async () => { + const invalidValues = [[], {}, 1, 'string']; + for (const value of invalidValues) { + await expectAsync( + reconfigureServer({ + appName: 'coolapp', + publicServerURL: 'http://localhost:1337/1', + emailAdapter: MockEmailAdapterWithOptions({ + fromAddress: 'parse@example.com', + apiKey: 'k', + domain: 'd', + }), + passwordPolicy: { + resetPasswordSuccessOnInvalidEmail: value, + }, + }) + ).toBeRejectedWith('resetPasswordSuccessOnInvalidEmail must be a boolean value'); + } + }); +}); diff --git a/spec/VerifyUserPassword.spec.js b/spec/VerifyUserPassword.spec.js new file mode 100644 index 0000000000..3d15a25e15 --- /dev/null +++ b/spec/VerifyUserPassword.spec.js @@ -0,0 +1,667 @@ +'use strict'; + +const request = require('../lib/request'); +const MockEmailAdapterWithOptions = require('./support/MockEmailAdapterWithOptions'); + +const verifyPassword = function (login, password, isEmail = false) { + const body = !isEmail ? { username: login, password } : { email: login, password }; + return request({ + url: Parse.serverURL + '/verifyPassword', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: body, + }) + .then(res => res) + .catch(err => err); +}; + +const isAccountLockoutError = function (username, password, duration, waitTime) { + return new Promise((resolve, reject) => { + setTimeout(() => { + Parse.User.logIn(username, password) + .then(() => reject('login should have failed')) + .catch(err => { + if ( + err.message === + 'Your account is locked due to multiple failed login attempts. Please try again after ' + + duration + + ' minute(s)' + ) { + resolve(); + } else { + reject(err); + } + }); + }, waitTime); + }); +}; + +describe('Verify User Password', () => { + it('fails to verify password when masterKey has locked out user', done => { + const user = new Parse.User(); + const ACL = new Parse.ACL(); + ACL.setPublicReadAccess(false); + ACL.setPublicWriteAccess(false); + user.setUsername('testuser'); + user.setPassword('mypass'); + user.setACL(ACL); + user + .signUp() + .then(() => { + return Parse.User.logIn('testuser', 'mypass'); + }) + .then(user => { + equal(user.get('username'), 'testuser'); + // Lock the user down + const ACL = new Parse.ACL(); + user.setACL(ACL); + return user.save(null, { useMasterKey: true }); + }) + .then(() => { + expect(user.getACL().getPublicReadAccess()).toBe(false); + return request({ + url: Parse.serverURL + '/verifyPassword', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + username: 'testuser', + password: 'mypass', + }, + }); + }) + .then(res => { + fail(res); + done(); + }) + .catch(err => { + expect(err.status).toBe(404); + expect(err.text).toMatch( + `{"code":${Parse.Error.OBJECT_NOT_FOUND},"error":"Invalid username/password."}` + ); + done(); + }); + }); + it('fails to verify password when username is not provided in query string REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return request({ + url: Parse.serverURL + '/verifyPassword', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + username: '', + password: 'mypass', + }, + }); + }) + .then(res => { + fail(res); + done(); + }) + .catch(err => { + expect(err.status).toBe(400); + expect(err.text).toMatch('{"code":200,"error":"username/email is required."}'); + done(); + }); + }); + it('fails to verify password when email is not provided in query string REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return request({ + url: Parse.serverURL + '/verifyPassword', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + email: '', + password: 'mypass', + }, + }); + }) + .then(res => { + fail(res); + done(); + }) + .catch(err => { + expect(err.status).toBe(400); + expect(err.text).toMatch('{"code":200,"error":"username/email is required."}'); + done(); + }); + }); + it('fails to verify password when username is not provided with json payload REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return verifyPassword('', 'mypass'); + }) + .then(res => { + expect(res.status).toBe(400); + expect(res.text).toMatch('{"code":200,"error":"username/email is required."}'); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + it('fails to verify password when email is not provided with json payload REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return verifyPassword('', 'mypass', true); + }) + .then(res => { + expect(res.status).toBe(400); + expect(res.text).toMatch('{"code":200,"error":"username/email is required."}'); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + it('fails to verify password when password is not provided with json payload REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return verifyPassword('testuser', ''); + }) + .then(res => { + expect(res.status).toBe(400); + expect(res.text).toMatch('{"code":201,"error":"password is required."}'); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + it('fails to verify password when username matches but password does not match hash with json payload REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return verifyPassword('testuser', 'wrong password'); + }) + .then(res => { + expect(res.status).toBe(404); + expect(res.text).toMatch( + `{"code":${Parse.Error.OBJECT_NOT_FOUND},"error":"Invalid username/password."}` + ); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + it('fails to verify password when email matches but password does not match hash with json payload REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return verifyPassword('my@user.com', 'wrong password', true); + }) + .then(res => { + expect(res.status).toBe(404); + expect(res.text).toMatch( + `{"code":${Parse.Error.OBJECT_NOT_FOUND},"error":"Invalid username/password."}` + ); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + it('fails to verify password when typeof username does not equal string REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return verifyPassword(123, 'mypass'); + }) + .then(res => { + expect(res.status).toBe(404); + expect(res.text).toMatch( + `{"code":${Parse.Error.OBJECT_NOT_FOUND},"error":"Invalid username/password."}` + ); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + it('fails to verify password when typeof email does not equal string REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return verifyPassword(123, 'mypass', true); + }) + .then(res => { + expect(res.status).toBe(404); + expect(res.text).toMatch( + `{"code":${Parse.Error.OBJECT_NOT_FOUND},"error":"Invalid username/password."}` + ); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + it('fails to verify password when typeof password does not equal string REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return verifyPassword('my@user.com', 123, true); + }) + .then(res => { + expect(res.status).toBe(404); + expect(res.text).toMatch( + `{"code":${Parse.Error.OBJECT_NOT_FOUND},"error":"Invalid username/password."}` + ); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + it('fails to verify password when username cannot be found REST API', done => { + verifyPassword('mytestuser', 'mypass') + .then(res => { + expect(res.status).toBe(404); + expect(res.text).toMatch( + `{"code":${Parse.Error.OBJECT_NOT_FOUND},"error":"Invalid username/password."}` + ); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + it('fails to verify password when email cannot be found REST API', done => { + verifyPassword('my@user.com', 'mypass', true) + .then(res => { + expect(res.status).toBe(404); + expect(res.text).toMatch( + `{"code":${Parse.Error.OBJECT_NOT_FOUND},"error":"Invalid username/password."}` + ); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + + it('fails to verify password when preventLoginWithUnverifiedEmail is set to true REST API', async () => { + await reconfigureServer({ + publicServerURL: 'http://localhost:8378/', + appName: 'emailVerify', + verifyUserEmails: true, + preventLoginWithUnverifiedEmail: true, + emailAdapter: MockEmailAdapterWithOptions({ + fromAddress: 'parse@example.com', + apiKey: 'k', + domain: 'd', + }), + }); + const user = new Parse.User(); + await user.save({ + username: 'unverified-user', + password: 'mypass', + email: 'unverified-email@example.com', + }); + const res = await verifyPassword('unverified-email@example.com', 'mypass', true); + expect(res.status).toBe(400); + expect(res.data).toEqual({ + code: Parse.Error.EMAIL_NOT_FOUND, + error: 'User email is not verified.', + }); + }); + + it('verify password lock account if failed verify password attempts are above threshold', done => { + reconfigureServer({ + appName: 'lockout threshold', + accountLockout: { + duration: 1, + threshold: 2, + }, + publicServerURL: 'http://localhost:8378/', + }) + .then(() => { + const user = new Parse.User(); + return user.save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }); + }) + .then(() => { + return verifyPassword('testuser', 'wrong password'); + }) + .then(() => { + return verifyPassword('testuser', 'wrong password'); + }) + .then(() => { + return verifyPassword('testuser', 'wrong password'); + }) + .then(() => { + return isAccountLockoutError('testuser', 'wrong password', 1, 1); + }) + .then(() => { + done(); + }) + .catch(err => { + fail('lock account after failed login attempts test failed: ' + JSON.stringify(err)); + done(); + }); + }); + it('succeed in verifying password when username and email are provided and password matches hash with json payload REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return request({ + url: Parse.serverURL + '/verifyPassword', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + username: 'testuser', + email: 'my@user.com', + password: 'mypass', + }, + json: true, + }) + .then(res => res) + .catch(err => err); + }) + .then(response => { + const res = response.data; + expect(typeof res).toBe('object'); + expect(typeof res['objectId']).toEqual('string'); + expect(Object.prototype.hasOwnProperty.call(res, 'sessionToken')).toEqual(false); + expect(Object.prototype.hasOwnProperty.call(res, 'password')).toEqual(false); + done(); + }) + .catch(err => { + fail(err); + done(); + }); + }); + it('succeed in verifying password when username and password matches hash with json payload REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return verifyPassword('testuser', 'mypass'); + }) + .then(response => { + const res = response.data; + expect(typeof res).toBe('object'); + expect(typeof res['objectId']).toEqual('string'); + expect(Object.prototype.hasOwnProperty.call(res, 'sessionToken')).toEqual(false); + expect(Object.prototype.hasOwnProperty.call(res, 'password')).toEqual(false); + done(); + }); + }); + it('succeed in verifying password when email and password matches hash with json payload REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return verifyPassword('my@user.com', 'mypass', true); + }) + .then(response => { + const res = response.data; + expect(typeof res).toBe('object'); + expect(typeof res['objectId']).toEqual('string'); + expect(Object.prototype.hasOwnProperty.call(res, 'sessionToken')).toEqual(false); + expect(Object.prototype.hasOwnProperty.call(res, 'password')).toEqual(false); + done(); + }); + }); + it('succeed to verify password when username and password provided in query string REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return request({ + url: Parse.serverURL + '/verifyPassword', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + username: 'testuser', + password: 'mypass', + }, + }); + }) + .then(response => { + const res = response.text; + expect(typeof res).toBe('string'); + const body = JSON.parse(res); + expect(typeof body['objectId']).toEqual('string'); + expect(Object.prototype.hasOwnProperty.call(body, 'sessionToken')).toEqual(false); + expect(Object.prototype.hasOwnProperty.call(body, 'password')).toEqual(false); + done(); + }); + }); + it('succeed to verify password when email and password provided in query string REST API', done => { + const user = new Parse.User(); + user + .save({ + username: 'testuser', + password: 'mypass', + email: 'my@user.com', + }) + .then(() => { + return request({ + url: Parse.serverURL + '/verifyPassword', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + email: 'my@user.com', + password: 'mypass', + }, + }); + }) + .then(response => { + const res = response.text; + expect(typeof res).toBe('string'); + const body = JSON.parse(res); + expect(typeof body['objectId']).toEqual('string'); + expect(Object.prototype.hasOwnProperty.call(body, 'sessionToken')).toEqual(false); + expect(Object.prototype.hasOwnProperty.call(body, 'password')).toEqual(false); + done(); + }); + }); + it('succeed to verify password with username when user1 has username === user2 email REST API', done => { + const user1 = new Parse.User(); + user1 + .save({ + username: 'email@user.com', + password: 'mypass1', + email: '1@user.com', + }) + .then(() => { + const user2 = new Parse.User(); + return user2.save({ + username: 'user2', + password: 'mypass2', + email: 'email@user.com', + }); + }) + .then(() => { + return verifyPassword('email@user.com', 'mypass1'); + }) + .then(response => { + const res = response.data; + expect(typeof res).toBe('object'); + expect(typeof res['objectId']).toEqual('string'); + expect(Object.prototype.hasOwnProperty.call(res, 'sessionToken')).toEqual(false); + expect(Object.prototype.hasOwnProperty.call(res, 'password')).toEqual(false); + done(); + }); + }); + + it('verify password of user with unverified email with master key and ignoreEmailVerification=true', async () => { + await reconfigureServer({ + publicServerURL: 'http://localhost:8378/', + appName: 'emailVerify', + verifyUserEmails: true, + preventLoginWithUnverifiedEmail: true, + emailAdapter: MockEmailAdapterWithOptions({ + fromAddress: 'parse@example.com', + apiKey: 'k', + domain: 'd', + }), + }); + + const user = new Parse.User(); + user.setUsername('user'); + user.setPassword('pass'); + user.setEmail('test@example.com'); + await user.signUp(); + + const { data: res } = await request({ + method: 'POST', + url: Parse.serverURL + '/verifyPassword', + headers: { + 'X-Parse-Master-Key': Parse.masterKey, + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + username: 'user', + password: 'pass', + ignoreEmailVerification: true, + }, + json: true, + }); + expect(res.objectId).toBe(user.id); + expect(Object.prototype.hasOwnProperty.call(res, 'sessionToken')).toEqual(false); + expect(Object.prototype.hasOwnProperty.call(res, 'password')).toEqual(false); + }); + + it('fails to verify password of user with unverified email with master key and ignoreEmailVerification=false', async () => { + await reconfigureServer({ + publicServerURL: 'http://localhost:8378/', + appName: 'emailVerify', + verifyUserEmails: true, + preventLoginWithUnverifiedEmail: true, + emailAdapter: MockEmailAdapterWithOptions({ + fromAddress: 'parse@example.com', + apiKey: 'k', + domain: 'd', + }), + }); + + const user = new Parse.User(); + user.setUsername('user'); + user.setPassword('pass'); + user.setEmail('test@example.com'); + await user.signUp(); + + const res = await request({ + method: 'POST', + url: Parse.serverURL + '/verifyPassword', + headers: { + 'X-Parse-Master-Key': Parse.masterKey, + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + username: 'user', + password: 'pass', + ignoreEmailVerification: false, + }, + json: true, + }).catch(e => e); + expect(res.status).toBe(400); + expect(res.text).toMatch(/User email is not verified/); + }); +}); diff --git a/spec/WinstonLoggerAdapter.spec.js b/spec/WinstonLoggerAdapter.spec.js new file mode 100644 index 0000000000..81bdc213de --- /dev/null +++ b/spec/WinstonLoggerAdapter.spec.js @@ -0,0 +1,278 @@ +'use strict'; + +const WinstonLoggerAdapter = require('../lib/Adapters/Logger/WinstonLoggerAdapter') + .WinstonLoggerAdapter; +const request = require('../lib/request'); + +describe_only(() => { + return process.env.PARSE_SERVER_LOG_LEVEL !== 'debug'; +})('info logs', () => { + it('Verify INFO logs', done => { + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('info', 'testing info logs with 1234'); + winstonLoggerAdapter.query( + { + from: new Date(Date.now() - 500), + size: 100, + level: 'info', + order: 'desc', + }, + results => { + if (results.length == 0) { + fail('The adapter should return non-empty results'); + } else { + const log = results.find(x => x.message === 'testing info logs with 1234'); + expect(log.level).toEqual('info'); + } + // Check the error log + // Regression #2639 + winstonLoggerAdapter.query( + { + from: new Date(Date.now() - 200), + size: 100, + level: 'error', + }, + errors => { + const log = errors.find(x => x.message === 'testing info logs with 1234'); + expect(log).toBeUndefined(); + done(); + } + ); + } + ); + }); + + it('info logs should interpolate string', async () => { + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('info', 'testing info logs with %s', 'replace'); + const results = await winstonLoggerAdapter.query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'info', + order: 'desc', + }); + expect(results.length > 0).toBeTruthy(); + const log = results.find(x => x.message === 'testing info logs with replace'); + expect(log); + }); + + it('info logs should interpolate json', async () => { + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('info', 'testing info logs with %j', { + hello: 'world', + }); + const results = await winstonLoggerAdapter.query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'info', + order: 'desc', + }); + expect(results.length > 0).toBeTruthy(); + const log = results.find(x => x.message === 'testing info logs with {"hello":"world"}'); + expect(log); + }); + + it('info logs should interpolate number', async () => { + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('info', 'testing info logs with %d', 123); + const results = await winstonLoggerAdapter.query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'info', + order: 'desc', + }); + expect(results.length > 0).toBeTruthy(); + const log = results.find(x => x.message === 'testing info logs with 123'); + expect(log); + }); +}); + +describe_only(() => { + return process.env.PARSE_SERVER_LOG_LEVEL !== 'debug'; +})('error logs', () => { + it('Verify ERROR logs', done => { + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('error', 'testing error logs'); + winstonLoggerAdapter.query( + { + from: new Date(Date.now() - 500), + size: 100, + level: 'error', + }, + results => { + if (results.length == 0) { + fail('The adapter should return non-empty results'); + done(); + } else { + expect(results[0].message).toEqual('testing error logs'); + done(); + } + } + ); + }); + + it('Should filter on query', done => { + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('error', 'testing error logs'); + winstonLoggerAdapter.query( + { + from: new Date(Date.now() - 500), + size: 100, + level: 'error', + }, + results => { + expect(results.filter(e => e.level !== 'error').length).toBe(0); + done(); + } + ); + }); + + it('error logs should interpolate string', async () => { + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('error', 'testing error logs with %s', 'replace'); + const results = await winstonLoggerAdapter.query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'error', + }); + expect(results.length > 0).toBeTruthy(); + const log = results.find(x => x.message === 'testing error logs with replace'); + expect(log); + }); + + it('error logs should interpolate json', async () => { + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('error', 'testing error logs with %j', { + hello: 'world', + }); + const results = await winstonLoggerAdapter.query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'error', + order: 'desc', + }); + expect(results.length > 0).toBeTruthy(); + const log = results.find(x => x.message === 'testing error logs with {"hello":"world"}'); + expect(log); + }); + + it('error logs should interpolate number', async () => { + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('error', 'testing error logs with %d', 123); + const results = await winstonLoggerAdapter.query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'error', + order: 'desc', + }); + expect(results.length > 0).toBeTruthy(); + const log = results.find(x => x.message === 'testing error logs with 123'); + expect(log); + }); +}); + +describe_only(() => { + return process.env.PARSE_SERVER_LOG_LEVEL !== 'debug'; +})('verbose logs', () => { + it_id('9ca72994-d255-4c11-a5a2-693c99ee2cdb')(it)('mask sensitive information in _User class', done => { + reconfigureServer({ verbose: true }) + .then(() => createTestUser()) + .then(() => { + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + return winstonLoggerAdapter.query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'verbose', + }); + }) + .then(results => { + const logString = JSON.stringify(results); + expect(logString.match(/\*\*\*\*\*\*\*\*/g).length).not.toBe(0); + expect(logString.match(/moon-y/g)).toBe(null); + + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + request({ + headers: headers, + url: 'http://localhost:8378/1/login?username=test&password=moon-y', + }).then(() => { + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + return winstonLoggerAdapter + .query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'verbose', + }) + .then(results => { + const logString = JSON.stringify(results); + expect(logString.match(/\*\*\*\*\*\*\*\*/g).length).not.toBe(0); + expect(logString.match(/moon-y/g)).toBe(null); + done(); + }); + }); + }) + .catch(err => { + fail(JSON.stringify(err)); + done(); + }); + }); + + it('verbose logs should interpolate string', async () => { + await reconfigureServer({ verbose: true }); + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('verbose', 'testing verbose logs with %s', 'replace'); + const results = await winstonLoggerAdapter.query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'verbose', + }); + expect(results.length > 0).toBeTruthy(); + const log = results.find(x => x.message === 'testing verbose logs with replace'); + expect(log); + }); + + it('verbose logs should interpolate json', async () => { + await reconfigureServer({ verbose: true }); + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('verbose', 'testing verbose logs with %j', { + hello: 'world', + }); + const results = await winstonLoggerAdapter.query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'verbose', + order: 'desc', + }); + expect(results.length > 0).toBeTruthy(); + const log = results.find(x => x.message === 'testing verbose logs with {"hello":"world"}'); + expect(log); + }); + + it('verbose logs should interpolate number', async () => { + await reconfigureServer({ verbose: true }); + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('verbose', 'testing verbose logs with %d', 123); + const results = await winstonLoggerAdapter.query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'verbose', + order: 'desc', + }); + expect(results.length > 0).toBeTruthy(); + const log = results.find(x => x.message === 'testing verbose logs with 123'); + expect(log); + }); + + it('verbose logs should interpolate stdout', async () => { + await reconfigureServer({ verbose: true, silent: false, logsFolder: null }); + spyOn(process.stdout, 'write'); + const winstonLoggerAdapter = new WinstonLoggerAdapter(); + winstonLoggerAdapter.log('verbose', 'testing verbose logs with %j', { + hello: 'world', + }); + const firstLog = process.stdout.write.calls.first().args[0]; + expect(firstLog).toBe('verbose: testing verbose logs with {"hello":"world"}\n'); + }); +}); diff --git a/spec/batch.spec.js b/spec/batch.spec.js new file mode 100644 index 0000000000..9df64bbdbe --- /dev/null +++ b/spec/batch.spec.js @@ -0,0 +1,912 @@ +const batch = require('../lib/batch'); +const request = require('../lib/request'); + +const originalURL = '/parse/batch'; +const serverURL = 'http://localhost:1234/parse'; +const serverURL1 = 'http://localhost:1234/1'; +const serverURLNaked = 'http://localhost:1234/'; +const publicServerURL = 'http://domain.com/parse'; +const publicServerURLNaked = 'http://domain.com/'; +const publicServerURLLong = 'https://domain.com/something/really/long'; + +const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Installation-Id': 'yolo', +}; + +describe('batch', () => { + let createSpy; + beforeEach(async () => { + createSpy = spyOn(databaseAdapter, 'createObject').and.callThrough(); + }); + + it('should return the proper url', () => { + const internalURL = batch.makeBatchRoutingPathFunction(originalURL)('/parse/classes/Object'); + expect(internalURL).toEqual('/classes/Object'); + }); + + it('should return the proper url given a public url-only path', () => { + const originalURL = '/something/really/long/batch'; + const internalURL = batch.makeBatchRoutingPathFunction( + originalURL, + serverURL, + publicServerURLLong + )('/parse/classes/Object'); + expect(internalURL).toEqual('/classes/Object'); + }); + + it('should return the proper url given a server url-only path', () => { + const originalURL = '/parse/batch'; + const internalURL = batch.makeBatchRoutingPathFunction( + originalURL, + serverURL, + publicServerURLLong + )('/parse/classes/Object'); + expect(internalURL).toEqual('/classes/Object'); + }); + + it('should return the proper url same public/local endpoint', () => { + const originalURL = '/parse/batch'; + const internalURL = batch.makeBatchRoutingPathFunction( + originalURL, + serverURL, + publicServerURL + )('/parse/classes/Object'); + + expect(internalURL).toEqual('/classes/Object'); + }); + + it('should return the proper url with different public/local mount', () => { + const originalURL = '/parse/batch'; + const internalURL = batch.makeBatchRoutingPathFunction( + originalURL, + serverURL1, + publicServerURL + )('/parse/classes/Object'); + + expect(internalURL).toEqual('/classes/Object'); + }); + + it('should return the proper url with naked public', () => { + const originalURL = '/batch'; + const internalURL = batch.makeBatchRoutingPathFunction( + originalURL, + serverURL, + publicServerURLNaked + )('/classes/Object'); + + expect(internalURL).toEqual('/classes/Object'); + }); + + it('should return the proper url with naked local', () => { + const originalURL = '/parse/batch'; + const internalURL = batch.makeBatchRoutingPathFunction( + originalURL, + serverURLNaked, + publicServerURL + )('/parse/classes/Object'); + + expect(internalURL).toEqual('/classes/Object'); + }); + + it('should return the proper url with no url provided', () => { + const originalURL = '/parse/batch'; + const internalURL = batch.makeBatchRoutingPathFunction( + originalURL, + undefined, + publicServerURL + )('/parse/classes/Object'); + + expect(internalURL).toEqual('/classes/Object'); + }); + + it('should return the proper url with no public url provided', () => { + const originalURL = '/parse/batch'; + const internalURL = batch.makeBatchRoutingPathFunction( + originalURL, + serverURLNaked, + undefined + )('/parse/classes/Object'); + + expect(internalURL).toEqual('/classes/Object'); + }); + + it('should return the proper url with bad url provided', () => { + const originalURL = '/parse/batch'; + const internalURL = batch.makeBatchRoutingPathFunction( + originalURL, + 'badurl.com', + publicServerURL + )('/parse/classes/Object'); + + expect(internalURL).toEqual('/classes/Object'); + }); + + it('should return the proper url with bad public url provided', () => { + const originalURL = '/parse/batch'; + const internalURL = batch.makeBatchRoutingPathFunction( + originalURL, + serverURLNaked, + 'badurl.com' + )('/parse/classes/Object'); + + expect(internalURL).toEqual('/classes/Object'); + }); + + it('should handle a batch request without transaction', async () => { + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value2' }, + }, + ], + }), + }); + expect(response.data.length).toEqual(2); + expect(response.data[0].success.objectId).toBeDefined(); + expect(response.data[0].success.createdAt).toBeDefined(); + expect(response.data[1].success.objectId).toBeDefined(); + expect(response.data[1].success.createdAt).toBeDefined(); + const query = new Parse.Query('MyObject'); + const results = await query.find(); + expect(createSpy.calls.count()).toBe(2); + expect(createSpy.calls.argsFor(0)[3]).toEqual(null); + expect(createSpy.calls.argsFor(1)[3]).toEqual(null); + expect(results.map(result => result.get('key')).sort()).toEqual(['value1', 'value2']); + }); + + it('should handle a batch request with transaction = false', async () => { + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value2' }, + }, + ], + transaction: false, + }), + }); + expect(response.data.length).toEqual(2); + expect(response.data[0].success.objectId).toBeDefined(); + expect(response.data[0].success.createdAt).toBeDefined(); + expect(response.data[1].success.objectId).toBeDefined(); + expect(response.data[1].success.createdAt).toBeDefined(); + + const query = new Parse.Query('MyObject'); + const results = await query.find(); + expect(createSpy.calls.count()).toBe(2); + expect(createSpy.calls.argsFor(0)[3]).toEqual(null); + expect(createSpy.calls.argsFor(1)[3]).toEqual(null); + expect(results.map(result => result.get('key')).sort()).toEqual(['value1', 'value2']); + }); + + if ( + process.env.MONGODB_TOPOLOGY === 'replicaset' || + process.env.PARSE_SERVER_TEST_DB === 'postgres' + ) { + describe('transactions', () => { + it('should handle a batch request with transaction = true', async () => { + const myObject = new Parse.Object('MyObject'); // This is important because transaction only works on pre-existing collections + await myObject.save(); + await myObject.destroy(); + createSpy.calls.reset(); + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value2' }, + }, + ], + transaction: true, + }), + }); + expect(response.data.length).toEqual(2); + expect(response.data[0].success.objectId).toBeDefined(); + expect(response.data[0].success.createdAt).toBeDefined(); + expect(response.data[1].success.objectId).toBeDefined(); + expect(response.data[1].success.createdAt).toBeDefined(); + const query = new Parse.Query('MyObject'); + const results = await query.find(); + expect(createSpy.calls.count()).toBe(2); + for (let i = 0; i + 1 < createSpy.calls.length; i = i + 2) { + expect(createSpy.calls.argsFor(i)[3]).toBe( + createSpy.calls.argsFor(i + 1)[3] + ); + } + expect(results.map(result => result.get('key')).sort()).toEqual(['value1', 'value2']); + }); + + it('should not save anything when one operation fails in a transaction', async () => { + const myObject = new Parse.Object('MyObject'); // This is important because transaction only works on pre-existing collections + await myObject.save({ key: 'stringField' }); + await myObject.destroy(); + createSpy.calls.reset(); + try { + // Saving a number to a string field should fail + await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 10 }, + }, + ], + transaction: true, + }), + }); + fail(); + } catch (error) { + expect(error).toBeDefined(); + const query = new Parse.Query('MyObject'); + const results = await query.find(); + expect(results.length).toBe(0); + } + }); + + it('should generate separate session for each call', async () => { + const myObject = new Parse.Object('MyObject'); // This is important because transaction only works on pre-existing collections + await myObject.save({ key: 'stringField' }); + await myObject.destroy(); + + const myObject2 = new Parse.Object('MyObject2'); // This is important because transaction only works on pre-existing collections + await myObject2.save({ key: 'stringField' }); + await myObject2.destroy(); + createSpy.calls.reset(); + + let myObjectCalls = 0; + Parse.Cloud.beforeSave('MyObject', async () => { + myObjectCalls++; + if (myObjectCalls === 2) { + try { + // Saving a number to a string field should fail + await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject2', + body: { key: 10 }, + }, + ], + transaction: true, + }), + }); + fail('should fail'); + } catch (e) { + expect(e).toBeDefined(); + } + } + }); + + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject', + body: { key: 'value2' }, + }, + ], + transaction: true, + }), + }); + + expect(response.data.length).toEqual(2); + expect(response.data[0].success.objectId).toBeDefined(); + expect(response.data[0].success.createdAt).toBeDefined(); + expect(response.data[1].success.objectId).toBeDefined(); + expect(response.data[1].success.createdAt).toBeDefined(); + + await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { + method: 'POST', + path: '/1/classes/MyObject3', + body: { key: 'value1' }, + }, + { + method: 'POST', + path: '/1/classes/MyObject3', + body: { key: 'value2' }, + }, + ], + }), + }); + + const query = new Parse.Query('MyObject'); + const results = await query.find(); + expect(results.map(result => result.get('key')).sort()).toEqual(['value1', 'value2']); + + const query2 = new Parse.Query('MyObject2'); + const results2 = await query2.find(); + expect(results2.length).toEqual(0); + + const query3 = new Parse.Query('MyObject3'); + const results3 = await query3.find(); + expect(results3.map(result => result.get('key')).sort()).toEqual(['value1', 'value2']); + + expect(createSpy.calls.count() >= 13).toEqual(true); + let transactionalSession; + let transactionalSession2; + let myObjectDBCalls = 0; + let myObject2DBCalls = 0; + let myObject3DBCalls = 0; + for (let i = 0; i < createSpy.calls.count(); i++) { + const args = createSpy.calls.argsFor(i); + switch (args[0]) { + case 'MyObject': + myObjectDBCalls++; + if (!transactionalSession || (myObjectDBCalls - 1) % 2 === 0) { + transactionalSession = args[3]; + } else { + expect(transactionalSession).toBe(args[3]); + } + if (transactionalSession2) { + expect(transactionalSession2).not.toBe(args[3]); + } + break; + case 'MyObject2': + myObject2DBCalls++; + if (!transactionalSession2 || (myObject2DBCalls - 1) % 9 === 0) { + transactionalSession2 = args[3]; + } else { + expect(transactionalSession2).toBe(args[3]); + } + if (transactionalSession) { + expect(transactionalSession).not.toBe(args[3]); + } + break; + case 'MyObject3': + myObject3DBCalls++; + expect(args[3]).toEqual(null); + break; + } + } + expect(myObjectDBCalls % 2).toEqual(0); + expect(myObjectDBCalls > 0).toEqual(true); + expect(myObject2DBCalls % 9).toEqual(0); + expect(myObject2DBCalls > 0).toEqual(true); + expect(myObject3DBCalls % 2).toEqual(0); + expect(myObject3DBCalls > 0).toEqual(true); + }); + }); + } + + describe('batch request size limit', () => { + it('should reject batch request when sub-requests exceed batchRequestLimit', async () => { + await reconfigureServer({ + requestComplexity: { batchRequestLimit: 2 }, + }); + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [ + { method: 'GET', path: '/1/classes/TestClass' }, + { method: 'GET', path: '/1/classes/TestClass' }, + { method: 'GET', path: '/1/classes/TestClass' }, + ], + }), + }) + ).toBeRejectedWith( + jasmine.objectContaining({ + status: 400, + data: jasmine.objectContaining({ + error: jasmine.stringContaining('3'), + }), + }) + ); + }); + + it('should allow batch request when sub-requests are within batchRequestLimit', async () => { + await reconfigureServer({ + requestComplexity: { batchRequestLimit: 5 }, + }); + const result = await request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/TestClass', body: { key: 'v1' } }, + { method: 'POST', path: '/1/classes/TestClass', body: { key: 'v2' } }, + ], + }), + }); + expect(result.data.length).toEqual(2); + expect(result.data[0].success.objectId).toBeDefined(); + expect(result.data[1].success.objectId).toBeDefined(); + }); + + it('should allow batch request at exactly batchRequestLimit', async () => { + await reconfigureServer({ + requestComplexity: { batchRequestLimit: 2 }, + }); + const result = await request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/TestClass', body: { key: 'v1' } }, + { method: 'POST', path: '/1/classes/TestClass', body: { key: 'v2' } }, + ], + }), + }); + expect(result.data.length).toEqual(2); + }); + + it('should not limit batch request when batchRequestLimit is -1 (disabled)', async () => { + await reconfigureServer({ + requestComplexity: { batchRequestLimit: -1 }, + }); + const requests = Array.from({ length: 20 }, (_, i) => ({ + method: 'POST', + path: '/1/classes/TestClass', + body: { key: `v${i}` }, + })); + const result = await request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ requests }), + }); + expect(result.data.length).toEqual(20); + }); + + it('should not limit batch request by default (no requestComplexity configured)', async () => { + const requests = Array.from({ length: 20 }, (_, i) => ({ + method: 'POST', + path: '/1/classes/TestClass', + body: { key: `v${i}` }, + })); + const result = await request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ requests }), + }); + expect(result.data.length).toEqual(20); + }); + + it('should bypass batchRequestLimit for master key requests', async () => { + await reconfigureServer({ + requestComplexity: { batchRequestLimit: 2 }, + }); + const result = await request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers: { + ...headers, + 'X-Parse-Master-Key': 'test', + }, + body: JSON.stringify({ + requests: [ + { method: 'GET', path: '/1/classes/TestClass' }, + { method: 'GET', path: '/1/classes/TestClass' }, + { method: 'GET', path: '/1/classes/TestClass' }, + ], + }), + }); + expect(result.data.length).toEqual(3); + }); + + it('should bypass batchRequestLimit for maintenance key requests', async () => { + await reconfigureServer({ + requestComplexity: { batchRequestLimit: 2 }, + }); + const result = await request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers: { + ...headers, + 'X-Parse-Maintenance-Key': 'testing', + }, + body: JSON.stringify({ + requests: [ + { method: 'GET', path: '/1/classes/TestClass' }, + { method: 'GET', path: '/1/classes/TestClass' }, + { method: 'GET', path: '/1/classes/TestClass' }, + ], + }), + }); + expect(result.data.length).toEqual(3); + }); + + it('should include limit in error message when batch exceeds batchRequestLimit', async () => { + await reconfigureServer({ + requestComplexity: { batchRequestLimit: 5 }, + }); + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: Array.from({ length: 10 }, () => ({ + method: 'GET', + path: '/1/classes/TestClass', + })), + }), + }) + ).toBeRejectedWith( + jasmine.objectContaining({ + status: 400, + data: jasmine.objectContaining({ + error: jasmine.stringContaining('5'), + }), + }) + ); + }); + }); + + describe('subrequest path type validation', () => { + it('rejects object path in batch subrequest with proper error instead of 500', async () => { + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [{ method: 'GET', path: { invalid: true } }], + }), + }) + ).toBeRejectedWith( + jasmine.objectContaining({ status: 400 }) + ); + }); + + it('rejects numeric path in batch subrequest', async () => { + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [{ method: 'GET', path: 123 }], + }), + }) + ).toBeRejectedWith( + jasmine.objectContaining({ status: 400 }) + ); + }); + + it('rejects array path in batch subrequest', async () => { + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [{ method: 'GET', path: ['/1/classes/Test'] }], + }), + }) + ).toBeRejectedWith( + jasmine.objectContaining({ status: 400 }) + ); + }); + + it('rejects null path in batch subrequest', async () => { + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [{ method: 'GET', path: null }], + }), + }) + ).toBeRejectedWith( + jasmine.objectContaining({ status: 400 }) + ); + }); + + it('rejects boolean path in batch subrequest', async () => { + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [{ method: 'GET', path: true }], + }), + }) + ).toBeRejectedWith( + jasmine.objectContaining({ status: 400 }) + ); + }); + + it('still accepts valid string path in batch subrequest', async () => { + const result = await request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [{ method: 'GET', path: '/1/classes/TestClass' }], + }), + }); + expect(result.data).toEqual(jasmine.any(Array)); + }); + }); + + describe('nested batch requests', () => { + it('rejects sub-request that targets the batch endpoint', async () => { + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [ + { + method: 'POST', + path: '/1/batch', + body: { + requests: [{ method: 'GET', path: '/1/classes/TestClass' }], + }, + }, + ], + }), + }) + ).toBeRejectedWith( + jasmine.objectContaining({ + status: 400, + data: jasmine.objectContaining({ + error: 'nested batch requests are not allowed', + }), + }) + ); + }); + + it('rejects when any sub-request among valid ones targets the batch endpoint', async () => { + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [ + { method: 'GET', path: '/1/classes/TestClass' }, + { + method: 'POST', + path: '/1/batch', + body: { requests: [{ method: 'GET', path: '/1/classes/TestClass' }] }, + }, + ], + }), + }) + ).toBeRejectedWith( + jasmine.objectContaining({ + status: 400, + data: jasmine.objectContaining({ + error: 'nested batch requests are not allowed', + }), + }) + ); + }); + }); +}); diff --git a/spec/buildConfigDefinitions.spec.js b/spec/buildConfigDefinitions.spec.js new file mode 100644 index 0000000000..bc15793a04 --- /dev/null +++ b/spec/buildConfigDefinitions.spec.js @@ -0,0 +1,219 @@ +const t = require('@babel/types'); +const { mapperFor } = require('../resources/buildConfigDefinitions'); + +describe('buildConfigDefinitions', () => { + describe('mapperFor', () => { + it('should return objectParser for ObjectTypeAnnotation', () => { + const mockElement = { + type: 'ObjectTypeAnnotation', + }; + + const result = mapperFor(mockElement, t); + + expect(t.isMemberExpression(result)).toBe(true); + expect(result.object.name).toBe('parsers'); + expect(result.property.name).toBe('objectParser'); + }); + + it('should return objectParser for AnyTypeAnnotation', () => { + const mockElement = { + type: 'AnyTypeAnnotation', + }; + + const result = mapperFor(mockElement, t); + + expect(t.isMemberExpression(result)).toBe(true); + expect(result.object.name).toBe('parsers'); + expect(result.property.name).toBe('objectParser'); + }); + + it('should return arrayParser for ArrayTypeAnnotation', () => { + const mockElement = { + type: 'ArrayTypeAnnotation', + }; + + const result = mapperFor(mockElement, t); + + expect(t.isMemberExpression(result)).toBe(true); + expect(result.object.name).toBe('parsers'); + expect(result.property.name).toBe('arrayParser'); + }); + + it('should return booleanParser for BooleanTypeAnnotation', () => { + const mockElement = { + type: 'BooleanTypeAnnotation', + }; + + const result = mapperFor(mockElement, t); + + expect(t.isMemberExpression(result)).toBe(true); + expect(result.object.name).toBe('parsers'); + expect(result.property.name).toBe('booleanParser'); + }); + + it('should return numberParser call expression for NumberTypeAnnotation', () => { + const mockElement = { + type: 'NumberTypeAnnotation', + name: 'testNumber', + }; + + const result = mapperFor(mockElement, t); + + expect(t.isCallExpression(result)).toBe(true); + expect(result.callee.property.name).toBe('numberParser'); + expect(result.arguments[0].value).toBe('testNumber'); + }); + + it('should return moduleOrObjectParser for Adapter GenericTypeAnnotation', () => { + const mockElement = { + type: 'GenericTypeAnnotation', + typeAnnotation: { + id: { + name: 'Adapter', + }, + }, + }; + + const result = mapperFor(mockElement, t); + + expect(t.isMemberExpression(result)).toBe(true); + expect(result.object.name).toBe('parsers'); + expect(result.property.name).toBe('moduleOrObjectParser'); + }); + + it('should return numberOrBooleanParser for NumberOrBoolean GenericTypeAnnotation', () => { + const mockElement = { + type: 'GenericTypeAnnotation', + typeAnnotation: { + id: { + name: 'NumberOrBoolean', + }, + }, + }; + + const result = mapperFor(mockElement, t); + + expect(t.isMemberExpression(result)).toBe(true); + expect(result.object.name).toBe('parsers'); + expect(result.property.name).toBe('numberOrBooleanParser'); + }); + + it('should return numberOrStringParser call expression for NumberOrString GenericTypeAnnotation', () => { + const mockElement = { + type: 'GenericTypeAnnotation', + name: 'testString', + typeAnnotation: { + id: { + name: 'NumberOrString', + }, + }, + }; + + const result = mapperFor(mockElement, t); + + expect(t.isCallExpression(result)).toBe(true); + expect(result.callee.property.name).toBe('numberOrStringParser'); + expect(result.arguments[0].value).toBe('testString'); + }); + + it('should return arrayParser for StringOrStringArray GenericTypeAnnotation', () => { + const mockElement = { + type: 'GenericTypeAnnotation', + typeAnnotation: { + id: { + name: 'StringOrStringArray', + }, + }, + }; + + const result = mapperFor(mockElement, t); + + expect(t.isMemberExpression(result)).toBe(true); + expect(result.object.name).toBe('parsers'); + expect(result.property.name).toBe('arrayParser'); + }); + + it('should return booleanOrFunctionParser for UnionTypeAnnotation containing boolean (nullable)', () => { + const mockElement = { + type: 'UnionTypeAnnotation', + typeAnnotation: { + types: [ + { type: 'BooleanTypeAnnotation' }, + { type: 'FunctionTypeAnnotation' }, + ], + }, + }; + + const result = mapperFor(mockElement, t); + + expect(t.isMemberExpression(result)).toBe(true); + expect(result.object.name).toBe('parsers'); + expect(result.property.name).toBe('booleanOrFunctionParser'); + }); + + it('should return booleanOrFunctionParser for UnionTypeAnnotation containing boolean (non-nullable)', () => { + const mockElement = { + type: 'UnionTypeAnnotation', + types: [ + { type: 'BooleanTypeAnnotation' }, + { type: 'FunctionTypeAnnotation' }, + ], + }; + + const result = mapperFor(mockElement, t); + + expect(t.isMemberExpression(result)).toBe(true); + expect(result.object.name).toBe('parsers'); + expect(result.property.name).toBe('booleanOrFunctionParser'); + }); + + it('should return undefined for UnionTypeAnnotation without boolean', () => { + const mockElement = { + type: 'UnionTypeAnnotation', + typeAnnotation: { + types: [ + { type: 'StringTypeAnnotation' }, + { type: 'NumberTypeAnnotation' }, + ], + }, + }; + + const result = mapperFor(mockElement, t); + + expect(result).toBeUndefined(); + }); + + it('should return undefined for UnionTypeAnnotation with boolean but without function', () => { + const mockElement = { + type: 'UnionTypeAnnotation', + typeAnnotation: { + types: [ + { type: 'BooleanTypeAnnotation' }, + { type: 'VoidTypeAnnotation' }, + ], + }, + }; + + const result = mapperFor(mockElement, t); + + expect(result).toBeUndefined(); + }); + + it('should return objectParser for unknown GenericTypeAnnotation', () => { + const mockElement = { + type: 'GenericTypeAnnotation', + typeAnnotation: { + id: { + name: 'UnknownType', + }, + }, + }; + + const result = mapperFor(mockElement, t); + + expect(t.isMemberExpression(result)).toBe(true); + expect(result.object.name).toBe('parsers'); + expect(result.property.name).toBe('objectParser'); + }); + }); +}); diff --git a/spec/cloud/cloudCodeAbsoluteFile.js b/spec/cloud/cloudCodeAbsoluteFile.js index f5fcf2b856..a62b4fcc24 100644 --- a/spec/cloud/cloudCodeAbsoluteFile.js +++ b/spec/cloud/cloudCodeAbsoluteFile.js @@ -1,3 +1,3 @@ -Parse.Cloud.define('cloudCodeInFile', (req, res) => { - res.success('It is possible to define cloud code in a file.'); +Parse.Cloud.define('cloudCodeInFile', () => { + return 'It is possible to define cloud code in a file.'; }); diff --git a/spec/cloud/cloudCodeModuleFile.js b/spec/cloud/cloudCodeModuleFile.js new file mode 100644 index 0000000000..a62b4fcc24 --- /dev/null +++ b/spec/cloud/cloudCodeModuleFile.js @@ -0,0 +1,3 @@ +Parse.Cloud.define('cloudCodeInFile', () => { + return 'It is possible to define cloud code in a file.'; +}); diff --git a/spec/cloud/cloudCodeRelativeFile.js b/spec/cloud/cloudCodeRelativeFile.js index f5fcf2b856..a62b4fcc24 100644 --- a/spec/cloud/cloudCodeRelativeFile.js +++ b/spec/cloud/cloudCodeRelativeFile.js @@ -1,3 +1,3 @@ -Parse.Cloud.define('cloudCodeInFile', (req, res) => { - res.success('It is possible to define cloud code in a file.'); +Parse.Cloud.define('cloudCodeInFile', () => { + return 'It is possible to define cloud code in a file.'; }); diff --git a/spec/configs/CLIConfigApps.json b/spec/configs/CLIConfigApps.json index 65ca875603..dc4a7cee74 100644 --- a/spec/configs/CLIConfigApps.json +++ b/spec/configs/CLIConfigApps.json @@ -1,9 +1,10 @@ { "apps": [ - { - "arg1": "my_app", - "arg2": 8888, - "arg3": "hello", - "arg4": "/1" - }] + { + "arg1": "my_app", + "arg2": 8888, + "arg3": "hello", + "arg4": "/1" + } + ] } diff --git a/spec/configs/CLIConfigAuth.json b/spec/configs/CLIConfigAuth.json new file mode 100644 index 0000000000..37a2a5f373 --- /dev/null +++ b/spec/configs/CLIConfigAuth.json @@ -0,0 +1,11 @@ +{ + "appName": "test", + "appId": "test", + "masterKey": "test", + "logLevel": "error", + "auth": { + "facebook": { + "appIds": "test" + } + } +} diff --git a/spec/configs/CLIConfigFailTooManyApps.json b/spec/configs/CLIConfigFailTooManyApps.json index 381517d38c..4367019581 100644 --- a/spec/configs/CLIConfigFailTooManyApps.json +++ b/spec/configs/CLIConfigFailTooManyApps.json @@ -1,16 +1,16 @@ { "apps": [ - { - "arg1": "my_app", - "arg2": "99999", - "arg3": "hello", - "arg4": "/1" - }, - { - "arg1": "my_app2", - "arg2": "9999", - "arg3": "hello", - "arg4": "/1" - } + { + "arg1": "my_app", + "arg2": "99999", + "arg3": "hello", + "arg4": "/1" + }, + { + "arg1": "my_app2", + "arg2": "9999", + "arg3": "hello", + "arg4": "/1" + } ] } diff --git a/spec/cryptoUtils.spec.js b/spec/cryptoUtils.spec.js index cd9967705f..8270e052cf 100644 --- a/spec/cryptoUtils.spec.js +++ b/spec/cryptoUtils.spec.js @@ -1,9 +1,9 @@ -var cryptoUtils = require('../src/cryptoUtils'); +const cryptoUtils = require('../lib/cryptoUtils'); function givesUniqueResults(fn, iterations) { - var results = {}; - for (var i = 0; i < iterations; i++) { - var s = fn(); + const results = {}; + for (let i = 0; i < iterations; i++) { + const s = fn(); if (results[s]) { return false; } @@ -63,6 +63,10 @@ describe('newObjectId', () => { expect(cryptoUtils.newObjectId().length).toBeGreaterThan(9); }); + it('returns result with required number of characters', () => { + expect(cryptoUtils.newObjectId(42).length).toBe(42); + }); + it('returns unique results', () => { expect(givesUniqueResults(() => cryptoUtils.newObjectId(), 100)).toBe(true); }); diff --git a/spec/defaultGraphQLTypes.spec.js b/spec/defaultGraphQLTypes.spec.js new file mode 100644 index 0000000000..4e3e311467 --- /dev/null +++ b/spec/defaultGraphQLTypes.spec.js @@ -0,0 +1,608 @@ +const { Kind } = require('graphql'); +const { + TypeValidationError, + parseStringValue, + parseIntValue, + parseFloatValue, + parseBooleanValue, + parseDateIsoValue, + parseValue, + parseListValues, + parseObjectFields, + BYTES, + DATE, + FILE, +} = require('../lib/GraphQL/loaders/defaultGraphQLTypes'); + +function createValue(kind, value, values, fields) { + return { + kind, + value, + values, + fields, + }; +} + +function createObjectField(name, value) { + return { + name: { + value: name, + }, + value, + }; +} + +describe('defaultGraphQLTypes', () => { + describe('TypeValidationError', () => { + it('should be an error with specific message', () => { + const typeValidationError = new TypeValidationError('somevalue', 'sometype'); + expect(typeValidationError).toEqual(jasmine.any(Error)); + expect(typeValidationError.message).toEqual('somevalue is not a valid sometype'); + }); + }); + + describe('parseStringValue', () => { + it('should return itself if a string', () => { + const myString = 'myString'; + expect(parseStringValue(myString)).toBe(myString); + }); + + it('should fail if not a string', () => { + expect(() => parseStringValue()).toThrow(jasmine.stringMatching('is not a valid String')); + expect(() => parseStringValue({})).toThrow(jasmine.stringMatching('is not a valid String')); + expect(() => parseStringValue([])).toThrow(jasmine.stringMatching('is not a valid String')); + expect(() => parseStringValue(123)).toThrow(jasmine.stringMatching('is not a valid String')); + }); + }); + + describe('parseIntValue', () => { + it('should parse to number if a string', () => { + const myString = '123'; + expect(parseIntValue(myString)).toBe(123); + }); + + it('should fail if not a string', () => { + expect(() => parseIntValue()).toThrow(jasmine.stringMatching('is not a valid Int')); + expect(() => parseIntValue({})).toThrow(jasmine.stringMatching('is not a valid Int')); + expect(() => parseIntValue([])).toThrow(jasmine.stringMatching('is not a valid Int')); + expect(() => parseIntValue(123)).toThrow(jasmine.stringMatching('is not a valid Int')); + }); + + it('should fail if not an integer string', () => { + expect(() => parseIntValue('a123')).toThrow(jasmine.stringMatching('is not a valid Int')); + expect(() => parseIntValue('123.4')).toThrow(jasmine.stringMatching('is not a valid Int')); + }); + }); + + describe('parseFloatValue', () => { + it('should parse to number if a string', () => { + expect(parseFloatValue('123')).toBe(123); + expect(parseFloatValue('123.4')).toBe(123.4); + }); + + it('should fail if not a string', () => { + expect(() => parseFloatValue()).toThrow(jasmine.stringMatching('is not a valid Float')); + expect(() => parseFloatValue({})).toThrow(jasmine.stringMatching('is not a valid Float')); + expect(() => parseFloatValue([])).toThrow(jasmine.stringMatching('is not a valid Float')); + }); + + it('should fail if not a float string', () => { + expect(() => parseIntValue('a123')).toThrow(jasmine.stringMatching('is not a valid Int')); + }); + }); + + describe('parseBooleanValue', () => { + it('should return itself if a boolean', () => { + let myBoolean = true; + expect(parseBooleanValue(myBoolean)).toBe(myBoolean); + myBoolean = false; + expect(parseBooleanValue(myBoolean)).toBe(myBoolean); + }); + + it('should fail if not a boolean', () => { + expect(() => parseBooleanValue()).toThrow(jasmine.stringMatching('is not a valid Boolean')); + expect(() => parseBooleanValue({})).toThrow(jasmine.stringMatching('is not a valid Boolean')); + expect(() => parseBooleanValue([])).toThrow(jasmine.stringMatching('is not a valid Boolean')); + expect(() => parseBooleanValue(123)).toThrow( + jasmine.stringMatching('is not a valid Boolean') + ); + expect(() => parseBooleanValue('true')).toThrow( + jasmine.stringMatching('is not a valid Boolean') + ); + }); + }); + + describe('parseDateValue', () => { + it('should parse to date if a string', () => { + const myDateString = '2019-05-09T23:12:00.000Z'; + const myDate = new Date(Date.UTC(2019, 4, 9, 23, 12, 0, 0)); + expect(parseDateIsoValue(myDateString)).toEqual(myDate); + }); + + it('should fail if not a string', () => { + expect(() => parseDateIsoValue()).toThrow(jasmine.stringMatching('is not a valid Date')); + expect(() => parseDateIsoValue({})).toThrow(jasmine.stringMatching('is not a valid Date')); + expect(() => parseDateIsoValue([])).toThrow(jasmine.stringMatching('is not a valid Date')); + expect(() => parseDateIsoValue(123)).toThrow(jasmine.stringMatching('is not a valid Date')); + }); + + it('should fail if not a date string', () => { + expect(() => parseDateIsoValue('not a date')).toThrow( + jasmine.stringMatching('is not a valid Date') + ); + }); + }); + + describe('parseValue', () => { + const someString = createValue(Kind.STRING, 'somestring'); + const someInt = createValue(Kind.INT, '123'); + const someFloat = createValue(Kind.FLOAT, '123.4'); + const someBoolean = createValue(Kind.BOOLEAN, true); + const someOther = createValue(undefined, new Object()); + const someObject = createValue(Kind.OBJECT, undefined, undefined, [ + createObjectField('someString', someString), + createObjectField('someInt', someInt), + createObjectField('someFloat', someFloat), + createObjectField('someBoolean', someBoolean), + createObjectField('someOther', someOther), + createObjectField( + 'someList', + createValue(Kind.LIST, undefined, [ + createValue(Kind.OBJECT, undefined, undefined, [ + createObjectField('someString', someString), + ]), + ]) + ), + createObjectField( + 'someObject', + createValue(Kind.OBJECT, undefined, undefined, [ + createObjectField('someString', someString), + ]) + ), + ]); + const someList = createValue(Kind.LIST, undefined, [ + someString, + someInt, + someFloat, + someBoolean, + someObject, + someOther, + createValue(Kind.LIST, undefined, [ + someString, + someInt, + someFloat, + someBoolean, + someObject, + someOther, + ]), + ]); + + it('should parse string', () => { + expect(parseValue(someString)).toEqual('somestring'); + }); + + it('should parse int', () => { + expect(parseValue(someInt)).toEqual(123); + }); + + it('should parse float', () => { + expect(parseValue(someFloat)).toEqual(123.4); + }); + + it('should parse boolean', () => { + expect(parseValue(someBoolean)).toEqual(true); + }); + + it('should parse list', () => { + expect(parseValue(someList)).toEqual([ + 'somestring', + 123, + 123.4, + true, + { + someString: 'somestring', + someInt: 123, + someFloat: 123.4, + someBoolean: true, + someOther: {}, + someList: [ + { + someString: 'somestring', + }, + ], + someObject: { + someString: 'somestring', + }, + }, + {}, + [ + 'somestring', + 123, + 123.4, + true, + { + someString: 'somestring', + someInt: 123, + someFloat: 123.4, + someBoolean: true, + someOther: {}, + someList: [ + { + someString: 'somestring', + }, + ], + someObject: { + someString: 'somestring', + }, + }, + {}, + ], + ]); + }); + + it('should parse object', () => { + expect(parseValue(someObject)).toEqual({ + someString: 'somestring', + someInt: 123, + someFloat: 123.4, + someBoolean: true, + someOther: {}, + someList: [ + { + someString: 'somestring', + }, + ], + someObject: { + someString: 'somestring', + }, + }); + }); + + it('should return value otherwise', () => { + expect(parseValue(someOther)).toEqual(new Object()); + }); + }); + + describe('parseListValues', () => { + it('should parse to list if an array', () => { + expect( + parseListValues([ + { kind: Kind.STRING, value: 'someString' }, + { kind: Kind.INT, value: '123' }, + ]) + ).toEqual(['someString', 123]); + }); + + it('should fail if not an array', () => { + expect(() => parseListValues()).toThrow(jasmine.stringMatching('is not a valid List')); + expect(() => parseListValues({})).toThrow(jasmine.stringMatching('is not a valid List')); + expect(() => parseListValues('some string')).toThrow( + jasmine.stringMatching('is not a valid List') + ); + expect(() => parseListValues(123)).toThrow(jasmine.stringMatching('is not a valid List')); + }); + }); + + describe('parseObjectFields', () => { + it('should parse to list if an array', () => { + expect( + parseObjectFields([ + { + name: { value: 'someString' }, + value: { kind: Kind.STRING, value: 'someString' }, + }, + { + name: { value: 'someInt' }, + value: { kind: Kind.INT, value: '123' }, + }, + ]) + ).toEqual({ + someString: 'someString', + someInt: 123, + }); + }); + + it('should fail if not an array', () => { + expect(() => parseObjectFields()).toThrow(jasmine.stringMatching('is not a valid Object')); + expect(() => parseObjectFields({})).toThrow(jasmine.stringMatching('is not a valid Object')); + expect(() => parseObjectFields('some string')).toThrow( + jasmine.stringMatching('is not a valid Object') + ); + expect(() => parseObjectFields(123)).toThrow(jasmine.stringMatching('is not a valid Object')); + }); + }); + + describe('Date', () => { + describe('parse literal', () => { + const { parseLiteral } = DATE; + + it('should parse to date if string', () => { + const date = '2019-05-09T23:12:00.000Z'; + expect(parseLiteral(createValue(Kind.STRING, date))).toEqual({ + __type: 'Date', + iso: new Date(date), + }); + }); + + it('should parse to date if object', () => { + const date = '2019-05-09T23:12:00.000Z'; + expect( + parseLiteral( + createValue(Kind.OBJECT, undefined, undefined, [ + createObjectField('__type', { value: 'Date' }), + createObjectField('iso', { value: date, kind: Kind.STRING }), + ]) + ) + ).toEqual({ + __type: 'Date', + iso: new Date(date), + }); + }); + + it('should fail if not an valid object or string', () => { + expect(() => parseLiteral({})).toThrow(jasmine.stringMatching('is not a valid Date')); + expect(() => + parseLiteral( + createValue(Kind.OBJECT, undefined, undefined, [ + createObjectField('__type', { value: 'Foo' }), + createObjectField('iso', { value: '2019-05-09T23:12:00.000Z' }), + ]) + ) + ).toThrow(jasmine.stringMatching('is not a valid Date')); + expect(() => parseLiteral([])).toThrow(jasmine.stringMatching('is not a valid Date')); + expect(() => parseLiteral(123)).toThrow(jasmine.stringMatching('is not a valid Date')); + }); + }); + + describe('parse value', () => { + const { parseValue } = DATE; + + it('should parse string value', () => { + const date = '2019-05-09T23:12:00.000Z'; + expect(parseValue(date)).toEqual({ + __type: 'Date', + iso: new Date(date), + }); + }); + + it('should parse object value', () => { + const input = { + __type: 'Date', + iso: new Date('2019-05-09T23:12:00.000Z'), + }; + expect(parseValue(input)).toEqual(input); + }); + + it('should fail if not an valid object or string', () => { + expect(() => parseValue({})).toThrow(jasmine.stringMatching('is not a valid Date')); + expect(() => + parseValue({ + __type: 'Foo', + iso: '2019-05-09T23:12:00.000Z', + }) + ).toThrow(jasmine.stringMatching('is not a valid Date')); + expect(() => + parseValue({ + __type: 'Date', + iso: 'foo', + }) + ).toThrow(jasmine.stringMatching('is not a valid Date')); + expect(() => parseValue([])).toThrow(jasmine.stringMatching('is not a valid Date')); + expect(() => parseValue(123)).toThrow(jasmine.stringMatching('is not a valid Date')); + }); + }); + + describe('serialize date type', () => { + const { serialize } = DATE; + + it('should do nothing if string', () => { + const str = '2019-05-09T23:12:00.000Z'; + expect(serialize(str)).toBe(str); + }); + + it('should serialize date', () => { + const date = new Date(); + expect(serialize(date)).toBe(date.toISOString()); + }); + + it('should return iso value if object', () => { + const iso = '2019-05-09T23:12:00.000Z'; + const date = { + __type: 'Date', + iso, + }; + expect(serialize(date)).toEqual(iso); + }); + + it('should fail if not an valid object or string', () => { + expect(() => serialize({})).toThrow(jasmine.stringMatching('is not a valid Date')); + expect(() => + serialize({ + __type: 'Foo', + iso: '2019-05-09T23:12:00.000Z', + }) + ).toThrow(jasmine.stringMatching('is not a valid Date')); + expect(() => serialize([])).toThrow(jasmine.stringMatching('is not a valid Date')); + expect(() => serialize(123)).toThrow(jasmine.stringMatching('is not a valid Date')); + }); + }); + }); + + describe('Bytes', () => { + describe('parse literal', () => { + const { parseLiteral } = BYTES; + + it('should parse to bytes if string', () => { + expect(parseLiteral(createValue(Kind.STRING, 'bytesContent'))).toEqual({ + __type: 'Bytes', + base64: 'bytesContent', + }); + }); + + it('should parse to bytes if object', () => { + expect( + parseLiteral( + createValue(Kind.OBJECT, undefined, undefined, [ + createObjectField('__type', { value: 'Bytes' }), + createObjectField('base64', { value: 'bytesContent' }), + ]) + ) + ).toEqual({ + __type: 'Bytes', + base64: 'bytesContent', + }); + }); + + it('should fail if not an valid object or string', () => { + expect(() => parseLiteral({})).toThrow(jasmine.stringMatching('is not a valid Bytes')); + expect(() => + parseLiteral( + createValue(Kind.OBJECT, undefined, undefined, [ + createObjectField('__type', { value: 'Foo' }), + createObjectField('base64', { value: 'bytesContent' }), + ]) + ) + ).toThrow(jasmine.stringMatching('is not a valid Bytes')); + expect(() => parseLiteral([])).toThrow(jasmine.stringMatching('is not a valid Bytes')); + expect(() => parseLiteral(123)).toThrow(jasmine.stringMatching('is not a valid Bytes')); + }); + }); + + describe('parse value', () => { + const { parseValue } = BYTES; + + it('should parse string value', () => { + expect(parseValue('bytesContent')).toEqual({ + __type: 'Bytes', + base64: 'bytesContent', + }); + }); + + it('should parse object value', () => { + const input = { + __type: 'Bytes', + base64: 'bytesContent', + }; + expect(parseValue(input)).toEqual(input); + }); + + it('should fail if not an valid object or string', () => { + expect(() => parseValue({})).toThrow(jasmine.stringMatching('is not a valid Bytes')); + expect(() => + parseValue({ + __type: 'Foo', + base64: 'bytesContent', + }) + ).toThrow(jasmine.stringMatching('is not a valid Bytes')); + expect(() => parseValue([])).toThrow(jasmine.stringMatching('is not a valid Bytes')); + expect(() => parseValue(123)).toThrow(jasmine.stringMatching('is not a valid Bytes')); + }); + }); + + describe('serialize bytes type', () => { + const { serialize } = BYTES; + + it('should do nothing if string', () => { + const str = 'foo'; + expect(serialize(str)).toBe(str); + }); + + it('should return base64 value if object', () => { + const base64Content = 'bytesContent'; + const bytes = { + __type: 'Bytes', + base64: base64Content, + }; + expect(serialize(bytes)).toEqual(base64Content); + }); + + it('should fail if not an valid object or string', () => { + expect(() => serialize({})).toThrow(jasmine.stringMatching('is not a valid Bytes')); + expect(() => + serialize({ + __type: 'Foo', + base64: 'bytesContent', + }) + ).toThrow(jasmine.stringMatching('is not a valid Bytes')); + expect(() => serialize([])).toThrow(jasmine.stringMatching('is not a valid Bytes')); + expect(() => serialize(123)).toThrow(jasmine.stringMatching('is not a valid Bytes')); + }); + }); + }); + + describe('File', () => { + describe('parse literal', () => { + const { parseLiteral } = FILE; + + it('should parse to file if string', () => { + expect(parseLiteral(createValue(Kind.STRING, 'parsefile'))).toEqual({ + __type: 'File', + name: 'parsefile', + }); + }); + + it('should parse to file if object', () => { + expect( + parseLiteral( + createValue(Kind.OBJECT, undefined, undefined, [ + createObjectField('__type', { value: 'File' }), + createObjectField('name', { value: 'parsefile' }), + createObjectField('url', { value: 'myurl' }), + ]) + ) + ).toEqual({ + __type: 'File', + name: 'parsefile', + url: 'myurl', + }); + }); + + it('should fail if not an valid object or string', () => { + expect(() => parseLiteral({})).toThrow(jasmine.stringMatching('is not a valid File')); + expect(() => + parseLiteral( + createValue(Kind.OBJECT, undefined, undefined, [ + createObjectField('__type', { value: 'Foo' }), + createObjectField('name', { value: 'parsefile' }), + createObjectField('url', { value: 'myurl' }), + ]) + ) + ).toThrow(jasmine.stringMatching('is not a valid File')); + expect(() => parseLiteral([])).toThrow(jasmine.stringMatching('is not a valid File')); + expect(() => parseLiteral(123)).toThrow(jasmine.stringMatching('is not a valid File')); + }); + }); + + describe('serialize file type', () => { + const { serialize } = FILE; + + it('should do nothing if string', () => { + const str = 'foo'; + expect(serialize(str)).toBe(str); + }); + + it('should return file name if object', () => { + const fileName = 'parsefile'; + const file = { + __type: 'File', + name: fileName, + url: 'myurl', + }; + expect(serialize(file)).toEqual(fileName); + }); + + it('should fail if not an valid object or string', () => { + expect(() => serialize({})).toThrow(jasmine.stringMatching('is not a valid File')); + expect(() => + serialize({ + __type: 'Foo', + name: 'parsefile', + url: 'myurl', + }) + ).toThrow(jasmine.stringMatching('is not a valid File')); + expect(() => serialize([])).toThrow(jasmine.stringMatching('is not a valid File')); + expect(() => serialize(123)).toThrow(jasmine.stringMatching('is not a valid File')); + }); + }); + }); +}); diff --git a/spec/dependencies/mock-files-adapter/index.js b/spec/dependencies/mock-files-adapter/index.js new file mode 100644 index 0000000000..ad5e301da7 --- /dev/null +++ b/spec/dependencies/mock-files-adapter/index.js @@ -0,0 +1,31 @@ +/** + * A mock files adapter for testing. + */ +class MockFilesAdapter { + constructor(options = {}) { + if (options.throw) { + throw 'MockFilesAdapterConstructor'; + } + } + createFile() { + return 'MockFilesAdapterCreateFile'; + } + deleteFile() { + return 'MockFilesAdapterDeleteFile'; + } + getFileData() { + return 'MockFilesAdapterGetFileData'; + } + getFileLocation() { + return 'MockFilesAdapterGetFileLocation'; + } + validateFilename() { + return 'MockFilesAdapterValidateFilename'; + } + handleFileStream() { + return 'MockFilesAdapterHandleFileStream'; + } +} + +module.exports = MockFilesAdapter; +module.exports.default = MockFilesAdapter; diff --git a/spec/dependencies/mock-files-adapter/package.json b/spec/dependencies/mock-files-adapter/package.json new file mode 100644 index 0000000000..8deb89f5a0 --- /dev/null +++ b/spec/dependencies/mock-files-adapter/package.json @@ -0,0 +1,6 @@ +{ + "name": "mock-files-adapter", + "version": "1.0.0", + "description": "Mock files adapter for tests.", + "main": "index.js" +} diff --git a/spec/dependencies/mock-mail-adapter/index.js b/spec/dependencies/mock-mail-adapter/index.js new file mode 100644 index 0000000000..63fd6e4e78 --- /dev/null +++ b/spec/dependencies/mock-mail-adapter/index.js @@ -0,0 +1,15 @@ +/** + * A mock mail adapter for testing. + */ +class MockMailAdapter { + constructor(options = {}) { + if (options.throw) { + throw 'MockMailAdapterConstructor'; + } + } + sendMail() { + return 'MockMailAdapterSendMail'; + } +} + +module.exports = MockMailAdapter; diff --git a/spec/dependencies/mock-mail-adapter/package.json b/spec/dependencies/mock-mail-adapter/package.json new file mode 100644 index 0000000000..60ed2fc8f6 --- /dev/null +++ b/spec/dependencies/mock-mail-adapter/package.json @@ -0,0 +1,6 @@ +{ + "name": "mock-mail-adapter", + "version": "1.0.0", + "description": "Mock mail adapter for tests.", + "main": "index.js" +} diff --git a/spec/eslint.config.js b/spec/eslint.config.js new file mode 100644 index 0000000000..211996ca21 --- /dev/null +++ b/spec/eslint.config.js @@ -0,0 +1,107 @@ +const js = require("@eslint/js"); +const globals = require("globals"); +module.exports = [ + js.configs.recommended, + { + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: { + ...globals.node, + ...globals.jasmine, + mockFetch: "readonly", + Parse: "readonly", + reconfigureServer: "readonly", + createTestUser: "readonly", + jfail: "readonly", + ok: "readonly", + strictEqual: "readonly", + TestObject: "readonly", + Item: "readonly", + Container: "readonly", + equal: "readonly", + expectAsync: "readonly", + notEqual: "readonly", + it_id: "readonly", + fit_id: "readonly", + it_only_db: "readonly", + fit_only_db: "readonly", + it_only_mongodb_version: "readonly", + it_only_postgres_version: "readonly", + it_only_node_version: "readonly", + fit_only_mongodb_version: "readonly", + fit_only_postgres_version: "readonly", + fit_only_node_version: "readonly", + it_exclude_dbs: "readonly", + fit_exclude_dbs: "readonly", + describe_only_db: "readonly", + fdescribe_only_db: "readonly", + describe_only: "readonly", + fdescribe_only: "readonly", + on_db: "readonly", + defaultConfiguration: "readonly", + range: "readonly", + jequal: "readonly", + create: "readonly", + arrayContains: "readonly", + databaseAdapter: "readonly", + databaseURI: "readonly" + }, + }, + rules: { + indent: ["error", 2, { SwitchCase: 1 }], + "linebreak-style": ["error", "unix"], + "no-trailing-spaces": "error", + "eol-last": "error", + "space-in-parens": ["error", "never"], + "no-multiple-empty-lines": "warn", + "prefer-const": "error", + "space-infix-ops": "error", + "no-useless-escape": "off", + "require-atomic-updates": "off", + "object-curly-spacing": ["error", "always"], + curly: ["error", "all"], + "block-spacing": ["error", "always"], + "no-unused-vars": "off", + "no-restricted-syntax": [ + "error", + { + selector: "BinaryExpression[operator='instanceof'][right.name='Date']", + message: "Use Utils.isDate() instead of instanceof Date (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='RegExp']", + message: "Use Utils.isRegExp() instead of instanceof RegExp (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Error']", + message: "Use Utils.isNativeError() instead of instanceof Error (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Promise']", + message: "Use Utils.isPromise() instead of instanceof Promise (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Map']", + message: "Use Utils.isMap() instead of instanceof Map (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Object']", + message: "Use Utils.isObject() instead of instanceof Object (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Set']", + message: "Use Utils.isSet() instead of instanceof Set (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Buffer']", + message: "Use Buffer.isBuffer() instead of instanceof Buffer (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Array']", + message: "Use Array.isArray() instead of instanceof Array (cross-realm safe).", + }, + ], + }, + }, +]; diff --git a/spec/features.spec.js b/spec/features.spec.js index c2a60ebd8e..201e01293d 100644 --- a/spec/features.spec.js +++ b/spec/features.spec.js @@ -1,20 +1,43 @@ 'use strict'; -const request = require("request"); +const request = require('../lib/request'); describe('features', () => { - it('requires the master key to get features', done => { - request.get({ + it('should return the serverInfo', async () => { + const response = await request({ url: 'http://localhost:8378/1/serverInfo', json: true, headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(403); - expect(body.error).toEqual('unauthorized: master key is required'); - done(); + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + }, }); + const data = response.data; + expect(data).toBeDefined(); + expect(data.features).toBeDefined(); + expect(data.parseServerVersion).toBeDefined(); + }); + + it('requires the master key to get features', async done => { + const logger = require('../lib/logger').default; + const loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + loggerErrorSpy.calls.reset(); + try { + await request({ + url: 'http://localhost:8378/1/serverInfo', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }); + done.fail('The serverInfo request should be rejected without the master key'); + } catch (error) { + expect(error.status).toEqual(403); + expect(error.data.error).toEqual('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('unauthorized: master key is required')); + done(); + } }); }); diff --git a/spec/graphQLObjectsQueries.js b/spec/graphQLObjectsQueries.js new file mode 100644 index 0000000000..f8783c67f8 --- /dev/null +++ b/spec/graphQLObjectsQueries.js @@ -0,0 +1,126 @@ +const { offsetToCursor } = require('graphql-relay'); +const { calculateSkipAndLimit } = require('../lib/GraphQL/helpers/objectsQueries'); + +describe('GraphQL objectsQueries', () => { + describe('calculateSkipAndLimit', () => { + it('should fail with invalid params', () => { + expect(() => calculateSkipAndLimit(-1)).toThrow( + jasmine.stringMatching('Skip should be a positive number') + ); + expect(() => calculateSkipAndLimit(1, -1)).toThrow( + jasmine.stringMatching('First should be a positive number') + ); + expect(() => calculateSkipAndLimit(1, 1, offsetToCursor(-1))).toThrow( + jasmine.stringMatching('After is not a valid curso') + ); + expect(() => calculateSkipAndLimit(1, 1, offsetToCursor(1), -1)).toThrow( + jasmine.stringMatching('Last should be a positive number') + ); + expect(() => calculateSkipAndLimit(1, 1, offsetToCursor(1), 1, offsetToCursor(-1))).toThrow( + jasmine.stringMatching('Before is not a valid curso') + ); + }); + + it('should work only with skip', () => { + expect(calculateSkipAndLimit(10)).toEqual({ + skip: 10, + limit: undefined, + needToPreCount: false, + }); + }); + + it('should work only with after', () => { + expect(calculateSkipAndLimit(undefined, undefined, offsetToCursor(9))).toEqual({ + skip: 10, + limit: undefined, + needToPreCount: false, + }); + }); + + it('should work with limit and after', () => { + expect(calculateSkipAndLimit(10, undefined, offsetToCursor(9))).toEqual({ + skip: 20, + limit: undefined, + needToPreCount: false, + }); + }); + + it('first alone should set the limit', () => { + expect(calculateSkipAndLimit(10, 30, offsetToCursor(9))).toEqual({ + skip: 20, + limit: 30, + needToPreCount: false, + }); + }); + + it('if before cursor is less than skipped items, no objects will be returned', () => { + expect( + calculateSkipAndLimit(10, 30, offsetToCursor(9), undefined, offsetToCursor(5)) + ).toEqual({ + skip: 20, + limit: 0, + needToPreCount: false, + }); + }); + + it('if before cursor is greater than returned objects set by limit, nothing is changed', () => { + expect( + calculateSkipAndLimit(10, 30, offsetToCursor(9), undefined, offsetToCursor(100)) + ).toEqual({ + skip: 20, + limit: 30, + needToPreCount: false, + }); + }); + + it('if before cursor is less than returned objects set by limit, limit is adjusted', () => { + expect( + calculateSkipAndLimit(10, 30, offsetToCursor(9), undefined, offsetToCursor(40)) + ).toEqual({ + skip: 20, + limit: 20, + needToPreCount: false, + }); + }); + + it('last should work alone but requires pre count', () => { + expect(calculateSkipAndLimit(undefined, undefined, undefined, 10)).toEqual({ + skip: undefined, + limit: 10, + needToPreCount: true, + }); + }); + + it('last should be adjusted to max limit', () => { + expect(calculateSkipAndLimit(undefined, undefined, undefined, 10, undefined, 5)).toEqual({ + skip: undefined, + limit: 5, + needToPreCount: true, + }); + }); + + it('no objects will be returned if last is equal to 0', () => { + expect(calculateSkipAndLimit(undefined, undefined, undefined, 0)).toEqual({ + skip: undefined, + limit: 0, + needToPreCount: false, + }); + }); + + it('nothing changes if last is bigger than the calculared limit', () => { + expect(calculateSkipAndLimit(10, 30, offsetToCursor(9), 30, offsetToCursor(40))).toEqual({ + skip: 20, + limit: 20, + needToPreCount: false, + }); + }); + + it('If last is small than limit, new limit is calculated', () => { + expect(calculateSkipAndLimit(10, 30, offsetToCursor(9), 10, offsetToCursor(40))).toEqual({ + skip: 30, + limit: 10, + needToPreCount: false, + }); + }); + }); +}); diff --git a/spec/helper.js b/spec/helper.js index c724ea93b7..fdfb9786c5 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -1,42 +1,114 @@ -"use strict" -// Sets up a Parse API server for testing. +'use strict'; +const dns = require('dns'); +const semver = require('semver'); +const Parse = require('parse/node'); +const CurrentSpecReporter = require('./support/CurrentSpecReporter.js'); +const { SpecReporter } = require('jasmine-spec-reporter'); +const SchemaCache = require('../lib/Adapters/Cache/SchemaCache').default; +const { sleep, Connections } = require('../lib/TestUtils'); + +const originalFetch = global.fetch; +let fetchWasMocked = false; + +global.restoreFetch = () => { + global.fetch = originalFetch; + fetchWasMocked = false; +} + -jasmine.DEFAULT_TIMEOUT_INTERVAL = process.env.PARSE_SERVER_TEST_TIMEOUT || 5000; +// Ensure localhost resolves to ipv4 address first on node v17+ +if (dns.setDefaultResultOrder) { + dns.setDefaultResultOrder('ipv4first'); +} + +// Sets up a Parse API server for testing. +jasmine.DEFAULT_TIMEOUT_INTERVAL = process.env.PARSE_SERVER_TEST_TIMEOUT || 10000; +jasmine.getEnv().addReporter(new CurrentSpecReporter()); +jasmine.getEnv().addReporter(new SpecReporter()); +global.normalizeAsyncTests(); +global.on_db = (db, callback, elseCallback) => { + if (process.env.PARSE_SERVER_TEST_DB == db) { + return callback(); + } else if (!process.env.PARSE_SERVER_TEST_DB && db == 'mongo') { + return callback(); + } + if (elseCallback) { + return elseCallback(); + } +}; -var cache = require('../src/cache').default; -var DatabaseAdapter = require('../src/DatabaseAdapter'); -var express = require('express'); -var facebook = require('../src/authDataManager/facebook'); -var ParseServer = require('../src/index').ParseServer; -var path = require('path'); -var TestUtils = require('../src/index').TestUtils; -var MongoStorageAdapter = require('../src/Adapters/Storage/Mongo/MongoStorageAdapter'); -const GridStoreAdapter = require('../src/Adapters/Files/GridStoreAdapter').GridStoreAdapter; -const PostgresStorageAdapter = require('../src/Adapters/Storage/Postgres/PostgresStorageAdapter'); +if (global._babelPolyfill) { + console.error('We should not use polyfilled tests'); + process.exit(1); +} +process.noDeprecation = true; + +const cache = require('../lib/cache').default; +const defaults = require('../lib/defaults').default; +const ParseServer = require('../lib/index').ParseServer; +const loadAdapter = require('../lib/Adapters/AdapterLoader').loadAdapter; +const path = require('path'); +const TestUtils = require('../lib/TestUtils'); +const GridFSBucketAdapter = require('../lib/Adapters/Files/GridFSBucketAdapter') + .GridFSBucketAdapter; +const FSAdapter = require('@parse/fs-files-adapter'); +const PostgresStorageAdapter = require('../lib/Adapters/Storage/Postgres/PostgresStorageAdapter') + .default; +const MongoStorageAdapter = require('../lib/Adapters/Storage/Mongo/MongoStorageAdapter').default; +const RedisCacheAdapter = require('../lib/Adapters/Cache/RedisCacheAdapter').default; +const RESTController = require('parse/lib/node/RESTController').default; +const { VolatileClassesSchemas } = require('../lib/Controllers/SchemaController'); const mongoURI = 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase'; +const postgresURI = 'postgres://localhost:5432/parse_server_postgres_adapter_test_database'; let databaseAdapter; -if (process.env.PARSE_SERVER_TEST_DB === 'postgres') { - var postgresURI = 'postgres://localhost:5432/parse_server_postgres_adapter_test_database'; +let databaseURI; + +if (process.env.PARSE_SERVER_DATABASE_ADAPTER) { + databaseAdapter = JSON.parse(process.env.PARSE_SERVER_DATABASE_ADAPTER); + databaseAdapter = loadAdapter(databaseAdapter); +} else if (process.env.PARSE_SERVER_TEST_DB === 'postgres') { + databaseURI = process.env.PARSE_SERVER_TEST_DATABASE_URI || postgresURI; databaseAdapter = new PostgresStorageAdapter({ - uri: postgresURI, + uri: databaseURI, collectionPrefix: 'test_', }); } else { + databaseURI = mongoURI; databaseAdapter = new MongoStorageAdapter({ - uri: mongoURI, + uri: databaseURI, collectionPrefix: 'test_', - }) + }); } -var port = 8378; +const port = 8378; +const serverURL = `http://localhost:${port}/1`; +let filesAdapter; -let gridStoreAdapter = new GridStoreAdapter(mongoURI); +on_db( + 'mongo', + () => { + filesAdapter = new GridFSBucketAdapter(mongoURI); + }, + () => { + filesAdapter = new FSAdapter(); + } +); +let logLevel; +let silent = true; +if (process.env.VERBOSE) { + silent = false; + logLevel = 'verbose'; +} +if (process.env.PARSE_SERVER_LOG_LEVEL) { + silent = false; + logLevel = process.env.PARSE_SERVER_LOG_LEVEL; +} // Default server configuration for tests. -var defaultConfiguration = { - filesAdapter: gridStoreAdapter, - serverURL: 'http://localhost:' + port + '/1', +const defaultConfiguration = { + filesAdapter, + serverURL, databaseAdapter, appId: 'test', javascriptKey: 'test', @@ -45,161 +117,199 @@ var defaultConfiguration = { restAPIKey: 'rest', webhookKey: 'hook', masterKey: 'test', + maintenanceKey: 'testing', + readOnlyMasterKey: 'read-only-test', fileKey: 'test', + directAccess: true, + silent, + verbose: !silent, + logLevel, + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + fileUpload: { + enableForPublic: true, + enableForAnonymousUser: true, + enableForAuthenticatedUser: true, + }, push: { - 'ios': { - cert: 'prodCert.pem', - key: 'prodKey.pem', - production: true, - bundleId: 'bundleId', - } + android: { + firebaseServiceAccount: { + "type": "service_account", + "project_id": "example-xxxx", + "private_key_id": "xxxx", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCxFcVMD9L2xJWW\nEMi4w/XIBPvX5bTStIEdt4GY+yfrmCHspaVdgpTcHlTLA60sAGTFdorPprOwAm6f\njaTG4j86zfW25GF6AlFO/8vE2B0tjreuQQtcP9gkWJmsTp8yzXDirDQ43Kv93Kbc\nUPmsyAN5WB8XiFjjWLnFCeDiOVdd8sHfG0HYldNzyYwXrOTLE5kOjASYSJDzdrfI\nwN9PzZC7+cCy/DDzTRKQCqfz9pEZmxqJk4Id5HLVNkGKgji3C3b6o3MXWPS+1+zD\nGheKC9WLDZnCVycAnNHFiPpsp7R82lLKC3Dth37b6qzJO+HwfTmzCb0/xCVJ0/mZ\nC4Mxih/bAgMBAAECggEACbL1DvDw75Yd0U3TCJenDxEC0DTjHgVH6x5BaWUcLyGy\nffkmoQQFbjb1Evd9FSNiYZRYDv6E6feAIpoJ8+CxcOGV+zHwCtQ0qtyExx/FHVkr\nQ06JtkBC8N6vcAoQWyJ4c9nVtGWVv/5FX1zKCAYedpd2gH31zGHwLtQXLpzQZbNO\nO/0rcggg4unGSUIyw5437XiyckJ3QdneSEPe9HvY2wxLn/f1PjMpRYiNLBSuaFBJ\n+MYXr//Vh7cMInQk5/pMFbGxugNb7dtjgvm3LKRssKnubEOyrKldo8DVJmAvjhP4\nWboOOBVEo2ZhXgnBjeMvI8btXlJ85h9lZ7xwqfWsjQKBgQDkrrLpA3Mm21rsP1Ar\nMLEnYTdMZ7k+FTm5pJffPOsC7wiLWdRLwwrtb0V3kC3jr2K4SZY/OEV8IAWHfut/\n8mP8cPQPJiFp92iOgde4Xq/Ycwx4ZAXUj7mHHgywFi2K0xATzgc9sgX3NCVl9utR\nIU/FbEDCLxyD4T3Jb5gL3xFdhwKBgQDGPS46AiHuYmV7OG4gEOsNdczTppBJCgTt\nKGSJOxZg8sQodNJeWTPP2iQr4yJ4EY57NQmH7WSogLrGj8tmorEaL7I2kYlHJzGm\nniwApWEZlFc00xgXwV5d8ATfmAf8W1ZSZ6THbHesDUGjXSoL95k3KKXhnztjUT6I\n8d5qkCygDQKBgFN7p1rDZKVZzO6UCntJ8lJS/jIJZ6nPa9xmxv67KXxPsQnWSFdE\nI9gcF/sXCnmlTF/ElXIM4+j1c69MWULDRVciESb6n5YkuOnVYuAuyPk2vuWwdiRs\nN6mpAa7C2etlM+hW/XO7aswdIE4B/1QF2i5TX6zEMB/A+aJw98vVqmw/AoGADOm9\nUiADb9DPBXjGi6YueYD756mI6okRixU/f0TvDz+hEXWSonyzCE4QXx97hlC2dEYf\nKdCH5wYDpJ2HRVdBrBABTtaqF41xCYZyHVSof48PIyzA/AMnj3zsBFiV5JVaiSGh\nNTBWl0mBxg9yhrcJLvOh4pGJv81yAl+m+lAL6B0CgYEArtqtQ1YVLIUn4Pb/HDn8\nN8o7WbhloWQnG34iSsAG8yNtzbbxdugFrEm5ejPSgZ+dbzSzi/hizOFS/+/fwEdl\nay9jqY1fngoqSrS8eddUsY1/WAcmd6wPWEamsSjazA4uxQERruuFOi94E4b895KA\nqYe0A3xb0JL2ieAOZsn8XNA=\n-----END PRIVATE KEY-----\n", + "client_email": "test@example.com", + "client_id": "1", + "auth_uri": "https://example.com", + "token_uri": "https://example.com", + "auth_provider_x509_cert_url": "https://example.com", + "client_x509_cert_url": "https://example.com", + "universe_domain": "example.com" + } + + }, }, - oauth: { // Override the facebook provider + auth: { + // Override the facebook provider + custom: mockCustom(), facebook: mockFacebook(), myoauth: { - module: path.resolve(__dirname, "myoauth") // relative path as it's run from src - } - } + module: path.resolve(__dirname, 'support/myoauth'), // relative path as it's run from src + }, + shortLivedAuth: mockShortLivedAuth(), + }, + allowClientClassCreation: true, }; -let openConnections = {}; +if (silent) { + defaultConfiguration.logLevels = { + cloudFunctionSuccess: 'silent', + cloudFunctionError: 'silent', + triggerAfter: 'silent', + triggerBeforeError: 'silent', + triggerBeforeSuccess: 'silent', + }; +} // Set up a default API server for testing with default configuration. -var api = new ParseServer(defaultConfiguration); -var app = express(); -app.use('/1', api); - -var server = app.listen(port); -server.on('connection', connection => { - let key = `${connection.remoteAddress}:${connection.remotePort}`; - openConnections[key] = connection; - connection.on('close', () => { delete openConnections[key] }); -}); +let parseServer; +let didChangeConfiguration = false; +const openConnections = new Connections(); + +const shutdownServer = async (_parseServer) => { + await _parseServer.handleShutdown(); + // Connection close events are not immediate on node 10+, so wait a bit + await sleep(0); + expect(openConnections.count() > 0).toBeFalsy(`There were ${openConnections.count()} open connections to the server left after the test finished`); + parseServer = undefined; +}; // Allows testing specific configurations of Parse Server -const reconfigureServer = changedConfiguration => { - return new Promise((resolve, reject) => { - server.close(() => { - try { - let newConfiguration = Object.assign({}, defaultConfiguration, changedConfiguration, { - __indexBuildCompletionCallbackForTests: indexBuildPromise => indexBuildPromise.then(resolve, reject) - }); - cache.clear(); - app = express(); - api = new ParseServer(newConfiguration); - app.use('/1', api); - - server = app.listen(port); - server.on('connection', connection => { - let key = `${connection.remoteAddress}:${connection.remotePort}`; - openConnections[key] = connection; - connection.on('close', () => { delete openConnections[key] }); - }); - } catch(error) { - reject(error); - } - }); +const reconfigureServer = async (changedConfiguration = {}) => { + if (parseServer) { + await shutdownServer(parseServer); + return reconfigureServer(changedConfiguration); + } + didChangeConfiguration = Object.keys(changedConfiguration).length !== 0; + databaseAdapter = new databaseAdapter.constructor({ + uri: databaseURI, + collectionPrefix: 'test_', }); -} - -// Set up a Parse client to talk to our test API server -var Parse = require('parse/node'); -Parse.serverURL = 'http://localhost:' + port + '/1'; + defaultConfiguration.databaseAdapter = databaseAdapter; + global.databaseAdapter = databaseAdapter; + if (filesAdapter instanceof GridFSBucketAdapter) { + defaultConfiguration.filesAdapter = new GridFSBucketAdapter(mongoURI); + } + if (process.env.PARSE_SERVER_TEST_CACHE === 'redis') { + defaultConfiguration.cacheAdapter = new RedisCacheAdapter(); + } + const newConfiguration = Object.assign({}, defaultConfiguration, changedConfiguration, { + mountPath: '/1', + port, + }); + cache.clear(); + parseServer = await ParseServer.startApp(newConfiguration); + Parse.CoreManager.setRESTController(RESTController); + parseServer.expressApp.use('/1', err => { + console.error(err); + fail('should not call next'); + }); + openConnections.track(parseServer.server); + if (parseServer.liveQueryServer?.server && parseServer.liveQueryServer.server !== parseServer.server) { + openConnections.track(parseServer.liveQueryServer.server); + } + return parseServer; +}; -// This is needed because we ported a bunch of tests from the non-A+ way. -// TODO: update tests to work in an A+ way -Parse.Promise.disableAPlusCompliant(); +beforeAll(async () => { + global.restoreFetch(); + await reconfigureServer(); + Parse.initialize('test', 'test', 'test'); + Parse.serverURL = serverURL; + Parse.User.enableUnsafeCurrentUser(); + Parse.CoreManager.set('REQUEST_ATTEMPT_LIMIT', 1); +}); -beforeEach(done => { - try { - Parse.User.enableUnsafeCurrentUser(); - } catch (error) { - if (error !== 'You need to call Parse.initialize before using Parse.') { - throw error; - } +beforeEach(async () => { + if(fetchWasMocked) { + global.restoreFetch(); } - TestUtils.destroyAllDataPermanently() - .catch(error => { - // For tests that connect to their own mongo, there won't be any data to delete. - if (error.message === 'ns not found' || error.message.startsWith('connect ECONNREFUSED')) { - return; - } else { - fail(error); - return; - } - }) - .then(reconfigureServer) - .then(() => { - Parse.initialize('test', 'test', 'test'); - Parse.serverURL = 'http://localhost:' + port + '/1'; - done(); - }, error => { - fail(JSON.stringify(error)); - done(); - }) }); -afterEach(function(done) { - let afterLogOut = () => { - if (Object.keys(openConnections).length > 0) { - fail('There were open connections to the server left after the test finished'); - } - done(); - }; +global.afterEachFn = async () => { + // Restore fetch to prevent mock pollution between tests (only if it was mocked) + if (fetchWasMocked) { + global.restoreFetch(); + } + Parse.Cloud._removeAllHooks(); - databaseAdapter.getAllClasses() - .then(allSchemas => { - allSchemas.forEach((schema) => { - var className = schema.className; - expect(className).toEqual({ asymmetricMatch: className => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(); + defaults.protectedFields = { _User: { '*': ['email'] } }; + + const allSchemas = await databaseAdapter.getAllClasses().catch(() => []); + + allSchemas.forEach(schema => { + const className = schema.className; + expect(className).toEqual({ + asymmetricMatch: className => { if (!className.startsWith('_')) { return true; - } else { - // Other system classes will break Parse.com, so make sure that we don't save anything to _SCHEMA that will - // break it. - return ['_User', '_Installation', '_Role', '_Session', '_Product'].includes(className); } - }}); + if (className.startsWith('_Join:')) { + return true; + } + return [ + '_User', + '_Installation', + '_Role', + '_Session', + '_Product', + '_Audience', + '_Idempotency', + ].includes(className); + }, }); - }) - .then(() => Parse.User.logOut()) - .then(afterLogOut, afterLogOut) + }); + await Parse.User.logOut().catch(() => {}); + await TestUtils.destroyAllDataPermanently(true); + SchemaCache.clear(); + + if (didChangeConfiguration) { + await reconfigureServer(); + } else { + await databaseAdapter.performInitialization({ VolatileClassesSchemas }); + } +} +afterEach(global.afterEachFn); + +afterAll(() => { + global.restoreFetch(); + global.displayTestStats(); }); -var TestObject = Parse.Object.extend({ - className: "TestObject" +const TestObject = Parse.Object.extend({ + className: 'TestObject', }); -var Item = Parse.Object.extend({ - className: "Item" +const Item = Parse.Object.extend({ + className: 'Item', }); -var Container = Parse.Object.extend({ - className: "Container" +const Container = Parse.Object.extend({ + className: 'Container', }); // Convenience method to create a new TestObject with a callback function create(options, callback) { - var t = new TestObject(options); - t.save(null, { success: callback }); + const t = new TestObject(options); + return t.save().then(callback); } -function createTestUser(success, error) { - var user = new Parse.User(); +function createTestUser() { + const user = new Parse.User(); user.set('username', 'test'); user.set('password', 'moon-y'); - var promise = user.signUp(); - if (success || error) { - promise.then(function(user) { - if (success) { - success(user); - } - }, function(err) { - if (error) { - error(err); - } - }); - } else { - return promise; - } + return user.signUp(); } // Shims for compatibility with the old qunit tests. @@ -215,35 +325,6 @@ function strictEqual(a, b, message) { function notEqual(a, b, message) { expect(a).not.toEqual(b, message); } -function expectSuccess(params) { - return { - success: params.success, - error: function(e) { - console.log('got error', e); - fail('failure happened in expectSuccess'); - }, - } -} -function expectError(errorCode, callback) { - return { - success: function(result) { - console.log('got result', result); - fail('expected error but got success'); - }, - error: function(obj, e) { - // Some methods provide 2 parameters. - e = e || obj; - if (!e) { - fail('expected a specific error but got a blank error'); - return; - } - expect(e.code).toEqual(errorCode, e.message); - if (callback) { - callback(e); - } - }, - } -} // Because node doesn't have Parse._.contains function arrayContains(arr, item) { @@ -255,11 +336,11 @@ function normalize(obj) { if (obj === null || typeof obj !== 'object') { return JSON.stringify(obj); } - if (obj instanceof Array) { + if (Array.isArray(obj)) { return '[' + obj.map(normalize).join(', ') + ']'; } - var answer = '{'; - for (var key of Object.keys(obj).sort()) { + let answer = '{'; + for (const key of Object.keys(obj).sort()) { answer += key + ': '; answer += normalize(obj[key]); answer += ', '; @@ -274,23 +355,41 @@ function jequal(o1, o2) { } function range(n) { - var answer = []; - for (var i = 0; i < n; i++) { + const answer = []; + for (let i = 0; i < n; i++) { answer.push(i); } return answer; } +function mockCustomAuthenticator(id, password) { + const custom = {}; + custom.validateAuthData = function (authData) { + if (authData.id === id && authData.password.startsWith(password)) { + return Promise.resolve(); + } + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'not validated'); + }; + custom.validateAppId = function () { + return Promise.resolve(); + }; + return custom; +} + +function mockCustom() { + return mockCustomAuthenticator('fastrde', 'password'); +} + function mockFacebookAuthenticator(id, token) { - var facebook = {}; - facebook.validateAuthData = function(authData) { + const facebook = {}; + facebook.validateAuthData = function (authData) { if (authData.id === id && authData.access_token.startsWith(token)) { return Promise.resolve(); } else { throw undefined; } }; - facebook.validateAppId = function(appId, authData) { + facebook.validateAppId = function (appId, authData) { if (authData.access_token.startsWith(token)) { return Promise.resolve(); } else { @@ -304,6 +403,59 @@ function mockFacebook() { return mockFacebookAuthenticator('8675309', 'jenny'); } +function mockShortLivedAuth() { + const auth = {}; + let accessToken; + auth.setValidAccessToken = function (validAccessToken) { + accessToken = validAccessToken; + }; + auth.validateAuthData = function (authData) { + if (authData.access_token == accessToken) { + return Promise.resolve(); + } else { + return Promise.reject('Invalid access token'); + } + }; + auth.validateAppId = function () { + return Promise.resolve(); + }; + return auth; +} + +function mockFetch(mockResponses) { + const spy = jasmine.createSpy('fetch'); + fetchWasMocked = true; // Track that fetch was mocked for cleanup + + global.fetch = (url, options = {}) => { + // Allow requests to the Parse Server to pass through WITHOUT recording in spy + // This prevents tests from failing when they check that fetch wasn't called + // but the Parse SDK makes internal requests to the Parse Server + if (typeof url === 'string' && url.includes(serverURL)) { + return originalFetch(url, options); + } + + // Record non-Parse-Server calls in the spy + spy(url, options); + + options.method ||= 'GET'; + const mockResponse = mockResponses?.find( + (mock) => mock.url === url && mock.method === options.method + ); + + if (mockResponse) { + return Promise.resolve(mockResponse.response); + } + + return Promise.resolve({ + ok: false, + statusText: 'Unknown URL or method', + }); + }; + + // Expose spy methods for test assertions + global.fetch.calls = spy.calls; + global.fetch.and = spy.and; +} // This is polluting, but, it makes it way easier to directly port old tests. @@ -317,46 +469,207 @@ global.ok = ok; global.equal = equal; global.strictEqual = strictEqual; global.notEqual = notEqual; -global.expectSuccess = expectSuccess; -global.expectError = expectError; global.arrayContains = arrayContains; global.jequal = jequal; global.range = range; global.reconfigureServer = reconfigureServer; +global.mockFetch = mockFetch; global.defaultConfiguration = defaultConfiguration; +global.mockCustomAuthenticator = mockCustomAuthenticator; global.mockFacebookAuthenticator = mockFacebookAuthenticator; +global.databaseAdapter = databaseAdapter; +global.databaseURI = databaseURI; +global.shutdownServer = shutdownServer; +global.jfail = function (err) { + fail(JSON.stringify(err)); +}; global.it_exclude_dbs = excluded => { - if (excluded.includes(process.env.PARSE_SERVER_TEST_DB)) { + if (excluded.indexOf(process.env.PARSE_SERVER_TEST_DB) >= 0) { return xit; } else { return it; } +}; + +let testExclusionList = []; +try { + // Fetch test exclusion list + testExclusionList = require('./testExclusionList.json'); + console.log(`Using test exclusion list with ${testExclusionList.length} entries`); +} catch (error) { + if (error.code !== 'MODULE_NOT_FOUND') { + throw error; + } } +/** + * Assign ID to test and run it. Disable test if its UUID is found in testExclusionList. + * @param {String} id The UUID of the test. + */ +global.it_id = id => { + return testFunc => { + if (testExclusionList.includes(id)) { + return xit; + } else { + return testFunc; + } + }; +}; + +global.it_only_db = db => { + if ( + process.env.PARSE_SERVER_TEST_DB === db || + (!process.env.PARSE_SERVER_TEST_DB && db == 'mongo') + ) { + return it; + } else { + return xit; + } +}; + +global.fit_only_db = db => { + if ( + process.env.PARSE_SERVER_TEST_DB === db || + (!process.env.PARSE_SERVER_TEST_DB && db == 'mongo') + ) { + return fit; + } else { + return xit; + } +}; + +global.it_only_mongodb_version = version => { + if (!semver.validRange(version)) { + throw new Error('Invalid version range'); + } + const envVersion = process.env.MONGODB_VERSION; + if (!envVersion || semver.satisfies(envVersion, version)) { + return it; + } else { + return xit; + } +}; + +global.it_only_postgres_version = version => { + if (!semver.validRange(version)) { + throw new Error('Invalid version range'); + } + const envVersion = process.env.POSTGRES_VERSION; + if (!envVersion || semver.satisfies(envVersion, version)) { + return it; + } else { + return xit; + } +}; + +global.it_only_node_version = version => { + if (!semver.validRange(version)) { + throw new Error('Invalid version range'); + } + const envVersion = process.version; + if (!envVersion || semver.satisfies(envVersion, version)) { + return it; + } else { + return xit; + } +}; + +global.fit_only_mongodb_version = version => { + if (!semver.validRange(version)) { + throw new Error('Invalid version range'); + } + const envVersion = process.env.MONGODB_VERSION; + if (!envVersion || semver.satisfies(envVersion, version)) { + return fit; + } else { + return xit; + } +}; + +global.fit_only_postgres_version = version => { + if (!semver.validRange(version)) { + throw new Error('Invalid version range'); + } + const envVersion = process.env.POSTGRES_VERSION; + if (!envVersion || semver.satisfies(envVersion, version)) { + return fit; + } else { + return xit; + } +}; + +global.fit_only_node_version = version => { + if (!semver.validRange(version)) { + throw new Error('Invalid version range'); + } + const envVersion = process.version; + if (!envVersion || semver.satisfies(envVersion, version)) { + return fit; + } else { + return xit; + } +}; + global.fit_exclude_dbs = excluded => { - if (excluded.includes(process.env.PARSE_SERVER_TEST_DB)) { + if (excluded.indexOf(process.env.PARSE_SERVER_TEST_DB) >= 0) { return xit; } else { return fit; } -} +}; + +global.describe_only_db = db => { + if (process.env.PARSE_SERVER_TEST_DB == db) { + return describe; + } else if (!process.env.PARSE_SERVER_TEST_DB && db == 'mongo') { + return describe; + } else { + return xdescribe; + } +}; -// LiveQuery test setting -require('../src/LiveQuery/PLog').logLevel = 'NONE'; -var libraryCache = {}; -jasmine.mockLibrary = function(library, name, mock) { - var original = require(library)[name]; +global.fdescribe_only_db = db => { + if (process.env.PARSE_SERVER_TEST_DB == db) { + return fdescribe; + } else if (!process.env.PARSE_SERVER_TEST_DB && db == 'mongo') { + return fdescribe; + } else { + return xdescribe; + } +}; + +global.describe_only = validator => { + if (validator()) { + return describe; + } else { + return xdescribe; + } +}; + +global.fdescribe_only = validator => { + if (validator()) { + return fdescribe; + } else { + return xdescribe; + } +}; + +const libraryCache = {}; +jasmine.mockLibrary = function (library, name, mock) { + const original = require(library)[name]; if (!libraryCache[library]) { libraryCache[library] = {}; } require(library)[name] = mock; libraryCache[library][name] = original; -} +}; -jasmine.restoreLibrary = function(library, name) { +jasmine.restoreLibrary = function (library, name) { if (!libraryCache[library] || !libraryCache[library][name]) { throw 'Can not find library ' + library + ' ' + name; } require(library)[name] = libraryCache[library][name]; -} +}; + +jasmine.timeout = (t = 100) => new Promise(resolve => setTimeout(resolve, t)); diff --git a/spec/index.spec.js b/spec/index.spec.js index b5e4b26b36..988f35cc3e 100644 --- a/spec/index.spec.js +++ b/spec/index.spec.js @@ -1,233 +1,330 @@ -"use strict" -var request = require('request'); -var parseServerPackage = require('../package.json'); -var MockEmailAdapterWithOptions = require('./MockEmailAdapterWithOptions'); -var ParseServer = require("../src/index"); -var Config = require('../src/Config'); -var express = require('express'); +'use strict'; +const request = require('../lib/request'); +const parseServerPackage = require('../package.json'); +const MockEmailAdapterWithOptions = require('./support/MockEmailAdapterWithOptions'); +const ParseServer = require('../lib/index'); +const Config = require('../lib/Config'); +const express = require('express'); -const MongoStorageAdapter = require('../src/Adapters/Storage/Mongo/MongoStorageAdapter'); +const MongoStorageAdapter = require('../lib/Adapters/Storage/Mongo/MongoStorageAdapter').default; describe('server', () => { - it_exclude_dbs(['postgres'])('requires a master key and app id', done => { + it('requires a master key and app id', done => { reconfigureServer({ appId: undefined }) - .catch(error => { - expect(error).toEqual('You must provide an appId!'); - return reconfigureServer({ masterKey: undefined }); - }) - .catch(error => { - expect(error).toEqual('You must provide a masterKey!'); - return reconfigureServer({ serverURL: undefined }); - }) - .catch(error => { - expect(error).toEqual('You must provide a serverURL!'); - done(); - }); + .catch(error => { + expect(error).toEqual('You must provide an appId!'); + return reconfigureServer({ masterKey: undefined }); + }) + .catch(error => { + expect(error).toEqual('You must provide a masterKey!'); + return reconfigureServer({ serverURL: undefined }); + }) + .catch(error => { + expect(error).toEqual('You must provide a serverURL!'); + done(); + }); }); - it_exclude_dbs(['postgres'])('support http basic authentication with masterkey', done => { - request.get({ - url: 'http://localhost:8378/1/classes/TestObject', - headers: { - 'Authorization': 'Basic ' + new Buffer('test:' + 'test').toString('base64') - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(200); - done(); + it('show warning if any reserved characters in appId', done => { + spyOn(console, 'warn').and.callFake(() => {}); + reconfigureServer({ appId: 'test!-^' }).then(() => { + expect(console.warn).toHaveBeenCalled(); + return done(); }); }); - it_exclude_dbs(['postgres'])('support http basic authentication with javascriptKey', done => { - request.get({ - url: 'http://localhost:8378/1/classes/TestObject', - headers: { - 'Authorization': 'Basic ' + new Buffer('test:javascript-key=' + 'test').toString('base64') - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(200); - done(); + it('support http basic authentication with masterkey', done => { + reconfigureServer({ appId: 'test' }).then(() => { + request({ + url: 'http://localhost:8378/1/classes/TestObject', + headers: { + Authorization: 'Basic ' + Buffer.from('test:' + 'test').toString('base64'), + }, + }).then(response => { + expect(response.status).toEqual(200); + done(); + }); }); }); - it_exclude_dbs(['postgres'])('fails if database is unreachable', done => { - reconfigureServer({ databaseAdapter: new MongoStorageAdapter({ uri: 'mongodb://fake:fake@localhost:43605/drew3' }) }) - .catch(() => { - //Need to use rest api because saving via JS SDK results in fail() not getting called - request.post({ - url: 'http://localhost:8378/1/classes/NewClass', + it('support http basic authentication with javascriptKey', done => { + reconfigureServer({ appId: 'test' }).then(() => { + request({ + url: 'http://localhost:8378/1/classes/TestObject', headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', + Authorization: 'Basic ' + Buffer.from('test:javascript-key=' + 'test').toString('base64'), }, - body: {}, - json: true, - }, (error, response, body) => { - expect(response.statusCode).toEqual(500); - expect(body.code).toEqual(1); - expect(body.message).toEqual('Internal server error.'); - reconfigureServer().then(done, done); + }).then(response => { + expect(response.status).toEqual(200); + done(); }); }); }); - it_exclude_dbs(['postgres'])('can load email adapter via object', done => { - reconfigureServer({ - appName: 'unused', - verifyUserEmails: true, - emailAdapter: MockEmailAdapterWithOptions({ - fromAddress: 'parse@example.com', - apiKey: 'k', - domain: 'd', + it('fails if database is unreachable', async () => { + spyOn(console, 'error').and.callFake(() => {}); + const server = new ParseServer.default({ + ...defaultConfiguration, + databaseAdapter: new MongoStorageAdapter({ + uri: 'mongodb://fake:fake@localhost:43605/drew3', + mongoOptions: { + serverSelectionTimeoutMS: 2000, + }, }), - publicServerURL: 'http://localhost:8378/1' - }).then(done, fail); + }); + const error = await server.start().catch(e => e); + expect(`${error}`.includes('Database error')).toBeTrue(); + await reconfigureServer(); }); - it_exclude_dbs(['postgres'])('can load email adapter via class', done => { - reconfigureServer({ - appName: 'unused', - verifyUserEmails: true, - emailAdapter: { - class: MockEmailAdapterWithOptions, - options: { + describe('mail adapter', () => { + it('can load email adapter via object', done => { + reconfigureServer({ + appName: 'unused', + verifyUserEmails: true, + emailAdapter: MockEmailAdapterWithOptions({ fromAddress: 'parse@example.com', apiKey: 'k', domain: 'd', - } - }, - publicServerURL: 'http://localhost:8378/1' - }).then(done, fail); - }); + }), + publicServerURL: 'http://localhost:8378/1', + }).then(done, fail); + }); - it_exclude_dbs(['postgres'])('can load email adapter via module name', done => { - reconfigureServer({ - appName: 'unused', - verifyUserEmails: true, - emailAdapter: { - module: 'parse-server-simple-mailgun-adapter', - options: { - fromAddress: 'parse@example.com', - apiKey: 'k', - domain: 'd', - } - }, - publicServerURL: 'http://localhost:8378/1' - }).then(done, fail); - }); + it('can load email adapter via class', done => { + reconfigureServer({ + appName: 'unused', + verifyUserEmails: true, + emailAdapter: { + class: MockEmailAdapterWithOptions, + options: { + fromAddress: 'parse@example.com', + apiKey: 'k', + domain: 'd', + }, + }, + publicServerURL: 'http://localhost:8378/1', + }).then(done, fail); + }); - it_exclude_dbs(['postgres'])('can load email adapter via only module name', done => { - reconfigureServer({ - appName: 'unused', - verifyUserEmails: true, - emailAdapter: 'parse-server-simple-mailgun-adapter', - publicServerURL: 'http://localhost:8378/1' - }) - .catch(error => { - expect(error).toEqual('SimpleMailgunAdapter requires an API Key, domain, and fromAddress.'); - done(); + it('can load email adapter via module name', async () => { + const options = { + appName: 'unused', + verifyUserEmails: true, + emailAdapter: { + module: 'mock-mail-adapter', + options: {}, + }, + publicServerURL: 'http://localhost:8378/1', + }; + await reconfigureServer(options); + const config = Config.get('test'); + const mailAdapter = config.userController.adapter; + expect(mailAdapter.sendMail).toBeDefined(); + }); + + it('can load email adapter via only module name', async () => { + const options = { + appName: 'unused', + verifyUserEmails: true, + emailAdapter: 'mock-mail-adapter', + publicServerURL: 'http://localhost:8378/1', + }; + await reconfigureServer(options); + const config = Config.get('test'); + const mailAdapter = config.userController.adapter; + expect(mailAdapter.sendMail).toBeDefined(); + }); + + it('throws if you initialize email adapter incorrectly', async () => { + const options = { + appName: 'unused', + verifyUserEmails: true, + emailAdapter: { + module: 'mock-mail-adapter', + options: { throw: true }, + }, + publicServerURL: 'http://localhost:8378/1', + }; + await expectAsync(reconfigureServer(options)).toBeRejected('MockMailAdapterConstructor'); }); }); - it_exclude_dbs(['postgres'])('throws if you initialize email adapter incorrecly', done => { - reconfigureServer({ - appName: 'unused', - verifyUserEmails: true, - emailAdapter: { - module: 'parse-server-simple-mailgun-adapter', - options: { - domain: 'd', - } + it('can report the server version', async done => { + await reconfigureServer(); + request({ + url: 'http://localhost:8378/1/serverInfo', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', }, - publicServerURL: 'http://localhost:8378/1' - }) - .catch(error => { - expect(error).toEqual('SimpleMailgunAdapter requires an API Key, domain, and fromAddress.'); + }).then(response => { + const body = response.data; + expect(body.parseServerVersion).toEqual(parseServerPackage.version); done(); }); }); - it_exclude_dbs(['postgres'])('can report the server version', done => { - request.get({ + it('can properly sets the push support', async done => { + await reconfigureServer(); + // default config passes push options + const config = Config.get('test'); + expect(config.hasPushSupport).toEqual(true); + expect(config.hasPushScheduledSupport).toEqual(false); + request({ url: 'http://localhost:8378/1/serverInfo', headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-Master-Key': 'test', }, json: true, - }, (error, response, body) => { - expect(body.parseServerVersion).toEqual(parseServerPackage.version); + }).then(response => { + const body = response.data; + expect(body.features.push.immediatePush).toEqual(true); + expect(body.features.push.scheduledPush).toEqual(false); done(); + }); + }); + + it('can properly sets the push support when not configured', done => { + reconfigureServer({ + push: undefined, // force no config }) + .then(() => { + const config = Config.get('test'); + expect(config.hasPushSupport).toEqual(false); + expect(config.hasPushScheduledSupport).toEqual(false); + request({ + url: 'http://localhost:8378/1/serverInfo', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + json: true, + }).then(response => { + const body = response.data; + expect(body.features.push.immediatePush).toEqual(false); + expect(body.features.push.scheduledPush).toEqual(false); + done(); + }); + }) + .catch(done.fail); }); - it_exclude_dbs(['postgres'])('can create a parse-server v1', done => { - var parseServer = new ParseServer.default(Object.assign({}, - defaultConfiguration, { - appId: "aTestApp", - masterKey: "aTestMasterKey", - serverURL: "http://localhost:12666/parse", - __indexBuildCompletionCallbackForTests: promise => { - promise - .then(() => { - expect(Parse.applicationId).toEqual("aTestApp"); - var app = express(); - app.use('/parse', parseServer.app); - - var server = app.listen(12666); - var obj = new Parse.Object("AnObject"); - var objId; - obj.save().then((obj) => { - objId = obj.id; - var q = new Parse.Query("AnObject"); - return q.first(); - }).then((obj) => { - expect(obj.id).toEqual(objId); - server.close(done); - }).fail((err) => { - server.close(done); - }) + it('can properly sets the push support ', done => { + reconfigureServer({ + push: { + adapter: { + send() {}, + getValidPushTypes() {}, + }, + }, + }) + .then(() => { + const config = Config.get('test'); + expect(config.hasPushSupport).toEqual(true); + expect(config.hasPushScheduledSupport).toEqual(false); + request({ + url: 'http://localhost:8378/1/serverInfo', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + json: true, + }).then(response => { + const body = response.data; + expect(body.features.push.immediatePush).toEqual(true); + expect(body.features.push.scheduledPush).toEqual(false); + done(); }); - }}) - ); + }) + .catch(done.fail); }); - it_exclude_dbs(['postgres'])('can create a parse-server v2', done => { - let objId; - let server - let parseServer = ParseServer.ParseServer(Object.assign({}, - defaultConfiguration, { - appId: "anOtherTestApp", - masterKey: "anOtherTestMasterKey", - serverURL: "http://localhost:12667/parse", - __indexBuildCompletionCallbackForTests: promise => { - promise - .then(() => { - expect(Parse.applicationId).toEqual("anOtherTestApp"); - let app = express(); - app.use('/parse', parseServer); - - server = app.listen(12667); - let obj = new Parse.Object("AnObject"); - return obj.save() - }) - .then(obj => { - objId = obj.id; - let q = new Parse.Query("AnObject"); - return q.first(); - }) - .then(obj => { - expect(obj.id).toEqual(objId); - server.close(done); - }) - .catch(error => { - fail(JSON.stringify(error)) + it('can properly sets the push schedule support', done => { + reconfigureServer({ + push: { + adapter: { + send() {}, + getValidPushTypes() {}, + }, + }, + scheduledPush: true, + }) + .then(() => { + const config = Config.get('test'); + expect(config.hasPushSupport).toEqual(true); + expect(config.hasPushScheduledSupport).toEqual(true); + request({ + url: 'http://localhost:8378/1/serverInfo', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + json: true, + }).then(response => { + const body = response.data; + expect(body.features.push.immediatePush).toEqual(true); + expect(body.features.push.scheduledPush).toEqual(true); done(); }); - }} - )); + }) + .catch(done.fail); }); - it_exclude_dbs(['postgres'])('has createLiveQueryServer', done => { + it('can respond 200 on path health', done => { + request({ + url: 'http://localhost:8378/1/health', + }).then(response => { + expect(response.status).toBe(200); + done(); + }); + }); + + it('can create a parse-server v1', async () => { + await reconfigureServer({ appId: 'aTestApp' }); + const parseServer = new ParseServer.default( + Object.assign({}, defaultConfiguration, { + appId: 'aTestApp', + masterKey: 'aTestMasterKey', + serverURL: 'http://localhost:12666/parse', + }) + ); + await parseServer.start(); + expect(Parse.applicationId).toEqual('aTestApp'); + const app = express(); + app.use('/parse', parseServer.app); + const server = app.listen(12666); + const obj = new Parse.Object('AnObject'); + await obj.save(); + const query = await new Parse.Query('AnObject').first(); + expect(obj.id).toEqual(query.id); + await new Promise(resolve => server.close(resolve)); + }); + + it('can create a parse-server v2', async () => { + await reconfigureServer({ appId: 'anOtherTestApp' }); + const parseServer = ParseServer.ParseServer( + Object.assign({}, defaultConfiguration, { + appId: 'anOtherTestApp', + masterKey: 'anOtherTestMasterKey', + serverURL: 'http://localhost:12667/parse', + }) + ); + + expect(Parse.applicationId).toEqual('anOtherTestApp'); + await parseServer.start(); + const app = express(); + app.use('/parse', parseServer.app); + const server = app.listen(12667); + const obj = new Parse.Object('AnObject'); + await obj.save(); + const q = await new Parse.Query('AnObject').first(); + expect(obj.id).toEqual(q.id); + await new Promise(resolve => server.close(resolve)); + }); + + it('has createLiveQueryServer', done => { // original implementation through the factory expect(typeof ParseServer.ParseServer.createLiveQueryServer).toEqual('function'); // For import calls @@ -235,74 +332,526 @@ describe('server', () => { done(); }); - it_exclude_dbs(['postgres'])('exposes correct adapters', done => { - expect(ParseServer.S3Adapter).toThrow(); - expect(ParseServer.GCSAdapter).toThrow('GCSAdapter is not provided by parse-server anymore; please install parse-server-gcs-adapter'); + it('exposes correct adapters', done => { + expect(ParseServer.S3Adapter).toThrow( + 'S3Adapter is not provided by parse-server anymore; please install @parse/s3-files-adapter' + ); + expect(ParseServer.GCSAdapter).toThrow( + 'GCSAdapter is not provided by parse-server anymore; please install @parse/gcs-files-adapter' + ); expect(ParseServer.FileSystemAdapter).toThrow(); expect(ParseServer.InMemoryCacheAdapter).toThrow(); + expect(ParseServer.NullCacheAdapter).toThrow(); done(); }); - it_exclude_dbs(['postgres'])('properly gives publicServerURL when set', done => { - reconfigureServer({ publicServerURL: 'https://myserver.com/1' }) - .then(() => { - var config = new Config('test', 'http://localhost:8378/1'); + it('properly gives publicServerURL when set', done => { + reconfigureServer({ publicServerURL: 'https://myserver.com/1' }).then(() => { + const config = Config.get('test', 'http://localhost:8378/1'); expect(config.mount).toEqual('https://myserver.com/1'); done(); }); }); - it_exclude_dbs(['postgres'])('properly removes trailing slash in mount', done => { - reconfigureServer({}) - .then(() => { - var config = new Config('test', 'http://localhost:8378/1/'); + it('properly removes trailing slash in mount', done => { + reconfigureServer({}).then(() => { + const config = Config.get('test', 'http://localhost:8378/1/'); expect(config.mount).toEqual('http://localhost:8378/1'); done(); }); }); - it_exclude_dbs(['postgres'])('should throw when getting invalid mount', done => { - reconfigureServer({ publicServerURL: 'blabla:/some' }) - .catch(error => { - expect(error).toEqual('publicServerURL should be a valid HTTPS URL starting with https://') + it('should throw when getting invalid mount', done => { + reconfigureServer({ publicServerURL: 'blabla:/some' }).catch(error => { + expect(error).toEqual('The option publicServerURL must be a valid URL starting with http:// or https://.'); done(); - }) + }); }); - it_exclude_dbs(['postgres'])('fails if the session length is not a number', done => { + it('should throw when extendSessionOnUse is invalid', async () => { + await expectAsync( + reconfigureServer({ + extendSessionOnUse: 'yolo', + }) + ).toBeRejectedWith('extendSessionOnUse must be a boolean value'); + }); + + it('should throw when revokeSessionOnPasswordReset is invalid', async () => { + await expectAsync( + reconfigureServer({ + revokeSessionOnPasswordReset: 'yolo', + }) + ).toBeRejectedWith('revokeSessionOnPasswordReset must be a boolean value'); + }); + + it('fails if the session length is not a number', done => { reconfigureServer({ sessionLength: 'test' }) - .catch(error => { - expect(error).toEqual('Session length must be a valid number.'); - done(); - }); + .then(done.fail) + .catch(error => { + expect(error).toEqual('Session length must be a valid number.'); + done(); + }); }); - it_exclude_dbs(['postgres'])('fails if the session length is less than or equal to 0', done => { + it('fails if the session length is less than or equal to 0', done => { reconfigureServer({ sessionLength: '-33' }) - .catch(error => { - expect(error).toEqual('Session length must be a value greater than 0.'); - return reconfigureServer({ sessionLength: '0' }) + .then(done.fail) + .catch(error => { + expect(error).toEqual('Session length must be a value greater than 0.'); + return reconfigureServer({ sessionLength: '0' }); + }) + .catch(error => { + expect(error).toEqual('Session length must be a value greater than 0.'); + done(); + }); + }); + + it('ignores the session length when expireInactiveSessions set to false', done => { + reconfigureServer({ + sessionLength: '-33', + expireInactiveSessions: false, }) - .catch(error => { - expect(error).toEqual('Session length must be a value greater than 0.'); + .then(() => + reconfigureServer({ + sessionLength: '0', + expireInactiveSessions: false, + }) + ) + .then(done); + }); + + it('fails if default limit is negative', async () => { + await expectAsync(reconfigureServer({ defaultLimit: -1 })).toBeRejectedWith( + 'Default limit must be a value greater than 0.' + ); + }); + + it('fails if default limit is wrong type', async () => { + for (const value of ['invalid', {}, [], true]) { + await expectAsync(reconfigureServer({ defaultLimit: value })).toBeRejectedWith( + 'Default limit must be a number.' + ); + } + }); + + it('fails if default limit is zero', async () => { + await expectAsync(reconfigureServer({ defaultLimit: 0 })).toBeRejectedWith( + 'Default limit must be a value greater than 0.' + ); + }); + + it('fails if maxLimit is negative', done => { + reconfigureServer({ maxLimit: -100 }).catch(error => { + expect(error).toEqual('Max limit must be a value greater than 0.'); done(); }); }); - it_exclude_dbs(['postgres'])('ignores the session length when expireInactiveSessions set to false', (done) => { + it('fails if you try to set revokeSessionOnPasswordReset to non-boolean', done => { + reconfigureServer({ revokeSessionOnPasswordReset: 'non-bool' }).catch(done); + }); + + it('fails if you provides invalid ip in masterKeyIps', done => { + reconfigureServer({ masterKeyIps: ['invalidIp', '1.2.3.4'] }).catch(error => { + expect(error).toEqual( + 'The Parse Server option "masterKeyIps" contains an invalid IP address "invalidIp".' + ); + done(); + }); + }); + + it('should succeed if you provide valid ip in masterKeyIps', done => { reconfigureServer({ - sessionLength: '-33', - expireInactiveSessions: false + masterKeyIps: ['1.2.3.4', '2001:0db8:0000:0042:0000:8a2e:0370:7334'], + }).then(done); + }); + + it('should set default masterKeyIps for IPv4 and IPv6 localhost', () => { + const definitions = require('../lib/Options/Definitions.js'); + expect(definitions.ParseServerOptions.masterKeyIps.default).toEqual(['127.0.0.1', '::1']); + }); + + it('should load a middleware', done => { + const obj = { + middleware: function (req, res, next) { + next(); + }, + }; + const spy = spyOn(obj, 'middleware').and.callThrough(); + reconfigureServer({ + middleware: obj.middleware, + }) + .then(() => { + const query = new Parse.Query('AnObject'); + return query.find(); + }) + .then(() => { + expect(spy).toHaveBeenCalled(); + done(); + }) + .catch(done.fail); + }); + + it('should allow direct access', async () => { + const RESTController = Parse.CoreManager.getRESTController(); + const spy = spyOn(Parse.CoreManager, 'setRESTController').and.callThrough(); + await reconfigureServer({ + directAccess: true, + }); + expect(spy).toHaveBeenCalledTimes(2); + Parse.CoreManager.setRESTController(RESTController); + }); + + it('should load a middleware from string', done => { + reconfigureServer({ + middleware: 'spec/support/CustomMiddleware', + }) + .then(() => { + return request({ url: 'http://localhost:8378/1' }).then(fail, res => { + // Just check that the middleware set the header + expect(res.headers['x-yolo']).toBe('1'); + done(); + }); + }) + .catch(done.fail); + }); + + it('can call start', async () => { + await reconfigureServer({ appId: 'aTestApp' }); + const config = { + ...defaultConfiguration, + appId: 'aTestApp', + masterKey: 'aTestMasterKey', + serverURL: 'http://localhost:12701/parse', + }; + const parseServer = new ParseServer.ParseServer(config); + await parseServer.start(); + expect(Parse.applicationId).toEqual('aTestApp'); + expect(Parse.serverURL).toEqual('http://localhost:12701/parse'); + const app = express(); + app.use('/parse', parseServer.app); + const server = app.listen(12701); + const testObject = new Parse.Object('TestObject'); + await expectAsync(testObject.save()).toBeResolved(); + await new Promise(resolve => server.close(resolve)); + }); + + it('start is required to mount', async () => { + await reconfigureServer({ appId: 'aTestApp' }); + const config = { + ...defaultConfiguration, + appId: 'aTestApp', + masterKey: 'aTestMasterKey', + serverURL: 'http://localhost:12701/parse', + }; + const parseServer = new ParseServer.ParseServer(config); + expect(Parse.applicationId).toEqual('aTestApp'); + expect(Parse.serverURL).toEqual('http://localhost:12701/parse'); + const app = express(); + app.use('/parse', parseServer.app); + const server = app.listen(12701); + const response = await request({ + headers: { + 'X-Parse-Application-Id': 'aTestApp', + }, + method: 'POST', + url: 'http://localhost:12701/parse/classes/TestObject', + }).catch(e => new Parse.Error(e.data.code, e.data.error)); + expect(response).toEqual( + new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Invalid server state: initialized') + ); + const health = await request({ + url: 'http://localhost:12701/parse/health', + }).catch(e => e); + spyOn(console, 'warn').and.callFake(() => {}); + const verify = await ParseServer.default.verifyServerUrl(); + expect(verify).not.toBeTrue(); + expect(console.warn).toHaveBeenCalledWith( + `\nWARNING, Unable to connect to 'http://localhost:12701/parse'. Cloud code and push notifications may be unavailable!\n` + ); + expect(health.data.status).toBe('initialized'); + expect(health.status).toBe(503); + await new Promise(resolve => server.close(resolve)); + }); + + it('can get starting state', async () => { + await reconfigureServer({ appId: 'test2' }); + const parseServer = new ParseServer.ParseServer({ + ...defaultConfiguration, + appId: 'test2', + masterKey: 'abc', + serverURL: 'http://localhost:12668/parse', + async cloud() { + await new Promise(resolve => setTimeout(resolve, 2000)); + }, + }); + const express = require('express'); + const app = express(); + app.use('/parse', parseServer.app); + const server = app.listen(12668); + const startingPromise = parseServer.start(); + const health = await request({ + url: 'http://localhost:12668/parse/health', + }).catch(e => e); + expect(health.data.status).toBe('starting'); + expect(health.status).toBe(503); + expect(health.headers['retry-after']).toBe('1'); + const response = await ParseServer.default.verifyServerUrl(); + expect(response).toBeTrue(); + await startingPromise; + await new Promise(resolve => server.close(resolve)); + }); + + it('should load masterKey', async () => { + await reconfigureServer({ + masterKey: () => 'testMasterKey', + masterKeyTtl: 1000, // TTL is set + }); + + await new Parse.Object('TestObject').save(); + + const config = Config.get(Parse.applicationId); + expect(config.masterKeyCache.masterKey).toEqual('testMasterKey'); + expect(config.masterKeyCache.expiresAt.getTime()).toBeGreaterThan(Date.now()); + }); + + it('should not reload if ttl is not set', async () => { + const masterKeySpy = jasmine.createSpy().and.returnValue(Promise.resolve('initialMasterKey')); + + await reconfigureServer({ + masterKey: masterKeySpy, + masterKeyTtl: null, // No TTL set + }); + + await new Parse.Object('TestObject').save(); + + const config = Config.get(Parse.applicationId); + const firstMasterKey = config.masterKeyCache.masterKey; + + // Simulate calling the method again + await config.loadMasterKey(); + const secondMasterKey = config.masterKeyCache.masterKey; + + expect(firstMasterKey).toEqual('initialMasterKey'); + expect(secondMasterKey).toEqual('initialMasterKey'); + expect(masterKeySpy).toHaveBeenCalledTimes(1); // Should only be called once + expect(config.masterKeyCache.expiresAt).toBeNull(); // TTL is not set, so expiresAt should remain null + }); + + it('should reload masterKey if ttl is set and expired', async () => { + const masterKeySpy = jasmine.createSpy() + .and.returnValues(Promise.resolve('firstMasterKey'), Promise.resolve('secondMasterKey')); + + await reconfigureServer({ + masterKey: masterKeySpy, + masterKeyTtl: 1 / 1000, // TTL is set to 1ms + }); + + await new Parse.Object('TestObject').save(); + + await new Promise(resolve => setTimeout(resolve, 10)); + + await new Parse.Object('TestObject').save(); + + const config = Config.get(Parse.applicationId); + expect(masterKeySpy).toHaveBeenCalledTimes(2); + expect(config.masterKeyCache.masterKey).toEqual('secondMasterKey'); + }); + + + it('should not fail when Google signin is introduced with clientId', done => { + const jwt = require('jsonwebtoken'); + const authUtils = require('../lib/Adapters/Auth/utils'); + + reconfigureServer({ + auth: { google: { clientId: 'secret' } }, }) - .then(() => reconfigureServer({ - sessionLength: '0', - expireInactiveSessions: false - })) - .then(done); - }) - - it_exclude_dbs(['postgres'])('fails if you try to set revokeSessionOnPasswordReset to non-boolean', done => { - reconfigureServer({ revokeSessionOnPasswordReset: 'non-bool' }) - .catch(done); + .then(() => { + const fakeClaim = { + iss: 'https://accounts.google.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { kid: '123', alg: 'RS256' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + const user = new Parse.User(); + user + .linkWith('google', { + authData: { id: 'the_user_id', id_token: 'the_token' }, + }) + .then(done); + }) + .catch(done.fail); + }); + + describe('publicServerURL', () => { + it('should load publicServerURL', async () => { + await reconfigureServer({ + publicServerURL: () => 'https://example.com/1', + }); + + await new Parse.Object('TestObject').save(); + + const config = Config.get(Parse.applicationId); + expect(config.publicServerURL).toEqual('https://example.com/1'); + }); + + it('should load publicServerURL from Promise', async () => { + await reconfigureServer({ + publicServerURL: () => Promise.resolve('https://example.com/1'), + }); + + await new Parse.Object('TestObject').save(); + + const config = Config.get(Parse.applicationId); + expect(config.publicServerURL).toEqual('https://example.com/1'); + }); + + it('should handle publicServerURL function throwing error', async () => { + const errorMessage = 'Failed to get public server URL'; + await reconfigureServer({ + publicServerURL: () => { + throw new Error(errorMessage); + }, + }); + + // The error should occur when trying to save an object (which triggers loadKeys in middleware) + await expectAsync( + new Parse.Object('TestObject').save() + ).toBeRejected(); + }); + + it('should handle publicServerURL Promise rejection', async () => { + const errorMessage = 'Async fetch of public server URL failed'; + await reconfigureServer({ + publicServerURL: () => Promise.reject(new Error(errorMessage)), + }); + + // The error should occur when trying to save an object (which triggers loadKeys in middleware) + await expectAsync( + new Parse.Object('TestObject').save() + ).toBeRejected(); + }); + + it('executes publicServerURL function on every config access', async () => { + let counter = 0; + await reconfigureServer({ + publicServerURL: () => { + counter++; + return `https://example.com/${counter}`; + }, + }); + + // First request - should call the function + await new Parse.Object('TestObject').save(); + const config1 = Config.get(Parse.applicationId); + expect(config1.publicServerURL).toEqual('https://example.com/1'); + expect(counter).toEqual(1); + + // Second request - should call the function again + await new Parse.Object('TestObject').save(); + const config2 = Config.get(Parse.applicationId); + expect(config2.publicServerURL).toEqual('https://example.com/2'); + expect(counter).toEqual(2); + + // Third request - should call the function again + await new Parse.Object('TestObject').save(); + const config3 = Config.get(Parse.applicationId); + expect(config3.publicServerURL).toEqual('https://example.com/3'); + expect(counter).toEqual(3); + }); + + it('executes publicServerURL function on every password reset email', async () => { + let counter = 0; + const emailCalls = []; + + const emailAdapter = MockEmailAdapterWithOptions({ + sendPasswordResetEmail: ({ link }) => { + emailCalls.push(link); + return Promise.resolve(); + }, + }); + + await reconfigureServer({ + appName: 'test-app', + publicServerURL: () => { + counter++; + return `https://example.com/${counter}`; + }, + emailAdapter, + }); + + // Create a user + const user = new Parse.User(); + user.setUsername('user'); + user.setPassword('pass'); + user.setEmail('user@example.com'); + await user.signUp(); + + // Should use first publicServerURL + const counterBefore1 = counter; + await Parse.User.requestPasswordReset('user@example.com'); + await jasmine.timeout(); + expect(emailCalls.length).toEqual(1); + expect(emailCalls[0]).toContain(`https://example.com/${counterBefore1 + 1}`); + expect(counter).toBeGreaterThanOrEqual(2); + + // Should use updated publicServerURL + const counterBefore2 = counter; + await Parse.User.requestPasswordReset('user@example.com'); + await jasmine.timeout(); + expect(emailCalls.length).toEqual(2); + expect(emailCalls[1]).toContain(`https://example.com/${counterBefore2 + 1}`); + expect(counterBefore2).toBeGreaterThan(counterBefore1); + }); + + it('executes publicServerURL function on every verification email', async () => { + let counter = 0; + const emailCalls = []; + + const emailAdapter = MockEmailAdapterWithOptions({ + sendVerificationEmail: ({ link }) => { + emailCalls.push(link); + return Promise.resolve(); + }, + }); + + await reconfigureServer({ + appName: 'test-app', + verifyUserEmails: true, + publicServerURL: () => { + counter++; + return `https://example.com/${counter}`; + }, + emailAdapter, + }); + + // Should trigger verification email with first publicServerURL + const counterBefore1 = counter; + const user1 = new Parse.User(); + user1.setUsername('user1'); + user1.setPassword('pass1'); + user1.setEmail('user1@example.com'); + await user1.signUp(); + await jasmine.timeout(); + expect(emailCalls.length).toEqual(1); + expect(emailCalls[0]).toContain(`https://example.com/${counterBefore1 + 1}`); + + // Should trigger verification email with updated publicServerURL + const counterBefore2 = counter; + const user2 = new Parse.User(); + user2.setUsername('user2'); + user2.setPassword('pass2'); + user2.setEmail('user2@example.com'); + await user2.signUp(); + await jasmine.timeout(); + expect(emailCalls.length).toEqual(2); + expect(emailCalls[1]).toContain(`https://example.com/${counterBefore2 + 1}`); + expect(counterBefore2).toBeGreaterThan(counterBefore1); + }); }); }); diff --git a/spec/parsers.spec.js b/spec/parsers.spec.js new file mode 100644 index 0000000000..a844016ba7 --- /dev/null +++ b/spec/parsers.spec.js @@ -0,0 +1,101 @@ +const { + numberParser, + numberOrBoolParser, + numberOrStringParser, + booleanParser, + booleanOrFunctionParser, + objectParser, + arrayParser, + moduleOrObjectParser, + nullParser, +} = require('../lib/Options/parsers'); + +describe('parsers', () => { + it('parses correctly with numberParser', () => { + const parser = numberParser('key'); + expect(parser(2)).toEqual(2); + expect(parser('2')).toEqual(2); + expect(() => { + parser('string'); + }).toThrow(); + }); + + it('parses correctly with numberOrStringParser', () => { + const parser = numberOrStringParser('key'); + expect(parser('100d')).toEqual('100d'); + expect(parser(100)).toEqual(100); + expect(() => { + parser(undefined); + }).toThrow(); + }); + + it('parses correctly with numberOrBoolParser', () => { + const parser = numberOrBoolParser('key'); + expect(parser(true)).toEqual(true); + expect(parser(false)).toEqual(false); + expect(parser('true')).toEqual(true); + expect(parser('false')).toEqual(false); + expect(parser(1)).toEqual(1); + expect(parser('1')).toEqual(1); + }); + + it('parses correctly with booleanParser', () => { + const parser = booleanParser; + expect(parser(true)).toEqual(true); + expect(parser(false)).toEqual(false); + expect(parser('true')).toEqual(true); + expect(parser('false')).toEqual(false); + expect(parser(1)).toEqual(true); + expect(parser(2)).toEqual(false); + }); + + it('parses correctly with booleanOrFunctionParser', () => { + const parser = booleanOrFunctionParser; + // Preserves functions + const fn = () => true; + expect(parser(fn)).toBe(fn); + const asyncFn = async () => false; + expect(parser(asyncFn)).toBe(asyncFn); + // Parses booleans and string booleans like booleanParser + expect(parser(true)).toEqual(true); + expect(parser(false)).toEqual(false); + expect(parser('true')).toEqual(true); + expect(parser('false')).toEqual(false); + expect(parser('1')).toEqual(true); + expect(parser(1)).toEqual(true); + expect(parser(0)).toEqual(false); + }); + + it('parses correctly with objectParser', () => { + const parser = objectParser; + expect(parser({ hello: 'world' })).toEqual({ hello: 'world' }); + expect(parser('{"hello": "world"}')).toEqual({ hello: 'world' }); + expect(() => { + parser('string'); + }).toThrow(); + }); + + it('parses correctly with moduleOrObjectParser', () => { + const parser = moduleOrObjectParser; + expect(parser({ hello: 'world' })).toEqual({ hello: 'world' }); + expect(parser('{"hello": "world"}')).toEqual({ hello: 'world' }); + expect(parser('string')).toEqual('string'); + }); + + it('parses correctly with arrayParser', () => { + const parser = arrayParser; + expect(parser([1, 2, 3])).toEqual([1, 2, 3]); + expect(parser('{"hello": "world"}')).toEqual(['{"hello": "world"}']); + expect(parser('1,2,3')).toEqual(['1', '2', '3']); + expect(() => { + parser(1); + }).toThrow(); + }); + + it('parses correctly with nullParser', () => { + const parser = nullParser; + expect(parser('null')).toEqual(null); + expect(parser(1)).toEqual(1); + expect(parser('blabla')).toEqual('blabla'); + }); +}); diff --git a/spec/rest.spec.js b/spec/rest.spec.js new file mode 100644 index 0000000000..9416d9230e --- /dev/null +++ b/spec/rest.spec.js @@ -0,0 +1,1761 @@ +'use strict'; +// These tests check the "create" / "update" functionality of the REST API. +const auth = require('../lib/Auth'); +const Config = require('../lib/Config'); +const Parse = require('parse/node').Parse; +const rest = require('../lib/rest'); +const RestWrite = require('../lib/RestWrite'); +const request = require('../lib/request'); + +let config; +let database; + +describe('rest create', () => { + let loggerErrorSpy; + + beforeEach(() => { + config = Config.get('test'); + database = config.database; + + const logger = require('../lib/logger').default; + loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + }); + + it('handles _id', done => { + rest + .create(config, auth.nobody(config), 'Foo', {}) + .then(() => database.adapter.find('Foo', { fields: {} }, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + const obj = results[0]; + expect(typeof obj.objectId).toEqual('string'); + expect(obj.objectId.length).toEqual(10); + expect(obj._id).toBeUndefined(); + done(); + }); + }); + + it('can use custom _id size', done => { + config.objectIdSize = 20; + rest + .create(config, auth.nobody(config), 'Foo', {}) + .then(() => database.adapter.find('Foo', { fields: {} }, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + const obj = results[0]; + expect(typeof obj.objectId).toEqual('string'); + expect(obj.objectId.length).toEqual(20); + done(); + }); + }); + + it('should use objectId from client when allowCustomObjectId true', async () => { + config.allowCustomObjectId = true; + + // use time as unique custom id for test reusability + const customId = `${Date.now()}`; + const obj = { + objectId: customId, + }; + + const { + status, + response: { objectId }, + } = await rest.create(config, auth.nobody(config), 'MyClass', obj); + + expect(status).toEqual(201); + expect(objectId).toEqual(customId); + }); + + it('should throw on invalid objectId when allowCustomObjectId true', () => { + config.allowCustomObjectId = true; + + const objIdNull = { + objectId: null, + }; + + const objIdUndef = { + objectId: undefined, + }; + + const objIdEmpty = { + objectId: '', + }; + + const err = 'objectId must not be empty, null or undefined'; + + expect(() => rest.create(config, auth.nobody(config), 'MyClass', objIdEmpty)).toThrowError(err); + + expect(() => rest.create(config, auth.nobody(config), 'MyClass', objIdNull)).toThrowError(err); + + expect(() => rest.create(config, auth.nobody(config), 'MyClass', objIdUndef)).toThrowError(err); + }); + + it('should generate objectId when not set by client with allowCustomObjectId true', async () => { + config.allowCustomObjectId = true; + + const { + status, + response: { objectId }, + } = await rest.create(config, auth.nobody(config), 'MyClass', {}); + + expect(status).toEqual(201); + expect(objectId).toBeDefined(); + }); + + it('is backwards compatible when _id size changes', done => { + rest + .create(config, auth.nobody(config), 'Foo', { size: 10 }) + .then(() => { + config.objectIdSize = 20; + return rest.find(config, auth.nobody(config), 'Foo', { size: 10 }); + }) + .then(response => { + expect(response.results.length).toEqual(1); + expect(response.results[0].objectId.length).toEqual(10); + return rest.update( + config, + auth.nobody(config), + 'Foo', + { objectId: response.results[0].objectId }, + { update: 20 } + ); + }) + .then(() => { + return rest.find(config, auth.nobody(config), 'Foo', { size: 10 }); + }) + .then(response => { + expect(response.results.length).toEqual(1); + expect(response.results[0].objectId.length).toEqual(10); + expect(response.results[0].update).toEqual(20); + return rest.create(config, auth.nobody(config), 'Foo', { size: 20 }); + }) + .then(() => { + config.objectIdSize = 10; + return rest.find(config, auth.nobody(config), 'Foo', { size: 20 }); + }) + .then(response => { + expect(response.results.length).toEqual(1); + expect(response.results[0].objectId.length).toEqual(20); + done(); + }); + }); + + describe('with maintenance key', () => { + let req; + + async function getObject(id) { + const res = await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'GET', + url: `http://localhost:8378/1/classes/TestObject/${id}`, + }); + + return res.data; + } + + beforeEach(() => { + req = { + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Maintenance-Key': 'testing', + }, + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + }; + }); + + it('allows createdAt', async () => { + const createdAt = { __type: 'Date', iso: '2019-01-01T00:00:00.000Z' }; + req.body = { createdAt }; + + const res = await request(req); + expect(res.data.createdAt).toEqual(createdAt.iso); + }); + + it('allows createdAt and updatedAt', async () => { + const createdAt = { __type: 'Date', iso: '2019-01-01T00:00:00.000Z' }; + const updatedAt = { __type: 'Date', iso: '2019-02-01T00:00:00.000Z' }; + req.body = { createdAt, updatedAt }; + + const res = await request(req); + + const obj = await getObject(res.data.objectId); + expect(obj.createdAt).toEqual(createdAt.iso); + expect(obj.updatedAt).toEqual(updatedAt.iso); + }); + + it('allows createdAt, updatedAt, and additional field', async () => { + const createdAt = { __type: 'Date', iso: '2019-01-01T00:00:00.000Z' }; + const updatedAt = { __type: 'Date', iso: '2019-02-01T00:00:00.000Z' }; + req.body = { createdAt, updatedAt, testing: 123 }; + + const res = await request(req); + + const obj = await getObject(res.data.objectId); + expect(obj.createdAt).toEqual(createdAt.iso); + expect(obj.updatedAt).toEqual(updatedAt.iso); + expect(obj.testing).toEqual(123); + }); + + it('cannot set updatedAt dated before createdAt', async () => { + const createdAt = { __type: 'Date', iso: '2019-01-01T00:00:00.000Z' }; + const updatedAt = { __type: 'Date', iso: '2018-12-01T00:00:00.000Z' }; + req.body = { createdAt, updatedAt }; + + try { + await request(req); + fail(); + } catch (err) { + expect(err.data.code).toEqual(Parse.Error.VALIDATION_ERROR); + } + }); + + it('cannot set updatedAt without createdAt', async () => { + const updatedAt = { __type: 'Date', iso: '2018-12-01T00:00:00.000Z' }; + req.body = { updatedAt }; + + const res = await request(req); + + const obj = await getObject(res.data.objectId); + expect(obj.updatedAt).not.toEqual(updatedAt.iso); + }); + + it('handles bad types for createdAt and updatedAt', async () => { + const createdAt = 12345; + const updatedAt = true; + req.body = { createdAt, updatedAt }; + + try { + await request(req); + fail(); + } catch (err) { + expect(err.data.code).toEqual(Parse.Error.INCORRECT_TYPE); + } + }); + + it('cannot set createdAt or updatedAt without maintenance key', async () => { + const createdAt = { __type: 'Date', iso: '2019-01-01T00:00:00.000Z' }; + const updatedAt = { __type: 'Date', iso: '2019-02-01T00:00:00.000Z' }; + req.body = { createdAt, updatedAt }; + delete req.headers['X-Parse-Maintenance-Key']; + + const res = await request(req); + + expect(res.data.createdAt).not.toEqual(createdAt.iso); + expect(res.data.updatedAt).not.toEqual(updatedAt.iso); + }); + }); + + it_id('6c30306f-328c-47f2-88a7-2deffaee997f')(it)('handles array, object, date', done => { + const now = new Date(); + const obj = { + array: [1, 2, 3], + object: { foo: 'bar' }, + date: Parse._encode(now), + }; + rest + .create(config, auth.nobody(config), 'MyClass', obj) + .then(() => + database.adapter.find( + 'MyClass', + { + fields: { + array: { type: 'Array' }, + object: { type: 'Object' }, + date: { type: 'Date' }, + }, + }, + {}, + {} + ) + ) + .then(results => { + expect(results.length).toEqual(1); + const mob = results[0]; + expect(Array.isArray(mob.array)).toBe(true); + expect(typeof mob.object).toBe('object'); + expect(mob.date.__type).toBe('Date'); + expect(new Date(mob.date.iso).getTime()).toBe(now.getTime()); + done(); + }); + }); + + it('handles object and subdocument', done => { + const obj = { subdoc: { foo: 'bar', wu: 'tan' } }; + + Parse.Cloud.beforeSave('MyClass', function () { + // this beforeSave trigger should do nothing but can mess with the object + }); + + rest + .create(config, auth.nobody(config), 'MyClass', obj) + .then(() => database.adapter.find('MyClass', { fields: {} }, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + const mob = results[0]; + expect(typeof mob.subdoc).toBe('object'); + expect(mob.subdoc.foo).toBe('bar'); + expect(mob.subdoc.wu).toBe('tan'); + expect(typeof mob.objectId).toEqual('string'); + const obj = { 'subdoc.wu': 'clan' }; + return rest.update(config, auth.nobody(config), 'MyClass', { objectId: mob.objectId }, obj); + }) + .then(() => database.adapter.find('MyClass', { fields: {} }, {}, {})) + .then(results => { + expect(results.length).toEqual(1); + const mob = results[0]; + expect(typeof mob.subdoc).toBe('object'); + expect(mob.subdoc.foo).toBe('bar'); + expect(mob.subdoc.wu).toBe('clan'); + done(); + }) + .catch(done.fail); + }); + + it('handles create on non-existent class when disabled client class creation', done => { + const customConfig = Object.assign({}, config, { + allowClientClassCreation: false, + }); + loggerErrorSpy.calls.reset(); + rest.create(customConfig, auth.nobody(customConfig), 'ClientClassCreation', {}).then( + () => { + fail('Should throw an error'); + done(); + }, + err => { + expect(err.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); + expect(err.message).toEqual('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('This user is not allowed to access ' + 'non-existent class: ClientClassCreation')); + done(); + } + ); + }); + + it('handles create on existent class when disabled client class creation', async () => { + const customConfig = Object.assign({}, config, { + allowClientClassCreation: false, + }); + const schema = await config.database.loadSchema(); + const actualSchema = await schema.addClassIfNotExists('ClientClassCreation', {}); + expect(actualSchema.className).toEqual('ClientClassCreation'); + + await schema.reloadData({ clearCache: true }); + // Should not throw + await rest.create(customConfig, auth.nobody(customConfig), 'ClientClassCreation', {}); + }); + + it('handles user signup', done => { + const user = { + username: 'asdf', + password: 'zxcv', + foo: 'bar', + }; + rest.create(config, auth.nobody(config), '_User', user).then(r => { + expect(Object.keys(r.response).length).toEqual(3); + expect(typeof r.response.objectId).toEqual('string'); + expect(typeof r.response.createdAt).toEqual('string'); + expect(typeof r.response.sessionToken).toEqual('string'); + done(); + }); + }); + + it('handles anonymous user signup', done => { + const data1 = { + authData: { + anonymous: { + id: '00000000-0000-0000-0000-000000000001', + }, + }, + }; + const data2 = { + authData: { + anonymous: { + id: '00000000-0000-0000-0000-000000000002', + }, + }, + }; + let username1; + rest + .create(config, auth.nobody(config), '_User', data1) + .then(r => { + expect(typeof r.response.objectId).toEqual('string'); + expect(typeof r.response.createdAt).toEqual('string'); + expect(typeof r.response.sessionToken).toEqual('string'); + expect(typeof r.response.username).toEqual('string'); + return rest.create(config, auth.nobody(config), '_User', data1); + }) + .then(r => { + expect(typeof r.response.objectId).toEqual('string'); + expect(typeof r.response.createdAt).toEqual('string'); + expect(typeof r.response.username).toEqual('string'); + expect(typeof r.response.updatedAt).toEqual('string'); + username1 = r.response.username; + return rest.create(config, auth.nobody(config), '_User', data2); + }) + .then(r => { + expect(typeof r.response.objectId).toEqual('string'); + expect(typeof r.response.createdAt).toEqual('string'); + expect(typeof r.response.sessionToken).toEqual('string'); + return rest.create(config, auth.nobody(config), '_User', data2); + }) + .then(r => { + expect(typeof r.response.objectId).toEqual('string'); + expect(typeof r.response.createdAt).toEqual('string'); + expect(typeof r.response.username).toEqual('string'); + expect(typeof r.response.updatedAt).toEqual('string'); + expect(r.response.username).not.toEqual(username1); + done(); + }); + }); + + it('handles anonymous user signup and upgrade to new user', done => { + const data1 = { + authData: { + anonymous: { + id: '00000000-0000-0000-0000-000000000001', + }, + }, + }; + + const updatedData = { + authData: { anonymous: null }, + username: 'hello', + password: 'world', + }; + let objectId; + rest + .create(config, auth.nobody(config), '_User', data1) + .then(r => { + expect(typeof r.response.objectId).toEqual('string'); + expect(typeof r.response.createdAt).toEqual('string'); + expect(typeof r.response.sessionToken).toEqual('string'); + objectId = r.response.objectId; + return auth.getAuthForSessionToken({ + config, + sessionToken: r.response.sessionToken, + }); + }) + .then(sessionAuth => { + return rest.update(config, sessionAuth, '_User', { objectId }, updatedData); + }) + .then(() => { + return Parse.User.logOut().then(() => { + return Parse.User.logIn('hello', 'world'); + }); + }) + .then(r => { + expect(r.id).toEqual(objectId); + expect(r.get('username')).toEqual('hello'); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); + }); + + it('handles no anonymous users config', done => { + const NoAnnonConfig = Object.assign({}, config); + NoAnnonConfig.authDataManager.setEnableAnonymousUsers(false); + const data1 = { + authData: { + anonymous: { + id: '00000000-0000-0000-0000-000000000001', + }, + }, + }; + rest.create(NoAnnonConfig, auth.nobody(NoAnnonConfig), '_User', data1).then( + () => { + fail('Should throw an error'); + done(); + }, + err => { + expect(err.code).toEqual(Parse.Error.UNSUPPORTED_SERVICE); + expect(err.message).toEqual('This authentication method is unsupported.'); + NoAnnonConfig.authDataManager.setEnableAnonymousUsers(true); + done(); + } + ); + }); + + it('test facebook signup and login', done => { + const data = { + authData: { + facebook: { + id: '8675309', + access_token: 'jenny', + }, + }, + }; + let newUserSignedUpByFacebookObjectId; + rest + .create(config, auth.nobody(config), '_User', data) + .then(r => { + expect(typeof r.response.objectId).toEqual('string'); + expect(typeof r.response.createdAt).toEqual('string'); + expect(typeof r.response.sessionToken).toEqual('string'); + newUserSignedUpByFacebookObjectId = r.response.objectId; + return rest.create(config, auth.nobody(config), '_User', data); + }) + .then(r => { + expect(typeof r.response.objectId).toEqual('string'); + expect(typeof r.response.createdAt).toEqual('string'); + expect(typeof r.response.username).toEqual('string'); + expect(typeof r.response.updatedAt).toEqual('string'); + expect(r.response.objectId).toEqual(newUserSignedUpByFacebookObjectId); + return rest.find(config, auth.master(config), '_Session', { + sessionToken: r.response.sessionToken, + }); + }) + .then(response => { + expect(response.results.length).toEqual(1); + const output = response.results[0]; + expect(output.user.objectId).toEqual(newUserSignedUpByFacebookObjectId); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); + }); + + it('stores pointers', done => { + const obj = { + foo: 'bar', + aPointer: { + __type: 'Pointer', + className: 'JustThePointer', + objectId: 'qwerty1234', // make it 10 chars to match PG storage + }, + }; + rest + .create(config, auth.nobody(config), 'APointerDarkly', obj) + .then(() => + database.adapter.find( + 'APointerDarkly', + { + fields: { + foo: { type: 'String' }, + aPointer: { type: 'Pointer', targetClass: 'JustThePointer' }, + }, + }, + {}, + {} + ) + ) + .then(results => { + expect(results.length).toEqual(1); + const output = results[0]; + expect(typeof output.foo).toEqual('string'); + expect(typeof output._p_aPointer).toEqual('undefined'); + expect(output._p_aPointer).toBeUndefined(); + expect(output.aPointer).toEqual({ + __type: 'Pointer', + className: 'JustThePointer', + objectId: 'qwerty1234', + }); + done(); + }); + }); + + it('stores pointers to objectIds larger than 10 characters', done => { + const obj = { + foo: 'bar', + aPointer: { + __type: 'Pointer', + className: 'JustThePointer', + objectId: '49F62F92-9B56-46E7-A3D4-BBD14C52F666', + }, + }; + rest + .create(config, auth.nobody(config), 'APointerDarkly', obj) + .then(() => + database.adapter.find( + 'APointerDarkly', + { + fields: { + foo: { type: 'String' }, + aPointer: { type: 'Pointer', targetClass: 'JustThePointer' }, + }, + }, + {}, + {} + ) + ) + .then(results => { + expect(results.length).toEqual(1); + const output = results[0]; + expect(typeof output.foo).toEqual('string'); + expect(typeof output._p_aPointer).toEqual('undefined'); + expect(output._p_aPointer).toBeUndefined(); + expect(output.aPointer).toEqual({ + __type: 'Pointer', + className: 'JustThePointer', + objectId: '49F62F92-9B56-46E7-A3D4-BBD14C52F666', + }); + done(); + }); + }); + + it('cannot set objectId', done => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: JSON.stringify({ + foo: 'bar', + objectId: 'hello', + }), + }).then(fail, response => { + const b = response.data; + expect(b.code).toEqual(105); + expect(b.error).toEqual('objectId is an invalid field name.'); + done(); + }); + }); + + it('cannot set id', done => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: JSON.stringify({ + foo: 'bar', + id: 'hello', + }), + }).then(fail, response => { + const b = response.data; + expect(b.code).toEqual(105); + expect(b.error).toEqual('id is an invalid field name.'); + done(); + }); + }); + + it('test default session length', done => { + const user = { + username: 'asdf', + password: 'zxcv', + foo: 'bar', + }; + const defaultSessionLength = 1000 * 3600 * 24 * 365; + const before = Date.now(); + + rest + .create(config, auth.nobody(config), '_User', user) + .then(r => { + expect(Object.keys(r.response).length).toEqual(3); + expect(typeof r.response.objectId).toEqual('string'); + expect(typeof r.response.createdAt).toEqual('string'); + expect(typeof r.response.sessionToken).toEqual('string'); + return rest.find(config, auth.master(config), '_Session', { + sessionToken: r.response.sessionToken, + }); + }) + .then(r => { + expect(r.results.length).toEqual(1); + + const session = r.results[0]; + const actual = new Date(session.expiresAt.iso).getTime(); + const after = Date.now(); + + expect(actual).toBeGreaterThanOrEqual(before + defaultSessionLength); + expect(actual).toBeLessThanOrEqual(after + defaultSessionLength); + + done(); + }); + }); + + it('test specified session length', done => { + const user = { + username: 'asdf', + password: 'zxcv', + foo: 'bar', + }; + const sessionLength = 3600; // 1 Hour ahead + config.sessionLength = sessionLength; + const before = Date.now(); + + rest + .create(config, auth.nobody(config), '_User', user) + .then(r => { + expect(Object.keys(r.response).length).toEqual(3); + expect(typeof r.response.objectId).toEqual('string'); + expect(typeof r.response.createdAt).toEqual('string'); + expect(typeof r.response.sessionToken).toEqual('string'); + return rest.find(config, auth.master(config), '_Session', { + sessionToken: r.response.sessionToken, + }); + }) + .then(r => { + expect(r.results.length).toEqual(1); + + const session = r.results[0]; + const actual = new Date(session.expiresAt.iso).getTime(); + const after = Date.now(); + + expect(actual).toBeGreaterThanOrEqual(before + sessionLength * 1000); + expect(actual).toBeLessThanOrEqual(after + sessionLength * 1000); + + done(); + }) + .catch(err => { + jfail(err); + done(); + }); + }); + + it('can create a session with no expiration', async () => { + await reconfigureServer({ expireInactiveSessions: false }); + config = Config.get('test'); + + const user = { + username: 'asdf', + password: 'zxcv', + foo: 'bar', + }; + + const r = await rest.create(config, auth.nobody(config), '_User', user); + expect(Object.keys(r.response).length).toEqual(3); + expect(typeof r.response.objectId).toEqual('string'); + expect(typeof r.response.createdAt).toEqual('string'); + expect(typeof r.response.sessionToken).toEqual('string'); + + const s = await rest.find(config, auth.master(config), '_Session', { + sessionToken: r.response.sessionToken, + }); + expect(s.results.length).toEqual(1); + expect(s.results[0].expiresAt).toBeUndefined(); + }); + + it('can create object in volatileClasses if masterKey', done => { + rest + .create(config, auth.master(config), '_PushStatus', {}) + .then(r => { + expect(r.response.objectId.length).toBe(10); + }) + .then(() => { + rest.create(config, auth.master(config), '_JobStatus', {}).then(r => { + expect(r.response.objectId.length).toBe(10); + done(); + }); + }); + }); + + it('cannot create object in volatileClasses if not masterKey', done => { + Promise.resolve() + .then(() => { + return rest.create(config, auth.nobody(config), '_PushStatus', {}); + }) + .catch(error => { + expect(error.code).toEqual(119); + done(); + }); + }); + + it('cannot get object in volatileClasses if not masterKey through pointer', async () => { + loggerErrorSpy.calls.reset(); + const masterKeyOnlyClassObject = new Parse.Object('_PushStatus'); + await masterKeyOnlyClassObject.save(null, { useMasterKey: true }); + const obj2 = new Parse.Object('TestObject'); + // Anyone is can basically create a pointer to any object + // or some developers can use master key in some hook to link + // private objects to standard objects + obj2.set('pointer', masterKeyOnlyClassObject); + await obj2.save(); + const query = new Parse.Query('TestObject'); + query.include('pointer'); + await expectAsync(query.get(obj2.id)).toBeRejectedWithError( + 'Permission denied' + ); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining("Clients aren't allowed to perform the get operation on the _PushStatus collection.")); + }); + + it_id('3ce563bf-93aa-4d0b-9af9-c5fb246ac9fc')(it)('cannot get object in _GlobalConfig if not masterKey through pointer', async () => { + loggerErrorSpy.calls.reset(); + await Parse.Config.save({ privateData: 'secret' }, { privateData: true }); + const obj2 = new Parse.Object('TestObject'); + obj2.set('globalConfigPointer', { + __type: 'Pointer', + className: '_GlobalConfig', + objectId: 1, + }); + await obj2.save(); + const query = new Parse.Query('TestObject'); + query.include('globalConfigPointer'); + await expectAsync(query.get(obj2.id)).toBeRejectedWithError( + 'Permission denied' + ); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining("Clients aren't allowed to perform the get operation on the _GlobalConfig collection.")); + }); + + it('should require master key for all volatile classes', () => { + // This test guards against drift between volatileClasses (SchemaController.js) + // and classesWithMasterOnlyAccess (SharedRest.js). If a new volatile class is + // added, it must also be added to classesWithMasterOnlyAccess and this test. + const volatileClasses = [ + '_JobStatus', + '_PushStatus', + '_Hooks', + '_GlobalConfig', + '_GraphQLConfig', + '_JobSchedule', + '_Audience', + '_Idempotency', + ]; + for (const className of volatileClasses) { + expect(() => + rest.create(config, auth.nobody(config), className, {}) + ).toThrowMatching( + e => e.code === Parse.Error.OPERATION_FORBIDDEN, + `Expected ${className} to require master key` + ); + } + }); + + it('cannot find objects in _GraphQLConfig without masterKey', async () => { + await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] }); + await expectAsync( + rest.find(config, auth.nobody(config), '_GraphQLConfig', {}) + ).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }) + ); + }); + + it('cannot update object in _GraphQLConfig without masterKey', async () => { + await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] }); + expect(() => + rest.update(config, auth.nobody(config), '_GraphQLConfig', '1', { + config: { enabledForClasses: [] }, + }) + ).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN); + }); + + it('cannot delete object in _GraphQLConfig without masterKey', async () => { + await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] }); + expect(() => + rest.del(config, auth.nobody(config), '_GraphQLConfig', '1') + ).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN); + }); + + it('can perform operations on _GraphQLConfig with masterKey', async () => { + await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] }); + const found = await rest.find(config, auth.master(config), '_GraphQLConfig', {}); + expect(found.results.length).toBeGreaterThan(0); + await rest.del(config, auth.master(config), '_GraphQLConfig', '1'); + const afterDelete = await rest.find(config, auth.master(config), '_GraphQLConfig', {}); + expect(afterDelete.results.length).toBe(0); + }); + + it('cannot create object in _Audience without masterKey', () => { + expect(() => + rest.create(config, auth.nobody(config), '_Audience', { + name: 'test', + query: '{}', + }) + ).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN); + }); + + it('cannot find objects in _Audience without masterKey', async () => { + await expectAsync( + rest.find(config, auth.nobody(config), '_Audience', {}) + ).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }) + ); + }); + + it('cannot update object in _Audience without masterKey', async () => { + const obj = await rest.create(config, auth.master(config), '_Audience', { + name: 'test', + query: '{}', + }); + expect(() => + rest.update(config, auth.nobody(config), '_Audience', obj.response.objectId, { + name: 'updated', + }) + ).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN); + }); + + it('cannot delete object in _Audience without masterKey', async () => { + const obj = await rest.create(config, auth.master(config), '_Audience', { + name: 'test', + query: '{}', + }); + expect(() => + rest.del(config, auth.nobody(config), '_Audience', obj.response.objectId) + ).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN); + }); + + it('can perform CRUD on _Audience with masterKey', async () => { + const obj = await rest.create(config, auth.master(config), '_Audience', { + name: 'test', + query: '{}', + }); + expect(obj.response.objectId).toBeDefined(); + const found = await rest.find(config, auth.master(config), '_Audience', {}); + expect(found.results.length).toBeGreaterThan(0); + await rest.del(config, auth.master(config), '_Audience', obj.response.objectId); + const afterDelete = await rest.find(config, auth.master(config), '_Audience', {}); + expect(afterDelete.results.length).toBe(0); + }); + + it('cannot access _GraphQLConfig via class route without masterKey', async () => { + await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] }); + try { + await request({ + url: 'http://localhost:8378/1/classes/_GraphQLConfig', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it('cannot access _Audience via class route without masterKey', async () => { + await rest.create(config, auth.master(config), '_Audience', { + name: 'test', + query: '{}', + }); + try { + await request({ + url: 'http://localhost:8378/1/classes/_Audience', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it('locks down session', done => { + let currentUser; + Parse.User.signUp('foo', 'bar') + .then(user => { + currentUser = user; + const sessionToken = user.getSessionToken(); + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }; + let sessionId; + return request({ + headers: headers, + url: 'http://localhost:8378/1/sessions/me', + }) + .then(response => { + sessionId = response.data.objectId; + return request({ + headers, + method: 'PUT', + url: 'http://localhost:8378/1/sessions/' + sessionId, + body: { + installationId: 'yolo', + }, + }); + }) + .then(done.fail, res => { + expect(res.status).toBe(400); + expect(res.data.code).toBe(105); + return request({ + headers, + method: 'PUT', + url: 'http://localhost:8378/1/sessions/' + sessionId, + body: { + sessionToken: 'yolo', + }, + }); + }) + .then(done.fail, res => { + expect(res.status).toBe(400); + expect(res.data.code).toBe(105); + return Parse.User.signUp('other', 'user'); + }) + .then(otherUser => { + const user = new Parse.User(); + user.id = otherUser.id; + return request({ + headers, + method: 'PUT', + url: 'http://localhost:8378/1/sessions/' + sessionId, + body: { + user: Parse._encode(user), + }, + }); + }) + .then(done.fail, res => { + expect(res.status).toBe(400); + expect(res.data.code).toBe(105); + const user = new Parse.User(); + user.id = currentUser.id; + return request({ + headers, + method: 'PUT', + url: 'http://localhost:8378/1/sessions/' + sessionId, + body: { + user: Parse._encode(user), + }, + }); + }) + .then(done) + .catch(done.fail); + }) + .catch(done.fail); + }); + + it('sets current user in new sessions', done => { + let currentUser; + Parse.User.signUp('foo', 'bar') + .then(user => { + currentUser = user; + const sessionToken = user.getSessionToken(); + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }; + return request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/sessions', + body: { + user: { __type: 'Pointer', className: '_User', objectId: 'fakeId' }, + }, + }); + }) + .then(response => { + if (response.data.user.objectId === currentUser.id) { + return done(); + } else { + return done.fail(); + } + }) + .catch(done.fail); + }); +}); + +describe('rest update', () => { + it('ignores createdAt', done => { + const config = Config.get('test'); + const nobody = auth.nobody(config); + const className = 'Foo'; + const newCreatedAt = new Date('1970-01-01T00:00:00.000Z'); + + rest + .create(config, nobody, className, {}) + .then(res => { + const objectId = res.response.objectId; + const restObject = { + createdAt: { __type: 'Date', iso: newCreatedAt }, // should be ignored + }; + + return rest.update(config, nobody, className, { objectId }, restObject).then(() => { + const restWhere = { + objectId: objectId, + }; + return rest.find(config, nobody, className, restWhere, {}); + }); + }) + .then(res2 => { + const updatedObject = res2.results[0]; + expect(new Date(updatedObject.createdAt)).not.toEqual(newCreatedAt); + done(); + }) + .then(done) + .catch(done.fail); + }); +}); + +describe('_Join table security', () => { + let config; + + beforeEach(() => { + config = Config.get('test'); + }); + + it('cannot create object in _Join table without masterKey', () => { + expect(() => + rest.create(config, auth.nobody(config), '_Join:users:_Role', { + relatedId: 'someUserId', + owningId: 'someRoleId', + }) + ).toThrowError(/Permission denied/); + }); + + it('cannot find objects in _Join table without masterKey', async () => { + await expectAsync( + rest.find(config, auth.nobody(config), '_Join:users:_Role', {}) + ).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.OPERATION_FORBIDDEN, + }) + ); + }); + + it('cannot update object in _Join table without masterKey', () => { + expect(() => + rest.update(config, auth.nobody(config), '_Join:users:_Role', { relatedId: 'someUserId' }, { owningId: 'newRoleId' }) + ).toThrowError(/Permission denied/); + }); + + it('cannot delete object in _Join table without masterKey', () => { + expect(() => + rest.del(config, auth.nobody(config), '_Join:users:_Role', 'someObjectId') + ).toThrowError(/Permission denied/); + }); + + it('cannot get object in _Join table without masterKey', async () => { + await expectAsync( + rest.get(config, auth.nobody(config), '_Join:users:_Role', 'someObjectId') + ).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.OPERATION_FORBIDDEN, + }) + ); + }); + + it('can find objects in _Join table with masterKey', async () => { + await expectAsync( + rest.find(config, auth.master(config), '_Join:users:_Role', {}) + ).toBeResolved(); + }); + + it('can find objects in _Join table with maintenance key', async () => { + await expectAsync( + rest.find(config, auth.maintenance(config), '_Join:users:_Role', {}) + ).toBeResolved(); + }); + + it('legitimate relation operations still work', async () => { + const role = new Parse.Role('admin', new Parse.ACL()); + const user = await Parse.User.signUp('testuser', 'password123'); + role.getUsers().add(user); + await role.save(null, { useMasterKey: true }); + const result = await rest.find(config, auth.master(config), '_Join:users:_Role', {}); + expect(result.results.length).toBe(1); + }); + + it('blocks _Join table access for any relation, not just _Role', () => { + expect(() => + rest.create(config, auth.nobody(config), '_Join:viewers:ConfidentialDoc', { + relatedId: 'someUserId', + owningId: 'someDocId', + }) + ).toThrowError(/Permission denied/); + }); + + it('cannot escalate role via direct _Join table write', async () => { + const role = new Parse.Role('superadmin', new Parse.ACL()); + await role.save(null, { useMasterKey: true }); + const user = await Parse.User.signUp('attacker', 'password123'); + const sessionToken = user.getSessionToken(); + const userAuth = await auth.getAuthForSessionToken({ + config, + sessionToken, + }); + expect(() => + rest.create(config, userAuth, '_Join:users:_Role', { + relatedId: user.id, + owningId: role.id, + }) + ).toThrowError(/Permission denied/); + }); + + it('cannot write to _Join table with read-only masterKey', () => { + expect(() => + rest.create(config, auth.readOnly(config), '_Join:users:_Role', { + relatedId: 'someUserId', + owningId: 'someRoleId', + }) + ).toThrowError(/Permission denied/); + }); + + it('can read _Join table with read-only masterKey', async () => { + await expectAsync( + rest.find(config, auth.readOnly(config), '_Join:users:_Role', {}) + ).toBeResolved(); + }); +}); + +describe('read-only masterKey', () => { + let loggerErrorSpy; + let logger; + + beforeEach(() => { + logger = require('../lib/logger').default; + loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + }); + + it('properly throws on rest.create, rest.update and rest.del', () => { + loggerErrorSpy.calls.reset(); + const config = Config.get('test'); + const readOnly = auth.readOnly(config); + expect(() => { + rest.create(config, readOnly, 'AnObject', {}); + }).toThrow( + new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + 'Permission denied' + ) + ); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining("read-only masterKey isn't allowed to perform the create operation.")); + expect(() => { + rest.update(config, readOnly, 'AnObject', {}); + }).toThrow(); + expect(() => { + rest.del(config, readOnly, 'AnObject', {}); + }).toThrow(); + }); + + it('properly blocks writes', async () => { + await reconfigureServer({ + readOnlyMasterKey: 'yolo-read-only', + }); + // Need to be re required because reconfigureServer resets the logger + const logger2 = require('../lib/logger').default; + loggerErrorSpy = spyOn(logger2, 'error').and.callThrough(); + try { + await request({ + url: `${Parse.serverURL}/classes/MyYolo`, + method: 'POST', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'yolo-read-only', + 'Content-Type': 'application/json', + }, + body: { foo: 'bar' }, + }); + fail(); + } catch (res) { + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe( + 'Permission denied' + ); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining("read-only masterKey isn't allowed to perform the create operation.")); + } + await reconfigureServer(); + }); + + it('should throw when masterKey and readOnlyMasterKey are the same', async () => { + try { + await reconfigureServer({ + masterKey: 'yolo', + readOnlyMasterKey: 'yolo', + }); + fail(); + } catch (err) { + expect(err).toEqual(new Error('masterKey and readOnlyMasterKey should be different')); + } + await reconfigureServer(); + }); + + it('should throw when masterKey and maintenanceKey are the same', async () => { + await expectAsync( + reconfigureServer({ + masterKey: 'yolo', + maintenanceKey: 'yolo', + }) + ).toBeRejectedWith(new Error('masterKey and maintenanceKey should be different')); + }); + + it('should throw when trying to create RestWrite', () => { + loggerErrorSpy.calls.reset(); + const config = Config.get('test'); + expect(() => { + new RestWrite(config, auth.readOnly(config)); + }).toThrow( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining("Cannot perform a write operation when using readOnlyMasterKey")); + }); + + it('should throw when trying to create schema', done => { + loggerErrorSpy.calls.reset(); + request({ + method: 'POST', + url: `${Parse.serverURL}/schemas`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'read-only-test', + 'Content-Type': 'application/json', + }, + json: {}, + }) + .then(done.fail) + .catch(res => { + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining("read-only masterKey isn't allowed to create a schema.")); + done(); + }); + }); + + it('should throw when trying to create schema with a name', done => { + loggerErrorSpy.calls.reset(); + request({ + url: `${Parse.serverURL}/schemas/MyClass`, + method: 'POST', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'read-only-test', + 'Content-Type': 'application/json', + }, + json: {}, + }) + .then(done.fail) + .catch(res => { + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining("read-only masterKey isn't allowed to create a schema.")); + done(); + }); + }); + + it('should throw when trying to update schema', done => { + loggerErrorSpy.calls.reset(); + request({ + url: `${Parse.serverURL}/schemas/MyClass`, + method: 'PUT', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'read-only-test', + 'Content-Type': 'application/json', + }, + json: {}, + }) + .then(done.fail) + .catch(res => { + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining("read-only masterKey isn't allowed to update a schema.")); + done(); + }); + }); + + it('should throw when trying to delete schema', done => { + loggerErrorSpy.calls.reset(); + request({ + url: `${Parse.serverURL}/schemas/MyClass`, + method: 'DELETE', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'read-only-test', + 'Content-Type': 'application/json', + }, + json: {}, + }) + .then(done.fail) + .catch(res => { + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining("read-only masterKey isn't allowed to delete a schema.")); + done(); + }); + }); + + it('should throw when trying to update the global config', done => { + loggerErrorSpy.calls.reset(); + request({ + url: `${Parse.serverURL}/config`, + method: 'PUT', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'read-only-test', + 'Content-Type': 'application/json', + }, + json: {}, + }) + .then(done.fail) + .catch(res => { + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining("read-only masterKey isn't allowed to update the config.")); + done(); + }); + }); + + it('should throw when trying to send push', done => { + loggerErrorSpy.calls.reset(); + request({ + url: `${Parse.serverURL}/push`, + method: 'POST', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'read-only-test', + 'Content-Type': 'application/json', + }, + json: {}, + }) + .then(done.fail) + .catch(res => { + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe( + 'Permission denied' + ); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining("read-only masterKey isn't allowed to send push notifications.")); + done(); + }); + }); + + it('should throw when trying to create a hook function', async () => { + loggerErrorSpy.calls.reset(); + try { + await request({ + url: `${Parse.serverURL}/hooks/functions`, + method: 'POST', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'read-only-test', + 'Content-Type': 'application/json', + }, + body: { functionName: 'readOnlyTest', url: 'https://example.com/hook' }, + }); + fail('should have thrown'); + } catch (res) { + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + } + }); + + it('should throw when trying to create a hook trigger', async () => { + loggerErrorSpy.calls.reset(); + try { + await request({ + url: `${Parse.serverURL}/hooks/triggers`, + method: 'POST', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'read-only-test', + 'Content-Type': 'application/json', + }, + body: { className: 'MyClass', triggerName: 'beforeSave', url: 'https://example.com/hook' }, + }); + fail('should have thrown'); + } catch (res) { + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + } + }); + + it('should throw when trying to update a hook function', async () => { + // First create the hook with the real master key + await request({ + url: `${Parse.serverURL}/hooks/functions`, + method: 'POST', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'Content-Type': 'application/json', + }, + body: { functionName: 'readOnlyUpdateTest', url: 'https://example.com/hook' }, + }); + loggerErrorSpy.calls.reset(); + try { + await request({ + url: `${Parse.serverURL}/hooks/functions/readOnlyUpdateTest`, + method: 'PUT', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'read-only-test', + 'Content-Type': 'application/json', + }, + body: { url: 'https://example.com/hacked' }, + }); + fail('should have thrown'); + } catch (res) { + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + } + }); + + it('should throw when trying to delete a hook function', async () => { + // First create the hook with the real master key + await request({ + url: `${Parse.serverURL}/hooks/functions`, + method: 'POST', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'Content-Type': 'application/json', + }, + body: { functionName: 'readOnlyDeleteTest', url: 'https://example.com/hook' }, + }); + loggerErrorSpy.calls.reset(); + try { + await request({ + url: `${Parse.serverURL}/hooks/functions/readOnlyDeleteTest`, + method: 'PUT', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'read-only-test', + 'Content-Type': 'application/json', + }, + body: { __op: 'Delete' }, + }); + fail('should have thrown'); + } catch (res) { + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + } + }); + + it('should throw when trying to run a job with readOnlyMasterKey', async () => { + Parse.Cloud.job('readOnlyTestJob', () => {}); + loggerErrorSpy.calls.reset(); + try { + await request({ + url: `${Parse.serverURL}/jobs/readOnlyTestJob`, + method: 'POST', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'read-only-test', + 'Content-Type': 'application/json', + }, + body: {}, + }); + fail('should have thrown'); + } catch (res) { + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + } + }); + + it('should allow reading hooks with readOnlyMasterKey', async () => { + const res = await request({ + url: `${Parse.serverURL}/hooks/functions`, + method: 'GET', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'read-only-test', + }, + }); + expect(Array.isArray(res.data)).toBe(true); + }); + + it('should throw when trying to delete a file with readOnlyMasterKey', async () => { + // Create a file with the real master key + const uploadRes = await request({ + method: 'POST', + url: `${Parse.serverURL}/files/readonly-delete-test.txt`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'Content-Type': 'text/plain', + }, + body: 'file content', + }); + const filename = uploadRes.data.name; + expect(filename).toBeDefined(); + + // Attempt delete with readOnlyMasterKey — should be rejected + loggerErrorSpy.calls.reset(); + try { + await request({ + method: 'DELETE', + url: `${Parse.serverURL}/files/${filename}`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'read-only-test', + }, + }); + fail('should have thrown'); + } catch (res) { + expect(res.status).toBe(403); + expect(res.data.error).toBe('Permission denied'); + } + + // Verify file still exists + const getRes = await request({ url: uploadRes.data.url }); + expect(getRes.status).toBe(200); + }); + + it('should throw when trying to create a file with readOnlyMasterKey', async () => { + loggerErrorSpy.calls.reset(); + try { + await request({ + method: 'POST', + url: `${Parse.serverURL}/files/readonly-create-test.txt`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'read-only-test', + 'Content-Type': 'text/plain', + }, + body: 'file content', + }); + fail('should have thrown'); + } catch (res) { + expect(res.status).toBe(403); + expect(res.data.error).toBe('Permission denied'); + } + }); + + it('should throw when trying to loginAs with readOnlyMasterKey', async () => { + // Create a target user + await Parse.User.signUp('readonly-loginas-test', 'password123'); + const userId = Parse.User.current().id; + await Parse.User.logOut(); + + // Attempt loginAs with readOnlyMasterKey — should be rejected + loggerErrorSpy.calls.reset(); + try { + await request({ + method: 'POST', + url: `${Parse.serverURL}/loginAs`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'read-only-test', + 'Content-Type': 'application/json', + }, + body: { userId }, + }); + fail('should have thrown'); + } catch (res) { + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + } + }); + + it('should expose isReadOnly in Cloud Function request when using readOnlyMasterKey', async () => { + let receivedMaster; + let receivedIsReadOnly; + Parse.Cloud.define('checkReadOnly', req => { + receivedMaster = req.master; + receivedIsReadOnly = req.isReadOnly; + return 'ok'; + }); + + await request({ + method: 'POST', + url: `${Parse.serverURL}/functions/checkReadOnly`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'read-only-test', + 'Content-Type': 'application/json', + }, + body: {}, + }); + + expect(receivedMaster).toBe(true); + expect(receivedIsReadOnly).toBe(true); + }); + + it('should not set isReadOnly in Cloud Function request when using masterKey', async () => { + let receivedMaster; + let receivedIsReadOnly; + Parse.Cloud.define('checkNotReadOnly', req => { + receivedMaster = req.master; + receivedIsReadOnly = req.isReadOnly; + return 'ok'; + }); + + await request({ + method: 'POST', + url: `${Parse.serverURL}/functions/checkNotReadOnly`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'Content-Type': 'application/json', + }, + body: {}, + }); + + expect(receivedMaster).toBe(true); + expect(receivedIsReadOnly).toBe(false); + }); + + it('should expose isReadOnly in beforeFind trigger when using readOnlyMasterKey', async () => { + let receivedMaster; + let receivedIsReadOnly; + Parse.Cloud.beforeFind('ReadOnlyTriggerTest', req => { + receivedMaster = req.master; + receivedIsReadOnly = req.isReadOnly; + }); + + const obj = new Parse.Object('ReadOnlyTriggerTest'); + await obj.save(null, { useMasterKey: true }); + + await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/ReadOnlyTriggerTest`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'read-only-test', + }, + }); + + expect(receivedMaster).toBe(true); + expect(receivedIsReadOnly).toBe(true); + }); + + it('should not set isReadOnly in beforeFind trigger when using masterKey', async () => { + let receivedMaster; + let receivedIsReadOnly; + Parse.Cloud.beforeFind('ReadOnlyTriggerTestNeg', req => { + receivedMaster = req.master; + receivedIsReadOnly = req.isReadOnly; + }); + + const obj = new Parse.Object('ReadOnlyTriggerTestNeg'); + await obj.save(null, { useMasterKey: true }); + + await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/ReadOnlyTriggerTestNeg`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + }); + + expect(receivedMaster).toBe(true); + expect(receivedIsReadOnly).toBe(false); + }); +}); + +describe('rest context', () => { + it('should support dependency injection on rest api', async () => { + const requestContextMiddleware = (req, res, next) => { + req.config.aCustomController = 'aCustomController'; + next(); + }; + + let called + await reconfigureServer({ requestContextMiddleware }); + Parse.Cloud.beforeSave('_User', request => { + expect(request.config.aCustomController).toEqual('aCustomController'); + called = true; + }); + const user = new Parse.User(); + user.setUsername('test'); + user.setPassword('test'); + await user.signUp(); + + expect(called).toBe(true); + }); +}); diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 3ad733a227..f9f29c733e 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -1,78 +1,92 @@ 'use strict'; -var Parse = require('parse/node').Parse; -var request = require('request'); -var dd = require('deep-diff'); -var Config = require('../src/Config'); +const Parse = require('parse/node').Parse; +const dd = require('deep-diff'); +const Config = require('../lib/Config'); +const request = require('../lib/request'); +const TestUtils = require('../lib/TestUtils'); +const SchemaController = require('../lib/Controllers/SchemaController').SchemaController; -var config = new Config('test'); +let config; -var hasAllPODobject = () => { - var obj = new Parse.Object('HasAllPOD'); +const hasAllPODobject = () => { + const obj = new Parse.Object('HasAllPOD'); obj.set('aNumber', 5); obj.set('aString', 'string'); obj.set('aBool', true); obj.set('aDate', new Date()); - obj.set('aObject', {k1: 'value', k2: true, k3: 5}); + obj.set('aObject', { k1: 'value', k2: true, k3: 5 }); obj.set('aArray', ['contents', true, 5]); - obj.set('aGeoPoint', new Parse.GeoPoint({latitude: 0, longitude: 0})); + obj.set('aGeoPoint', new Parse.GeoPoint({ latitude: 0, longitude: 0 })); obj.set('aFile', new Parse.File('f.txt', { base64: 'V29ya2luZyBhdCBQYXJzZSBpcyBncmVhdCE=' })); - var objACL = new Parse.ACL(); + const objACL = new Parse.ACL(); objACL.setPublicWriteAccess(false); obj.setACL(objACL); return obj; }; -let defaultClassLevelPermissions = { +const defaultClassLevelPermissions = { + ACL: { + '*': { + read: true, + write: true, + }, + }, find: { - '*': true + '*': true, + }, + count: { + '*': true, }, create: { - '*': true + '*': true, }, get: { - '*': true + '*': true, }, update: { - '*': true + '*': true, }, addField: { - '*': true + '*': true, }, delete: { - '*': true - } -} + '*': true, + }, + protectedFields: { + '*': [], + }, +}; -var plainOldDataSchema = { +const plainOldDataSchema = { className: 'HasAllPOD', fields: { //Default fields - ACL: {type: 'ACL'}, - createdAt: {type: 'Date'}, - updatedAt: {type: 'Date'}, - objectId: {type: 'String'}, + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, //Custom fields - aNumber: {type: 'Number'}, - aString: {type: 'String'}, - aBool: {type: 'Boolean'}, - aDate: {type: 'Date'}, - aObject: {type: 'Object'}, - aArray: {type: 'Array'}, - aGeoPoint: {type: 'GeoPoint'}, - aFile: {type: 'File'} + aNumber: { type: 'Number' }, + aString: { type: 'String' }, + aBool: { type: 'Boolean' }, + aDate: { type: 'Date' }, + aObject: { type: 'Object' }, + aArray: { type: 'Array' }, + aGeoPoint: { type: 'GeoPoint' }, + aFile: { type: 'File' }, }, - classLevelPermissions: defaultClassLevelPermissions + classLevelPermissions: defaultClassLevelPermissions, }; -var pointersAndRelationsSchema = { +const pointersAndRelationsSchema = { className: 'HasPointersAndRelations', fields: { //Default fields - ACL: {type: 'ACL'}, - createdAt: {type: 'Date'}, - updatedAt: {type: 'Date'}, - objectId: {type: 'String'}, + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, //Custom fields aPointer: { type: 'Pointer', @@ -83,140 +97,234 @@ var pointersAndRelationsSchema = { targetClass: 'HasAllPOD', }, }, - classLevelPermissions: defaultClassLevelPermissions -} + classLevelPermissions: defaultClassLevelPermissions, +}; const userSchema = { - "className": "_User", - "fields": { - "objectId": {"type": "String"}, - "createdAt": {"type": "Date"}, - "updatedAt": {"type": "Date"}, - "ACL": {"type": "ACL"}, - "username": {"type": "String"}, - "password": {"type": "String"}, - "email": {"type": "String"}, - "emailVerified": {"type": "Boolean"} + className: '_User', + fields: { + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + username: { type: 'String' }, + password: { type: 'String' }, + email: { type: 'String' }, + emailVerified: { type: 'Boolean' }, + authData: { type: 'Object' }, + }, + classLevelPermissions: defaultClassLevelPermissions, +}; + +const roleSchema = { + className: '_Role', + fields: { + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + ACL: { type: 'ACL' }, + name: { type: 'String' }, + users: { type: 'Relation', targetClass: '_User' }, + roles: { type: 'Relation', targetClass: '_Role' }, }, - "classLevelPermissions": defaultClassLevelPermissions, -} + classLevelPermissions: defaultClassLevelPermissions, +}; -var noAuthHeaders = { +const noAuthHeaders = { 'X-Parse-Application-Id': 'test', }; -var restKeyHeaders = { +const restKeyHeaders = { 'X-Parse-Application-Id': 'test', 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', }; -var masterKeyHeaders = { +const masterKeyHeaders = { 'X-Parse-Application-Id': 'test', 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', }; describe('schemas', () => { + let loggerErrorSpy; - beforeEach(() => { - config.database.schemaCache.clear(); + beforeEach(async () => { + await reconfigureServer(); + config = Config.get('test'); + + const logger = require('../lib/logger').default; + loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); }); - it('requires the master key to get all schemas', (done) => { - request.get({ + it('requires the master key to get all schemas', done => { + request({ url: 'http://localhost:8378/1/schemas', json: true, headers: noAuthHeaders, - }, (error, response, body) => { + }).then(fail, response => { //api.parse.com uses status code 401, but due to the lack of keys //being necessary in parse-server, 403 makes more sense - expect(response.statusCode).toEqual(403); - expect(body.error).toEqual('unauthorized'); + expect(response.status).toEqual(403); + expect(response.data.error).toEqual('unauthorized'); done(); }); }); - it('requires the master key to get one schema', (done) => { - request.get({ + it('requires the master key to get one schema', done => { + loggerErrorSpy.calls.reset(); + request({ url: 'http://localhost:8378/1/schemas/SomeSchema', json: true, headers: restKeyHeaders, - }, (error, response, body) => { - expect(response.statusCode).toEqual(403); - expect(body.error).toEqual('unauthorized: master key is required'); + }).then(fail, response => { + expect(response.status).toEqual(403); + expect(response.data.error).toEqual('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining("unauthorized: master key is required")); done(); }); }); - it('asks for the master key if you use the rest key', (done) => { - request.get({ + it('asks for the master key if you use the rest key', done => { + loggerErrorSpy.calls.reset(); + request({ url: 'http://localhost:8378/1/schemas', json: true, headers: restKeyHeaders, - }, (error, response, body) => { - expect(response.statusCode).toEqual(403); - expect(body.error).toEqual('unauthorized: master key is required'); + }).then(fail, response => { + expect(response.status).toEqual(403); + expect(response.data.error).toEqual('Permission denied'); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining("unauthorized: master key is required")); done(); }); }); - it_exclude_dbs(['postgres'])('creates _User schema when server starts', done => { - request.get({ + it('creates _User schema when server starts', done => { + request({ url: 'http://localhost:8378/1/schemas', json: true, headers: masterKeyHeaders, - }, (error, response, body) => { - expect(dd(body.results, [userSchema])).toEqual(); + }).then(response => { + const expected = { + results: [userSchema, roleSchema], + }; + expect( + response.data.results + .sort((s1, s2) => s1.className.localeCompare(s2.className)) + .map(s => { + const withoutIndexes = Object.assign({}, s); + delete withoutIndexes.indexes; + return withoutIndexes; + }) + ).toEqual(expected.results.sort((s1, s2) => s1.className.localeCompare(s2.className))); done(); }); }); - it_exclude_dbs(['postgres'])('responds with a list of schemas after creating objects', done => { - var obj1 = hasAllPODobject(); - obj1.save().then(savedObj1 => { - var obj2 = new Parse.Object('HasPointersAndRelations'); - obj2.set('aPointer', savedObj1); - var relation = obj2.relation('aRelation'); - relation.add(obj1); - return obj2.save(); - }).then(() => { - request.get({ - url: 'http://localhost:8378/1/schemas', - json: true, - headers: masterKeyHeaders, - }, (error, response, body) => { - var expected = { - results: [userSchema,plainOldDataSchema,pointersAndRelationsSchema] - }; - expect(dd(body, expected)).toEqual(undefined); - done(); + it('responds with a list of schemas after creating objects', done => { + const obj1 = hasAllPODobject(); + obj1 + .save() + .then(savedObj1 => { + const obj2 = new Parse.Object('HasPointersAndRelations'); + obj2.set('aPointer', savedObj1); + const relation = obj2.relation('aRelation'); + relation.add(obj1); + return obj2.save(); }) + .then(() => { + request({ + url: 'http://localhost:8378/1/schemas', + json: true, + headers: masterKeyHeaders, + }).then(response => { + const expected = { + results: [userSchema, roleSchema, plainOldDataSchema, pointersAndRelationsSchema], + }; + expect( + response.data.results + .sort((s1, s2) => s1.className.localeCompare(s2.className)) + .map(s => { + const withoutIndexes = Object.assign({}, s); + delete withoutIndexes.indexes; + return withoutIndexes; + }) + ).toEqual(expected.results.sort((s1, s2) => s1.className.localeCompare(s2.className))); + done(); + }); + }); + }); + + it('ensure refresh cache after creating a class', async done => { + spyOn(SchemaController.prototype, 'reloadData').and.callFake(() => Promise.resolve()); + await request({ + url: 'http://localhost:8378/1/schemas', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: { + className: 'A', + }, + }); + const response = await request({ + url: 'http://localhost:8378/1/schemas', + method: 'GET', + headers: masterKeyHeaders, + json: true, }); + const expected = { + results: [ + userSchema, + roleSchema, + { + className: 'A', + fields: { + //Default fields + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + }, + ], + }; + expect( + response.data.results + .sort((s1, s2) => s1.className.localeCompare(s2.className)) + .map(s => { + const withoutIndexes = Object.assign({}, s); + delete withoutIndexes.indexes; + return withoutIndexes; + }) + ).toEqual(expected.results.sort((s1, s2) => s1.className.localeCompare(s2.className))); + done(); }); - it_exclude_dbs(['postgres'])('responds with a single schema', done => { - var obj = hasAllPODobject(); + it('responds with a single schema', done => { + const obj = hasAllPODobject(); obj.save().then(() => { - request.get({ + request({ url: 'http://localhost:8378/1/schemas/HasAllPOD', json: true, headers: masterKeyHeaders, - }, (error, response, body) => { - expect(body).toEqual(plainOldDataSchema); + }).then(response => { + expect(response.data).toEqual(plainOldDataSchema); done(); }); }); }); - it_exclude_dbs(['postgres'])('treats class names case sensitively', done => { - var obj = hasAllPODobject(); + it('treats class names case sensitively', done => { + const obj = hasAllPODobject(); obj.save().then(() => { - request.get({ + request({ url: 'http://localhost:8378/1/schemas/HASALLPOD', json: true, headers: masterKeyHeaders, - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body).toEqual({ + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data).toEqual({ code: 103, error: 'Class HASALLPOD does not exist.', }); @@ -226,46 +334,33 @@ describe('schemas', () => { }); it('requires the master key to create a schema', done => { - request.post({ + request({ url: 'http://localhost:8378/1/schemas', + method: 'POST', json: true, headers: noAuthHeaders, - body: { - className: 'MyClass', - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(403); - expect(body.error).toEqual('unauthorized'); - done(); - }); - }); - - it('asks for the master key if you use the rest key', done => { - request.post({ - url: 'http://localhost:8378/1/schemas', - json: true, - headers: restKeyHeaders, body: { className: 'MyClass', }, - }, (error, response, body) => { - expect(response.statusCode).toEqual(403); - expect(body.error).toEqual('unauthorized: master key is required'); + }).then(fail, response => { + expect(response.status).toEqual(403); + expect(response.data.error).toEqual('unauthorized'); done(); }); }); it('sends an error if you use mismatching class names', done => { - request.post({ + request({ url: 'http://localhost:8378/1/schemas/A', + method: 'POST', headers: masterKeyHeaders, json: true, body: { className: 'B', - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body).toEqual({ + }, + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data).toEqual({ code: Parse.Error.INVALID_CLASS_NAME, error: 'Class name mismatch between B and A.', }); @@ -274,512 +369,1140 @@ describe('schemas', () => { }); it('sends an error if you use no class name', done => { - request.post({ + request({ url: 'http://localhost:8378/1/schemas', + method: 'POST', headers: masterKeyHeaders, json: true, body: {}, - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body).toEqual({ + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data).toEqual({ code: 135, error: 'POST /schemas needs a class name.', }); done(); - }) + }); }); it('sends an error if you try to create the same class twice', done => { - request.post({ + request({ url: 'http://localhost:8378/1/schemas', + method: 'POST', headers: masterKeyHeaders, json: true, body: { className: 'A', }, - }, (error, response, body) => { - expect(error).toEqual(null); - request.post({ + }).then(() => { + request({ url: 'http://localhost:8378/1/schemas', + method: 'POST', headers: masterKeyHeaders, json: true, body: { className: 'A', - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body).toEqual({ + }, + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data).toEqual({ code: Parse.Error.INVALID_CLASS_NAME, - error: 'Class A already exists.' + error: 'Class A already exists.', }); done(); }); }); }); - it_exclude_dbs(['postgres'])('responds with all fields when you create a class', done => { - request.post({ + it('responds with all fields when you create a class', done => { + request({ url: 'http://localhost:8378/1/schemas', + method: 'POST', headers: masterKeyHeaders, json: true, body: { - className: "NewClass", + className: 'NewClass', fields: { - foo: {type: 'Number'}, - ptr: {type: 'Pointer', targetClass: 'SomeClass'} - } - } - }, (error, response, body) => { - expect(body).toEqual({ + foo: { type: 'Number' }, + ptr: { type: 'Pointer', targetClass: 'SomeClass' }, + }, + }, + }).then(response => { + expect(response.data).toEqual({ className: 'NewClass', fields: { - ACL: {type: 'ACL'}, - createdAt: {type: 'Date'}, - updatedAt: {type: 'Date'}, - objectId: {type: 'String'}, - foo: {type: 'Number'}, - ptr: {type: 'Pointer', targetClass: 'SomeClass'}, + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + foo: { type: 'Number' }, + ptr: { type: 'Pointer', targetClass: 'SomeClass' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + }); + done(); + }); + }); + + it('responds with all fields and options when you create a class with field options', done => { + request({ + url: 'http://localhost:8378/1/schemas', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClassWithOptions', + fields: { + foo1: { type: 'Number' }, + foo2: { type: 'Number', required: true, defaultValue: 10 }, + foo3: { + type: 'String', + required: false, + defaultValue: 'some string', + }, + foo4: { type: 'Date', required: true }, + foo5: { type: 'Number', defaultValue: 5 }, + ptr: { type: 'Pointer', targetClass: 'SomeClass', required: false }, + defaultFalse: { + type: 'Boolean', + required: true, + defaultValue: false, + }, + defaultZero: { type: 'Number', defaultValue: 0 }, + relation: { type: 'Relation', targetClass: 'SomeClass' }, + }, + }, + }).then(async response => { + expect(response.data).toEqual({ + className: 'NewClassWithOptions', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + foo1: { type: 'Number' }, + foo2: { type: 'Number', required: true, defaultValue: 10 }, + foo3: { + type: 'String', + required: false, + defaultValue: 'some string', + }, + foo4: { type: 'Date', required: true }, + foo5: { type: 'Number', defaultValue: 5 }, + ptr: { type: 'Pointer', targetClass: 'SomeClass', required: false }, + defaultFalse: { + type: 'Boolean', + required: true, + defaultValue: false, + }, + defaultZero: { type: 'Number', defaultValue: 0 }, + relation: { type: 'Relation', targetClass: 'SomeClass' }, }, - classLevelPermissions: defaultClassLevelPermissions + classLevelPermissions: defaultClassLevelPermissions, }); + const obj = new Parse.Object('NewClassWithOptions'); + try { + await obj.save(); + fail('should fail'); + } catch (e) { + expect(e.code).toEqual(142); + } + const date = new Date(); + obj.set('foo4', date); + await obj.save(); + expect(obj.get('foo1')).toBeUndefined(); + expect(obj.get('foo2')).toEqual(10); + expect(obj.get('foo3')).toEqual('some string'); + expect(obj.get('foo4')).toEqual(date); + expect(obj.get('foo5')).toEqual(5); + expect(obj.get('ptr')).toBeUndefined(); + expect(obj.get('defaultFalse')).toEqual(false); + expect(obj.get('defaultZero')).toEqual(0); + expect(obj.get('ptr')).toBeUndefined(); + expect(obj.get('relation')).toBeUndefined(); done(); }); }); - it_exclude_dbs(['postgres'])('responds with all fields when getting incomplete schema', done => { - config.database.loadSchema() - .then(schemaController => schemaController.addClassIfNotExists('_Installation', {}, defaultClassLevelPermissions)) - .then(() => { - request.get({ - url: 'http://localhost:8378/1/schemas/_Installation', + it('try to set a relation field as a required field', async done => { + try { + await request({ + url: 'http://localhost:8378/1/schemas', + method: 'POST', headers: masterKeyHeaders, - json: true - }, (error, response, body) => { - expect(dd(body,{ - className: '_Installation', + json: true, + body: { + className: 'NewClassWithRelationRequired', fields: { - objectId: {type: 'String'}, - updatedAt: {type: 'Date'}, - createdAt: {type: 'Date'}, - installationId: {type: 'String'}, - deviceToken: {type: 'String'}, - channels: {type: 'Array'}, - deviceType: {type: 'String'}, - pushType: {type: 'String'}, - GCMSenderId: {type: 'String'}, - timeZone: {type: 'String'}, - badge: {type: 'Number'}, - appIdentifier: {type: 'String'}, - localeIdentifier: {type: 'String'}, - appVersion: {type: 'String'}, - appName: {type: 'String'}, - parseVersion: {type: 'String'}, - ACL: {type: 'ACL'} - }, - classLevelPermissions: defaultClassLevelPermissions - })).toBeUndefined(); - done(); + foo: { type: 'String' }, + relation: { + type: 'Relation', + targetClass: 'SomeClass', + required: true, + }, + }, + }, }); - }) - .catch(error => { - fail(JSON.stringify(error)) - done(); + fail('should fail'); + } catch (e) { + expect(e.data.code).toEqual(111); + } + done(); + }); + + it('try to set a relation field with a default value', async done => { + try { + await request({ + url: 'http://localhost:8378/1/schemas', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClassRelationWithOptions', + fields: { + foo: { type: 'String' }, + relation: { + type: 'Relation', + targetClass: 'SomeClass', + defaultValue: { __type: 'Relation', className: '_User' }, + }, + }, + }, + }); + fail('should fail'); + } catch (e) { + expect(e.data.code).toEqual(111); + } + done(); + }); + + it('try to update schemas with a relation field with options', async done => { + await request({ + url: 'http://localhost:8378/1/schemas', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClassRelationWithOptions', + fields: { + foo: { type: 'String' }, + }, + }, }); + try { + await request({ + url: 'http://localhost:8378/1/schemas/NewClassRelationWithOptions', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClassRelationWithOptions', + fields: { + relation: { + type: 'Relation', + targetClass: 'SomeClass', + required: true, + }, + }, + _method: 'PUT', + }, + }); + fail('should fail'); + } catch (e) { + expect(e.data.code).toEqual(111); + } + + try { + await request({ + url: 'http://localhost:8378/1/schemas/NewClassRelationWithOptions', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClassRelationWithOptions', + fields: { + relation: { + type: 'Relation', + targetClass: 'SomeClass', + defaultValue: { __type: 'Relation', className: '_User' }, + }, + }, + _method: 'PUT', + }, + }); + fail('should fail'); + } catch (e) { + expect(e.data.code).toEqual(111); + } + done(); + }); + + it('validated the data type of default values when creating a new class', async () => { + try { + await request({ + url: 'http://localhost:8378/1/schemas', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClassWithValidation', + fields: { + foo: { type: 'String', defaultValue: 10 }, + }, + }, + }); + fail('should fail'); + } catch (e) { + expect(e.data.error).toEqual( + 'schema mismatch for NewClassWithValidation.foo default value; expected String but got Number' + ); + } + }); + + it('validated the data type of default values when adding new fields', async () => { + try { + await request({ + url: 'http://localhost:8378/1/schemas', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClassWithValidation', + fields: { + foo: { type: 'String', defaultValue: 'some value' }, + }, + }, + }); + await request({ + url: 'http://localhost:8378/1/schemas/NewClassWithValidation', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClassWithValidation', + fields: { + foo2: { type: 'String', defaultValue: 10 }, + }, + }, + }); + fail('should fail'); + } catch (e) { + expect(e.data.error).toEqual( + 'schema mismatch for NewClassWithValidation.foo2 default value; expected String but got Number' + ); + } + }); + + it('responds with all fields when getting incomplete schema', done => { + config.database + .loadSchema() + .then(schemaController => + schemaController.addClassIfNotExists('_Installation', {}, defaultClassLevelPermissions) + ) + .then(() => { + request({ + url: 'http://localhost:8378/1/schemas/_Installation', + headers: masterKeyHeaders, + json: true, + }).then(response => { + expect( + dd(response.data, { + className: '_Installation', + fields: { + objectId: { type: 'String' }, + updatedAt: { type: 'Date' }, + createdAt: { type: 'Date' }, + installationId: { type: 'String' }, + deviceToken: { type: 'String' }, + channels: { type: 'Array' }, + deviceType: { type: 'String' }, + pushType: { type: 'String' }, + GCMSenderId: { type: 'String' }, + timeZone: { type: 'String' }, + badge: { type: 'Number' }, + appIdentifier: { type: 'String' }, + localeIdentifier: { type: 'String' }, + appVersion: { type: 'String' }, + appName: { type: 'String' }, + parseVersion: { type: 'String' }, + ACL: { type: 'ACL' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + }) + ).toBeUndefined(); + done(); + }); + }) + .catch(error => { + fail(JSON.stringify(error)); + done(); + }); }); - it_exclude_dbs(['postgres'])('lets you specify class name in both places', done => { - request.post({ + it('lets you specify class name in both places', done => { + request({ url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', headers: masterKeyHeaders, json: true, body: { - className: "NewClass", - } - }, (error, response, body) => { - expect(body).toEqual({ + className: 'NewClass', + }, + }).then(response => { + expect(response.data).toEqual({ className: 'NewClass', fields: { - ACL: {type: 'ACL'}, - createdAt: {type: 'Date'}, - updatedAt: {type: 'Date'}, - objectId: {type: 'String'}, + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, }, - classLevelPermissions: defaultClassLevelPermissions + classLevelPermissions: defaultClassLevelPermissions, }); done(); }); }); it('requires the master key to modify schemas', done => { - request.post({ + request({ url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', headers: masterKeyHeaders, json: true, body: {}, - }, (error, response, body) => { - request.put({ + }).then(() => { + request({ url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', headers: noAuthHeaders, json: true, body: {}, - }, (error, response, body) => { - expect(response.statusCode).toEqual(403); - expect(body.error).toEqual('unauthorized'); + }).then(fail, response => { + expect(response.status).toEqual(403); + expect(response.data.error).toEqual('unauthorized'); done(); }); }); }); it('rejects class name mis-matches in put', done => { - request.put({ + request({ url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', headers: masterKeyHeaders, json: true, - body: {className: 'WrongClassName'} - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(body.error).toEqual('Class name mismatch between WrongClassName and NewClass.'); + body: { className: 'WrongClassName' }, + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(response.data.error).toEqual( + 'Class name mismatch between WrongClassName and NewClass.' + ); done(); }); }); it('refuses to add fields to non-existent classes', done => { - request.put({ + request({ url: 'http://localhost:8378/1/schemas/NoClass', + method: 'PUT', headers: masterKeyHeaders, json: true, body: { fields: { - newField: {type: 'String'} - } - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(body.error).toEqual('Class NoClass does not exist.'); + newField: { type: 'String' }, + }, + }, + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(response.data.error).toEqual('Class NoClass does not exist.'); done(); }); }); - it_exclude_dbs(['postgres'])('refuses to put to existing fields, even if it would not be a change', done => { - var obj = hasAllPODobject(); - obj.save() - .then(() => { - request.put({ + it('refuses to put to existing fields with different type, even if it would not be a change', done => { + const obj = hasAllPODobject(); + obj.save().then(() => { + request({ url: 'http://localhost:8378/1/schemas/HasAllPOD', + method: 'PUT', headers: masterKeyHeaders, json: true, body: { fields: { - aString: {type: 'String'} - } - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body.code).toEqual(255); - expect(body.error).toEqual('Field aString exists, cannot update.'); + aString: { type: 'Number' }, + }, + }, + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data.code).toEqual(255); + expect(response.data.error).toEqual('Field aString exists, cannot update.'); done(); }); - }) + }); }); - it_exclude_dbs(['postgres'])('refuses to delete non-existent fields', done => { - var obj = hasAllPODobject(); - obj.save() - .then(() => { - request.put({ + it('refuses to delete non-existent fields', done => { + const obj = hasAllPODobject(); + obj.save().then(() => { + request({ url: 'http://localhost:8378/1/schemas/HasAllPOD', + method: 'PUT', headers: masterKeyHeaders, json: true, body: { fields: { - nonExistentKey: {__op: "Delete"}, - } - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body.code).toEqual(255); - expect(body.error).toEqual('Field nonExistentKey does not exist, cannot delete.'); + nonExistentKey: { __op: 'Delete' }, + }, + }, + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data.code).toEqual(255); + expect(response.data.error).toEqual('Field nonExistentKey does not exist, cannot delete.'); done(); }); }); }); - it_exclude_dbs(['postgres'])('refuses to add a geopoint to a class that already has one', done => { - var obj = hasAllPODobject(); - obj.save() - .then(() => { - request.put({ + it('refuses to add a geopoint to a class that already has one', done => { + const obj = hasAllPODobject(); + obj.save().then(() => { + request({ url: 'http://localhost:8378/1/schemas/HasAllPOD', + method: 'PUT', headers: masterKeyHeaders, json: true, body: { fields: { - newGeo: {type: 'GeoPoint'} - } - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body.code).toEqual(Parse.Error.INCORRECT_TYPE); - expect(body.error).toEqual('currently, only one GeoPoint field may exist in an object. Adding newGeo when aGeoPoint already exists.'); + newGeo: { type: 'GeoPoint' }, + }, + }, + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data.code).toEqual(Parse.Error.INCORRECT_TYPE); + expect(response.data.error).toEqual( + 'currently, only one GeoPoint field may exist in an object. Adding newGeo when aGeoPoint already exists.' + ); done(); }); }); }); it('refuses to add two geopoints', done => { - var obj = new Parse.Object('NewClass'); + const obj = new Parse.Object('NewClass'); obj.set('aString', 'aString'); - obj.save() - .then(() => { - request.put({ + obj.save().then(() => { + request({ url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', headers: masterKeyHeaders, json: true, body: { fields: { - newGeo1: {type: 'GeoPoint'}, - newGeo2: {type: 'GeoPoint'}, - } - } - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body.code).toEqual(Parse.Error.INCORRECT_TYPE); - expect(body.error).toEqual('currently, only one GeoPoint field may exist in an object. Adding newGeo2 when newGeo1 already exists.'); + newGeo1: { type: 'GeoPoint' }, + newGeo2: { type: 'GeoPoint' }, + }, + }, + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data.code).toEqual(Parse.Error.INCORRECT_TYPE); + expect(response.data.error).toEqual( + 'currently, only one GeoPoint field may exist in an object. Adding newGeo2 when newGeo1 already exists.' + ); done(); }); }); }); - it_exclude_dbs(['postgres'])('allows you to delete and add a geopoint in the same request', done => { - var obj = new Parse.Object('NewClass'); - obj.set('geo1', new Parse.GeoPoint({latitude: 0, longitude: 0})); - obj.save() - .then(() => { - request.put({ + it('allows you to delete and add a geopoint in the same request', done => { + const obj = new Parse.Object('NewClass'); + obj.set('geo1', new Parse.GeoPoint({ latitude: 0, longitude: 0 })); + obj.save().then(() => { + request({ url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', headers: masterKeyHeaders, json: true, body: { fields: { - geo2: {type: 'GeoPoint'}, - geo1: {__op: 'Delete'} - } - } - }, (error, response, body) => { - expect(dd(body, { - "className": "NewClass", - "fields": { - "ACL": {"type": "ACL"}, - "createdAt": {"type": "Date"}, - "objectId": {"type": "String"}, - "updatedAt": {"type": "Date"}, - "geo2": {"type": "GeoPoint"}, - }, - classLevelPermissions: defaultClassLevelPermissions - })).toEqual(undefined); + geo2: { type: 'GeoPoint' }, + geo1: { __op: 'Delete' }, + }, + }, + }).then(response => { + expect( + dd(response.data, { + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + objectId: { type: 'String' }, + updatedAt: { type: 'Date' }, + geo2: { type: 'GeoPoint' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + }) + ).toEqual(undefined); done(); }); - }) + }); }); - it_exclude_dbs(['postgres'])('put with no modifications returns all fields', done => { - var obj = hasAllPODobject(); - obj.save() - .then(() => { - request.put({ + it('put with no modifications returns all fields', done => { + const obj = hasAllPODobject(); + obj.save().then(() => { + request({ url: 'http://localhost:8378/1/schemas/HasAllPOD', + method: 'PUT', headers: masterKeyHeaders, json: true, body: {}, - }, (error, response, body) => { - expect(body).toEqual(plainOldDataSchema); + }).then(response => { + expect(response.data).toEqual(plainOldDataSchema); done(); }); - }) + }); }); - it_exclude_dbs(['postgres'])('lets you add fields', done => { - request.post({ + it('lets you add fields', done => { + request({ url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', headers: masterKeyHeaders, json: true, body: {}, - }, (error, response, body) => { - request.put({ + }).then(() => { + request({ + method: 'PUT', url: 'http://localhost:8378/1/schemas/NewClass', headers: masterKeyHeaders, json: true, body: { fields: { - newField: {type: 'String'} - } - } - }, (error, response, body) => { - expect(dd(body, { - className: 'NewClass', - fields: { - "ACL": {"type": "ACL"}, - "createdAt": {"type": "Date"}, - "objectId": {"type": "String"}, - "updatedAt": {"type": "Date"}, - "newField": {"type": "String"}, - }, - classLevelPermissions: defaultClassLevelPermissions - })).toEqual(undefined); - request.get({ + newField: { type: 'String' }, + }, + }, + }).then(response => { + expect( + dd(response.data, { + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + objectId: { type: 'String' }, + updatedAt: { type: 'Date' }, + newField: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + }) + ).toEqual(undefined); + request({ url: 'http://localhost:8378/1/schemas/NewClass', headers: masterKeyHeaders, json: true, - }, (error, response, body) => { - expect(body).toEqual({ + }).then(response => { + expect(response.data).toEqual({ className: 'NewClass', fields: { - ACL: {type: 'ACL'}, - createdAt: {type: 'Date'}, - updatedAt: {type: 'Date'}, - objectId: {type: 'String'}, - newField: {type: 'String'}, + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + newField: { type: 'String' }, }, - classLevelPermissions: defaultClassLevelPermissions + classLevelPermissions: defaultClassLevelPermissions, }); done(); }); }); - }) + }); }); - it_exclude_dbs(['postgres'])('lets you add fields to system schema', done => { - request.post({ - url: 'http://localhost:8378/1/schemas/_User', + it('lets you add fields with options', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', headers: masterKeyHeaders, - json: true - }, (error, response, body) => { - request.put({ - url: 'http://localhost:8378/1/schemas/_User', + json: true, + body: {}, + }).then(() => { + request({ + method: 'PUT', + url: 'http://localhost:8378/1/schemas/NewClass', headers: masterKeyHeaders, json: true, body: { fields: { - newField: {type: 'String'} - } - } - }, (error, response, body) => { - expect(dd(body,{ - className: '_User', - fields: { - objectId: {type: 'String'}, - updatedAt: {type: 'Date'}, - createdAt: {type: 'Date'}, - username: {type: 'String'}, - password: {type: 'String'}, - email: {type: 'String'}, - emailVerified: {type: 'Boolean'}, - newField: {type: 'String'}, - ACL: {type: 'ACL'} - }, - classLevelPermissions: defaultClassLevelPermissions - })).toBeUndefined(); - request.get({ - url: 'http://localhost:8378/1/schemas/_User', + newField: { + type: 'String', + required: true, + defaultValue: 'some value', + }, + }, + }, + }).then(response => { + expect( + dd(response.data, { + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + objectId: { type: 'String' }, + updatedAt: { type: 'Date' }, + newField: { + type: 'String', + required: true, + defaultValue: 'some value', + }, + }, + classLevelPermissions: defaultClassLevelPermissions, + }) + ).toEqual(undefined); + request({ + url: 'http://localhost:8378/1/schemas/NewClass', headers: masterKeyHeaders, - json: true - }, (error, response, body) => { - expect(dd(body,{ - className: '_User', + json: true, + }).then(response => { + expect(response.data).toEqual({ + className: 'NewClass', fields: { - objectId: {type: 'String'}, - updatedAt: {type: 'Date'}, - createdAt: {type: 'Date'}, - username: {type: 'String'}, - password: {type: 'String'}, - email: {type: 'String'}, - emailVerified: {type: 'Boolean'}, - newField: {type: 'String'}, - ACL: {type: 'ACL'} + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + newField: { + type: 'String', + required: true, + defaultValue: 'some value', + }, }, - classLevelPermissions: defaultClassLevelPermissions - })).toBeUndefined(); + classLevelPermissions: defaultClassLevelPermissions, + }); done(); }); }); - }) + }); }); - it_exclude_dbs(['postgres'])('lets you delete multiple fields and add fields', done => { - var obj1 = hasAllPODobject(); - obj1.save() - .then(() => { - request.put({ - url: 'http://localhost:8378/1/schemas/HasAllPOD', - headers: masterKeyHeaders, + it('should validate required fields', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }).then(() => { + request({ + method: 'PUT', + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, json: true, body: { fields: { - aString: {__op: 'Delete'}, - aNumber: {__op: 'Delete'}, - aNewString: {type: 'String'}, - aNewNumber: {type: 'Number'}, - aNewRelation: {type: 'Relation', targetClass: 'HasAllPOD'}, - aNewPointer: {type: 'Pointer', targetClass: 'HasAllPOD'}, - } + newRequiredField: { + type: 'String', + required: true, + }, + newRequiredFieldWithDefaultValue: { + type: 'String', + required: true, + defaultValue: 'some value', + }, + newNotRequiredField: { + type: 'String', + required: false, + }, + newNotRequiredFieldWithDefaultValue: { + type: 'String', + required: false, + defaultValue: 'some value', + }, + newRegularFieldWithDefaultValue: { + type: 'String', + defaultValue: 'some value', + }, + newRegularField: { + type: 'String', + }, + }, + }, + }).then(async () => { + let obj = new Parse.Object('NewClass'); + try { + await obj.save(); + fail('Should fail'); + } catch (e) { + expect(e.code).toEqual(142); + expect(e.message).toEqual('newRequiredField is required'); } - }, (error, response, body) => { - expect(body).toEqual({ + obj.set('newRequiredField', 'some value'); + await obj.save(); + expect(obj.get('newRequiredField')).toEqual('some value'); + expect(obj.get('newRequiredFieldWithDefaultValue')).toEqual('some value'); + expect(obj.get('newNotRequiredField')).toEqual(undefined); + expect(obj.get('newNotRequiredFieldWithDefaultValue')).toEqual('some value'); + expect(obj.get('newRegularField')).toEqual(undefined); + obj.set('newRequiredField', null); + try { + await obj.save(); + fail('Should fail'); + } catch (e) { + expect(e.code).toEqual(142); + expect(e.message).toEqual('newRequiredField is required'); + } + obj.unset('newRequiredField'); + try { + await obj.save(); + fail('Should fail'); + } catch (e) { + expect(e.code).toEqual(142); + expect(e.message).toEqual('newRequiredField is required'); + } + obj.set('newRequiredField', 'some value2'); + await obj.save(); + expect(obj.get('newRequiredField')).toEqual('some value2'); + expect(obj.get('newRequiredFieldWithDefaultValue')).toEqual('some value'); + expect(obj.get('newNotRequiredField')).toEqual(undefined); + expect(obj.get('newNotRequiredFieldWithDefaultValue')).toEqual('some value'); + expect(obj.get('newRegularField')).toEqual(undefined); + obj.unset('newRequiredFieldWithDefaultValue'); + try { + await obj.save(); + fail('Should fail'); + } catch (e) { + expect(e.code).toEqual(142); + expect(e.message).toEqual('newRequiredFieldWithDefaultValue is required'); + } + obj.set('newRequiredFieldWithDefaultValue', ''); + try { + await obj.save(); + fail('Should fail'); + } catch (e) { + expect(e.code).toEqual(142); + expect(e.message).toEqual('newRequiredFieldWithDefaultValue is required'); + } + obj.set('newRequiredFieldWithDefaultValue', 'some value2'); + obj.set('newNotRequiredField', ''); + obj.set('newNotRequiredFieldWithDefaultValue', null); + obj.unset('newRegularField'); + await obj.save(); + expect(obj.get('newRequiredField')).toEqual('some value2'); + expect(obj.get('newRequiredFieldWithDefaultValue')).toEqual('some value2'); + expect(obj.get('newNotRequiredField')).toEqual(''); + expect(obj.get('newNotRequiredFieldWithDefaultValue')).toEqual(null); + expect(obj.get('newRegularField')).toEqual(undefined); + obj = new Parse.Object('NewClass'); + obj.set('newRequiredField', 'some value3'); + obj.set('newRequiredFieldWithDefaultValue', 'some value3'); + obj.set('newNotRequiredField', 'some value3'); + obj.set('newNotRequiredFieldWithDefaultValue', 'some value3'); + obj.set('newRegularField', 'some value3'); + await obj.save(); + expect(obj.get('newRequiredField')).toEqual('some value3'); + expect(obj.get('newRequiredFieldWithDefaultValue')).toEqual('some value3'); + expect(obj.get('newNotRequiredField')).toEqual('some value3'); + expect(obj.get('newNotRequiredFieldWithDefaultValue')).toEqual('some value3'); + expect(obj.get('newRegularField')).toEqual('some value3'); + done(); + }); + }); + }); + + it('should validate required fields and set default values after before save trigger', async () => { + await request({ + url: 'http://localhost:8378/1/schemas', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClassForBeforeSaveTest', + fields: { + foo1: { type: 'String' }, + foo2: { type: 'String', required: true }, + foo3: { + type: 'String', + required: true, + defaultValue: 'some default value 3', + }, + foo4: { type: 'String', defaultValue: 'some default value 4' }, + }, + }, + }); + + Parse.Cloud.beforeSave('NewClassForBeforeSaveTest', req => { + req.object.set('foo1', 'some value 1'); + req.object.set('foo2', 'some value 2'); + req.object.set('foo3', 'some value 3'); + req.object.set('foo4', 'some value 4'); + }); + + let obj = new Parse.Object('NewClassForBeforeSaveTest'); + await obj.save(); + + expect(obj.get('foo1')).toEqual('some value 1'); + expect(obj.get('foo2')).toEqual('some value 2'); + expect(obj.get('foo3')).toEqual('some value 3'); + expect(obj.get('foo4')).toEqual('some value 4'); + + Parse.Cloud.beforeSave('NewClassForBeforeSaveTest', req => { + req.object.set('foo1', 'some value 1'); + req.object.set('foo2', 'some value 2'); + }); + + obj = new Parse.Object('NewClassForBeforeSaveTest'); + await obj.save(); + + expect(obj.get('foo1')).toEqual('some value 1'); + expect(obj.get('foo2')).toEqual('some value 2'); + expect(obj.get('foo3')).toEqual('some default value 3'); + expect(obj.get('foo4')).toEqual('some default value 4'); + + Parse.Cloud.beforeSave('NewClassForBeforeSaveTest', req => { + req.object.set('foo1', 'some value 1'); + req.object.set('foo2', 'some value 2'); + req.object.set('foo3', undefined); + req.object.unset('foo4'); + }); + + obj = new Parse.Object('NewClassForBeforeSaveTest'); + obj.set('foo3', 'some value 3'); + obj.set('foo4', 'some value 4'); + await obj.save(); + + expect(obj.get('foo1')).toEqual('some value 1'); + expect(obj.get('foo2')).toEqual('some value 2'); + expect(obj.get('foo3')).toEqual('some default value 3'); + expect(obj.get('foo4')).toEqual('some default value 4'); + + Parse.Cloud.beforeSave('NewClassForBeforeSaveTest', req => { + req.object.set('foo1', 'some value 1'); + req.object.set('foo2', undefined); + req.object.set('foo3', undefined); + req.object.unset('foo4'); + }); + + obj = new Parse.Object('NewClassForBeforeSaveTest'); + obj.set('foo2', 'some value 2'); + obj.set('foo3', 'some value 3'); + obj.set('foo4', 'some value 4'); + + try { + await obj.save(); + fail('should fail'); + } catch (e) { + expect(e.message).toEqual('foo2 is required'); + } + + Parse.Cloud.beforeSave('NewClassForBeforeSaveTest', req => { + req.object.set('foo1', 'some value 1'); + req.object.unset('foo2'); + req.object.set('foo3', undefined); + req.object.unset('foo4'); + }); + + obj = new Parse.Object('NewClassForBeforeSaveTest'); + obj.set('foo2', 'some value 2'); + obj.set('foo3', 'some value 3'); + obj.set('foo4', 'some value 4'); + + try { + await obj.save(); + fail('should fail'); + } catch (e) { + expect(e.message).toEqual('foo2 is required'); + } + }); + + it('lets you add fields to system schema', done => { + request({ + method: 'POST', + url: 'http://localhost:8378/1/schemas/_User', + headers: masterKeyHeaders, + json: true, + }).then(fail, () => { + request({ + url: 'http://localhost:8378/1/schemas/_User', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + newField: { type: 'String' }, + }, + }, + }).then(response => { + delete response.data.indexes; + expect( + dd(response.data, { + className: '_User', + fields: { + objectId: { type: 'String' }, + updatedAt: { type: 'Date' }, + createdAt: { type: 'Date' }, + username: { type: 'String' }, + password: { type: 'String' }, + email: { type: 'String' }, + emailVerified: { type: 'Boolean' }, + authData: { type: 'Object' }, + newField: { type: 'String' }, + ACL: { type: 'ACL' }, + }, + classLevelPermissions: { + ...defaultClassLevelPermissions, + protectedFields: { + '*': ['email'], + }, + }, + }) + ).toBeUndefined(); + request({ + url: 'http://localhost:8378/1/schemas/_User', + headers: masterKeyHeaders, + json: true, + }).then(response => { + delete response.data.indexes; + expect( + dd(response.data, { + className: '_User', + fields: { + objectId: { type: 'String' }, + updatedAt: { type: 'Date' }, + createdAt: { type: 'Date' }, + username: { type: 'String' }, + password: { type: 'String' }, + email: { type: 'String' }, + emailVerified: { type: 'Boolean' }, + authData: { type: 'Object' }, + newField: { type: 'String' }, + ACL: { type: 'ACL' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + }) + ).toBeUndefined(); + done(); + }); + }); + }); + }); + + it('lets you delete multiple fields and check schema', done => { + const simpleOneObject = () => { + const obj = new Parse.Object('SimpleOne'); + obj.set('aNumber', 5); + obj.set('aString', 'string'); + obj.set('aBool', true); + return obj; + }; + + simpleOneObject() + .save() + .then(() => { + request({ + url: 'http://localhost:8378/1/schemas/SimpleOne', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: { __op: 'Delete' }, + aNumber: { __op: 'Delete' }, + }, + }, + }).then(response => { + expect(response.data).toEqual({ + className: 'SimpleOne', + fields: { + //Default fields + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + //Custom fields + aBool: { type: 'Boolean' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + }); + + done(); + }); + }); + }); + + it('lets you delete multiple fields and add fields', done => { + const obj1 = hasAllPODobject(); + obj1.save().then(() => { + request({ + url: 'http://localhost:8378/1/schemas/HasAllPOD', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: { __op: 'Delete' }, + aNumber: { __op: 'Delete' }, + aNewString: { type: 'String' }, + aNewNumber: { type: 'Number' }, + aNewRelation: { type: 'Relation', targetClass: 'HasAllPOD' }, + aNewPointer: { type: 'Pointer', targetClass: 'HasAllPOD' }, + }, + }, + }).then(response => { + expect(response.data).toEqual({ className: 'HasAllPOD', fields: { //Default fields - ACL: {type: 'ACL'}, - createdAt: {type: 'Date'}, - updatedAt: {type: 'Date'}, - objectId: {type: 'String'}, + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, //Custom fields - aBool: {type: 'Boolean'}, - aDate: {type: 'Date'}, - aObject: {type: 'Object'}, - aArray: {type: 'Array'}, - aGeoPoint: {type: 'GeoPoint'}, - aFile: {type: 'File'}, - aNewNumber: {type: 'Number'}, - aNewString: {type: 'String'}, - aNewPointer: {type: 'Pointer', targetClass: 'HasAllPOD'}, - aNewRelation: {type: 'Relation', targetClass: 'HasAllPOD'}, - }, - classLevelPermissions: defaultClassLevelPermissions + aBool: { type: 'Boolean' }, + aDate: { type: 'Date' }, + aObject: { type: 'Object' }, + aArray: { type: 'Array' }, + aGeoPoint: { type: 'GeoPoint' }, + aFile: { type: 'File' }, + aNewNumber: { type: 'Number' }, + aNewString: { type: 'String' }, + aNewPointer: { type: 'Pointer', targetClass: 'HasAllPOD' }, + aNewRelation: { type: 'Relation', targetClass: 'HasAllPOD' }, + }, + classLevelPermissions: defaultClassLevelPermissions, }); - var obj2 = new Parse.Object('HasAllPOD'); + const obj2 = new Parse.Object('HasAllPOD'); obj2.set('aNewPointer', obj1); - var relation = obj2.relation('aNewRelation'); + const relation = obj2.relation('aNewRelation'); relation.add(obj1); obj2.save().then(done); //Just need to make sure saving works on the new object. }); }); }); - it_exclude_dbs(['postgres'])('will not delete any fields if the additions are invalid', done => { - var obj = hasAllPODobject(); - obj.save() - .then(() => { - request.put({ + it('will not delete any fields if the additions are invalid', done => { + const obj = hasAllPODobject(); + obj.save().then(() => { + request({ url: 'http://localhost:8378/1/schemas/HasAllPOD', + method: 'PUT', headers: masterKeyHeaders, json: true, body: { fields: { - fakeNewField: {type: 'fake type'}, - aString: {__op: 'Delete'} - } - } - }, (error, response, body) => { - expect(body.code).toEqual(Parse.Error.INCORRECT_TYPE); - expect(body.error).toEqual('invalid field type: fake type'); - request.get({ + fakeNewField: { type: 'fake type' }, + aString: { __op: 'Delete' }, + }, + }, + }).then(fail, response => { + expect(response.data.code).toEqual(Parse.Error.INCORRECT_TYPE); + expect(response.data.error).toEqual('invalid field type: fake type'); + request({ + method: 'PUT', url: 'http://localhost:8378/1/schemas/HasAllPOD', headers: masterKeyHeaders, json: true, - }, (error, response, body) => { - expect(response.body).toEqual(plainOldDataSchema); + }).then(response => { + expect(response.data).toEqual(plainOldDataSchema); done(); }); }); @@ -787,170 +1510,226 @@ describe('schemas', () => { }); it('requires the master key to delete schemas', done => { - request.del({ + request({ url: 'http://localhost:8378/1/schemas/DoesntMatter', + method: 'DELETE', headers: noAuthHeaders, json: true, - }, (error, response, body) => { - expect(response.statusCode).toEqual(403); - expect(body.error).toEqual('unauthorized'); + }).then(fail, response => { + expect(response.status).toEqual(403); + expect(response.data.error).toEqual('unauthorized'); done(); }); }); - it_exclude_dbs(['postgres'])('refuses to delete non-empty collection', done => { - var obj = hasAllPODobject(); - obj.save() - .then(() => { - request.del({ + it('refuses to delete non-empty collection', done => { + const obj = hasAllPODobject(); + obj.save().then(() => { + request({ url: 'http://localhost:8378/1/schemas/HasAllPOD', + method: 'DELETE', headers: masterKeyHeaders, json: true, - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body.code).toEqual(255); - expect(body.error).toMatch(/HasAllPOD/); - expect(body.error).toMatch(/contains 1/); + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data.code).toEqual(255); + expect(response.data.error).toMatch(/HasAllPOD/); + expect(response.data.error).toMatch(/contains 1/); done(); }); }); }); it('fails when deleting collections with invalid class names', done => { - request.del({ + request({ url: 'http://localhost:8378/1/schemas/_GlobalConfig', + method: 'DELETE', headers: masterKeyHeaders, json: true, - }, (error, response, body) => { - expect(response.statusCode).toEqual(400); - expect(body.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(body.error).toEqual('Invalid classname: _GlobalConfig, classnames can only have alphanumeric characters and _, and must start with an alpha character '); + }).then(fail, response => { + expect(response.status).toEqual(400); + expect(response.data.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(response.data.error).toEqual( + 'Invalid classname: _GlobalConfig, classnames can only have alphanumeric characters and _, and must start with an alpha character ' + ); done(); - }) + }); }); - it_exclude_dbs(['postgres'])('does not fail when deleting nonexistant collections', done => { - request.del({ + it('does not fail when deleting nonexistant collections', done => { + request({ url: 'http://localhost:8378/1/schemas/Missing', + method: 'DELETE', headers: masterKeyHeaders, json: true, - }, (error, response, body) => { - expect(response.statusCode).toEqual(200); - expect(body).toEqual({}); + }).then(response => { + expect(response.status).toEqual(200); + expect(response.data).toEqual({}); done(); }); }); - it_exclude_dbs(['postgres'])('deletes collections including join tables', done => { - var obj = new Parse.Object('MyClass'); + it('ensure refresh cache after deleting a class', async done => { + config = Config.get('test'); + spyOn(config.schemaCache, 'del').and.callFake(() => {}); + spyOn(SchemaController.prototype, 'reloadData').and.callFake(() => Promise.resolve()); + await request({ + url: 'http://localhost:8378/1/schemas', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: { + className: 'A', + }, + }); + await request({ + method: 'DELETE', + url: 'http://localhost:8378/1/schemas/A', + headers: masterKeyHeaders, + json: true, + }); + const response = await request({ + url: 'http://localhost:8378/1/schemas', + method: 'GET', + headers: masterKeyHeaders, + json: true, + }); + const expected = { + results: [userSchema, roleSchema], + }; + expect( + response.data.results + .sort((s1, s2) => s1.className.localeCompare(s2.className)) + .map(s => { + const withoutIndexes = Object.assign({}, s); + delete withoutIndexes.indexes; + return withoutIndexes; + }) + ).toEqual(expected.results.sort((s1, s2) => s1.className.localeCompare(s2.className))); + done(); + }); + + it('deletes collections including join tables', done => { + const obj = new Parse.Object('MyClass'); obj.set('data', 'data'); - obj.save() - .then(() => { - var obj2 = new Parse.Object('MyOtherClass'); - var relation = obj2.relation('aRelation'); - relation.add(obj); - return obj2.save(); - }) - .then(obj2 => obj2.destroy()) - .then(() => { - request.del({ - url: 'http://localhost:8378/1/schemas/MyOtherClass', - headers: masterKeyHeaders, - json: true, - }, (error, response, body) => { - expect(response.statusCode).toEqual(200); - expect(response.body).toEqual({}); - config.database.collectionExists('_Join:aRelation:MyOtherClass').then(exists => { - if (exists) { - fail('Relation collection should be deleted.'); - done(); - } - return config.database.collectionExists('MyOtherClass'); - }).then(exists => { - if (exists) { - fail('Class collection should be deleted.'); - done(); - } - }).then(() => { - request.get({ - url: 'http://localhost:8378/1/schemas/MyOtherClass', - headers: masterKeyHeaders, - json: true, - }, (error, response, body) => { - //Expect _SCHEMA entry to be gone. - expect(response.statusCode).toEqual(400); - expect(body.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(body.error).toEqual('Class MyOtherClass does not exist.'); - done(); - }); + obj + .save() + .then(() => { + const obj2 = new Parse.Object('MyOtherClass'); + const relation = obj2.relation('aRelation'); + relation.add(obj); + return obj2.save(); + }) + .then(obj2 => obj2.destroy()) + .then(() => { + request({ + url: 'http://localhost:8378/1/schemas/MyOtherClass', + method: 'DELETE', + headers: masterKeyHeaders, + json: true, + }).then(response => { + expect(response.status).toEqual(200); + expect(response.data).toEqual({}); + config.database + .collectionExists('_Join:aRelation:MyOtherClass') + .then(exists => { + if (exists) { + fail('Relation collection should be deleted.'); + done(); + } + return config.database.collectionExists('MyOtherClass'); + }) + .then(exists => { + if (exists) { + fail('Class collection should be deleted.'); + done(); + } + }) + .then(() => { + request({ + url: 'http://localhost:8378/1/schemas/MyOtherClass', + headers: masterKeyHeaders, + json: true, + }).then(fail, response => { + //Expect _SCHEMA entry to be gone. + expect(response.status).toEqual(400); + expect(response.data.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(response.data.error).toEqual('Class MyOtherClass does not exist.'); + done(); + }); + }); }); - }); - }).then(() => { - }, error => { - fail(error); - done(); - }); + }) + .then( + () => {}, + error => { + fail(error); + done(); + } + ); }); - it_exclude_dbs(['postgres'])('deletes schema when actual collection does not exist', done => { - request.post({ + it('deletes schema when actual collection does not exist', done => { + request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/NewClassForDelete', headers: masterKeyHeaders, json: true, body: { - className: 'NewClassForDelete' - } - }, (error, response, body) => { - expect(error).toEqual(null); - expect(response.body.className).toEqual('NewClassForDelete'); - request.del({ + className: 'NewClassForDelete', + }, + }).then(response => { + expect(response.data.className).toEqual('NewClassForDelete'); + request({ url: 'http://localhost:8378/1/schemas/NewClassForDelete', + method: 'DELETE', headers: masterKeyHeaders, json: true, - }, (error, response, body) => { - expect(response.statusCode).toEqual(200); - expect(response.body).toEqual({}); + }).then(response => { + expect(response.status).toEqual(200); + expect(response.data).toEqual({}); config.database.loadSchema().then(schema => { schema.hasClass('NewClassForDelete').then(exist => { expect(exist).toEqual(false); done(); }); - }) + }); }); }); }); - it_exclude_dbs(['postgres'])('deletes schema when actual collection exists', done => { - request.post({ + it('deletes schema when actual collection exists', done => { + request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/NewClassForDelete', headers: masterKeyHeaders, json: true, body: { - className: 'NewClassForDelete' - } - }, (error, response, body) => { - expect(error).toEqual(null); - expect(response.body.className).toEqual('NewClassForDelete'); - request.post({ + className: 'NewClassForDelete', + }, + }).then(response => { + expect(response.data.className).toEqual('NewClassForDelete'); + request({ url: 'http://localhost:8378/1/classes/NewClassForDelete', + method: 'POST', headers: restKeyHeaders, - json: true - }, (error, response, body) => { - expect(error).toEqual(null); - expect(typeof response.body.objectId).toEqual('string'); - request.del({ - url: 'http://localhost:8378/1/classes/NewClassForDelete/' + response.body.objectId, + json: true, + }).then(response => { + expect(typeof response.data.objectId).toEqual('string'); + request({ + method: 'DELETE', + url: 'http://localhost:8378/1/classes/NewClassForDelete/' + response.data.objectId, headers: restKeyHeaders, json: true, - }, (error, response, body) => { - expect(error).toEqual(null); - request.del({ + }).then(() => { + request({ + method: 'DELETE', url: 'http://localhost:8378/1/schemas/NewClassForDelete', headers: masterKeyHeaders, json: true, - }, (error, response, body) => { - expect(response.statusCode).toEqual(200); - expect(response.body).toEqual({}); + }).then(response => { + expect(response.status).toEqual(200); + expect(response.data).toEqual({}); config.database.loadSchema().then(schema => { schema.hasClass('NewClassForDelete').then(exist => { expect(exist).toEqual(false); @@ -963,40 +1742,42 @@ describe('schemas', () => { }); }); - it_exclude_dbs(['postgres'])('should set/get schema permissions', done => { - request.post({ + it('should set/get schema permissions', done => { + request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, body: { classLevelPermissions: { find: { - '*': true + '*': true, }, create: { - 'role:admin': true - } - } - } - }, (error, response, body) => { - expect(error).toEqual(null); - request.get({ + 'role:admin': true, + }, + }, + }, + }).then(() => { + request({ url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, - }, (error, response, body) => { - expect(response.statusCode).toEqual(200); - expect(response.body.classLevelPermissions).toEqual({ + }).then(response => { + expect(response.status).toEqual(200); + expect(response.data.classLevelPermissions).toEqual({ find: { - '*': true + '*': true, }, create: { - 'role:admin': true + 'role:admin': true, }, get: {}, + count: {}, update: {}, delete: {}, - addField: {} + addField: {}, + protectedFields: {}, }); done(); }); @@ -1004,634 +1785,2098 @@ describe('schemas', () => { }); it('should fail setting schema permissions with invalid key', done => { - - let object = new Parse.Object('AClass'); + const object = new Parse.Object('AClass'); object.save().then(() => { - request.put({ + request({ + method: 'PUT', url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, body: { classLevelPermissions: { find: { - '*': true + '*': true, }, create: { - 'role:admin': true + 'role:admin': true, }, dummy: { - 'some': true - } - } - } - }, (error, response, body) => { - expect(error).toEqual(null); - expect(body.code).toEqual(107); - expect(body.error).toEqual('dummy is not a valid operation for class level permissions'); + some: true, + }, + }, + }, + }).then(fail, response => { + expect(response.data.code).toEqual(107); + expect(response.data.error).toEqual( + 'dummy is not a valid operation for class level permissions' + ); done(); }); }); }); it('should not be able to add a field', done => { - request.post({ + request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, body: { classLevelPermissions: { create: { - '*': true + '*': true, }, find: { - '*': true + '*': true, }, addField: { - 'role:admin': true - } - } - } - }, (error, response, body) => { - expect(error).toEqual(null); - let object = new Parse.Object('AClass'); + 'role:admin': true, + }, + }, + }, + }).then(() => { + loggerErrorSpy.calls.reset(); + const object = new Parse.Object('AClass'); object.set('hello', 'world'); - return object.save().then(() => { - fail('should not be able to add a field'); - done(); - }, (err) => { - expect(err.message).toEqual('Permission denied for action addField on class AClass.'); - done(); - }) - }) + return object.save().then( + () => { + fail('should not be able to add a field'); + done(); + }, + err => { + expect(err.message).toEqual('Permission denied'); + expect(err.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('Permission denied for action addField on class AClass')); + done(); + } + ); + }); }); it('should be able to add a field', done => { - request.post({ + request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, body: { classLevelPermissions: { create: { - '*': true + '*': true, }, addField: { - '*': true - } - } - } - }, (error, response, body) => { - expect(error).toEqual(null); - let object = new Parse.Object('AClass'); + '*': true, + }, + }, + }, + }).then(() => { + const object = new Parse.Object('AClass'); object.set('hello', 'world'); - return object.save().then(() => { - done(); - }, (err) => { - fail('should be able to add a field'); - done(); - }) - }) + return object.save().then( + () => { + done(); + }, + () => { + fail('should be able to add a field'); + done(); + } + ); + }); + }); + + describe('Nested documents', () => { + beforeAll(async () => { + const testSchema = new Parse.Schema('test_7371'); + testSchema.setCLP({ + create: { ['*']: true }, + update: { ['*']: true }, + addField: {}, + }); + testSchema.addObject('a'); + await testSchema.save(); + }); + + it('addField permission not required for adding a nested property', async () => { + const obj = new Parse.Object('test_7371'); + obj.set('a', {}); + await obj.save(); + obj.set('a.b', 2); + await obj.save(); + }); + it('addField permission not required for modifying a nested property', async () => { + const obj = new Parse.Object('test_7371'); + obj.set('a', { b: 1 }); + await obj.save(); + obj.set('a.b', 2); + await obj.save(); + }); }); - it('should throw with invalid userId (>10 chars)', done => { - request.post({ + it('should aceept class-level permission with userid of any length', async done => { + await global.reconfigureServer({ + customIdSize: 11, + }); + + const id = 'e1evenChars'; + + const { data } = await request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, body: { classLevelPermissions: { find: { - '1234567890A': true + [id]: true, }, - } - } - }, (error, response, body) => { - expect(body.error).toEqual("'1234567890A' is not a valid key for class level permissions"); - done(); - }) + }, + }, + }); + + expect(data.classLevelPermissions.find[id]).toBe(true); + + done(); }); - it('should throw with invalid userId (<10 chars)', done => { - request.post({ + it('should allow set class-level permission for custom userid of any length and chars', async done => { + await global.reconfigureServer({ + allowCustomObjectId: true, + }); + + const symbolsId = 'set:ID+symbol$=@llowed'; + const shortId = '1'; + const { data } = await request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, body: { classLevelPermissions: { find: { - 'a12345678': true + [symbolsId]: true, + [shortId]: true, }, - } - } - }, (error, response, body) => { - expect(body.error).toEqual("'a12345678' is not a valid key for class level permissions"); - done(); - }) + }, + }, + }); + + expect(data.classLevelPermissions.find[symbolsId]).toBe(true); + expect(data.classLevelPermissions.find[shortId]).toBe(true); + + done(); + }); + + it('should allow set ACL for custom userid', async done => { + await global.reconfigureServer({ + allowCustomObjectId: true, + }); + + const symbolsId = 'symbols:id@allowed='; + const shortId = '1'; + const normalId = 'tensymbols'; + + const { data } = await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/AClass', + headers: masterKeyHeaders, + json: true, + body: { + ACL: { + [symbolsId]: { read: true, write: true }, + [shortId]: { read: true, write: true }, + [normalId]: { read: true, write: true }, + }, + }, + }); + + const { data: created } = await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/AClass/${data.objectId}`, + headers: masterKeyHeaders, + json: true, + }); + + expect(created.ACL[normalId].write).toBe(true); + expect(created.ACL[symbolsId].write).toBe(true); + expect(created.ACL[shortId].write).toBe(true); + done(); }); it('should throw with invalid userId (invalid char)', done => { - request.post({ + request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, body: { classLevelPermissions: { find: { - '12345_6789': true + '12345_6789': true, }, - } - } - }, (error, response, body) => { - expect(body.error).toEqual("'12345_6789' is not a valid key for class level permissions"); - done(); - }) + }, + }, + }).then(fail, response => { + expect(response.data.error).toEqual( + "'12345_6789' is not a valid key for class level permissions" + ); + done(); + }); }); - it('should throw with invalid * (spaces)', done => { - request.post({ + it('should throw with invalid * (spaces before)', done => { + request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, body: { classLevelPermissions: { find: { - ' *': true + ' *': true, }, - } - } - }, (error, response, body) => { - expect(body.error).toEqual("' *' is not a valid key for class level permissions"); + }, + }, + }).then(fail, response => { + expect(response.data.error).toEqual("' *' is not a valid key for class level permissions"); done(); - }) + }); }); - it('should throw with invalid * (spaces)', done => { - request.post({ + it('should throw with invalid * (spaces after)', done => { + request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, body: { classLevelPermissions: { find: { - '* ': true + '* ': true, }, - } - } - }, (error, response, body) => { - expect(body.error).toEqual("'* ' is not a valid key for class level permissions"); + }, + }, + }).then(fail, response => { + expect(response.data.error).toEqual("'* ' is not a valid key for class level permissions"); done(); - }) + }); }); - it('should throw with invalid value', done => { - request.post({ + it('should throw if permission is number', done => { + request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, body: { classLevelPermissions: { find: { - '*': 1 + '*': 1, }, - } - } - }, (error, response, body) => { - expect(body.error).toEqual("'1' is not a valid value for class level permissions find:*:1"); + }, + }, + }).then(fail, response => { + expect(response.data.error).toEqual( + "'1' is not a valid value for class level permissions acl find:*" + ); done(); - }) + }); + }); + + it('should validate defaultAcl with class level permissions when request is not an object', async () => { + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/schemas/AClass', + headers: masterKeyHeaders, + json: true, + body: { + classLevelPermissions: { + ACL: { + '*': true, + }, + }, + }, + }).catch(error => error.data); + + expect(response.error).toEqual(`'true' is not a valid value for class level permissions acl`); + }); + + it('should validate defaultAcl with class level permissions when request is an object and invalid key', async () => { + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/schemas/AClass', + headers: masterKeyHeaders, + json: true, + body: { + classLevelPermissions: { + ACL: { + '*': { + foo: true, + }, + }, + }, + }, + }).catch(error => error.data); + + expect(response.error).toEqual(`'foo' is not a valid key for class level permissions acl`); + }); + + it('should validate defaultAcl with class level permissions when request is an object and invalid value', async () => { + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/schemas/AClass', + headers: masterKeyHeaders, + json: true, + body: { + classLevelPermissions: { + ACL: { + '*': { + read: 1, + }, + }, + }, + }, + }).catch(error => error.data); + + expect(response.error).toEqual(`'1' is not a valid value for class level permissions acl`); }); - it('should throw with invalid value', done => { - request.post({ + it('should throw if permission is empty string', done => { + request({ + method: 'POST', url: 'http://localhost:8378/1/schemas/AClass', headers: masterKeyHeaders, json: true, body: { classLevelPermissions: { find: { - '*': "" + '*': '', }, - } - } - }, (error, response, body) => { - expect(body.error).toEqual("'' is not a valid value for class level permissions find:*:"); + }, + }, + }).then(fail, response => { + expect(response.data.error).toEqual( + `'' is not a valid value for class level permissions acl find:*` + ); done(); - }) + }); }); function setPermissionsOnClass(className, permissions, doPut) { - let op = request.post; - if (doPut) - { - op = request.put; - } - return new Promise((resolve, reject) => { - op({ - url: 'http://localhost:8378/1/schemas/'+className, + return request({ + url: 'http://localhost:8378/1/schemas/' + className, + method: doPut ? 'PUT' : 'POST', headers: masterKeyHeaders, json: true, body: { - classLevelPermissions: permissions - } - }, (error, response, body) => { - if (error) { - return reject(error); - } - if (body.error) { - return reject(body); + classLevelPermissions: permissions, + }, + }).then(response => { + if (response.data.error) { + throw response.data; } - return resolve(body); - }) + return response.data; }); } - it_exclude_dbs(['postgres'])('validate CLP 1', done => { - let user = new Parse.User(); + it('validate CLP 1', done => { + const user = new Parse.User(); user.setUsername('user'); user.setPassword('user'); - let admin = new Parse.User(); + const admin = new Parse.User(); admin.setUsername('admin'); admin.setPassword('admin'); - let role = new Parse.Role('admin', new Parse.ACL()); + const role = new Parse.Role('admin', new Parse.ACL()); setPermissionsOnClass('AClass', { - 'find': { - 'role:admin': true - } - }).then(() => { - return Parse.Object.saveAll([user, admin, role], {useMasterKey: true}); - }).then(()=> { - role.relation('users').add(admin); - return role.save(null, {useMasterKey: true}); - }).then(() => { - return Parse.User.logIn('user', 'user').then(() => { - let obj = new Parse.Object('AClass'); - return obj.save(null, {useMasterKey: true}); - }) - }).then(() => { - let query = new Parse.Query('AClass'); - return query.find().then((err) => { - fail('Use should hot be able to find!') - }, (err) => { - expect(err.message).toEqual('Permission denied for action find on class AClass.'); - return Promise.resolve(); - }) - }).then(() => { - return Parse.User.logIn('admin', 'admin'); - }).then( () => { - let query = new Parse.Query('AClass'); - return query.find(); - }).then((results) => { - expect(results.length).toBe(1); - done(); - }, () => { - fail("should not fail!"); - done(); - }).catch( (err) => { - done(); + find: { + 'role:admin': true, + }, }) + .then(() => { + return Parse.Object.saveAll([user, admin, role], { + useMasterKey: true, + }); + }) + .then(() => { + role.relation('users').add(admin); + return role.save(null, { useMasterKey: true }); + }) + .then(() => { + return Parse.User.logIn('user', 'user').then(() => { + const obj = new Parse.Object('AClass'); + return obj.save(null, { useMasterKey: true }); + }); + }) + .then(() => { + loggerErrorSpy.calls.reset(); + const query = new Parse.Query('AClass'); + return query.find().then( + () => { + fail('Use should hot be able to find!'); + }, + err => { + expect(err.message).toEqual('Permission denied'); + expect(err.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('Permission denied for action find on class AClass')); + return Promise.resolve(); + } + ); + }) + .then(() => { + return Parse.User.logIn('admin', 'admin'); + }) + .then(() => { + const query = new Parse.Query('AClass'); + return query.find(); + }) + .then(results => { + expect(results.length).toBe(1); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); }); - it_exclude_dbs(['postgres'])('validate CLP 2', done => { - let user = new Parse.User(); + it('validate CLP 2', done => { + const user = new Parse.User(); user.setUsername('user'); user.setPassword('user'); - let admin = new Parse.User(); + const admin = new Parse.User(); admin.setUsername('admin'); admin.setPassword('admin'); - let role = new Parse.Role('admin', new Parse.ACL()); + const role = new Parse.Role('admin', new Parse.ACL()); setPermissionsOnClass('AClass', { - 'find': { - 'role:admin': true - } - }).then(() => { - return Parse.Object.saveAll([user, admin, role], {useMasterKey: true}); - }).then(()=> { - role.relation('users').add(admin); - return role.save(null, {useMasterKey: true}); - }).then(() => { - return Parse.User.logIn('user', 'user').then(() => { - let obj = new Parse.Object('AClass'); - return obj.save(null, {useMasterKey: true}); + find: { + 'role:admin': true, + }, + }) + .then(() => { + return Parse.Object.saveAll([user, admin, role], { + useMasterKey: true, + }); }) - }).then(() => { - let query = new Parse.Query('AClass'); - return query.find().then((err) => { - fail('User should not be able to find!') - }, (err) => { - expect(err.message).toEqual('Permission denied for action find on class AClass.'); - return Promise.resolve(); + .then(() => { + role.relation('users').add(admin); + return role.save(null, { useMasterKey: true }); }) - }).then(() => { - // let everyone see it now - return setPermissionsOnClass('AClass', { - 'find': { - 'role:admin': true, - '*': true - } - }, true); - }).then(() => { - let query = new Parse.Query('AClass'); - return query.find().then((result) => { - expect(result.length).toBe(1); - }, (err) => { - fail('User should be able to find!') + .then(() => { + return Parse.User.logIn('user', 'user').then(() => { + const obj = new Parse.Object('AClass'); + return obj.save(null, { useMasterKey: true }); + }); + }) + .then(() => { + loggerErrorSpy.calls.reset(); + const query = new Parse.Query('AClass'); + return query.find().then( + () => { + fail('User should not be able to find!'); + }, + err => { + expect(err.message).toEqual('Permission denied'); + expect(err.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('Permission denied for action find on class AClass')); + return Promise.resolve(); + } + ); + }) + .then(() => { + // let everyone see it now + return setPermissionsOnClass( + 'AClass', + { + find: { + 'role:admin': true, + '*': true, + }, + }, + true + ); + }) + .then(() => { + const query = new Parse.Query('AClass'); + return query.find().then( + result => { + expect(result.length).toBe(1); + }, + () => { + fail('User should be able to find!'); + done(); + } + ); + }) + .then(() => { + return Parse.User.logIn('admin', 'admin'); + }) + .then(() => { + const query = new Parse.Query('AClass'); + return query.find(); + }) + .then(results => { + expect(results.length).toBe(1); + done(); + }) + .catch(err => { + jfail(err); done(); }); - }).then(() => { - return Parse.User.logIn('admin', 'admin'); - }).then( () => { - let query = new Parse.Query('AClass'); - return query.find(); - }).then((results) => { - expect(results.length).toBe(1); - done(); - }, (err) => { - fail("should not fail!"); - done(); - }).catch( (err) => { - done(); - }) }); - it_exclude_dbs(['postgres'])('validate CLP 3', done => { - let user = new Parse.User(); + it('validate CLP 3', done => { + const user = new Parse.User(); user.setUsername('user'); user.setPassword('user'); - let admin = new Parse.User(); + const admin = new Parse.User(); admin.setUsername('admin'); admin.setPassword('admin'); - let role = new Parse.Role('admin', new Parse.ACL()); + const role = new Parse.Role('admin', new Parse.ACL()); setPermissionsOnClass('AClass', { - 'find': { - 'role:admin': true - } - }).then(() => { - return Parse.Object.saveAll([user, admin, role], {useMasterKey: true}); - }).then(()=> { - role.relation('users').add(admin); - return role.save(null, {useMasterKey: true}); - }).then(() => { - return Parse.User.logIn('user', 'user').then(() => { - let obj = new Parse.Object('AClass'); - return obj.save(null, {useMasterKey: true}); + find: { + 'role:admin': true, + }, + }) + .then(() => { + return Parse.Object.saveAll([user, admin, role], { + useMasterKey: true, + }); }) - }).then(() => { - let query = new Parse.Query('AClass'); - return query.find().then((err) => { - fail('User should not be able to find!') - }, (err) => { - expect(err.message).toEqual('Permission denied for action find on class AClass.'); - return Promise.resolve(); + .then(() => { + role.relation('users').add(admin); + return role.save(null, { useMasterKey: true }); }) - }).then(() => { - // delete all CLP - return setPermissionsOnClass('AClass', null, true); - }).then(() => { - let query = new Parse.Query('AClass'); - return query.find().then((result) => { - expect(result.length).toBe(1); - }, (err) => { - fail('User should be able to find!') + .then(() => { + return Parse.User.logIn('user', 'user').then(() => { + const obj = new Parse.Object('AClass'); + return obj.save(null, { useMasterKey: true }); + }); + }) + .then(() => { + loggerErrorSpy.calls.reset(); + const query = new Parse.Query('AClass'); + return query.find().then( + () => { + fail('User should not be able to find!'); + }, + err => { + expect(err.message).toEqual('Permission denied'); + expect(err.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('Permission denied for action find on class AClass')); + return Promise.resolve(); + } + ); + }) + .then(() => { + // delete all CLP + return setPermissionsOnClass('AClass', null, true); + }) + .then(() => { + const query = new Parse.Query('AClass'); + return query.find().then( + result => { + expect(result.length).toBe(1); + }, + () => { + fail('User should be able to find!'); + done(); + } + ); + }) + .then(() => { + return Parse.User.logIn('admin', 'admin'); + }) + .then(() => { + const query = new Parse.Query('AClass'); + return query.find(); + }) + .then(results => { + expect(results.length).toBe(1); + done(); + }) + .catch(err => { + jfail(err); done(); }); - }).then(() => { - return Parse.User.logIn('admin', 'admin'); - }).then( () => { - let query = new Parse.Query('AClass'); - return query.find(); - }).then((results) => { - expect(results.length).toBe(1); - done(); - }, (err) => { - fail("should not fail!"); - done(); - }); }); - it_exclude_dbs(['postgres'])('validate CLP 4', done => { - let user = new Parse.User(); + it('validate CLP 4', done => { + const user = new Parse.User(); user.setUsername('user'); user.setPassword('user'); - let admin = new Parse.User(); + const admin = new Parse.User(); admin.setUsername('admin'); admin.setPassword('admin'); - let role = new Parse.Role('admin', new Parse.ACL()); + const role = new Parse.Role('admin', new Parse.ACL()); setPermissionsOnClass('AClass', { - 'find': { - 'role:admin': true - } - }).then(() => { - return Parse.Object.saveAll([user, admin, role], {useMasterKey: true}); - }).then(()=> { - role.relation('users').add(admin); - return role.save(null, {useMasterKey: true}); - }).then(() => { - return Parse.User.logIn('user', 'user').then(() => { - let obj = new Parse.Object('AClass'); - return obj.save(null, {useMasterKey: true}); + find: { + 'role:admin': true, + }, + }) + .then(() => { + return Parse.Object.saveAll([user, admin, role], { + useMasterKey: true, + }); }) - }).then(() => { - let query = new Parse.Query('AClass'); - return query.find().then((err) => { - fail('User should not be able to find!') - }, (err) => { - expect(err.message).toEqual('Permission denied for action find on class AClass.'); - return Promise.resolve(); + .then(() => { + role.relation('users').add(admin); + return role.save(null, { useMasterKey: true }); }) - }).then(() => { - // borked CLP should not affec security - return setPermissionsOnClass('AClass', { - 'found': { - 'role:admin': true - } - }, true).then(() => { - fail("Should not be able to save a borked CLP"); - }, () => { - return Promise.resolve(); + .then(() => { + return Parse.User.logIn('user', 'user').then(() => { + const obj = new Parse.Object('AClass'); + return obj.save(null, { useMasterKey: true }); + }); }) - }).then(() => { - let query = new Parse.Query('AClass'); - return query.find().then((result) => { - fail('User should not be able to find!') - }, (err) => { - expect(err.message).toEqual('Permission denied for action find on class AClass.'); - return Promise.resolve(); - }); - }).then(() => { - return Parse.User.logIn('admin', 'admin'); - }).then( () => { - let query = new Parse.Query('AClass'); - return query.find(); - }).then((results) => { - expect(results.length).toBe(1); - done(); - }, (err) => { - fail("should not fail!"); - done(); - }).catch( (err) => { - done(); - }) + .then(() => { + loggerErrorSpy.calls.reset(); + const query = new Parse.Query('AClass'); + return query.find().then( + () => { + fail('User should not be able to find!'); + }, + err => { + expect(err.message).toEqual('Permission denied'); + expect(err.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('Permission denied for action find on class AClass')); + return Promise.resolve(); + } + ); + }) + .then(() => { + // borked CLP should not affec security + return setPermissionsOnClass( + 'AClass', + { + found: { + 'role:admin': true, + }, + }, + true + ).then( + () => { + fail('Should not be able to save a borked CLP'); + }, + () => { + return Promise.resolve(); + } + ); + }) + .then(() => { + loggerErrorSpy.calls.reset(); + const query = new Parse.Query('AClass'); + return query.find().then( + () => { + fail('User should not be able to find!'); + }, + err => { + expect(err.message).toEqual('Permission denied'); + expect(err.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('Permission denied for action find on class AClass')); + return Promise.resolve(); + } + ); + }) + .then(() => { + return Parse.User.logIn('admin', 'admin'); + }) + .then(() => { + const query = new Parse.Query('AClass'); + return query.find(); + }) + .then(results => { + expect(results.length).toBe(1); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); }); - it_exclude_dbs(['postgres'])('validate CLP 5', done => { - let user = new Parse.User(); + it('validate CLP 5', done => { + const user = new Parse.User(); user.setUsername('user'); user.setPassword('user'); - let user2 = new Parse.User(); + const user2 = new Parse.User(); user2.setUsername('user2'); user2.setPassword('user2'); - let admin = new Parse.User(); + const admin = new Parse.User(); admin.setUsername('admin'); admin.setPassword('admin'); - let role = new Parse.Role('admin', new Parse.ACL()); - - Promise.resolve().then(() => { - return Parse.Object.saveAll([user, user2, admin, role], {useMasterKey: true}); - }).then(()=> { - role.relation('users').add(admin); - return role.save(null, {useMasterKey: true}).then(() => { - let perm = { - find: {} - }; - // let the user find - perm['find'][user.id] = true; - return setPermissionsOnClass('AClass', perm); - }) - }).then(() => { - return Parse.User.logIn('user', 'user').then(() => { - let obj = new Parse.Object('AClass'); - return obj.save(); + const role = new Parse.Role('admin', new Parse.ACL()); + + Promise.resolve() + .then(() => { + return Parse.Object.saveAll([user, user2, admin, role], { + useMasterKey: true, + }); }) - }).then(() => { - let query = new Parse.Query('AClass'); - return query.find().then((res) => { - expect(res.length).toEqual(1); - }, (err) => { - fail('User should be able to find!') - return Promise.resolve(); - }) - }).then(() => { - return Parse.User.logIn('admin', 'admin'); - }).then( () => { - let query = new Parse.Query('AClass'); - return query.find(); - }).then((results) => { - fail("should not be able to read!"); - return Promise.resolve(); - }, (err) => { - expect(err.message).toEqual('Permission denied for action create on class AClass.'); - return Promise.resolve(); - }).then(() => { - return Parse.User.logIn('user2', 'user2'); - }).then( () => { - let query = new Parse.Query('AClass'); - return query.find(); - }).then((results) => { - fail("should not be able to read!"); - return Promise.resolve(); - }, (err) => { - expect(err.message).toEqual('Permission denied for action find on class AClass.'); - return Promise.resolve(); - }).then(() => { - done(); - }); + .then(() => { + role.relation('users').add(admin); + return role.save(null, { useMasterKey: true }).then(() => { + const perm = { + find: {}, + }; + // let the user find + perm['find'][user.id] = true; + return setPermissionsOnClass('AClass', perm); + }); + }) + .then(() => { + return Parse.User.logIn('user', 'user').then(() => { + const obj = new Parse.Object('AClass'); + return obj.save(); + }); + }) + .then(() => { + const query = new Parse.Query('AClass'); + return query.find().then( + res => { + expect(res.length).toEqual(1); + }, + () => { + fail('User should be able to find!'); + return Promise.resolve(); + } + ); + }) + .then(() => { + return Parse.User.logIn('admin', 'admin'); + }) + .then(() => { + loggerErrorSpy.calls.reset(); + const query = new Parse.Query('AClass'); + return query.find(); + }) + .then( + () => { + fail('should not be able to read!'); + return Promise.resolve(); + }, + err => { + expect(err.message).toEqual('Permission denied'); + expect(err.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('Permission denied for action create on class AClass')); + return Promise.resolve(); + } + ) + .then(() => { + return Parse.User.logIn('user2', 'user2'); + }) + .then(() => { + loggerErrorSpy.calls.reset(); + const query = new Parse.Query('AClass'); + return query.find(); + }) + .then( + () => { + fail('should not be able to read!'); + return Promise.resolve(); + }, + err => { + expect(err.message).toEqual('Permission denied'); + expect(err.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('Permission denied for action find on class AClass')); + return Promise.resolve(); + } + ) + .then(() => { + done(); + }); + }); + + it('can query with include and CLP (issue #2005)', done => { + setPermissionsOnClass('AnotherObject', { + get: { '*': true }, + find: {}, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + }) + .then(() => { + const obj = new Parse.Object('AnObject'); + const anotherObject = new Parse.Object('AnotherObject'); + return obj.save({ + anotherObject, + }); + }) + .then(() => { + const query = new Parse.Query('AnObject'); + query.include('anotherObject'); + return query.find(); + }) + .then(res => { + expect(res.length).toBe(1); + expect(res[0].get('anotherObject')).not.toBeUndefined(); + done(); + }) + .catch(err => { + jfail(err); + done(); + }); }); - it('can add field as master (issue #1257)', (done) => { + it('can add field as master (issue #1257)', done => { setPermissionsOnClass('AClass', { - 'addField': {} - }).then(() => { - var obj = new Parse.Object('AClass'); - obj.set('key', 'value'); - return obj.save(null, {useMasterKey: true}) - }).then((obj) => { - expect(obj.get('key')).toEqual('value'); - done(); - }, (err) => { - fail('should not fail'); - done(); - }); + addField: {}, + }) + .then(() => { + const obj = new Parse.Object('AClass'); + obj.set('key', 'value'); + return obj.save(null, { useMasterKey: true }); + }) + .then( + obj => { + expect(obj.get('key')).toEqual('value'); + done(); + }, + () => { + fail('should not fail'); + done(); + } + ); }); - it_exclude_dbs(['postgres'])('can login when addFields is false (issue #1355)', (done) => { - setPermissionsOnClass('_User', { - 'create': {'*': true}, - 'addField': {} - }, true).then(() => { - return Parse.User.signUp('foo', 'bar'); - }).then((user) => { - expect(user.getUsername()).toBe('foo'); - done() - }, error => { - fail(JSON.stringify(error)); - done(); + it('can login when addFields is false (issue #1355)', done => { + setPermissionsOnClass( + '_User', + { + create: { '*': true }, + addField: {}, + }, + true + ) + .then(() => { + return Parse.User.signUp('foo', 'bar'); + }) + .then( + user => { + expect(user.getUsername()).toBe('foo'); + done(); + }, + error => { + fail(JSON.stringify(error)); + done(); + } + ); + }); + + it('unset field in beforeSave should not stop object creation', done => { + const hook = { + method: function (req) { + if (req.object.get('undesiredField')) { + req.object.unset('undesiredField'); + } + }, + }; + spyOn(hook, 'method').and.callThrough(); + Parse.Cloud.beforeSave('AnObject', hook.method); + setPermissionsOnClass('AnObject', { + get: { '*': true }, + find: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, }) - }) - - it_exclude_dbs(['postgres'])('gives correct response when deleting a schema with CLPs (regression test #1919)', done => { - new Parse.Object('MyClass').save({ data: 'foo'}) - .then(obj => obj.destroy()) - .then(() => setPermissionsOnClass('MyClass', { find: {}, get: {} }, true)) - .then(() => { - request.del({ - url: 'http://localhost:8378/1/schemas/MyClass', - headers: masterKeyHeaders, - json: true, - }, (error, response, body) => { - expect(response.statusCode).toEqual(200); - expect(response.body).toEqual({}); + .then(() => { + const obj = new Parse.Object('AnObject'); + obj.set('desiredField', 'createMe'); + return obj.save(null, { useMasterKey: true }); + }) + .then(() => { + const obj = new Parse.Object('AnObject'); + obj.set('desiredField', 'This value should be kept'); + obj.set('undesiredField', 'This value should be IGNORED'); + return obj.save(); + }) + .then(() => { + const query = new Parse.Query('AnObject'); + return query.find(); + }) + .then(results => { + expect(results.length).toBe(2); + expect(results[0].has('desiredField')).toBe(true); + expect(results[1].has('desiredField')).toBe(true); + expect(results[0].has('undesiredField')).toBe(false); + expect(results[1].has('undesiredField')).toBe(false); + expect(hook.method).toHaveBeenCalled(); done(); }); - }); }); - it_exclude_dbs(['postgres'])("regression test for #1991", done => { - let user = new Parse.User(); + it('gives correct response when deleting a schema with CLPs (regression test #1919)', done => { + new Parse.Object('MyClass') + .save({ data: 'foo' }) + .then(obj => obj.destroy()) + .then(() => setPermissionsOnClass('MyClass', { find: {}, get: {} }, true)) + .then(() => { + request({ + method: 'DELETE', + url: 'http://localhost:8378/1/schemas/MyClass', + headers: masterKeyHeaders, + json: true, + }).then(response => { + expect(response.status).toEqual(200); + expect(response.data).toEqual({}); + done(); + }); + }); + }); + + it('regression test for #1991', done => { + const user = new Parse.User(); user.setUsername('user'); user.setPassword('user'); - let role = new Parse.Role('admin', new Parse.ACL()); - let obj = new Parse.Object('AnObject'); - Parse.Object.saveAll([user, role]).then(() => { - role.relation('users').add(user); - return role.save(null, {useMasterKey: true}); - }).then(() => { - return setPermissionsOnClass('AnObject', { - 'get': {"*": true}, - 'find': {"*": true}, - 'create': {'*': true}, - 'update': {'role:admin': true}, - 'delete': {'role:admin': true} - }) - }).then(() => { - return obj.save(); - }).then(() => { - return Parse.User.logIn('user', 'user') - }).then(() => { - return obj.destroy(); - }).then((result) => { - let query = new Parse.Query('AnObject'); - return query.find(); - }).then((results) => { - expect(results.length).toBe(0); - done(); - }).catch((err) => { - fail('should not fail'); - console.error(err); - done(); + const role = new Parse.Role('admin', new Parse.ACL()); + const obj = new Parse.Object('AnObject'); + Parse.Object.saveAll([user, role]) + .then(() => { + role.relation('users').add(user); + return role.save(null, { useMasterKey: true }); + }) + .then(() => { + return setPermissionsOnClass('AnObject', { + get: { '*': true }, + find: { '*': true }, + create: { '*': true }, + update: { 'role:admin': true }, + delete: { 'role:admin': true }, + }); + }) + .then(() => { + return obj.save(); + }) + .then(() => { + return Parse.User.logIn('user', 'user'); + }) + .then(() => { + return obj.destroy(); + }) + .then(() => { + const query = new Parse.Query('AnObject'); + return query.find(); + }) + .then(results => { + expect(results.length).toBe(0); + done(); + }) + .catch(err => { + fail('should not fail'); + jfail(err); + done(); + }); + }); + + it('regression test for #4409 (indexes override the clp)', done => { + setPermissionsOnClass( + '_Role', + { + ACL: { + '*': { + read: true, + write: true, + }, + }, + get: { '*': true }, + find: { '*': true }, + count: { '*': true }, + create: { '*': true }, + }, + true + ) + .then(() => { + const config = Config.get('test'); + return config.database.adapter.updateSchemaWithIndexes(); + }) + .then(() => { + return request({ + url: 'http://localhost:8378/1/schemas/_Role', + headers: masterKeyHeaders, + json: true, + }); + }) + .then(res => { + expect(res.data.classLevelPermissions).toEqual({ + ACL: { + '*': { + read: true, + write: true, + }, + }, + get: { '*': true }, + find: { '*': true }, + count: { '*': true }, + create: { '*': true }, + update: {}, + delete: {}, + addField: {}, + protectedFields: {}, + }); + }) + .then(done) + .catch(done.fail); + }); + + it('regression test for #5177', async () => { + Parse.Object.disableSingleInstance(); + Parse.Cloud.beforeSave('AClass', () => {}); + await setPermissionsOnClass( + 'AClass', + { + update: { '*': true }, + }, + false + ); + const obj = new Parse.Object('AClass'); + await obj.save({ key: 1 }, { useMasterKey: true }); + obj.increment('key', 10); + const objectAgain = await obj.save(); + expect(objectAgain.get('key')).toBe(11); + }); + + it('regression test for #2246', done => { + const profile = new Parse.Object('UserProfile'); + const user = new Parse.User(); + function initialize() { + return user + .save({ + username: 'user', + password: 'password', + }) + .then(() => { + return profile.save({ user }).then(() => { + return user.save( + { + userProfile: profile, + }, + { useMasterKey: true } + ); + }); + }); + } + + initialize() + .then(() => { + return setPermissionsOnClass( + 'UserProfile', + { + readUserFields: ['user'], + writeUserFields: ['user'], + }, + true + ); + }) + .then(() => { + return Parse.User.logIn('user', 'password'); + }) + .then(() => { + const query = new Parse.Query('_User'); + query.include('userProfile'); + return query.get(user.id); + }) + .then( + user => { + expect(user.get('userProfile')).not.toBeUndefined(); + done(); + }, + err => { + jfail(err); + done(); + } + ); + }); + + it('should reject creating class schema with field with invalid key', async done => { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + + const fieldName = '1invalid'; + + const schemaCreation = () => + schemaController.addClassIfNotExists('AnObject', { + [fieldName]: { __type: 'String' }, + }); + + await expectAsync(schemaCreation()).toBeRejectedWith( + new Parse.Error(Parse.Error.INVALID_KEY_NAME, `invalid field name: ${fieldName}`) + ); + done(); + }); + + it('should reject creating invalid field name', async done => { + const object = new Parse.Object('AnObject'); + + await expectAsync( + object.save({ + '!12field': 'field', + }) + ).toBeRejectedWith(new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'Invalid key name: !12field')); + done(); + }); + + it('should be rejected if CLP operation is not an object', async done => { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + + const operationKey = 'get'; + const operation = true; + + const schemaSetup = async () => + await schemaController.addClassIfNotExists( + 'AnObject', + {}, + { + [operationKey]: operation, + } + ); + + await expectAsync(schemaSetup()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_JSON, + `'${operation}' is not a valid value for class level permissions ${operationKey} - must be an object` + ) + ); + + done(); + }); + + it('should be rejected if CLP protectedFields is not an object', async done => { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + + const operationKey = 'get'; + const operation = 'wrongtype'; + + const schemaSetup = async () => + await schemaController.addClassIfNotExists( + 'AnObject', + {}, + { + [operationKey]: operation, + } + ); + + await expectAsync(schemaSetup()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_JSON, + `'${operation}' is not a valid value for class level permissions ${operationKey} - must be an object` + ) + ); + + done(); + }); + + it('should be rejected if CLP read/writeUserFields is not an array', async done => { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + + const operationKey = 'readUserFields'; + const operation = true; + + const schemaSetup = async () => + await schemaController.addClassIfNotExists( + 'AnObject', + {}, + { + [operationKey]: operation, + } + ); + + await expectAsync(schemaSetup()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_JSON, + `'${operation}' is not a valid value for class level permissions ${operationKey} - must be an array` + ) + ); + + done(); + }); + + it('should be rejected if CLP pointerFields is not an array', async done => { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + + const operationKey = 'get'; + const entity = 'pointerFields'; + const value = {}; + + const schemaSetup = async () => + await schemaController.addClassIfNotExists( + 'AnObject', + {}, + { + [operationKey]: { + [entity]: value, + }, + } + ); + + await expectAsync(schemaSetup()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_JSON, + `'${value}' is not a valid value for ${operationKey}[${entity}] - expected an array.` + ) + ); + + done(); + }); + + describe('index management', () => { + beforeEach(async () => { + await TestUtils.destroyAllDataPermanently(false); + await config.database.adapter.performInitialization({ VolatileClassesSchemas: [] }); + databaseAdapter.disableIndexFieldValidation = false; + }); + + it('cannot create index if field does not exist', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }).then(() => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + indexes: { + name1: { aString: 1 }, + }, + }, + }).then(fail, response => { + expect(response.data.code).toBe(Parse.Error.INVALID_QUERY); + expect(response.data.error).toBe('Field aString does not exist, cannot add index.'); + done(); + }); + }); + }); + + it('can create index if field does not exist with disableIndexFieldValidation true ', async () => { + databaseAdapter.disableIndexFieldValidation = true; + await request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }); + const response = await request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + indexes: { + name1: { aString: 1 }, + }, + }, + }); + expect(response.data.indexes.name1).toEqual({ aString: 1 }); + }); + + it('can create index on default field', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }).then(() => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + indexes: { + name1: { createdAt: 1 }, + }, + }, + }).then(response => { + expect(response.data.indexes.name1).toEqual({ createdAt: 1 }); + done(); + }); + }); + }); + + it('cannot create compound index if field does not exist', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }).then(() => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: { type: 'String' }, + }, + indexes: { + name1: { aString: 1, bString: 1 }, + }, + }, + }).then(fail, response => { + expect(response.data.code).toBe(Parse.Error.INVALID_QUERY); + expect(response.data.error).toBe('Field bString does not exist, cannot add index.'); + done(); + }); + }); + }); + + it('allows add index when you create a class', done => { + request({ + url: 'http://localhost:8378/1/schemas', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClass', + fields: { + aString: { type: 'String' }, + }, + indexes: { + name1: { aString: 1 }, + }, + }, + }).then(response => { + expect(response.data).toEqual({ + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + name1: { aString: 1 }, + }, + }); + config.database.adapter.getIndexes('NewClass').then(indexes => { + expect(indexes.length).toBe(2); + done(); + }); + }); + }); + + it('empty index returns nothing', done => { + request({ + url: 'http://localhost:8378/1/schemas', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClass', + fields: { + aString: { type: 'String' }, + }, + indexes: {}, + }, + }).then(response => { + expect(response.data).toEqual({ + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + }); + done(); + }); + }); + + it('lets you add indexes', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }).then(() => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: { type: 'String' }, + }, + indexes: { + name1: { aString: 1 }, + }, + }, + }).then(response => { + expect( + dd(response.data, { + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name1: { aString: 1 }, + }, + }) + ).toEqual(undefined); + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + }).then(response => { + expect(response.data).toEqual({ + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name1: { aString: 1 }, + }, + }); + config.database.adapter.getIndexes('NewClass').then(indexes => { + expect(indexes.length).toEqual(2); + done(); + }); + }); + }); + }); + }); + + it_only_db('mongo')('lets you add index with with pointer like structure', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }).then(() => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aPointer: { type: 'Pointer', targetClass: 'NewClass' }, + }, + indexes: { + pointer: { _p_aPointer: 1 }, + }, + }, + }).then(response => { + expect( + dd(response.data, { + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aPointer: { type: 'Pointer', targetClass: 'NewClass' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + pointer: { _p_aPointer: 1 }, + }, + }) + ).toEqual(undefined); + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + }).then(response => { + expect(response.data).toEqual({ + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aPointer: { type: 'Pointer', targetClass: 'NewClass' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + pointer: { _p_aPointer: 1 }, + }, + }); + config.database.adapter.getIndexes('NewClass').then(indexes => { + expect(indexes.length).toEqual(2); + done(); + }); + }); + }); + }); + }); + + it('lets you add multiple indexes', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }).then(() => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: { type: 'String' }, + bString: { type: 'String' }, + cString: { type: 'String' }, + dString: { type: 'String' }, + }, + indexes: { + name1: { aString: 1 }, + name2: { bString: 1 }, + name3: { cString: 1, dString: 1 }, + }, + }, + }).then(response => { + expect( + dd(response.data, { + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + bString: { type: 'String' }, + cString: { type: 'String' }, + dString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name1: { aString: 1 }, + name2: { bString: 1 }, + name3: { cString: 1, dString: 1 }, + }, + }) + ).toEqual(undefined); + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + }).then(response => { + expect(response.data).toEqual({ + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + bString: { type: 'String' }, + cString: { type: 'String' }, + dString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name1: { aString: 1 }, + name2: { bString: 1 }, + name3: { cString: 1, dString: 1 }, + }, + }); + config.database.adapter.getIndexes('NewClass').then(indexes => { + expect(indexes.length).toEqual(4); + done(); + }); + }); + }); + }); + }); + + it('lets you delete indexes', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }).then(() => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: { type: 'String' }, + }, + indexes: { + name1: { aString: 1 }, + }, + }, + }).then(response => { + expect( + dd(response.data, { + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name1: { aString: 1 }, + }, + }) + ).toEqual(undefined); + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + indexes: { + name1: { __op: 'Delete' }, + }, + }, + }).then(response => { + expect(response.data).toEqual({ + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + }, + }); + config.database.adapter.getIndexes('NewClass').then(indexes => { + expect(indexes.length).toEqual(1); + done(); + }); + }); + }); + }); + }); + + it('lets you delete multiple indexes', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }).then(() => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: { type: 'String' }, + bString: { type: 'String' }, + cString: { type: 'String' }, + }, + indexes: { + name1: { aString: 1 }, + name2: { bString: 1 }, + name3: { cString: 1 }, + }, + }, + }).then(response => { + expect( + dd(response.data, { + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + bString: { type: 'String' }, + cString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name1: { aString: 1 }, + name2: { bString: 1 }, + name3: { cString: 1 }, + }, + }) + ).toEqual(undefined); + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + indexes: { + name1: { __op: 'Delete' }, + name2: { __op: 'Delete' }, + }, + }, + }).then(response => { + expect(response.data).toEqual({ + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + bString: { type: 'String' }, + cString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name3: { cString: 1 }, + }, + }); + config.database.adapter.getIndexes('NewClass').then(indexes => { + expect(indexes.length).toEqual(2); + done(); + }); + }); + }); + }); + }); + + it('lets you add and delete indexes', async () => { + // Wait due to index building in MongoDB on background process with collection lock + const waitForIndexBuild = new Promise(r => setTimeout(r, 500)); + + await request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }); + + let response = await request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: { type: 'String' }, + bString: { type: 'String' }, + cString: { type: 'String' }, + dString: { type: 'String' }, + }, + indexes: { + name1: { aString: 1 }, + name2: { bString: 1 }, + name3: { cString: 1 }, + }, + }, + }); + + expect( + dd(response.data, { + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + bString: { type: 'String' }, + cString: { type: 'String' }, + dString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name1: { aString: 1 }, + name2: { bString: 1 }, + name3: { cString: 1 }, + }, + }) + ).toEqual(undefined); + + await waitForIndexBuild; + response = await request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + indexes: { + name1: { __op: 'Delete' }, + name2: { __op: 'Delete' }, + }, + }, + }); + + expect(response.data).toEqual({ + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + bString: { type: 'String' }, + cString: { type: 'String' }, + dString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name3: { cString: 1 }, + }, + }); + + await waitForIndexBuild; + response = await request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + indexes: { + name4: { dString: 1 }, + }, + }, + }); + + expect(response.data).toEqual({ + className: 'NewClass', + fields: { + ACL: { type: 'ACL' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + objectId: { type: 'String' }, + aString: { type: 'String' }, + bString: { type: 'String' }, + cString: { type: 'String' }, + dString: { type: 'String' }, + }, + classLevelPermissions: defaultClassLevelPermissions, + indexes: { + _id_: { _id: 1 }, + name3: { cString: 1 }, + name4: { dString: 1 }, + }, + }); + + await waitForIndexBuild; + const indexes = await config.database.adapter.getIndexes('NewClass'); + expect(indexes.length).toEqual(3); + }); + + it('cannot delete index that does not exist', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }).then(() => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + indexes: { + unknownIndex: { __op: 'Delete' }, + }, + }, + }).then(fail, response => { + expect(response.data.code).toBe(Parse.Error.INVALID_QUERY); + expect(response.data.error).toBe('Index unknownIndex does not exist, cannot delete.'); + done(); + }); + }); + }); + + it('cannot update index that exist', done => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'POST', + headers: masterKeyHeaders, + json: true, + body: {}, + }).then(() => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: { type: 'String' }, + }, + indexes: { + name1: { aString: 1 }, + }, + }, + }).then(() => { + request({ + url: 'http://localhost:8378/1/schemas/NewClass', + method: 'PUT', + headers: masterKeyHeaders, + json: true, + body: { + indexes: { + name1: { field2: 1 }, + }, + }, + }).then(fail, response => { + expect(response.data.code).toBe(Parse.Error.INVALID_QUERY); + expect(response.data.error).toBe('Index name1 exists, cannot update.'); + done(); + }); + }); + }); + }); + + it_id('5d0926b2-2d31-459d-a2b1-23ecc32e72a3')(it_exclude_dbs(['postgres']))('get indexes on startup', done => { + const obj = new Parse.Object('TestObject'); + obj + .save() + .then(() => { + return reconfigureServer({ + appId: 'test', + restAPIKey: 'test', + publicServerURL: 'http://localhost:8378/1', + }); + }) + .then(() => { + request({ + url: 'http://localhost:8378/1/schemas/TestObject', + headers: masterKeyHeaders, + json: true, + }).then(response => { + expect(response.data.indexes._id_).toBeDefined(); + done(); + }); + }); + }); + + it_id('9f2ba51a-6a9c-4b25-9da0-51c82ac65f90')(it_exclude_dbs(['postgres']))('get compound indexes on startup', done => { + const obj = new Parse.Object('TestObject'); + obj.set('subject', 'subject'); + obj.set('comment', 'comment'); + obj + .save() + .then(() => { + return config.database.adapter.createIndex('TestObject', { + subject: 'text', + comment: 'text', + }); + }) + .then(() => { + return reconfigureServer({ + appId: 'test', + restAPIKey: 'test', + publicServerURL: 'http://localhost:8378/1', + }); + }) + .then(() => { + request({ + url: 'http://localhost:8378/1/schemas/TestObject', + headers: masterKeyHeaders, + json: true, + }).then(response => { + expect(response.data.indexes._id_).toBeDefined(); + expect(response.data.indexes._id_._id).toEqual(1); + expect(response.data.indexes.subject_text_comment_text).toBeDefined(); + expect(response.data.indexes.subject_text_comment_text.subject).toEqual('text'); + expect(response.data.indexes.subject_text_comment_text.comment).toEqual('text'); + done(); + }); + }); + }); + + it_id('cbd5d897-b938-43a4-8f5a-5d02dd2be9be')(it_exclude_dbs(['postgres']))('cannot update to duplicate value on unique index', done => { + loggerErrorSpy.calls.reset(); + const index = { + code: 1, + }; + const obj1 = new Parse.Object('UniqueIndexClass'); + obj1.set('code', 1); + const obj2 = new Parse.Object('UniqueIndexClass'); + obj2.set('code', 2); + const adapter = config.database.adapter; + adapter + ._adaptiveCollection('UniqueIndexClass') + .then(collection => { + return collection._ensureSparseUniqueIndexInBackground(index); + }) + .then(() => { + return obj1.save(); + }) + .then(() => { + return obj2.save(); + }) + .then(() => { + obj1.set('code', 2); + return obj1.save(); + }) + .then(done.fail) + .catch(error => { + expect(error.code).toEqual(Parse.Error.DUPLICATE_VALUE); + // Client should only see generic message (no schema info exposed) + expect(error.message).toEqual('A duplicate value for a field with unique values was provided'); + // Server logs should contain full MongoDB error message with detailed information + expect(loggerErrorSpy).toHaveBeenCalledWith('Duplicate key error:', jasmine.stringContaining('E11000 duplicate key error')); + expect(loggerErrorSpy).toHaveBeenCalledWith('Duplicate key error:', jasmine.stringContaining('test_UniqueIndexClass')); + expect(loggerErrorSpy).toHaveBeenCalledWith('Duplicate key error:', jasmine.stringContaining('code_1')); + done(); + }); }); }); }); diff --git a/spec/support/CurrentSpecReporter.js b/spec/support/CurrentSpecReporter.js new file mode 100755 index 0000000000..0b63b6db04 --- /dev/null +++ b/spec/support/CurrentSpecReporter.js @@ -0,0 +1,100 @@ +// Sets a global variable to the current test spec +// ex: global.currentSpec.description +const { performance } = require('perf_hooks'); + +global.currentSpec = null; + +/** The minimum execution time in seconds for a test to be considered slow. */ +const slowTestLimit = 2; + +const timerMap = {}; +const duplicates = []; +class CurrentSpecReporter { + specStarted(spec) { + if (timerMap[spec.fullName]) { + console.log('Duplicate spec: ' + spec.fullName); + duplicates.push(spec.fullName); + } + timerMap[spec.fullName] = performance.now(); + global.currentSpec = spec; + } + specDone(result) { + if (result.status === 'excluded') { + delete timerMap[result.fullName]; + return; + } + timerMap[result.fullName] = (performance.now() - timerMap[result.fullName]) / 1000; + global.currentSpec = null; + } +} + +global.displayTestStats = function() { + const times = Object.values(timerMap).sort((a,b) => b - a).filter(time => time >= slowTestLimit); + if (times.length > 0) { + console.log(`Slow tests with execution time >=${slowTestLimit}s:`); + } + times.forEach((time) => { + console.warn(`${time.toFixed(1)}s:`, Object.keys(timerMap).find(key => timerMap[key] === time)); + }); + console.log('\n'); + duplicates.forEach((spec) => { + console.warn('Duplicate spec: ' + spec); + }); + console.log('\n'); +}; + +/** + * Transitional compatibility shim for Jasmine 5. + * + * Jasmine 5 throws when a test or hook function uses both `async` and a `done` callback: + * "An asynchronous before/it/after function was defined with the async keyword + * but also took a done callback." + * + * Many existing tests use `async (done) => { ... done(); }`. This wrapper converts + * those to promise-based functions by intercepting the `done` callback and resolving + * a promise instead, so Jasmine sees a plain async function. + * + * To remove this shim, convert each file below so that tests and hooks use plain + * `async () => {}` without a `done` parameter, then remove the file from this list. + * Once the list is empty, delete this function and its call in `helper.js`. + */ +global.normalizeAsyncTests = function() { + function wrapDoneCallback(fn) { + if (fn.length > 0) { + return function() { + return new Promise((resolve) => { + fn.call(this, resolve); + }); + }; + } + return fn; + } + + function wrapGlobal(name) { + const original = global[name]; + global[name] = function(descriptionOrFn, fn, timeout) { + const args = Array.from(arguments); + if (typeof descriptionOrFn === 'function') { + args[0] = wrapDoneCallback(descriptionOrFn); + return original.apply(this, args); + } + if (typeof fn === 'function') { + args[1] = wrapDoneCallback(fn); + return original.apply(this, args); + } + return original.apply(this, args); + }; + if (original.each) { + global[name].each = original.each; + } + } + + wrapGlobal('it'); + wrapGlobal('fit'); + wrapGlobal('beforeEach'); + wrapGlobal('afterEach'); + wrapGlobal('beforeAll'); + wrapGlobal('afterAll'); +}; + +module.exports = CurrentSpecReporter; diff --git a/spec/support/CustomAuth.js b/spec/support/CustomAuth.js new file mode 100644 index 0000000000..f6698e5b03 --- /dev/null +++ b/spec/support/CustomAuth.js @@ -0,0 +1,11 @@ +module.exports = { + validateAppId: function () { + return Promise.resolve(); + }, + validateAuthData: function (authData) { + if (authData.token == 'my-token') { + return Promise.resolve(); + } + return Promise.reject(); + }, +}; diff --git a/spec/support/CustomAuthFunction.js b/spec/support/CustomAuthFunction.js new file mode 100644 index 0000000000..721ed54388 --- /dev/null +++ b/spec/support/CustomAuthFunction.js @@ -0,0 +1,13 @@ +module.exports = function (validAuthData) { + return { + validateAppId: function () { + return Promise.resolve(); + }, + validateAuthData: function (authData) { + if (authData.token == validAuthData.token) { + return Promise.resolve(); + } + return Promise.reject(); + }, + }; +}; diff --git a/spec/support/CustomMiddleware.js b/spec/support/CustomMiddleware.js new file mode 100644 index 0000000000..97e71bd67b --- /dev/null +++ b/spec/support/CustomMiddleware.js @@ -0,0 +1,4 @@ +module.exports = function (req, res, next) { + res.set('X-Yolo', '1'); + next(); +}; diff --git a/spec/support/FailingServer.js b/spec/support/FailingServer.js new file mode 100755 index 0000000000..60112ae82c --- /dev/null +++ b/spec/support/FailingServer.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node +const MongoStorageAdapter = require('../../lib/Adapters/Storage/Mongo/MongoStorageAdapter').default; +const { GridFSBucketAdapter } = require('../../lib/Adapters/Files/GridFSBucketAdapter'); + +const ParseServer = require('../../lib/index').ParseServer; + +const databaseURI = 'mongodb://doesnotexist:27017/parseServerMongoAdapterTestDatabase'; + +(async () => { + try { + await ParseServer.startApp({ + appId: 'test', + masterKey: 'test', + databaseAdapter: new MongoStorageAdapter({ + uri: databaseURI, + mongoOptions: { + serverSelectionTimeoutMS: 2000, + }, + }), + filesAdapter: new GridFSBucketAdapter(databaseURI), + }); + } catch (e) { + process.exit(1); + } +})(); diff --git a/spec/support/MockAdapter.js b/spec/support/MockAdapter.js new file mode 100644 index 0000000000..b1fcd416a7 --- /dev/null +++ b/spec/support/MockAdapter.js @@ -0,0 +1,5 @@ +module.exports = function (options) { + return { + options: options, + }; +}; diff --git a/spec/support/MockDatabaseAdapter.js b/spec/support/MockDatabaseAdapter.js new file mode 100644 index 0000000000..136b4a086d --- /dev/null +++ b/spec/support/MockDatabaseAdapter.js @@ -0,0 +1,9 @@ +module.exports = function (options) { + return { + options: options, + send: function () {}, + getDatabaseURI: function () { + return options.databaseURI; + }, + }; +}; diff --git a/spec/MockEmailAdapter.js b/spec/support/MockEmailAdapter.js similarity index 75% rename from spec/MockEmailAdapter.js rename to spec/support/MockEmailAdapter.js index b143e37e6e..295e6c6c91 100644 --- a/spec/MockEmailAdapter.js +++ b/spec/support/MockEmailAdapter.js @@ -1,5 +1,5 @@ module.exports = { sendVerificationEmail: () => Promise.resolve(), sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => Promise.resolve() -} + sendMail: () => Promise.resolve(), +}; diff --git a/spec/support/MockEmailAdapterWithOptions.js b/spec/support/MockEmailAdapterWithOptions.js new file mode 100644 index 0000000000..71d23892ef --- /dev/null +++ b/spec/support/MockEmailAdapterWithOptions.js @@ -0,0 +1,21 @@ +module.exports = options => { + if (!options) { + throw 'Options were not provided'; + } + const adapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve(), + }; + if (options.sendMail) { + adapter.sendMail = options.sendMail; + } + if (options.sendPasswordResetEmail) { + adapter.sendPasswordResetEmail = options.sendPasswordResetEmail; + } + if (options.sendVerificationEmail) { + adapter.sendVerificationEmail = options.sendVerificationEmail; + } + + return adapter; +}; diff --git a/spec/support/MockLdapServer.js b/spec/support/MockLdapServer.js new file mode 100644 index 0000000000..935f0703d6 --- /dev/null +++ b/spec/support/MockLdapServer.js @@ -0,0 +1,54 @@ +const ldapjs = require('ldapjs'); +const fs = require('fs'); + +const tlsOptions = { + key: fs.readFileSync(__dirname + '/cert/key.pem'), + certificate: fs.readFileSync(__dirname + '/cert/cert.pem'), +}; + +function newServer(port, dn, provokeSearchError = false, ssl = false) { + const server = ssl ? ldapjs.createServer(tlsOptions) : ldapjs.createServer(); + + server.bind('o=example', function (req, res, next) { + if (req.dn.toString() !== dn || req.credentials !== 'secret') + { return next(new ldapjs.InvalidCredentialsError()); } + res.end(); + return next(); + }); + + server.search('o=example', function (req, res, next) { + if (provokeSearchError) { + res.end(ldapjs.LDAP_SIZE_LIMIT_EXCEEDED); + return next(); + } + const obj = { + dn: req.dn.toString(), + attributes: { + objectclass: ['organization', 'top'], + o: 'example', + }, + }; + + const group = { + dn: req.dn.toString(), + attributes: { + objectClass: ['groupOfUniqueNames', 'top'], + uniqueMember: ['uid=testuser, o=example'], + cn: 'powerusers', + ou: 'powerusers', + }, + }; + + if (req.filter.matches(obj.attributes)) { + res.send(obj); + } + + if (req.filter.matches(group.attributes)) { + res.send(group); + } + res.end(); + }); + return new Promise(resolve => server.listen(port, () => resolve(server))); +} + +module.exports = newServer; diff --git a/spec/support/MockPushAdapter.js b/spec/support/MockPushAdapter.js new file mode 100644 index 0000000000..bb31a36595 --- /dev/null +++ b/spec/support/MockPushAdapter.js @@ -0,0 +1,9 @@ +module.exports = function (options) { + return { + options: options, + send: function () {}, + getValidPushTypes: function () { + return Object.keys(options.options); + }, + }; +}; diff --git a/spec/support/cert/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem b/spec/support/cert/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem new file mode 100644 index 0000000000..640c15243d --- /dev/null +++ b/spec/support/cert/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem @@ -0,0 +1,38 @@ +-----BEGIN CERTIFICATE----- +MIIGsDCCBJigAwIBAgIQCK1AsmDSnEyfXs2pvZOu2TANBgkqhkiG9w0BAQwFADBi +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3Qg +RzQwHhcNMjEwNDI5MDAwMDAwWhcNMzYwNDI4MjM1OTU5WjBpMQswCQYDVQQGEwJV +UzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0IFRy +dXN0ZWQgRzQgQ29kZSBTaWduaW5nIFJTQTQwOTYgU0hBMzg0IDIwMjEgQ0ExMIIC +IjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA1bQvQtAorXi3XdU5WRuxiEL1 +M4zrPYGXcMW7xIUmMJ+kjmjYXPXrNCQH4UtP03hD9BfXHtr50tVnGlJPDqFX/IiZ +wZHMgQM+TXAkZLON4gh9NH1MgFcSa0OamfLFOx/y78tHWhOmTLMBICXzENOLsvsI +8IrgnQnAZaf6mIBJNYc9URnokCF4RS6hnyzhGMIazMXuk0lwQjKP+8bqHPNlaJGi +TUyCEUhSaN4QvRRXXegYE2XFf7JPhSxIpFaENdb5LpyqABXRN/4aBpTCfMjqGzLm +ysL0p6MDDnSlrzm2q2AS4+jWufcx4dyt5Big2MEjR0ezoQ9uo6ttmAaDG7dqZy3S +vUQakhCBj7A7CdfHmzJawv9qYFSLScGT7eG0XOBv6yb5jNWy+TgQ5urOkfW+0/tv +k2E0XLyTRSiDNipmKF+wc86LJiUGsoPUXPYVGUztYuBeM/Lo6OwKp7ADK5GyNnm+ +960IHnWmZcy740hQ83eRGv7bUKJGyGFYmPV8AhY8gyitOYbs1LcNU9D4R+Z1MI3s +MJN2FKZbS110YU0/EpF23r9Yy3IQKUHw1cVtJnZoEUETWJrcJisB9IlNWdt4z4FK +PkBHX8mBUHOFECMhWWCKZFTBzCEa6DgZfGYczXg4RTCZT/9jT0y7qg0IU0F8WD1H +s/q27IwyCQLMbDwMVhECAwEAAaOCAVkwggFVMBIGA1UdEwEB/wQIMAYBAf8CAQAw +HQYDVR0OBBYEFGg34Ou2O/hfEYb7/mF7CIhl9E5CMB8GA1UdIwQYMBaAFOzX44LS +cV1kTN8uZz/nupiuHA9PMA4GA1UdDwEB/wQEAwIBhjATBgNVHSUEDDAKBggrBgEF +BQcDAzB3BggrBgEFBQcBAQRrMGkwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRp +Z2ljZXJ0LmNvbTBBBggrBgEFBQcwAoY1aHR0cDovL2NhY2VydHMuZGlnaWNlcnQu +Y29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5jcnQwQwYDVR0fBDwwOjA4oDagNIYy +aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5j +cmwwHAYDVR0gBBUwEzAHBgVngQwBAzAIBgZngQwBBAEwDQYJKoZIhvcNAQEMBQAD +ggIBADojRD2NCHbuj7w6mdNW4AIapfhINPMstuZ0ZveUcrEAyq9sMCcTEp6QRJ9L +/Z6jfCbVN7w6XUhtldU/SfQnuxaBRVD9nL22heB2fjdxyyL3WqqQz/WTauPrINHV +UHmImoqKwba9oUgYftzYgBoRGRjNYZmBVvbJ43bnxOQbX0P4PpT/djk9ntSZz0rd +KOtfJqGVWEjVGv7XJz/9kNF2ht0csGBc8w2o7uCJob054ThO2m67Np375SFTWsPK +6Wrxoj7bQ7gzyE84FJKZ9d3OVG3ZXQIUH0AzfAPilbLCIXVzUstG2MQ0HKKlS43N +b3Y3LIU/Gs4m6Ri+kAewQ3+ViCCCcPDMyu/9KTVcH4k4Vfc3iosJocsL6TEa/y4Z +XDlx4b6cpwoG1iZnt5LmTl/eeqxJzy6kdJKt2zyknIYf48FWGysj/4+16oh7cGvm +oLr9Oj9FpsToFpFSi0HASIRLlk2rREDjjfAVKM7t8RhWByovEMQMCGQ8M4+uKIw8 +y4+ICw2/O/TOHnuO77Xry7fwdxPm5yg/rBKupS8ibEH5glwVZsxsDsrFhsP2JjMM +B0ug0wcCampAMEhLNKhRILutG4UI4lkNbcoFUCvqShyepf2gpx8GdOfy1lKQ/a+F +SCH5Vzu0nAPthkX0tGFuv2jiJmCG6sivqf6UHedjGzqGVnhO +-----END CERTIFICATE----- diff --git a/spec/support/cert/anothercert.pem b/spec/support/cert/anothercert.pem new file mode 100644 index 0000000000..488b1cdb94 --- /dev/null +++ b/spec/support/cert/anothercert.pem @@ -0,0 +1,29 @@ +-----BEGIN CERTIFICATE----- +MIIE8DCCAtgCCQDjXCYv/hK1rjANBgkqhkiG9w0BAQsFADA5MRIwEAYDVQQDDAls +b2NhbGhvc3QxIzAhBgkqhkiG9w0BCQEWFG5vLXJlcGx5QGV4YW1wbGUuY29tMCAX +DTIwMTExNzEzMTAwMFoYDzIxMjAxMDI0MTMxMDAwWjA5MRIwEAYDVQQDDAlsb2Nh +bGhvc3QxIzAhBgkqhkiG9w0BCQEWFG5vLXJlcGx5QGV4YW1wbGUuY29tMIICIjAN +BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAmsOhxCNw3cEA3TLyqZXMI5p/LNSu +W9doIvLEs1Ah8L/Gbl7xmSagkTZYzkTJDxITy0d45NVfmDsm0ctQrPV5MEbFE571 +lLQRnCFMpB3dqejfqQWpVCMfJKR1p8p5FTtcC5u5g7bcf2YeujwbUVDEtbeHwUeo +XBnKfmv0UdGiLQf0uel5dcGWNp8dFo+hO4wCTA/risIdWawG8RHtzfhRIT2PqUa8 +ljgPyuPU2NQ19gUkV1LkXKJby+6VHhD6pSfzptbsJjalaGawTku7ZgBoZiax8wRk +Bdwyd3ScMQg2VLGIn7YaMwb4ANtHqREekl0q7tPTu+PBmYqGXqa3lKa/s1OebUyS +GQQXZB5T/Brm2fvJWqO9oJjZiTZzZIkBWDP0Cn+pmW/T4dADUms/vONEJE9IPFn1 +id5Q8vjSf5V1MaZJjWek38Y98xfYlKecHIqBAYQAydxdxuzG/DJu+2GzOZeffETD +lzNwrLZp5lBzSrOwVntonvFo04lIq+DepVF+OqK8qV+7pnKCij5bGvdwxaY290pW ++VTzK8kw0VUmpyYrDWIr7C52txaleY/AqsHy6wlVgdMbwXDjQ00twkJJT3tecL9I +eWtLOuh7BeokvDFOXRVI2ZB2KN0sOBXsPfM6G4o9RK305Q9TFEXARnly9cwoV/i9 +8yeJ5teQHw3dm7kCAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAIWUqZSMCGlzWsCtU +Xayr4qshfhqhm6PzgCWGjg8Xsm8gwrbYtQRwsKdJvw7ynLhbeX65i7p3/3AO0W6k +8Zpf58MHgepMOHmVT/KVBy7tUb83wJuoEvZzH50sO0rcA32c3p2FFUvt3JW+Dfo5 +BMX6GDlymtZPAplD9Rw5S5CXkZAgraDCbx1JMGFh0FfbP9v7jdo+so35y8UqmJ10 +3U0NX2UJoWGE6RvV2P/1TE0v4pWyFzz1dF2k/gcmzYtMgIkJGGO8qhIGo2rSVJhC +gVlYxyW/Rxogxz4wN0EqPIJNnkRby/g40OkPN8ATkHs09F4Jyax+cU0iJ3Hbn5t/ +0Ou5oaAs4t1+u11iahUMP6evaXooZONawM7h0RT4HHHZkXT95+kmaMz/+JZRp9DA +Cafp9IsTjLzHvRy5DLX2kithqXaKRdpgTylx0qwW+8HxRjCcJEsFN3lXWqX12R8D +OM8DnVsFX61Ygp7kTj2CQ+Y3Wqrj+jEkyJLRvMeTNPlxfazwudgFuDYsDErMCUwG +U67vPoCkvIShFrnR9X4ojpG8aqWF8M/o8nvKIQp+FEW0Btm6rZT9lGba6nZw76Yj ++48bsJCQ7UzhKkeFO4Bmj0fDkBTAElV2oEJXbHbB6+0DQE48uLWAr4xb7Vswph8c +wHgxPsgsd2h0gr21doWB1BsdAu8= +-----END CERTIFICATE----- diff --git a/spec/support/cert/cert.pem b/spec/support/cert/cert.pem new file mode 100644 index 0000000000..ba66211f28 --- /dev/null +++ b/spec/support/cert/cert.pem @@ -0,0 +1,29 @@ +-----BEGIN CERTIFICATE----- +MIIE8DCCAtgCCQDaLjopNQCJuTANBgkqhkiG9w0BAQsFADA5MRIwEAYDVQQDDAls +b2NhbGhvc3QxIzAhBgkqhkiG9w0BCQEWFG5vLXJlcGx5QGV4YW1wbGUuY29tMCAX +DTIwMTExNzExNDEzM1oYDzIxMjAxMDI0MTE0MTMzWjA5MRIwEAYDVQQDDAlsb2Nh +bGhvc3QxIzAhBgkqhkiG9w0BCQEWFG5vLXJlcGx5QGV4YW1wbGUuY29tMIICIjAN +BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvFf3I2RnIbp82Dd0AooAMamxMCgu +g4zurMdA40mV8G+MA4Y5XFcGmOYT7LC94Z2nZ4tI+MNSiLKQY3Zq+OYGGmn/zVkr +e8+02afxTjGmLVJWJXxXV2rsf8+UuJMOPbmVq87nJmD2gs9T6czOE3eQdDTRUzTg +ubWhp3hV291gMfCIQeBbSqfbBscz0Nboj8NHStWDif5Io94l08tdW9oHIu99NYE0 +DMWIfBeztHpmSfkgPKH8lNar1dMsuCRW2Q/b01TNPKCNp8ZxyIhzkOq2gC5l60i5 +/iALWeEJii8g71V3DMbU5KoPEB+jFZ/z7qAi8TH9VqgaUycs/M96VXMIZbDhXywJ +pg7qHxG/RT16bXwFotreThcla2M3VxsZEnYPEVmQEyVQeG7XyvqFMC3DhGCflW35 +dumJlkuGn9e9Lg6oiidp2RMnZuTsie+y3e3XJz2ZjFihGQNy2VzUrDz4ymi2fosV +GMeHn3iK2nEqxf1mx021j3v40/8I5gtkS+zZuchclae0gRHaNN1tO0osedUdlV7D +0dvi9xezsfelqSqJjChLfl4R3HqC8k7cwUfK4RmKXhI5GX4ESr+1KWPIaqH5AxYB ++ee2WYBQGhi6aXKpVcj9dvq+OAmDMPCJr0xnWMMZqR5dnxY1eEq2x28n2b1SyIw1 ++IctNX0nLwGAMgUCAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAEYTLXvHWmNuYlG6e +CAiK1ubJ98nO6PSJsl+qosB1kWKlPeWPOLLAeZxSDh0tKRPvQoXoH/AtMRGHFGLS +lk7fbCAbgEqvfA9+8VhgpWSRXD2iodt444P+m93NiMNeusiRFzozXKZZvU4Ie97H +mDuwLjpGgi8DUShebM2Ngif8t4DmSgSfLQ3OEac7oKUP6ffHMXbqnDwjh8ZCCh1m +DN+0i4Y5WpKD7Z+JjGHJRm1Cx/G5pwP16Et6YejQMnNU70VDOzGSvNABmiexiR5p +m8pOTkyxrYViYqamLZG5to5vpI6RmEoA/5vbU59dZ5DzPmSoyNbIeaz+dkSGoy6D +SWKZMwGTf++xS5y+oy2lNS2iddc845qCcDy4jeel3N9JPlJPwrArfapATcrX3Rpy +GsVPvWsKA3q7kwIQo3qscg0CkYwHo5VCnWHDNqgOeFo35J7y+CKxYRolD9/lCtAU +Pw8CBGp1x8jgIv7yKNiPVDtWYztqfsFrplLf/yiZSH53zghSY3v5qnFRkmGq1HRC +G6lz0yjI7RUEA2a/XA2dv9Hv6CdmWUzrsXvocH5VgQz2RtkyvSaLFzRv8gnESrY1 +7qq55D1QIkO8UzzmCSpYPi5tUTGAYE1aHP/B1S5LpBrpaJ8Q9nfqA/9Bb+aho2ze +N0vpdSSemKGQcrzquNqDJhUoXgQ= +-----END CERTIFICATE----- diff --git a/spec/support/cert/game_center.pem b/spec/support/cert/game_center.pem new file mode 100644 index 0000000000..b5dffcd832 --- /dev/null +++ b/spec/support/cert/game_center.pem @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIEvDCCA6SgAwIBAgIQXRHxNXkw1L9z5/3EZ/T/hDANBgkqhkiG9w0BAQsFADB/ +MQswCQYDVQQGEwJVUzEdMBsGA1UEChMUU3ltYW50ZWMgQ29ycG9yYXRpb24xHzAd +BgNVBAsTFlN5bWFudGVjIFRydXN0IE5ldHdvcmsxMDAuBgNVBAMTJ1N5bWFudGVj +IENsYXNzIDMgU0hBMjU2IENvZGUgU2lnbmluZyBDQTAeFw0xODA5MTcwMDAwMDBa +Fw0xOTA5MTcyMzU5NTlaMHMxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9y +bmlhMRIwEAYDVQQHDAlDdXBlcnRpbm8xFDASBgNVBAoMC0FwcGxlLCBJbmMuMQ8w +DQYDVQQLDAZHQyBTUkUxFDASBgNVBAMMC0FwcGxlLCBJbmMuMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEA06fwIi8fgKrTQu7cBcFkJVF6+Tqvkg7MKJTM +IOYPPQtPF3AZYPsbUoRKAD7/JXrxxOSVJ7vU1mP77tYG8TcUteZ3sAwvt2dkRbm7 +ZO6DcmSggv1Dg4k3goNw4GYyCY4Z2/8JSmsQ80Iv/UOOwynpBziEeZmJ4uck6zlA +17cDkH48LBpKylaqthym5bFs9gj11pto7mvyb5BTcVuohwi6qosvbs/4VGbC2Nsz +ie416nUZfv+xxoXH995gxR2mw5cDdeCew7pSKxEhvYjT2nVdQF0q/hnPMFnOaEyT +q79n3gwFXyt0dy8eP6KBF7EW9J6b7ubu/j7h+tQfxPM+gTXOBQIDAQABo4IBPjCC +ATowCQYDVR0TBAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUH +AwMwYQYDVR0gBFowWDBWBgZngQwBBAEwTDAjBggrBgEFBQcCARYXaHR0cHM6Ly9k +LnN5bWNiLmNvbS9jcHMwJQYIKwYBBQUHAgIwGQwXaHR0cHM6Ly9kLnN5bWNiLmNv +bS9ycGEwHwYDVR0jBBgwFoAUljtT8Hkzl699g+8uK8zKt4YecmYwKwYDVR0fBCQw +IjAgoB6gHIYaaHR0cDovL3N2LnN5bWNiLmNvbS9zdi5jcmwwVwYIKwYBBQUHAQEE +SzBJMB8GCCsGAQUFBzABhhNodHRwOi8vc3Yuc3ltY2QuY29tMCYGCCsGAQUFBzAC +hhpodHRwOi8vc3Yuc3ltY2IuY29tL3N2LmNydDANBgkqhkiG9w0BAQsFAAOCAQEA +I/j/PcCNPebSAGrcqSFBSa2mmbusOX01eVBg8X0G/z8Z+ZWUfGFzDG0GQf89MPxV +woec+nZuqui7o9Bg8s8JbHV0TC52X14CbTj9w/qBF748WbH9gAaTkrJYPm+MlNhu +tjEuQdNl/YXVMvQW4O8UMHTi09GyJQ0NC4q92Wxvx1m/qzjvTLvrXHGQ9pEHhPyz +vfBLxQkWpNoCNKU7UeESyH06XOrGc9MsII9deeKsDJp9a0jtx+pP4MFVtFME9SSQ +tMBs0It7WwEf7qcRLpialxKwY2EzQ9g4WnANHqo18PrDBE10TFpZPzUh7JhMViVr +EEbl0YdElmF8Hlamah/yNw== +-----END CERTIFICATE----- diff --git a/spec/support/cert/game_center_2.pem b/spec/support/cert/game_center_2.pem new file mode 100644 index 0000000000..21a7c7327a --- /dev/null +++ b/spec/support/cert/game_center_2.pem @@ -0,0 +1,42 @@ +-----BEGIN CERTIFICATE----- +MIIHbDCCBVSgAwIBAgIQAwuBj1pc45FkhpmTbIvZOjANBgkqhkiG9w0BAQsFADBp +MQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMT +OERpZ2lDZXJ0IFRydXN0ZWQgRzQgQ29kZSBTaWduaW5nIFJTQTQwOTYgU0hBMzg0 +IDIwMjEgQ0ExMB4XDTIxMDcyOTAwMDAwMFoXDTIyMDcyODIzNTk1OVowcTELMAkG +A1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExEjAQBgNVBAcTCUN1cGVydGlu +bzETMBEGA1UEChMKQXBwbGUgSW5jLjEPMA0GA1UECxMGR0MgU1JFMRMwEQYDVQQD +EwpBcHBsZSBJbmMuMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAyGXC +hfNKtSFUkayI4RGDl1T7cTqs9Ni6vnwJpU/9nTT3BWWxZ2Yng4muIhMeA3oZfDZu +T1ShS5y3CQV9/9SaUU1NNfnPxenvrrE8xSn8a9bo2adTrn9ASrEMqRD6bp+fS5Cp +kFHYH+VD5a8XTyOuDGpQyqIpUpYqGABXITWrEpjnpAw1IjMaeNO9sYJkWuLdw0gg +IMpBqmiiJXHgasl8D59S93PVHD1xkEjZcPT9NEWJXSRHUW+Xe+JUhrFSzEfjyWNS +spgJrnVtv4ec30Uz0qUC683lkfE446VPiIyo3xmjh3rs3G75JYJd5925YVM0uz1U +Wn0VmOTN5s81V6CBdYRc3J0sCGd5QEmDo4pwPwCMej+fT6fktIXUWZ1i/ycI1//m +Vc4kkuyiJ2msv8GSACPG6XkL+zKTjYC+GElj/WCX+hVJKzsYtL51zRr4KNnqhG7/ +GK5kJ9eVTgTEKqdB0DZ7ZpOD3EoE2D9kj4zaoq/7r6Syi7Efw230zDMQyIJnoUQc +GDWUR2ZPQ+U+aUOKdWpgbhy4vOzTi24hOVcACbvc/CFTQ2gI7SfCSao9WLVqqGO5 +waHhoOidTYY9Ey2PQvYHqXm5R2Ol+3V+GQl0NkiDt5kc7OpYIm7cDyQ04ZaHnUDt +ZljI5N1fdlhYVKntEzX4sNhcx1pNB1C/T5Wfw68CAwEAAaOCAgYwggICMB8GA1Ud +IwQYMBaAFGg34Ou2O/hfEYb7/mF7CIhl9E5CMB0GA1UdDgQWBBRS7TCGHb7iPnHR +/odXPWLpAxPVKjAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwMw +gbUGA1UdHwSBrTCBqjBToFGgT4ZNaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0Rp +Z2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25pbmdSU0E0MDk2U0hBMzg0MjAyMUNBMS5j +cmwwU6BRoE+GTWh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0 +ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNIQTM4NDIwMjFDQTEuY3JsMD4GA1UdIAQ3 +MDUwMwYGZ4EMAQQBMCkwJwYIKwYBBQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNlcnQu +Y29tL0NQUzCBlAYIKwYBBQUHAQEEgYcwgYQwJAYIKwYBBQUHMAGGGGh0dHA6Ly9v +Y3NwLmRpZ2ljZXJ0LmNvbTBcBggrBgEFBQcwAoZQaHR0cDovL2NhY2VydHMuZGln +aWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25pbmdSU0E0MDk2U0hB +Mzg0MjAyMUNBMS5jcnQwDAYDVR0TAQH/BAIwADANBgkqhkiG9w0BAQsFAAOCAgEA +uk71YLf55ne94hEeQtYsjCn38Tw3h78CH195J8H4T4r2p7p9MPjrA2zz+ZXza+kb +z5OTZ9k1/nu9vKnh4ljZS33uTh5AcdWhQNUeSuByjhVu+YTnVKqVYH/jaZXEFFe/ +4/n23Shn2xN5jtkCEwYeqEaO6+8uBCFQldnUgbSag2Le9s/lICUJvGsKTAUhEGrK +R4u4OyJGGk8JO5Ozbnoe1AGBK9pKMWOAl+SY/b/CLLTgypwZwD/6xszM1MhcfzPS +aBbJ7MX2Uiq91/PNJdPnZI/PoqAQEzDL+5MZnwKwNpeC1rH8ZhlCn1BXbxI5jemw +Tfo2U6cDN1ObJ4LBzsVioWA0KoNnp4eWkMmbGGH5iWRcwoCjhkzot8VvXoll0uSe +F9v1RMOCM+Vcr++MYdJxdoQDNMunEoUnpHQbreHSLMcwPUhSNO4+EtZA86hob2u0 +6yMXdAi9pEs9Aj13LAW74MCDrToCzoa2ZaisvxbRfQSpXryUQEnqpuQqCVjglxaJ +FIMhV0DRWIaLF9vhv6zF9kL77qr+arLd/wJlXubtD/P9tJZRlEh6/0iHvyyH2+Rg +u05//UQ7ex/j15PLFSVkQXIFPpN1ZgN0FrJKAJOL+MWiB5RncKxjin8Y9xfC3XKS +fbV6c7J9AGi8bE8aFMM2ISg7v/dOQzcLPPScWbe5cTg= +-----END CERTIFICATE----- diff --git a/spec/support/cert/gc-prod-4.cer b/spec/support/cert/gc-prod-4.cer new file mode 100644 index 0000000000..873d6f31f6 Binary files /dev/null and b/spec/support/cert/gc-prod-4.cer differ diff --git a/spec/support/cert/key.pem b/spec/support/cert/key.pem new file mode 100644 index 0000000000..1330bc9629 --- /dev/null +++ b/spec/support/cert/key.pem @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIJKQIBAAKCAgEAvFf3I2RnIbp82Dd0AooAMamxMCgug4zurMdA40mV8G+MA4Y5 +XFcGmOYT7LC94Z2nZ4tI+MNSiLKQY3Zq+OYGGmn/zVkre8+02afxTjGmLVJWJXxX +V2rsf8+UuJMOPbmVq87nJmD2gs9T6czOE3eQdDTRUzTgubWhp3hV291gMfCIQeBb +SqfbBscz0Nboj8NHStWDif5Io94l08tdW9oHIu99NYE0DMWIfBeztHpmSfkgPKH8 +lNar1dMsuCRW2Q/b01TNPKCNp8ZxyIhzkOq2gC5l60i5/iALWeEJii8g71V3DMbU +5KoPEB+jFZ/z7qAi8TH9VqgaUycs/M96VXMIZbDhXywJpg7qHxG/RT16bXwFotre +Thcla2M3VxsZEnYPEVmQEyVQeG7XyvqFMC3DhGCflW35dumJlkuGn9e9Lg6oiidp +2RMnZuTsie+y3e3XJz2ZjFihGQNy2VzUrDz4ymi2fosVGMeHn3iK2nEqxf1mx021 +j3v40/8I5gtkS+zZuchclae0gRHaNN1tO0osedUdlV7D0dvi9xezsfelqSqJjChL +fl4R3HqC8k7cwUfK4RmKXhI5GX4ESr+1KWPIaqH5AxYB+ee2WYBQGhi6aXKpVcj9 +dvq+OAmDMPCJr0xnWMMZqR5dnxY1eEq2x28n2b1SyIw1+IctNX0nLwGAMgUCAwEA +AQKCAgAEsuEche24vrFMp52CTrUQiB4+iFIYwBRYRSROR1CxTecdU2Ts89LbT6oh +los2LLu3bpckdaMCfAn0IUkr6nkugYR7OAVIsnbdkz4G6GAv80To7IA1UxqRWblp +HWoWiiG8xo2nvHWJ7+g1BgICJFJ7Q7IRNFmC6JAe4Har5Ir40/piQlmktClXsvKM +/D+TDpkhuc/tSmW/iNRCw2kR2I+jBHyIMC//PZJZHjJCh2cz4z41pQjrIavpyrnr +4iQ0iBvA2vW/1HWUQPQnv5e6ftCMxBuQ0iCpwVznIiEdzG0y61vr+q3nAoMbsN5d +tL7eLiqQ/+FFHy6A8pJBwF9Z8GO+MsN0GbD4Ttd2WkXVM4AJwWsB6SWx7znrgWhy +JHy/5r20/0J0VniX63qjt8RRUG9VyHxr8Vx0/jkd+3z23cn/ecBf41sLFy30HsIN +Gg2KJf4Wf1kFaEgdT2xO2fahBWOeN7uKJokNaSkocE6NRdfoxhj/r/RLcJJqE4V9 +a4FOMmdZtCgxvNN2Cb3GS76ImQjfJpA8wrBOWxW+XFuQi5ohory9mdLjbnk9/w/v +6yT76DN+gcgfrgHW1w5ttwfnyQF9fQ2hRobbGqbYFOMaxE1Qds46Vl+GN9KlMhhO +S0zK7ZSKE9pqaLTo5Hb4po/0A4TXAL0v2iap+9bD3NKoRnDBoQKCAQEA5IDHxRGu +mgAuW29PidvrNcRDQBMmkm89BvPr1Om50l6Zk/DuwgE7/73eiCBA/yXuqkjUTJXT +iAuQE0yLjU6YFGdl7lNncfD+Zl9CztOkNpfO6z5vyvvvkLXU3pL0ytTW4RNaV0fQ +ccGF0gnzOp6DoWCSkNz1Pz3VLyn1m4rnOaFu2a2O2Ljs1Nrc+FGP1LFrsiQnpPP9 +ArXpjSqTs5tUMKNJ1y3Y1bkpfx9B+LWXLTP2eLNlIjiCEzbyEtAldSZFfz30Tjmx +3Yr4aqgdHGcMm66MeLCXGdnuoBLpll6UpDC6oZT9Nh8uFlQXrhiy+0Gsxw4UjAZd +ilY+jqHQqmqFSQKCAQEA0wIKnmKYIc76niu3fUAN3iuO3bZ5Q0k/OBonVMNnwBc4 +1YWG4p2ecEQrA2CJmoz0J6rEm+y+DHRw6LH1zBjl3riCDbomwIVGZ/puub7Ibcbc +t0P6DzUeP0jz2o+JaPWClZxFOlikhjkWwmAWl+iyx3hh/sRXtrmkKkhSxEk8CUAa +yM78AG3maI36LpGEYf3sP5EZV/EsyEAV0uKJpmuHGcgkytq/x893R37HfzDdMlN6 +ejk6rbCbCOaXO8AXrKwWpUuudlfDBzPgQ/kl8dKJwgv8u5NlshjknkhKi6Hoprsi +N/zhR7Rns/Z/N4g5zNtKTrQXh4reFF2CWREssMwS3QKCAQA6tvyeHtUGrVU8GXYO +rnvZ7Px60nDu37aGuta2dvhQng5IfXhcUYThSiCMSf1pko2pI92pcDZSluYGj3ys +aq2ZUJhYjQXfuVUlaQT5sFhZzthUik6fke0U+iQgrRJJrDcqzpZAJyvgjyGbvwLI +5UJdjTscDirWfUTyQY3i0eZoYJrjRD2YYqw4ZaSyCgMzXAOYWsH1GNzCfYvtwisB +07/mX47xw84b3OBU0etZxQ97hganLTGngW2rEktRmjqFx7fD4l+MWjbh/numrFwO +mEwdFNTzjizFb8JpT3LGOLdpGTxbmLUX2xs0kZckHSSge1eyLmQJNvmCOncIn3vG +zmhBAoIBAQDBZxyegZYZXuIdOcqr9ZsAaQJAu3C4OJnGbUphid09lstUAlhYu8mt +8v1N0h0t2EYtWXttw3eKaOvYjMzTLnr7QjiKJnZAfafDxCna/EAvRlelbpvzdmdr +8Az65hc3adgwExTs3rSmBguTS4lJ4VKEPBXt8r7Gz67lxnZ+TPXHMMecCQO3zQOk +D4YhSuWA/8Gbnf4Rug+m1/5o1ZT/QY2KFwWKHSgtFz6n/E8UiJAmAZfAEVZ0PuxL +Ize431+TuAPlq9GTzOsIXgcPpnyeArCbeGtE7lwG+oQJhA83nsZklB9QG+vM0lE/ +BQ8jsivwVYrtSmpKpQDav76qrnA8+D/NAoIBAQCm80sB4L+2gIb/Qg/rvTW7atc2 +q7GCZ/YHmHb3TeV8QiKEr7lXIAS9tFrCbWLUwBqXJIkOJUFmk2BQg/78OPJyorcE +7qTptaO0qnp9BjxvZimE3wwM7WVa8pQCAYt96unHlQoQoT9xeyti/ZKMzHaoMVuL +J0DfPa71yW7uTCWoyVCNQwqIourHFv6sKsiERE/OjhRVLyXG/5uLZjc0lYY/qaQ1 +ax/UxjyTOakil8MBnta/q1NpSv8SQmFXCWjrREepkJF0/CzC7/1AULBdy0h1132C +B5CWnSKpHPePuczojgXjmw+Xg6vAXwsA4CXVJF1AUBlg7q91PtZYpCAqMPwA +-----END RSA PRIVATE KEY----- diff --git a/spec/support/dev.js b/spec/support/dev.js new file mode 100644 index 0000000000..3415387c14 --- /dev/null +++ b/spec/support/dev.js @@ -0,0 +1,92 @@ +const Config = require('../../lib/Config'); +const Parse = require('parse/node'); + +const className = 'AnObject'; +const defaultRoleName = 'tester'; + +module.exports = { + /* AnObject */ + className, + + /** + * Creates and returns new user. + * + * This method helps to avoid 'User already exists' when re-running/debugging a single test. + * @param {string} username - username base, will be postfixed with current time in millis; + * @param {string} [password='password'] - optional, defaults to "password" if not set; + */ + createUser: async (username, password = 'password') => { + const user = new Parse.User({ + username: username + Date.now(), + password, + }); + await user.save(); + return user; + }, + + /** + * Logs the user in. + * + * If password not provided, default 'password' is used. + * @param {string} username - username base, will be postfixed with current time in millis; + * @param {string} [password='password'] - optional, defaults to "password" if not set; + */ + logIn: async (userObject, password) => { + return await Parse.User.logIn(userObject.getUsername(), password || 'password'); + }, + + /** + * Sets up Class-Level Permissions for 'AnObject' class. + * @param clp {ClassLevelPermissions} + */ + updateCLP: async (clp, targetClass = className) => { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + + await schemaController.updateClass(targetClass, {}, clp); + }, + + /** + * Creates and returns role. Adds user(s) if provided. + * + * This method helps to avoid errors when re-running/debugging a single test. + * + * @param {Parse.User|Parse.User[]} [users] - user or array of users to be related with this role; + * @param {string?} [roleName] - uses this name for role if provided. Generates from datetime if not set; + * @param {string?} [exactName] - sets exact name (no generated part added); + * @param {Parse.Role[]} [roles] - uses this name for role if provided. Generates from datetime if not set; + * @param {boolean} [read] - value for role's acl public read. Defaults to true; + * @param {boolean} [write] - value for role's acl public write. Defaults to true; + */ + createRole: async ({ + users = null, + exactName = defaultRoleName + Date.now(), + roleName = null, + roles = null, + read = true, + write = true, + }) => { + const acl = new Parse.ACL(); + acl.setPublicReadAccess(read); + acl.setPublicWriteAccess(write); + + const role = new Parse.Object('_Role'); + role.setACL(acl); + + // generate name based on roleName or use exactName (if botth not provided name is generated) + const name = roleName ? roleName + Date.now() : exactName; + role.set('name', name); + + if (roles) { + role.relation('roles').add(roles); + } + + if (users) { + role.relation('users').add(users); + } + + await role.save({ useMasterKey: true }); + + return role; + }, +}; diff --git a/spec/support/jasmine.json b/spec/support/jasmine.json index e0347ebfe7..1fbab72636 100644 --- a/spec/support/jasmine.json +++ b/spec/support/jasmine.json @@ -1,10 +1,6 @@ { "spec_dir": "spec", - "spec_files": [ - "*spec.js" - ], - "helpers": [ - "../node_modules/babel-core/register.js", - "helper.js" - ] + "spec_files": ["**/*.[sS]pec.js"], + "helpers": ["helper.js"], + "random": true } diff --git a/spec/support/lorem.txt b/spec/support/lorem.txt new file mode 100644 index 0000000000..2e7cd518cc --- /dev/null +++ b/spec/support/lorem.txt @@ -0,0 +1,5 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus lobortis semper diam, ac euismod diam pharetra sed. Etiam eget efficitur neque. Proin nec diam mi. Sed ut purus dolor. Nulla nulla nibh, ornare vitae ornare et, scelerisque rutrum eros. Mauris venenatis tincidunt turpis a mollis. Donec gravida eget enim in luctus. + +Sed porttitor commodo orci, ut pretium eros convallis eget. Curabitur pretium velit in odio dictum luctus. Vivamus ac tristique arcu, a semper tellus. Morbi euismod purus dapibus vestibulum sagittis. Nunc dapibus vehicula leo at scelerisque. Donec porta mauris quis nulla imperdiet consectetur. Curabitur sagittis eleifend arcu eget elementum. Aenean interdum tincidunt ornare. Pellentesque sit amet interdum tortor. Pellentesque blandit nisl eget euismod consequat. Etiam feugiat felis sit amet porta pulvinar. Lorem ipsum dolor sit amet, consectetur adipiscing elit. + +Nulla faucibus sem ipsum, at rhoncus diam pulvinar at. Vivamus consectetur, diam at aliquet vestibulum, sem purus elementum nulla, eget tincidunt nullam. diff --git a/spec/myoauth.js b/spec/support/myoauth.js similarity index 75% rename from spec/myoauth.js rename to spec/support/myoauth.js index d28f9e8130..2367ad62ce 100644 --- a/spec/myoauth.js +++ b/spec/support/myoauth.js @@ -2,7 +2,7 @@ // Returns a promise that fulfills iff this user id is valid. function validateAuthData(authData) { - if (authData.id == "12345" && authData.access_token == "12345") { + if (authData.id == '12345' && authData.access_token == '12345') { return Promise.resolve(); } return Promise.reject(); @@ -13,5 +13,5 @@ function validateAppId() { module.exports = { validateAppId: validateAppId, - validateAuthData: validateAuthData + validateAuthData: validateAuthData, }; diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js new file mode 100644 index 0000000000..c3f9296af0 --- /dev/null +++ b/spec/vulnerabilities.spec.js @@ -0,0 +1,5888 @@ +const http = require('http'); +const express = require('express'); +const fetch = (...args) => + import('node-fetch').then(({ default: fetch }) => { + const [url, options = {}] = args; + return fetch(url, { agent: new http.Agent({ keepAlive: false }), ...options }); + }); +const ws = require('ws'); +const request = require('../lib/request'); +const Config = require('../lib/Config'); +const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer'); + +describe('Vulnerabilities', () => { + describe('(GHSA-8xq9-g7ch-35hg) Custom object ID allows to acquire role privilege', () => { + beforeAll(async () => { + await reconfigureServer({ allowCustomObjectId: true }); + Parse.allowCustomObjectId = true; + }); + + afterAll(async () => { + await reconfigureServer({ allowCustomObjectId: false }); + Parse.allowCustomObjectId = false; + }); + + it('denies user creation with poisoned object ID', async () => { + const logger = require('../lib/logger').default; + const loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + loggerErrorSpy.calls.reset(); + await expectAsync( + new Parse.User({ id: 'role:a', username: 'a', password: '123' }).save() + ).toBeRejectedWith(new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied')); + expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining("Invalid object ID.")); + }); + + describe('existing sessions for users with poisoned object ID', () => { + /** @type {Parse.User} */ + let poisonedUser; + /** @type {Parse.User} */ + let innocentUser; + + beforeAll(async () => { + const parseServer = await global.reconfigureServer(); + const databaseController = parseServer.config.databaseController; + [poisonedUser, innocentUser] = await Promise.all( + ['role:abc', 'abc'].map(async id => { + // Create the users directly on the db to bypass the user creation check + await databaseController.create('_User', { objectId: id }); + // Use the master key to create a session for them to bypass the session check + return Parse.User.loginAs(id); + }) + ); + }); + + it('refuses session token of user with poisoned object ID', async () => { + await expectAsync( + new Parse.Query(Parse.User).find({ sessionToken: poisonedUser.getSessionToken() }) + ).toBeRejectedWith(new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Invalid object ID.')); + await new Parse.Query(Parse.User).find({ sessionToken: innocentUser.getSessionToken() }); + }); + + }); + + describe('legacy session upgrade for user with poisoned object ID', () => { + // Legacy session tokens (_session_token on _User) are a MongoDB-only legacy feature + it_only_db('mongo')('refuses legacy session upgrade for user with poisoned object ID', async () => { + const parseServer = await global.reconfigureServer(); + const databaseController = parseServer.config.databaseController; + const poisonedId = 'role:legacy'; + const legacyToken = 'legacy-poisoned-token'; + // Create user with poisoned ID and legacy session token directly in DB + await databaseController.create('_User', { + objectId: poisonedId, + _session_token: legacyToken, + }); + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/upgradeToRevocableSession', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': legacyToken, + }, + body: JSON.stringify({}), + }) + ).toBeRejected(); + }); + }); + }); + + describe('Object prototype pollution', () => { + it('denies object prototype to be polluted with keyword "constructor"', async () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const response = await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/PP', + body: JSON.stringify({ + obj: { + constructor: { + prototype: { + dummy: 0, + }, + }, + }, + }), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toBe('Prohibited keyword in request data: {"key":"constructor"}.'); + expect(Object.prototype.dummy).toBeUndefined(); + }); + + it('denies object prototype to be polluted with keypath string "constructor"', async () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const objResponse = await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/PP', + body: JSON.stringify({ + obj: {}, + }), + }).catch(e => e); + const pollResponse = await request({ + headers: headers, + method: 'PUT', + url: `http://localhost:8378/1/classes/PP/${objResponse.data.objectId}`, + body: JSON.stringify({ + 'obj.constructor.prototype.dummy': { + __op: 'Increment', + amount: 1, + }, + }), + }).catch(e => e); + expect(Object.prototype.dummy).toBeUndefined(); + expect(pollResponse.status).toBe(400); + const text = JSON.parse(pollResponse.text); + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toBe('Prohibited keyword in request data: {"key":"constructor"}.'); + expect(Object.prototype.dummy).toBeUndefined(); + }); + + it('denies object prototype to be polluted with keyword "__proto__"', async () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const response = await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/PP', + body: JSON.stringify({ 'obj.__proto__.dummy': 0 }), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toBe('Prohibited keyword in request data: {"key":"__proto__"}.'); + expect(Object.prototype.dummy).toBeUndefined(); + }); + }); + + describe('(GHSA-5j86-7r7m-p8h6) Cloud function name prototype chain bypass', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('rejects "constructor" as cloud function name', async () => { + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/constructor', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(text.error).toContain('Invalid function'); + }); + + it('rejects "toString" as cloud function name', async () => { + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/toString', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(text.error).toContain('Invalid function'); + }); + + it('rejects "valueOf" as cloud function name', async () => { + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/valueOf', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(text.error).toContain('Invalid function'); + }); + + it('rejects "hasOwnProperty" as cloud function name', async () => { + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/hasOwnProperty', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(text.error).toContain('Invalid function'); + }); + + it('rejects "__proto__.toString" as cloud function name', async () => { + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/__proto__.toString', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(text.error).toContain('Invalid function'); + }); + + it('still executes a legitimately defined cloud function', async () => { + Parse.Cloud.define('legitimateFunction', () => 'hello'); + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/legitimateFunction', + body: JSON.stringify({}), + }); + expect(response.status).toBe(200); + expect(JSON.parse(response.text).result).toBe('hello'); + }); + }); + + describe('(GHSA-4263-jgmp-7pf4) Cloud function prototype chain dispatch via registered function', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + beforeEach(() => { + Parse.Cloud.define('legitimateFunction', () => 'ok'); + }); + + it('rejects prototype chain traversal from a registered function name', async () => { + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/legitimateFunction.__proto__.__proto__.constructor', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(text.error).toContain('Invalid function'); + }); + + it('rejects prototype chain traversal via single __proto__ from a registered function', async () => { + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/legitimateFunction.__proto__.constructor', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(text.error).toContain('Invalid function'); + }); + + it('does not crash the server when prototype chain traversal is attempted', async () => { + const maliciousNames = [ + 'legitimateFunction.__proto__.__proto__.constructor', + 'legitimateFunction.__proto__.constructor', + 'legitimateFunction.constructor', + 'legitimateFunction.__proto__', + ]; + for (const name of maliciousNames) { + const response = await request({ + headers, + method: 'POST', + url: `http://localhost:8378/1/functions/${encodeURIComponent(name)}`, + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(400); + } + // Verify server is still responsive after all attempts + const healthResponse = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/legitimateFunction', + body: JSON.stringify({}), + }); + expect(healthResponse.status).toBe(200); + expect(JSON.parse(healthResponse.text).result).toBe('ok'); + }); + }); + + describe('(GHSA-vpj2-qq7w-5qq6) Cloud function validator bypass via prototype.constructor traversal', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('rejects prototype.constructor traversal on function keyword handler', async () => { + Parse.Cloud.define('protectedFn', function () { return 'secret'; }, { requireUser: true }); + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/protectedFn.prototype.constructor', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(text.error).toContain('Invalid function'); + }); + + it('rejects prototype traversal without constructor suffix', async () => { + Parse.Cloud.define('protectedFn2', function () { return 'secret'; }, { requireUser: true }); + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/protectedFn2.prototype', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(text.error).toContain('Invalid function'); + }); + + it('enforces validator when calling function normally', async () => { + Parse.Cloud.define('protectedFn3', function () { return 'secret'; }, { requireUser: true }); + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/protectedFn3', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.VALIDATION_ERROR); + }); + + it('enforces requireMaster validator against prototype.constructor bypass', async () => { + Parse.Cloud.define('masterOnlyFn', function () { return 'admin data'; }, { requireMaster: true }); + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/masterOnlyFn.prototype.constructor', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(text.error).toContain('Invalid function'); + }); + }); + + describe('(GHSA-3v4q-4q9g-x83q) Prototype pollution via application ID in trigger store', () => { + const prototypeProperties = ['constructor', 'toString', 'valueOf', 'hasOwnProperty', '__proto__']; + + for (const prop of prototypeProperties) { + it(`rejects "${prop}" as application ID in cloud function call`, async () => { + const response = await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': prop, + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'POST', + url: 'http://localhost:8378/1/functions/testFunction', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(403); + }); + + it(`rejects "${prop}" as application ID with arbitrary API key in cloud function call`, async () => { + const response = await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': prop, + 'X-Parse-REST-API-Key': 'ANY_KEY', + }, + method: 'POST', + url: 'http://localhost:8378/1/functions/testFunction', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(403); + }); + + it(`rejects "${prop}" as application ID in class query`, async () => { + const response = await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': prop, + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'GET', + url: 'http://localhost:8378/1/classes/TestClass', + }).catch(e => e); + expect(response.status).toBe(403); + }); + } + }); + + describe('Request denylist', () => { + describe('(GHSA-q342-9w2p-57fp) Denylist bypass via sibling nested objects', () => { + it('denies _bsontype:Code after a sibling nested object', async () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/Bypass', + body: JSON.stringify({ + obj: { + metadata: {}, + _bsontype: 'Code', + code: 'malicious', + }, + }), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toBe( + 'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.' + ); + }); + + it('denies _bsontype:Code after a sibling nested array', async () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/Bypass', + body: JSON.stringify({ + obj: { + tags: ['safe'], + _bsontype: 'Code', + code: 'malicious', + }, + }), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toBe( + 'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.' + ); + }); + + it('denies __proto__ after a sibling nested object', async () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/PP', + body: JSON.stringify( + JSON.parse('{"profile": {"name": "alice"}, "__proto__": {"isAdmin": true}}') + ), + }).catch(e => e); + expect(response.status).toBe(400); + const text = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toContain('__proto__'); + }); + + it('denies constructor after a sibling nested object', async () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/Bypass', + body: JSON.stringify({ + obj: { + data: {}, + constructor: { prototype: { polluted: true } }, + }, + }), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toBe( + 'Prohibited keyword in request data: {"key":"constructor"}.' + ); + }); + + it('denies _bsontype:Code nested inside a second sibling object', async () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/Bypass', + body: JSON.stringify({ + field1: { safe: true }, + field2: { _bsontype: 'Code', code: 'malicious' }, + }), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toBe( + 'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.' + ); + }); + + it('handles circular references without infinite loop', () => { + const Utils = require('../lib/Utils'); + const obj = { name: 'test', nested: { value: 1 } }; + obj.nested.self = obj; + expect(Utils.objectContainsKeyValue(obj, 'nonexistent', undefined)).toBe(false); + }); + + it('denies _bsontype:Code in file metadata after a sibling nested object', async () => { + const str = 'Hello World!'; + const data = []; + for (let i = 0; i < str.length; i++) { + data.push(str.charCodeAt(i)); + } + const file = new Parse.File('hello.txt', data, 'text/plain'); + file.addMetadata('nested', { safe: true }); + file.addMetadata('_bsontype', 'Code'); + file.addMetadata('code', 'malicious'); + await expectAsync(file.save()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + 'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.' + ) + ); + }); + }); + + it('denies BSON type code data in write request by default', async () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const params = { + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/RCE', + body: JSON.stringify({ + obj: { + _bsontype: 'Code', + code: 'delete Object.prototype.evalFunctions', + }, + }), + }; + const response = await request(params).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toBe( + 'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.' + ); + }); + + it('denies expanding existing object with polluted keys', async () => { + const obj = await new Parse.Object('RCE', { a: { foo: [] } }).save(); + await reconfigureServer({ + requestKeywordDenylist: ['foo'], + }); + obj.addUnique('a.foo', 'abc'); + await expectAsync(obj.save()).toBeRejectedWith( + new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Prohibited keyword in request data: "foo".`) + ); + }); + + it('denies creating a cloud trigger with polluted data', async () => { + Parse.Cloud.beforeSave('TestObject', ({ object }) => { + object.set('obj', { + constructor: { + prototype: { + dummy: 0, + }, + }, + }); + }); + // The new Parse SDK handles prototype pollution prevention in .set() + // so no error is thrown, but the object prototype should not be polluted + await new Parse.Object('TestObject').save(); + expect(Object.prototype.dummy).toBeUndefined(); + }); + + it('denies creating global config with polluted data', async () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }; + const params = { + method: 'PUT', + url: 'http://localhost:8378/1/config', + json: true, + body: { + params: { + welcomeMesssage: 'Welcome to Parse', + foo: { _bsontype: 'Code', code: 'shell' }, + }, + }, + headers, + }; + const response = await request(params).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toBe( + 'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.' + ); + }); + + it('denies direct database write wih prohibited keys', async () => { + const Config = require('../lib/Config'); + const config = Config.get(Parse.applicationId); + const user = { + objectId: '1234567890', + username: 'hello', + password: 'pass', + _session_token: 'abc', + foo: { _bsontype: 'Code', code: 'shell' }, + }; + await expectAsync(config.database.create('_User', user)).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + 'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.' + ) + ); + }); + + it('denies direct database update wih prohibited keys', async () => { + const Config = require('../lib/Config'); + const config = Config.get(Parse.applicationId); + const user = { + objectId: '1234567890', + username: 'hello', + password: 'pass', + _session_token: 'abc', + foo: { _bsontype: 'Code', code: 'shell' }, + }; + await expectAsync( + config.database.update('_User', { _id: user.objectId }, user) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + 'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.' + ) + ); + }); + + it_id('e8b5f1e1-8326-4c70-b5f4-1e8678dfff8d')(it)('denies creating a hook with polluted data', async () => { + const express = require('express'); + const port = 34567; + const hookServerURL = 'http://localhost:' + port; + const app = express(); + app.use(express.json({ type: '*/*' })); + const server = await new Promise(resolve => { + const res = app.listen(port, undefined, () => resolve(res)); + }); + app.post('/BeforeSave', function (req, res) { + const object = Parse.Object.fromJSON(req.body.object); + object.set('hello', 'world'); + object.set('obj', { + constructor: { + prototype: { + dummy: 0, + }, + }, + }); + res.json({ success: object }); + }); + await Parse.Hooks.createTrigger('TestObject', 'beforeSave', hookServerURL + '/BeforeSave'); + // The new Parse SDK handles prototype pollution prevention in .set() + // so no error is thrown, but the object prototype should not be polluted + await new Parse.Object('TestObject').save(); + expect(Object.prototype.dummy).toBeUndefined(); + await new Promise(resolve => server.close(resolve)); + }); + + it('denies write request with custom denylist of key/value', async () => { + await reconfigureServer({ + requestKeywordDenylist: [{ key: 'a[K]ey', value: 'aValue[123]*' }], + }); + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const params = { + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/RCE', + body: JSON.stringify({ + obj: { + aKey: 'aValue321', + code: 'delete Object.prototype.evalFunctions', + }, + }), + }; + const response = await request(params).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toBe( + 'Prohibited keyword in request data: {"key":"a[K]ey","value":"aValue[123]*"}.' + ); + }); + + it('denies write request with custom denylist of nested key/value', async () => { + await reconfigureServer({ + requestKeywordDenylist: [{ key: 'a[K]ey', value: 'aValue[123]*' }], + }); + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const params = { + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/RCE', + body: JSON.stringify({ + obj: { + nested: { + aKey: 'aValue321', + code: 'delete Object.prototype.evalFunctions', + }, + }, + }), + }; + const response = await request(params).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toBe( + 'Prohibited keyword in request data: {"key":"a[K]ey","value":"aValue[123]*"}.' + ); + }); + + it('denies write request with custom denylist of key/value in array', async () => { + await reconfigureServer({ + requestKeywordDenylist: [{ key: 'a[K]ey', value: 'aValue[123]*' }], + }); + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const params = { + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/RCE', + body: JSON.stringify({ + obj: [ + { + aKey: 'aValue321', + code: 'delete Object.prototype.evalFunctions', + }, + ], + }), + }; + const response = await request(params).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toBe( + 'Prohibited keyword in request data: {"key":"a[K]ey","value":"aValue[123]*"}.' + ); + }); + + it('denies write request with custom denylist of key', async () => { + await reconfigureServer({ + requestKeywordDenylist: [{ key: 'a[K]ey' }], + }); + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const params = { + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/RCE', + body: JSON.stringify({ + obj: { + aKey: 'aValue321', + code: 'delete Object.prototype.evalFunctions', + }, + }), + }; + const response = await request(params).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toBe('Prohibited keyword in request data: {"key":"a[K]ey"}.'); + }); + + it('denies write request with custom denylist of value', async () => { + await reconfigureServer({ + requestKeywordDenylist: [{ value: 'aValue[123]*' }], + }); + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const params = { + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/RCE', + body: JSON.stringify({ + obj: { + aKey: 'aValue321', + code: 'delete Object.prototype.evalFunctions', + }, + }), + }; + const response = await request(params).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toBe('Prohibited keyword in request data: {"value":"aValue[123]*"}.'); + }); + + it('denies BSON type code data in file metadata', async () => { + const str = 'Hello World!'; + const data = []; + for (let i = 0; i < str.length; i++) { + data.push(str.charCodeAt(i)); + } + const file = new Parse.File('hello.txt', data, 'text/plain'); + file.addMetadata('obj', { + _bsontype: 'Code', + code: 'delete Object.prototype.evalFunctions', + }); + await expectAsync(file.save()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + `Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.` + ) + ); + }); + + it('denies BSON type code data in file tags', async () => { + const str = 'Hello World!'; + const data = []; + for (let i = 0; i < str.length; i++) { + data.push(str.charCodeAt(i)); + } + const file = new Parse.File('hello.txt', data, 'text/plain'); + file.addTag('obj', { + _bsontype: 'Code', + code: 'delete Object.prototype.evalFunctions', + }); + await expectAsync(file.save()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + `Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.` + ) + ); + }); + }); + + describe('Ignore non-matches', () => { + it('ignores write request that contains only fraction of denied keyword', async () => { + await reconfigureServer({ + requestKeywordDenylist: [{ key: 'abc' }], + }); + // Initially saving an object executes the keyword detection in RestWrite.js + const obj = new TestObject({ a: { b: { c: 0 } } }); + await expectAsync(obj.save()).toBeResolved(); + // Modifying a nested key executes the keyword detection in DatabaseController.js + obj.increment('a.b.c'); + await expectAsync(obj.save()).toBeResolved(); + }); + }); + + describe('(GHSA-mmg8-87c5-jrc2) LiveQuery protected-field guard bypass via array-like $or/$and/$nor', () => { + const { sleep } = require('../lib/TestUtils'); + let obj; + + beforeEach(async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['SecretClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.addClassIfNotExists( + 'SecretClass', + { secretObj: { type: 'Object' }, publicField: { type: 'String' } }, + ); + await schemaController.updateClass( + 'SecretClass', + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretObj'] }, + } + ); + + obj = new Parse.Object('SecretClass'); + obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 }); + obj.set('publicField', 'visible'); + await obj.save(null, { useMasterKey: true }); + }); + + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + }); + + it('should reject subscription with array-like $or containing protected field', async () => { + const query = new Parse.Query('SecretClass'); + query._where = { + $or: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, length: 1 }, + }; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY }) + ); + }); + + it('should reject subscription with array-like $and containing protected field', async () => { + const query = new Parse.Query('SecretClass'); + query._where = { + $and: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, '1': { publicField: 'visible' }, length: 2 }, + }; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY }) + ); + }); + + it('should reject subscription with array-like $nor containing protected field', async () => { + const query = new Parse.Query('SecretClass'); + query._where = { + $nor: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, length: 1 }, + }; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY }) + ); + }); + + it('should reject subscription with array-like $or even on non-protected fields', async () => { + const query = new Parse.Query('SecretClass'); + query._where = { + $or: { '0': { publicField: 'visible' }, length: 1 }, + }; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY }) + ); + }); + + it('should not create oracle via array-like $or bypass on protected fields', async () => { + const query = new Parse.Query('SecretClass'); + query._where = { + $or: { '0': { 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, length: 1 }, + }; + + // Subscription must be rejected; no event oracle should be possible + let subscriptionError; + let subscription; + try { + subscription = await query.subscribe(); + } catch (e) { + subscriptionError = e; + } + + if (!subscriptionError) { + const updateSpy = jasmine.createSpy('update'); + subscription.on('create', updateSpy); + subscription.on('update', updateSpy); + + // Trigger an object change + obj.set('publicField', 'changed'); + await obj.save(null, { useMasterKey: true }); + await sleep(500); + + // If subscription somehow accepted, verify no events fired (evaluator defense) + expect(updateSpy).not.toHaveBeenCalled(); + fail('Expected subscription to be rejected'); + } + expect(subscriptionError).toEqual( + jasmine.objectContaining({ code: Parse.Error.INVALID_QUERY }) + ); + }); + }); + + describe('Malformed $regex information disclosure', () => { + it('should not leak database error internals for invalid regex pattern in class query', async () => { + const logger = require('../lib/logger').default; + const loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + const obj = new Parse.Object('TestObject'); + await obj.save({ field: 'value' }); + + try { + await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestObject`, + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + where: JSON.stringify({ field: { $regex: '[abc' } }), + }, + }); + fail('Request should have failed'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR); + expect(e.data.error).toBe('An internal server error occurred'); + expect(typeof e.data.error).toBe('string'); + expect(JSON.stringify(e.data)).not.toContain('errmsg'); + expect(JSON.stringify(e.data)).not.toContain('codeName'); + expect(JSON.stringify(e.data)).not.toContain('errorResponse'); + expect(loggerErrorSpy).toHaveBeenCalledWith( + 'Sanitized error:', + jasmine.stringMatching(/[Rr]egular expression/i) + ); + } + }); + + it('should not leak database error internals for invalid regex pattern in role query', async () => { + const logger = require('../lib/logger').default; + const loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + const role = new Parse.Role('testrole', new Parse.ACL()); + await role.save(null, { useMasterKey: true }); + try { + await request({ + method: 'GET', + url: `http://localhost:8378/1/roles`, + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + where: JSON.stringify({ name: { $regex: '[abc' } }), + }, + }); + fail('Request should have failed'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR); + expect(e.data.error).toBe('An internal server error occurred'); + expect(typeof e.data.error).toBe('string'); + expect(JSON.stringify(e.data)).not.toContain('errmsg'); + expect(JSON.stringify(e.data)).not.toContain('codeName'); + expect(JSON.stringify(e.data)).not.toContain('errorResponse'); + expect(loggerErrorSpy).toHaveBeenCalledWith( + 'Sanitized error:', + jasmine.stringMatching(/[Rr]egular expression/i) + ); + } + }); + }); + + describe('Postgres regex sanitizater', () => { + it('sanitizes the regex correctly to prevent Injection', async () => { + const user = new Parse.User(); + user.set('username', 'username'); + user.set('password', 'password'); + user.set('email', 'email@example.com'); + await user.signUp(); + + const response = await request({ + method: 'GET', + url: + "http://localhost:8378/1/classes/_User?where[username][$regex]=A'B'%3BSELECT+PG_SLEEP(3)%3B--", + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }); + + expect(response.status).toBe(200); + expect(response.data.results).toEqual(jasmine.any(Array)); + expect(response.data.results.length).toBe(0); + }); + }); + + describe('(GHSA-mf3j-86qx-cq5j) ReDoS via $regex in LiveQuery subscription', () => { + it('does not block event loop with catastrophic backtracking regex in LiveQuery', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['TestObject'] }, + startLiveQueryServer: true, + }); + const client = new Parse.LiveQueryClient({ + applicationId: 'test', + serverURL: 'ws://localhost:1337', + javascriptKey: 'test', + }); + client.open(); + const query = new Parse.Query('TestObject'); + // Set a catastrophic backtracking regex pattern directly + query._addCondition('field', '$regex', '(a+)+b'); + const subscription = await client.subscribe(query); + // Create an object that would trigger regex evaluation + const obj = new Parse.Object('TestObject'); + // With 30 'a's followed by 'c', an unprotected regex would hang for seconds + obj.set('field', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaac'); + // Set a timeout to detect if the event loop is blocked + const timeout = 5000; + const start = Date.now(); + const savePromise = obj.save(); + const eventPromise = new Promise(resolve => { + subscription.on('create', () => resolve('matched')); + setTimeout(() => resolve('timeout'), timeout); + }); + await savePromise; + const result = await eventPromise; + const elapsed = Date.now() - start; + // The regex should be rejected (not match), and the operation should complete quickly + expect(result).toBe('timeout'); + expect(elapsed).toBeLessThan(timeout + 1000); + client.close(); + }); + }); + + describe('(GHSA-qpr4-jrj4-6f27) SQL Injection via sort dot-notation field name', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it_only_db('postgres')('does not execute injected SQL via sort order dot-notation', async () => { + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + obj.set('name', 'original'); + await obj.save(); + + // This payload would execute a stacked query if single quotes are not escaped + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = 'hacked' WHERE true--", + }, + }).catch(() => {}); + + // Verify the data was not modified by injected SQL + const verify = await new Parse.Query('InjectionTest').get(obj.id); + expect(verify.get('name')).toBe('original'); + }); + + it_only_db('postgres')('does not execute injected SQL via sort order with pg_sleep', async () => { + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + await obj.save(); + + const start = Date.now(); + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x' ASC; SELECT pg_sleep(3)--", + }, + }).catch(() => {}); + const elapsed = Date.now() - start; + + // If injection succeeded, query would take >= 3 seconds + expect(elapsed).toBeLessThan(3000); + }); + + it_only_db('postgres')('does not execute injection via dollar-sign quoting bypass', async () => { + // PostgreSQL supports $$string$$ as alternative to 'string' + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + obj.set('name', 'original'); + await obj.save(); + + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = $$hacked$$ WHERE true--", + }, + }).catch(() => {}); + + const verify = await new Parse.Query('InjectionTest').get(obj.id); + expect(verify.get('name')).toBe('original'); + }); + + it_only_db('postgres')('does not execute injection via tagged dollar quoting bypass', async () => { + // PostgreSQL supports $tag$string$tag$ as alternative to 'string' + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + obj.set('name', 'original'); + await obj.save(); + + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = $t$hacked$t$ WHERE true--", + }, + }).catch(() => {}); + + const verify = await new Parse.Query('InjectionTest').get(obj.id); + expect(verify.get('name')).toBe('original'); + }); + + it_only_db('postgres')('does not execute injection via CHR() concatenation bypass', async () => { + // CHR(104)||CHR(97)||... builds 'hacked' without quotes + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + obj.set('name', 'original'); + await obj.save(); + + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = CHR(104)||CHR(97)||CHR(99)||CHR(107) WHERE true--", + }, + }).catch(() => {}); + + const verify = await new Parse.Query('InjectionTest').get(obj.id); + expect(verify.get('name')).toBe('original'); + }); + + it_only_db('postgres')('does not execute injection via backslash escape bypass', async () => { + // Backslash before quote could interact with '' escaping in some configurations + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + obj.set('name', 'original'); + await obj.save(); + + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x\\' ASC; UPDATE \"InjectionTest\" SET name = 'hacked' WHERE true--", + }, + }).catch(() => {}); + + const verify = await new Parse.Query('InjectionTest').get(obj.id); + expect(verify.get('name')).toBe('original'); + }); + + it('allows valid dot-notation sort on object field', async () => { + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + await obj.save(); + + const response = await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: 'data.key', + }, + }); + expect(response.status).toBe(200); + }); + + it('allows valid dot-notation with special characters in sub-field', async () => { + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { 'my-field': 'value' }); + await obj.save(); + + const response = await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: 'data.my-field', + }, + }); + expect(response.status).toBe(200); + }); + }); + + describe('(GHSA-v5hf-f4c3-m5rv) Stored XSS via .svgz, .xht, .xml, .xsl, .xslt file upload', () => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + beforeEach(async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + }); + + it('blocks .svgz file upload by default', async () => { + const svgContent = Buffer.from( + '' + ).toString('base64'); + for (const extension of ['svgz', 'SVGZ', 'Svgz']) { + await expectAsync( + request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'image/svg+xml', + base64: svgContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File upload of extension ${extension} is disabled.` + ) + ); + } + }); + + it('blocks .xht file upload by default', async () => { + const xhtContent = Buffer.from( + '' + ).toString('base64'); + for (const extension of ['xht', 'XHT', 'Xht']) { + await expectAsync( + request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/xhtml+xml', + base64: xhtContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File upload of extension ${extension} is disabled.` + ) + ); + } + }); + + it('blocks .xml file upload by default', async () => { + const xmlContent = Buffer.from( + 'test' + ).toString('base64'); + for (const extension of ['xml', 'XML', 'Xml']) { + await expectAsync( + request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/xml', + base64: xmlContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File upload of extension ${extension} is disabled.` + ) + ); + } + }); + + it('blocks .xsl file upload by default', async () => { + const xslContent = Buffer.from( + '' + ).toString('base64'); + for (const extension of ['xsl', 'XSL', 'Xsl']) { + await expectAsync( + request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/xml', + base64: xslContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File upload of extension ${extension} is disabled.` + ) + ); + } + }); + + it('blocks .xslt file upload by default', async () => { + const xsltContent = Buffer.from( + '' + ).toString('base64'); + for (const extension of ['xslt', 'XSLT', 'Xslt']) { + await expectAsync( + request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/xslt+xml', + base64: xsltContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File upload of extension ${extension} is disabled.` + ) + ); + } + }); + + // Headers are intentionally omitted below so that the middleware parses _ContentType + // from the JSON body and sets it as the content-type header. When X-Parse-Application-Id + // is sent as a header, the middleware skips body parsing and _ContentType is ignored. + it('blocks extensionless upload with application/xhtml+xml content type', async () => { + const xhtContent = Buffer.from( + '' + ).toString('base64'); + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/files/payload', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/xhtml+xml', + base64: xhtContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + 'File upload of extension xhtml+xml is disabled.' + ) + ); + }); + + it('blocks extensionless upload with application/xslt+xml content type', async () => { + const xsltContent = Buffer.from( + '' + ).toString('base64'); + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/files/payload', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/xslt+xml', + base64: xsltContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + 'File upload of extension xslt+xml is disabled.' + ) + ); + }); + + it('still allows common file types', async () => { + for (const type of ['txt', 'png', 'jpg', 'gif', 'pdf', 'doc']) { + const file = new Parse.File(`file.${type}`, { base64: 'ParseA==' }); + await file.save(); + } + }); + }); + + describe('(GHSA-42ph-pf9q-cr72) Stored XSS filter bypass via parameterized Content-Type and additional XML extensions', () => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + beforeEach(async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + }); + + for (const { ext, contentType } of [ + { ext: 'xsd', contentType: 'application/xml' }, + { ext: 'rng', contentType: 'application/xml' }, + { ext: 'rdf', contentType: 'application/rdf+xml' }, + { ext: 'owl', contentType: 'application/rdf+xml' }, + { ext: 'mathml', contentType: 'application/mathml+xml' }, + ]) { + it(`blocks .${ext} file upload by default`, async () => { + const content = Buffer.from( + '' + ).toString('base64'); + for (const extension of [ext, ext.toUpperCase(), ext[0].toUpperCase() + ext.slice(1)]) { + await expectAsync( + request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: contentType, + base64: content, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File upload of extension ${extension} is disabled.` + ) + ); + } + }); + } + + it('blocks extensionless upload with parameterized Content-Type that bypasses regex', async () => { + const content = Buffer.from( + '' + ).toString('base64'); + // MIME parameters like ;charset=utf-8 should not bypass the extension filter + const dangerousContentTypes = [ + 'application/xhtml+xml;charset=utf-8', + 'application/xhtml+xml; charset=utf-8', + 'application/xhtml+xml\t;charset=utf-8', + 'image/svg+xml;charset=utf-8', + 'application/xml;charset=utf-8', + 'text/html;charset=utf-8', + 'application/xslt+xml;charset=utf-8', + 'application/rdf+xml;charset=utf-8', + 'application/mathml+xml;charset=utf-8', + ]; + for (const contentType of dangerousContentTypes) { + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/files/payload', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: contentType, + base64: content, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith(jasmine.objectContaining({ + message: jasmine.stringMatching(/File upload of extension .+ is disabled/), + })); + } + }); + }); + + describe('(GHSA-3jmq-rrxf-gqrg) Stored XSS via file serving', () => { + it('sets X-Content-Type-Options: nosniff on file GET response', async () => { + const file = new Parse.File('hello.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }); + expect(response.headers['x-content-type-options']).toBe('nosniff'); + }); + + it('sets X-Content-Type-Options: nosniff on streaming file GET response', async () => { + const file = new Parse.File('hello.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Range': 'bytes=0-2', + }, + }); + expect(response.headers['x-content-type-options']).toBe('nosniff'); + }); + }); + + describe('(GHSA-vr5f-2r24-w5hc) Stored XSS via Content-Type and file extension mismatch', () => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('overrides mismatched Content-Type with extension-derived MIME type on buffered upload', async () => { + const adapter = Config.get('test').filesController.adapter; + const spy = spyOn(adapter, 'createFile').and.callThrough(); + const content = Buffer.from('').toString('base64'); + await request({ + method: 'POST', + url: 'http://localhost:8378/1/files/evil.txt', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'text/html', + base64: content, + }), + headers, + }); + expect(spy).toHaveBeenCalled(); + const contentTypeArg = spy.calls.mostRecent().args[2]; + expect(contentTypeArg).toBe('text/plain'); + }); + + it('overrides mismatched Content-Type with extension-derived MIME type on stream upload', async () => { + const adapter = Config.get('test').filesController.adapter; + const spy = spyOn(adapter, 'createFile').and.callThrough(); + const body = ''; + await request({ + method: 'POST', + url: 'http://localhost:8378/1/files/evil.txt', + headers: { + ...headers, + 'Content-Type': 'text/html', + 'X-Parse-Upload-Mode': 'stream', + }, + body, + }); + expect(spy).toHaveBeenCalled(); + const contentTypeArg = spy.calls.mostRecent().args[2]; + expect(contentTypeArg).toBe('text/plain'); + }); + + it('preserves Content-Type when no file extension is present', async () => { + const adapter = Config.get('test').filesController.adapter; + const spy = spyOn(adapter, 'createFile').and.callThrough(); + await request({ + method: 'POST', + url: 'http://localhost:8378/1/files/noextension', + headers: { + ...headers, + 'Content-Type': 'image/png', + }, + body: Buffer.from('fake png content'), + }); + expect(spy).toHaveBeenCalled(); + const contentTypeArg = spy.calls.mostRecent().args[2]; + expect(contentTypeArg).toBe('image/png'); + }); + + it('infers Content-Type from extension when none is provided', async () => { + const adapter = Config.get('test').filesController.adapter; + const spy = spyOn(adapter, 'createFile').and.callThrough(); + const content = Buffer.from('test content').toString('base64'); + await request({ + method: 'POST', + url: 'http://localhost:8378/1/files/data.txt', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + base64: content, + }), + headers, + }); + expect(spy).toHaveBeenCalled(); + const contentTypeArg = spy.calls.mostRecent().args[2]; + expect(contentTypeArg).toBe('text/plain'); + }); + }); + + describe('(GHSA-q3vj-96h2-gwvg) SQL Injection via Increment amount on nested Object field', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('rejects non-number Increment amount on nested object field', async () => { + const obj = new Parse.Object('IncrTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); + + const response = await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.counter': { __op: 'Increment', amount: '1' }, + }), + }).catch(e => e); + + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.INVALID_JSON); + }); + + it_only_db('postgres')('does not execute injected SQL via Increment amount with pg_sleep', async () => { + const obj = new Parse.Object('IncrTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); + + const start = Date.now(); + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.counter': { __op: 'Increment', amount: '0+(SELECT 1 FROM pg_sleep(3))' }, + }), + }).catch(() => {}); + const elapsed = Date.now() - start; + + // If injection succeeded, query would take >= 3 seconds + expect(elapsed).toBeLessThan(3000); + }); + + it_only_db('postgres')('does not execute injected SQL via Increment amount for data exfiltration', async () => { + const obj = new Parse.Object('IncrTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); + + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.counter': { + __op: 'Increment', + amount: '0+(SELECT ascii(substr(current_database(),1,1)))', + }, + }), + }).catch(() => {}); + + // Verify counter was not modified by injected SQL + const verify = await new Parse.Query('IncrTest').get(obj.id); + expect(verify.get('stats').counter).toBe(0); + }); + + it('allows valid numeric Increment on nested object field', async () => { + const obj = new Parse.Object('IncrTest'); + obj.set('stats', { counter: 5 }); + await obj.save(); + + const response = await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.counter': { __op: 'Increment', amount: 3 }, + }), + }); + + expect(response.status).toBe(200); + const verify = await new Parse.Query('IncrTest').get(obj.id); + expect(verify.get('stats').counter).toBe(8); + }); + }); + + describe('(GHSA-gqpp-xgvh-9h7h) SQL Injection via dot-notation sub-key name in Increment operation', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it_only_db('postgres')('does not execute injected SQL via single quote in sub-key name', async () => { + const obj = new Parse.Object('SubKeyTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); + + const start = Date.now(); + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/SubKeyTest/${obj.id}`, + headers, + body: JSON.stringify({ + "stats.x' || (SELECT pg_sleep(3))::text || '": { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + const elapsed = Date.now() - start; + + // If injection succeeded, query would take >= 3 seconds + expect(elapsed).toBeLessThan(3000); + // The escaped payload becomes a harmless literal key; original data is untouched + const verify = await new Parse.Query('SubKeyTest').get(obj.id); + expect(verify.get('stats').counter).toBe(0); + }); + + it_only_db('postgres')('does not execute injected SQL via double quote in sub-key name', async () => { + const obj = new Parse.Object('SubKeyTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); + + const start = Date.now(); + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/SubKeyTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.x" || (SELECT pg_sleep(3))::text || "': { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + const elapsed = Date.now() - start; + + // Double quotes are escaped in the JSON context, producing a harmless literal key + // name. No SQL injection occurs. If injection succeeded, the query would take + // >= 3 seconds due to pg_sleep. + expect(elapsed).toBeLessThan(3000); + const verify = await new Parse.Query('SubKeyTest').get(obj.id); + // Original counter is untouched + expect(verify.get('stats').counter).toBe(0); + }); + + it_only_db('postgres')('does not inject additional JSONB keys via double quote crafted as valid JSONB in sub-key name', async () => { + const obj = new Parse.Object('SubKeyTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); + + // This payload attempts to craft a sub-key that produces valid JSONB with + // injected keys (e.g. '{"x":0,"evil":1}'). Double quotes are escaped in the + // JSON context, so the payload becomes a harmless literal key name instead. + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/SubKeyTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.x":0,"pg_sleep(3)': { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + + const verify = await new Parse.Query('SubKeyTest').get(obj.id); + // Original counter is untouched + expect(verify.get('stats').counter).toBe(0); + // No injected key exists — the payload is treated as a single literal key name + expect(verify.get('stats')['pg_sleep(3)']).toBeUndefined(); + }); + + it_only_db('postgres')('allows valid Increment on nested object field with normal sub-key', async () => { + const obj = new Parse.Object('SubKeyTest'); + obj.set('stats', { counter: 5 }); + await obj.save(); + + const response = await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/SubKeyTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.counter': { __op: 'Increment', amount: 2 }, + }), + }); + + expect(response.status).toBe(200); + const verify = await new Parse.Query('SubKeyTest').get(obj.id); + expect(verify.get('stats').counter).toBe(7); + }); + }); + + describe('(GHSA-r2m8-pxm9-9c4g) Protected fields WHERE clause bypass via dot-notation on object-type fields', () => { + let obj; + + beforeEach(async () => { + const schema = new Parse.Schema('SecretClass'); + schema.addObject('secretObj'); + schema.addString('publicField'); + schema.setCLP({ + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretObj'] }, + }); + await schema.save(); + + obj = new Parse.Object('SecretClass'); + obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 }); + obj.set('publicField', 'visible'); + await obj.save(null, { useMasterKey: true }); + }); + + it('should deny query with dot-notation on protected field in where clause', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { where: JSON.stringify({ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }) }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny query with dot-notation on protected field in $or', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + where: JSON.stringify({ + $or: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, { 'secretObj.apiKey': 'other' }], + }), + }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny query with dot-notation on protected field in $and', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + where: JSON.stringify({ + $and: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, { publicField: 'visible' }], + }), + }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny query with dot-notation on protected field in $nor', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + where: JSON.stringify({ + $nor: [{ 'secretObj.apiKey': 'WRONG' }], + }), + }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny query with deeply nested dot-notation on protected field', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { where: JSON.stringify({ 'secretObj.nested.deep.key': 'value' }) }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny sort on protected field via dot-notation', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { order: 'secretObj.score' }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny sort on protected field directly', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { order: 'secretObj' }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny descending sort on protected field via dot-notation', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { order: '-secretObj.score' }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should still allow queries on non-protected fields', async () => { + const response = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { where: JSON.stringify({ publicField: 'visible' }) }, + }); + expect(response.data.results.length).toBe(1); + expect(response.data.results[0].publicField).toBe('visible'); + expect(response.data.results[0].secretObj).toBeUndefined(); + }); + + it('should still allow sort on non-protected fields', async () => { + const response = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { order: 'publicField' }, + }); + expect(response.data.results.length).toBe(1); + }); + + it('should still allow master key to query protected fields with dot-notation', async () => { + const response = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + qs: { where: JSON.stringify({ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }) }, + }); + expect(response.data.results.length).toBe(1); + }); + + it('should still block direct query on protected field (existing behavior)', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { where: JSON.stringify({ secretObj: { apiKey: 'SENSITIVE_KEY_123' } }) }, + }).catch(e => e); + expect(res.status).toBe(400); + }); + }); + + describe('(GHSA-j7mm-f4rv-6q6q) Protected fields bypass via LiveQuery dot-notation WHERE', () => { + let obj; + + beforeEach(async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['SecretClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.addClassIfNotExists( + 'SecretClass', + { secretObj: { type: 'Object' }, publicField: { type: 'String' } }, + ); + await schemaController.updateClass( + 'SecretClass', + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretObj'] }, + } + ); + + obj = new Parse.Object('SecretClass'); + obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 }); + obj.set('publicField', 'visible'); + await obj.save(null, { useMasterKey: true }); + }); + + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + }); + + it('should reject LiveQuery subscription with dot-notation on protected field in where clause', async () => { + const query = new Parse.Query('SecretClass'); + query._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with protected field directly in where clause', async () => { + const query = new Parse.Query('SecretClass'); + query.exists('secretObj'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with protected field in $or', async () => { + const q1 = new Parse.Query('SecretClass'); + q1._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123'); + const q2 = new Parse.Query('SecretClass'); + q2._addCondition('secretObj.apiKey', '$eq', 'other'); + const query = Parse.Query.or(q1, q2); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with protected field in $and', async () => { + // Build $and manually since Parse SDK doesn't expose it directly + const query = new Parse.Query('SecretClass'); + query._where = { $and: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, { publicField: 'visible' }] }; + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with protected field in $nor', async () => { + // Build $nor manually since Parse SDK doesn't expose it directly + const query = new Parse.Query('SecretClass'); + query._where = { $nor: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }] }; + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with $regex on protected field (boolean oracle)', async () => { + const query = new Parse.Query('SecretClass'); + query._addCondition('secretObj.apiKey', '$regex', '^S'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with deeply nested dot-notation on protected field', async () => { + const query = new Parse.Query('SecretClass'); + query._addCondition('secretObj.nested.deep.key', '$eq', 'value'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should allow LiveQuery subscription on non-protected fields and strip protected fields from response', async () => { + const query = new Parse.Query('SecretClass'); + query.exists('publicField'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('update', object => { + expect(object.get('secretObj')).toBeUndefined(); + expect(object.get('publicField')).toBe('updated'); + resolve(); + }); + }), + obj.save({ publicField: 'updated' }, { useMasterKey: true }), + ]); + }); + + it('should reject admin user querying protected field when both * and role protect it', async () => { + // Common case: protectedFields has both '*' and 'role:admin' entries. + // Even without resolving user roles, the '*' protection applies and blocks the query. + // This validates that role-based exemptions are irrelevant when '*' covers the field. + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.updateClass( + 'SecretClass', + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretObj'], 'role:admin': ['secretObj'] }, + } + ); + + const user = new Parse.User(); + user.setUsername('adminuser'); + user.setPassword('password'); + await user.signUp(); + + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + const role = new Parse.Role('admin', roleACL); + role.getUsers().add(user); + await role.save(null, { useMasterKey: true }); + + const query = new Parse.Query('SecretClass'); + query._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123'); + await expectAsync(query.subscribe(user.getSessionToken())).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should not reject when role-only protection exists without * entry', async () => { + // Edge case: protectedFields only has a role entry, no '*'. + // Without resolving roles, the protection set is empty, so the subscription is allowed. + // This is a correctness gap, not a security issue: the role entry means "protect this + // field FROM role members" (i.e. admins should not see it). Not resolving roles means + // the admin loses their own restriction — they see data meant to be hidden from them. + // This does not allow unprivileged users to access protected data. + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.updateClass( + 'SecretClass', + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { 'role:admin': ['secretObj'] }, + } + ); + + const user = new Parse.User(); + user.setUsername('adminuser2'); + user.setPassword('password'); + await user.signUp(); + + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + const role = new Parse.Role('admin', roleACL); + role.getUsers().add(user); + await role.save(null, { useMasterKey: true }); + + // This subscribes successfully because without '*' entry, no fields are protected + // for purposes of WHERE clause validation. The role-only config means "hide secretObj + // from admins" — a restriction ON the privileged user, not a security boundary. + const query = new Parse.Query('SecretClass'); + query._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123'); + const subscription = await query.subscribe(user.getSessionToken()); + expect(subscription).toBeDefined(); + }); + + // Note: master key bypass is inherently tested by the `!client.hasMasterKey` guard + // in the implementation. Testing master key LiveQuery requires configuring keyPairs + // in the LiveQuery server config, which is not part of the default test setup. + }); + + describe('(GHSA-w54v-hf9p-8856) User enumeration via email verification endpoint', () => { + let sendVerificationEmail; + + async function createTestUsers() { + const user = new Parse.User(); + user.setUsername('testuser'); + user.setPassword('password123'); + user.set('email', 'unverified@example.com'); + await user.signUp(); + + const user2 = new Parse.User(); + user2.setUsername('verifieduser'); + user2.setPassword('password123'); + user2.set('email', 'verified@example.com'); + await user2.signUp(); + const config = Config.get(Parse.applicationId); + await config.database.update( + '_User', + { username: 'verifieduser' }, + { emailVerified: true } + ); + } + + describe('default (emailVerifySuccessOnInvalidEmail: true)', () => { + beforeEach(async () => { + sendVerificationEmail = jasmine.createSpy('sendVerificationEmail'); + await reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + verifyUserEmails: true, + emailAdapter: { + sendVerificationEmail, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + }); + await createTestUsers(); + }); + it('returns success for non-existent email', async () => { + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'nonexistent@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(response.status).toBe(200); + expect(response.data).toEqual({}); + }); + + it('returns success for already verified email', async () => { + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'verified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(response.status).toBe(200); + expect(response.data).toEqual({}); + }); + + it('returns success for unverified email', async () => { + sendVerificationEmail.calls.reset(); + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'unverified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(response.status).toBe(200); + expect(response.data).toEqual({}); + await jasmine.timeout(); + expect(sendVerificationEmail).toHaveBeenCalledTimes(1); + }); + + it('does not send verification email for non-existent email', async () => { + sendVerificationEmail.calls.reset(); + await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'nonexistent@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(sendVerificationEmail).not.toHaveBeenCalled(); + }); + + it('does not send verification email for already verified email', async () => { + sendVerificationEmail.calls.reset(); + await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'verified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(sendVerificationEmail).not.toHaveBeenCalled(); + }); + }); + + describe('opt-out (emailVerifySuccessOnInvalidEmail: false)', () => { + beforeEach(async () => { + sendVerificationEmail = jasmine.createSpy('sendVerificationEmail'); + await reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + verifyUserEmails: true, + emailVerifySuccessOnInvalidEmail: false, + emailAdapter: { + sendVerificationEmail, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + }); + await createTestUsers(); + }); + + it('returns error for non-existent email', async () => { + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'nonexistent@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.EMAIL_NOT_FOUND); + }); + + it('returns error for already verified email', async () => { + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'verified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.OTHER_CAUSE); + expect(response.data.error).toBe('Email verified@example.com is already verified.'); + }); + + it('sends verification email for unverified email', async () => { + sendVerificationEmail.calls.reset(); + await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'unverified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + await jasmine.timeout(); + expect(sendVerificationEmail).toHaveBeenCalledTimes(1); + }); + }); + + it('rejects invalid emailVerifySuccessOnInvalidEmail values', async () => { + const invalidValues = [[], {}, 0, 1, '', 'string']; + for (const value of invalidValues) { + await expectAsync( + reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + verifyUserEmails: true, + emailVerifySuccessOnInvalidEmail: value, + emailAdapter: { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + }) + ).toBeRejectedWith('emailVerifySuccessOnInvalidEmail must be a boolean value'); + } + }); + }); + + describe('(GHSA-4m9m-p9j9-5hjw) User enumeration via signup endpoint', () => { + async function updateCLP(permissions) { + const response = await fetch(Parse.serverURL + '/schemas/_User', { + method: 'PUT', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ classLevelPermissions: permissions }), + }); + const body = await response.json(); + if (body.error) { + throw body; + } + } + + it('does not reveal existing username when public create CLP is disabled', async () => { + const user = new Parse.User(); + user.setUsername('existingUser'); + user.setPassword('password123'); + await user.signUp(); + await Parse.User.logOut(); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + create: {}, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + }); + + const response = await request({ + url: 'http://localhost:8378/1/classes/_User', + method: 'POST', + body: { username: 'existingUser', password: 'otherpassword' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).catch(e => e); + expect(response.data.code).not.toBe(Parse.Error.USERNAME_TAKEN); + expect(response.data.error).not.toContain('Account already exists'); + expect(response.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + }); + + it('does not reveal existing email when public create CLP is disabled', async () => { + const user = new Parse.User(); + user.setUsername('emailUser'); + user.setPassword('password123'); + user.setEmail('existing@example.com'); + await user.signUp(); + await Parse.User.logOut(); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + create: {}, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + }); + + const response = await request({ + url: 'http://localhost:8378/1/classes/_User', + method: 'POST', + body: { username: 'newUser', password: 'otherpassword', email: 'existing@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).catch(e => e); + expect(response.data.code).not.toBe(Parse.Error.EMAIL_TAKEN); + expect(response.data.error).not.toContain('Account already exists'); + expect(response.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + }); + + it('still returns username taken error when public create CLP is enabled', async () => { + const user = new Parse.User(); + user.setUsername('existingUser'); + user.setPassword('password123'); + await user.signUp(); + await Parse.User.logOut(); + + const response = await request({ + url: 'http://localhost:8378/1/classes/_User', + method: 'POST', + body: { username: 'existingUser', password: 'otherpassword' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.USERNAME_TAKEN); + }); + }); + + describe('(GHSA-c442-97qw-j6c6) SQL Injection via $regex query operator field name in PostgreSQL adapter', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + }; + const serverURL = 'http://localhost:8378/1'; + + beforeEach(async () => { + const obj = new Parse.Object('TestClass'); + obj.set('playerName', 'Alice'); + obj.set('score', 100); + await obj.save(null, { useMasterKey: true }); + }); + + it('rejects field names containing double quotes in $regex query with master key', async () => { + const maliciousField = 'playerName" OR 1=1 --'; + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + [maliciousField]: { $regex: 'x' }, + }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it('rejects field names containing single quotes in $regex query with master key', async () => { + const maliciousField = "playerName' OR '1'='1"; + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + [maliciousField]: { $regex: 'x' }, + }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it('rejects field names containing semicolons in $regex query with master key', async () => { + const maliciousField = 'playerName; DROP TABLE "TestClass" --'; + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + [maliciousField]: { $regex: 'x' }, + }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it('rejects field names containing parentheses in $regex query with master key', async () => { + const maliciousField = 'playerName" ~ \'x\' OR (SELECT 1) --'; + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + [maliciousField]: { $regex: 'x' }, + }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it('allows legitimate $regex query with master key', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + playerName: { $regex: 'Ali' }, + }), + }, + }); + expect(response.data.results.length).toBe(1); + expect(response.data.results[0].playerName).toBe('Alice'); + }); + + it('allows legitimate $regex query with dot notation and master key', async () => { + const obj = new Parse.Object('TestClass'); + obj.set('metadata', { tag: 'hello-world' }); + await obj.save(null, { useMasterKey: true }); + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + 'metadata.tag': { $regex: 'hello' }, + }), + }, + }); + expect(response.data.results.length).toBe(1); + expect(response.data.results[0].metadata.tag).toBe('hello-world'); + }); + + it('allows legitimate $regex query without master key', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + where: JSON.stringify({ + playerName: { $regex: 'Ali' }, + }), + }, + }); + expect(response.data.results.length).toBe(1); + expect(response.data.results[0].playerName).toBe('Alice'); + }); + + it('rejects field names with SQL injection via non-$regex operators with master key', async () => { + const maliciousField = 'playerName" OR 1=1 --'; + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + [maliciousField]: { $exists: true }, + }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + describe('validateQuery key name enforcement', () => { + const maliciousField = 'field"; DROP TABLE test --'; + const noMasterHeaders = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('rejects malicious field name in find without master key', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers: noMasterHeaders, + qs: { + where: JSON.stringify({ [maliciousField]: 'value' }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it('rejects malicious field name in find with master key', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ [maliciousField]: 'value' }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it('allows master key to query whitelisted internal field _email_verify_token', async () => { + await reconfigureServer({ + verifyUserEmails: true, + emailAdapter: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + }); + const user = new Parse.User(); + user.setUsername('testuser'); + user.setPassword('testpass'); + user.setEmail('test@example.com'); + await user.signUp(); + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/_User`, + headers, + qs: { + where: JSON.stringify({ _email_verify_token: { $exists: true } }), + }, + }); + expect(response.data.results.length).toBeGreaterThan(0); + }); + + it('rejects non-master key querying internal field _email_verify_token', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/_User`, + headers: noMasterHeaders, + qs: { + where: JSON.stringify({ _email_verify_token: { $exists: true } }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + describe('non-master key cannot update internal fields', () => { + const internalFields = [ + '_rperm', + '_wperm', + '_hashed_password', + '_email_verify_token', + '_perishable_token', + '_perishable_token_expires_at', + '_email_verify_token_expires_at', + '_failed_login_count', + '_account_lockout_expires_at', + '_password_changed_at', + '_password_history', + '_tombstone', + '_session_token', + ]; + + for (const field of internalFields) { + it(`rejects non-master key updating ${field}`, async () => { + const user = new Parse.User(); + user.setUsername(`updatetest_${field}`); + user.setPassword('password123'); + await user.signUp(); + const response = await request({ + method: 'PUT', + url: `${serverURL}/classes/_User/${user.id}`, + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ [field]: 'malicious_value' }), + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + } + }); + }); + + describe('(GHSA-2cjm-2gwv-m892) OAuth2 adapter singleton shares mutable state across providers', () => { + it('should return isolated adapter instances for different OAuth2 providers', () => { + const { loadAuthAdapter } = require('../lib/Adapters/Auth/index'); + + const authOptions = { + providerA: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://a.example.com/introspect', + useridField: 'sub', + appidField: 'aud', + appIds: ['appA'], + }, + providerB: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://b.example.com/introspect', + useridField: 'sub', + appidField: 'aud', + appIds: ['appB'], + }, + }; + + const resultA = loadAuthAdapter('providerA', authOptions); + const resultB = loadAuthAdapter('providerB', authOptions); + + // Adapters must be different instances to prevent cross-contamination + expect(resultA.adapter).not.toBe(resultB.adapter); + + // After loading providerB, providerA's config must still be intact + expect(resultA.adapter.tokenIntrospectionEndpointUrl).toBe('https://a.example.com/introspect'); + expect(resultA.adapter.appIds).toEqual(['appA']); + expect(resultB.adapter.tokenIntrospectionEndpointUrl).toBe('https://b.example.com/introspect'); + expect(resultB.adapter.appIds).toEqual(['appB']); + }); + + it('should not allow concurrent OAuth2 auth requests to cross-contaminate provider config', async () => { + await reconfigureServer({ + auth: { + oauthProviderA: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://a.example.com/introspect', + useridField: 'sub', + appidField: 'aud', + appIds: ['appA'], + }, + oauthProviderB: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://b.example.com/introspect', + useridField: 'sub', + appidField: 'aud', + appIds: ['appB'], + }, + }, + }); + + // Provider A: valid token with appA audience + // Provider B: valid token with appB audience + mockFetch([ + { + url: 'https://a.example.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ active: true, sub: 'user1', aud: 'appA' }), + }, + }, + { + url: 'https://b.example.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ active: true, sub: 'user2', aud: 'appB' }), + }, + }, + ]); + + // Both providers should authenticate independently without cross-contamination + const [userA, userB] = await Promise.all([ + Parse.User.logInWith('oauthProviderA', { + authData: { id: 'user1', access_token: 'tokenA' }, + }), + Parse.User.logInWith('oauthProviderB', { + authData: { id: 'user2', access_token: 'tokenB' }, + }), + ]); + + expect(userA.id).toBeDefined(); + expect(userB.id).toBeDefined(); + }); + }); + + describe('(GHSA-p2x3-8689-cwpg) GraphQL WebSocket middleware bypass', () => { + let httpServer; + const gqlPort = 13399; + + const gqlHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'Content-Type': 'application/json', + }; + + async function setupGraphQLServer(serverOptions = {}, graphQLOptions = {}) { + if (httpServer) { + await new Promise(resolve => httpServer.close(resolve)); + } + const server = await reconfigureServer(serverOptions); + const expressApp = express(); + httpServer = http.createServer(expressApp); + expressApp.use('/parse', server.app); + const parseGraphQLServer = new ParseGraphQLServer(server, { + graphQLPath: '/graphql', + ...graphQLOptions, + }); + parseGraphQLServer.applyGraphQL(expressApp); + await new Promise(resolve => httpServer.listen({ port: gqlPort }, resolve)); + return parseGraphQLServer; + } + + async function gqlRequest(query, headers = gqlHeaders) { + const response = await fetch(`http://localhost:${gqlPort}/graphql`, { + method: 'POST', + headers, + body: JSON.stringify({ query }), + }); + return { status: response.status, body: await response.json().catch(() => null) }; + } + + afterEach(async () => { + if (httpServer) { + await new Promise(resolve => httpServer.close(resolve)); + httpServer = null; + } + }); + + it('should not have createSubscriptions method', async () => { + const pgServer = await setupGraphQLServer(); + expect(pgServer.createSubscriptions).toBeUndefined(); + }); + + it('should not accept WebSocket connections on /subscriptions path', async () => { + await setupGraphQLServer(); + const connectionResult = await new Promise((resolve) => { + const socket = new ws(`ws://localhost:${gqlPort}/subscriptions`); + socket.on('open', () => { + socket.close(); + resolve('connected'); + }); + socket.on('error', () => { + resolve('refused'); + }); + setTimeout(() => { + socket.close(); + resolve('timeout'); + }, 2000); + }); + expect(connectionResult).not.toBe('connected'); + }); + + it('HTTP GraphQL should still work with API key', async () => { + await setupGraphQLServer(); + const result = await gqlRequest('{ health }'); + expect(result.status).toBe(200); + expect(result.body?.data?.health).toBeTruthy(); + }); + + it('HTTP GraphQL should still reject requests without API key', async () => { + await setupGraphQLServer(); + const result = await gqlRequest('{ health }', { 'Content-Type': 'application/json' }); + expect(result.status).toBe(403); + }); + + it('HTTP introspection control should still work', async () => { + await setupGraphQLServer({}, { graphQLPublicIntrospection: false }); + const result = await gqlRequest('{ __schema { types { name } } }'); + expect(result.body?.errors).toBeDefined(); + expect(result.body.errors[0].message).toContain('Introspection is not allowed'); + }); + + it('HTTP complexity limits should still work', async () => { + await setupGraphQLServer({ requestComplexity: { graphQLFields: 5 } }); + const fields = Array.from({ length: 10 }, (_, i) => `f${i}: health`).join(' '); + const result = await gqlRequest(`{ ${fields} }`); + expect(result.body?.errors).toBeDefined(); + expect(result.body.errors[0].message).toMatch(/exceeds maximum allowed/); + }); + }); + + describe('(GHSA-9ccr-fpp6-78qf) Schema poisoning via __proto__ bypassing requestKeywordDenylist and addField CLP', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('rejects __proto__ in request body via HTTP', async () => { + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/ProtoTest', + body: JSON.stringify(JSON.parse('{"name":"test","__proto__":{"injected":"value"}}')), + }).catch(e => e); + expect(response.status).toBe(400); + const text = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toContain('__proto__'); + }); + + it('does not add fields to a locked schema via __proto__', async () => { + const schema = new Parse.Schema('LockedSchema'); + schema.addString('name'); + schema.setCLP({ + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + }); + await schema.save(); + + // Attempt to inject a field via __proto__ + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/LockedSchema', + body: JSON.stringify(JSON.parse('{"name":"test","__proto__":{"newField":"bypassed"}}')), + }).catch(e => e); + + // Should be rejected by denylist + expect(response.status).toBe(400); + + // Verify schema was not modified + const schemaResponse = await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + method: 'GET', + url: 'http://localhost:8378/1/schemas/LockedSchema', + }); + const fields = schemaResponse.data.fields; + expect(fields.newField).toBeUndefined(); + }); + + it('does not cause schema type conflict via __proto__', async () => { + const schema = new Parse.Schema('TypeConflict'); + schema.addString('name'); + schema.addString('score'); + schema.setCLP({ + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + }); + await schema.save(); + + // Attempt to inject 'score' as Number via __proto__ + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/TypeConflict', + body: JSON.stringify(JSON.parse('{"name":"test","__proto__":{"score":42}}')), + }).catch(e => e); + + // Should be rejected by denylist + expect(response.status).toBe(400); + + // Verify 'score' field is still String type + const obj = new Parse.Object('TypeConflict'); + obj.set('name', 'valid'); + obj.set('score', 'string-value'); + await obj.save(); + expect(obj.get('score')).toBe('string-value'); + }); + }); + }); + + describe('(GHSA-9xp9-j92r-p88v) Stack overflow process crash via deeply nested query operators', () => { + it('rejects deeply nested $or query when queryDepth is set', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 10 }, + }); + const auth = require('../lib/Auth'); + const rest = require('../lib/rest'); + const config = Config.get('test'); + let where = { username: 'test' }; + for (let i = 0; i < 15; i++) { + where = { $or: [where, { username: 'test' }] }; + } + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); + }); + + it('rejects deeply nested query before transform pipeline processes it', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 10 }, + }); + const auth = require('../lib/Auth'); + const rest = require('../lib/rest'); + const config = Config.get('test'); + // Depth 50 bypasses the fix because RestQuery.js transform pipeline + // recursively traverses the structure before validateQuery() is reached + let where = { username: 'test' }; + for (let i = 0; i < 50; i++) { + where = { $and: [where] }; + } + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); + }); + + it('rejects deeply nested query via REST API without authentication', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 10 }, + }); + let where = { username: 'test' }; + for (let i = 0; i < 50; i++) { + where = { $or: [where] }; + } + await expectAsync( + request({ + method: 'GET', + url: `${Parse.serverURL}/classes/_User`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { where: JSON.stringify(where) }, + }) + ).toBeRejectedWith( + jasmine.objectContaining({ + data: jasmine.objectContaining({ + code: Parse.Error.INVALID_QUERY, + }), + }) + ); + }); + + it('rejects deeply nested $nor query before transform pipeline', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 10 }, + }); + const auth = require('../lib/Auth'); + const rest = require('../lib/rest'); + const config = Config.get('test'); + let where = { username: 'test' }; + for (let i = 0; i < 50; i++) { + where = { $nor: [where] }; + } + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); + }); + + it('allows queries within the depth limit', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 10 }, + }); + const auth = require('../lib/Auth'); + const rest = require('../lib/rest'); + const config = Config.get('test'); + let where = { username: 'test' }; + for (let i = 0; i < 5; i++) { + where = { $or: [where] }; + } + const result = await rest.find(config, auth.nobody(config), '_User', where); + expect(result.results).toBeDefined(); + }); + }); + + describe('(GHSA-fjxm-vhvc-gcmj) LiveQuery Operator Type Confusion', () => { + const matchesQuery = require('../lib/LiveQuery/QueryTools').matchesQuery; + + // Unit tests: matchesQuery receives the raw where clause (not {className, where}) + // just as _matchesSubscription passes subscription.query (the where clause) + describe('matchesQuery with type-confused operators', () => { + it('$in with object instead of array throws', () => { + const object = { className: 'TestObject', objectId: 'obj1', name: 'abc' }; + const where = { name: { $in: { x: 1 } } }; + expect(() => matchesQuery(object, where)).toThrow(); + }); + + it('$nin with object instead of array throws', () => { + const object = { className: 'TestObject', objectId: 'obj1', name: 'abc' }; + const where = { name: { $nin: { x: 1 } } }; + expect(() => matchesQuery(object, where)).toThrow(); + }); + + it('$containedBy with object instead of array throws', () => { + const object = { className: 'TestObject', objectId: 'obj1', name: ['abc'] }; + const where = { name: { $containedBy: { x: 1 } } }; + expect(() => matchesQuery(object, where)).toThrow(); + }); + + it('$containedBy with missing field throws', () => { + const object = { className: 'TestObject', objectId: 'obj1' }; + const where = { name: { $containedBy: ['abc', 'xyz'] } }; + expect(() => matchesQuery(object, where)).toThrow(); + }); + + it('$all with object field value throws', () => { + const object = { className: 'TestObject', objectId: 'obj1', name: { x: 1 } }; + const where = { name: { $all: ['abc'] } }; + expect(() => matchesQuery(object, where)).toThrow(); + }); + + it('$in with valid array does not throw', () => { + const object = { className: 'TestObject', objectId: 'obj1', name: 'abc' }; + const where = { name: { $in: ['abc', 'xyz'] } }; + expect(() => matchesQuery(object, where)).not.toThrow(); + expect(matchesQuery(object, where)).toBe(true); + }); + }); + + // Integration test: verify that a LiveQuery subscription with type-confused + // operators does not crash the server and other subscriptions continue working + describe('LiveQuery integration', () => { + beforeEach(async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestObject'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + }); + + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + }); + + it('server does not crash and other subscriptions work when type-confused subscription exists', async () => { + // First subscribe with a malformed query via manual client + const malClient = new Parse.LiveQueryClient({ + applicationId: 'test', + serverURL: 'ws://localhost:1337', + javascriptKey: 'test', + }); + malClient.open(); + const malformedQuery = new Parse.Query('TestObject'); + malformedQuery._where = { name: { $in: { x: 1 } } }; + await malClient.subscribe(malformedQuery); + + // Then subscribe with a valid query using the default client + const validQuery = new Parse.Query('TestObject'); + validQuery.equalTo('name', 'test'); + const validSubscription = await validQuery.subscribe(); + + try { + const createPromise = new Promise(resolve => { + validSubscription.on('create', object => { + expect(object.get('name')).toBe('test'); + resolve(); + }); + }); + + const obj = new Parse.Object('TestObject'); + obj.set('name', 'test'); + await obj.save(); + await createPromise; + } finally { + malClient.close(); + } + }); + }); + + describe('(GHSA-wjqw-r9x4-j59v) Empty authData session issuance bypass', () => { + const signupHeaders = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('rejects signup with empty authData and no credentials', async () => { + await reconfigureServer({ enableAnonymousUsers: false }); + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ authData: {} }), + }).catch(e => e); + expect(res.status).toBe(400); + expect(res.data.code).toBe(Parse.Error.USERNAME_MISSING); + }); + + it('rejects signup with empty authData and no credentials when anonymous users enabled', async () => { + await reconfigureServer({ enableAnonymousUsers: true }); + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ authData: {} }), + }).catch(e => e); + expect(res.status).toBe(400); + expect(res.data.code).toBe(Parse.Error.USERNAME_MISSING); + }); + + it('rejects signup with authData containing only empty provider data and no credentials', async () => { + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ authData: { bogus: {} } }), + }).catch(e => e); + expect(res.status).toBe(400); + expect(res.data.code).toBe(Parse.Error.USERNAME_MISSING); + }); + + it('rejects signup with authData containing null provider data and no credentials', async () => { + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ authData: { bogus: null } }), + }).catch(e => e); + expect(res.status).toBe(400); + expect(res.data.code).toBe(Parse.Error.USERNAME_MISSING); + }); + + it('rejects signup with non-object authData provider value even when credentials are provided', async () => { + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ username: 'bogusauth', password: 'pass1234', authData: { bogus: 'x' } }), + }).catch(e => e); + expect(res.status).toBe(400); + expect(res.data.code).toBe(Parse.Error.UNSUPPORTED_SERVICE); + }); + + it('allows signup with empty authData when username and password are provided', async () => { + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ username: 'emptyauth', password: 'pass1234', authData: {} }), + }); + expect(res.data.objectId).toBeDefined(); + expect(res.data.sessionToken).toBeDefined(); + }); + }); + + describe('(GHSA-r3xq-68wh-gwvh) Password reset single-use token bypass via concurrent requests', () => { + let sendPasswordResetEmail; + + beforeAll(async () => { + sendPasswordResetEmail = jasmine.createSpy('sendPasswordResetEmail'); + await reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + emailAdapter: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail, + sendMail: () => {}, + }, + }); + }); + + it('rejects concurrent password resets using the same token', async () => { + const user = new Parse.User(); + user.setUsername('resetuser'); + user.setPassword('originalPass1!'); + user.setEmail('resetuser@example.com'); + await user.signUp(); + + await Parse.User.requestPasswordReset('resetuser@example.com'); + + // Get the perishable token directly from the database + const config = Config.get('test'); + const results = await config.database.adapter.find( + '_User', + { fields: {} }, + { username: 'resetuser' }, + { limit: 1 } + ); + const token = results[0]._perishable_token; + expect(token).toBeDefined(); + + // Send two concurrent password reset requests with different passwords + const resetRequest = password => + request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=${encodeURIComponent(password)}&token=${encodeURIComponent(token)}`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest', + }, + followRedirects: false, + }); + + const [resultA, resultB] = await Promise.allSettled([ + resetRequest('PasswordA1!'), + resetRequest('PasswordB1!'), + ]); + + // Exactly one request should succeed and one should fail + const succeeded = [resultA, resultB].filter(r => r.status === 'fulfilled'); + const failed = [resultA, resultB].filter(r => r.status === 'rejected'); + expect(succeeded.length).toBe(1); + expect(failed.length).toBe(1); + + // The failed request should indicate invalid token + expect(failed[0].reason.text).toContain( + 'Failed to reset password: username / email / token is invalid' + ); + + // The token should be consumed + const afterResults = await config.database.adapter.find( + '_User', + { fields: {} }, + { username: 'resetuser' }, + { limit: 1 } + ); + expect(afterResults[0]._perishable_token).toBeUndefined(); + + // Verify login works with the winning password + const winningPassword = + succeeded[0] === resultA ? 'PasswordA1!' : 'PasswordB1!'; + const loggedIn = await Parse.User.logIn('resetuser', winningPassword); + expect(loggedIn.getUsername()).toBe('resetuser'); + }); + }); + }); + + describe('(GHSA-5hmj-jcgp-6hff) Protected fields leak via LiveQuery afterEvent trigger', () => { + let obj; + + beforeEach(async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['SecretClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent('SecretClass', () => {}); + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.addClassIfNotExists('SecretClass', { + secretField: { type: 'String' }, + publicField: { type: 'String' }, + }); + await schemaController.updateClass( + 'SecretClass', + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretField'] }, + } + ); + obj = new Parse.Object('SecretClass'); + obj.set('secretField', 'SENSITIVE_DATA'); + obj.set('publicField', 'visible'); + await obj.save(null, { useMasterKey: true }); + }); + + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + }); + + it('should not leak protected fields on update event when afterEvent trigger is registered', async () => { + const query = new Parse.Query('SecretClass'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('update', (object, original) => { + expect(object.get('secretField')).toBeUndefined(); + expect(object.get('publicField')).toBe('updated'); + expect(original.get('secretField')).toBeUndefined(); + expect(original.get('publicField')).toBe('visible'); + resolve(); + }); + }), + obj.save({ publicField: 'updated' }, { useMasterKey: true }), + ]); + }); + + it('should not leak protected fields on create event when afterEvent trigger is registered', async () => { + const query = new Parse.Query('SecretClass'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('create', object => { + expect(object.get('secretField')).toBeUndefined(); + expect(object.get('publicField')).toBe('new'); + resolve(); + }); + }), + new Parse.Object('SecretClass').save( + { secretField: 'SECRET', publicField: 'new' }, + { useMasterKey: true } + ), + ]); + }); + + it('should not leak protected fields on delete event when afterEvent trigger is registered', async () => { + const query = new Parse.Query('SecretClass'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('delete', object => { + expect(object.get('secretField')).toBeUndefined(); + expect(object.get('publicField')).toBe('visible'); + resolve(); + }); + }), + obj.destroy({ useMasterKey: true }), + ]); + }); + + it('should not leak protected fields on enter event when afterEvent trigger is registered', async () => { + const query = new Parse.Query('SecretClass'); + query.equalTo('publicField', 'match'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('enter', (object, original) => { + expect(object.get('secretField')).toBeUndefined(); + expect(object.get('publicField')).toBe('match'); + expect(original.get('secretField')).toBeUndefined(); + resolve(); + }); + }), + obj.save({ publicField: 'match' }, { useMasterKey: true }), + ]); + }); + + it('should not leak protected fields on leave event when afterEvent trigger is registered', async () => { + const query = new Parse.Query('SecretClass'); + query.equalTo('publicField', 'visible'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('leave', (object, original) => { + expect(object.get('secretField')).toBeUndefined(); + expect(object.get('publicField')).toBe('changed'); + expect(original.get('secretField')).toBeUndefined(); + expect(original.get('publicField')).toBe('visible'); + resolve(); + }); + }), + obj.save({ publicField: 'changed' }, { useMasterKey: true }), + ]); + }); + + describe('(GHSA-m983-v2ff-wq65) LiveQuery shared mutable state race across concurrent subscribers', () => { + // Helper: create a LiveQuery client, wait for open, subscribe, wait for subscription ACK + async function createSubscribedClient({ className, masterKey, installationId }) { + const opts = { + applicationId: 'test', + serverURL: 'ws://localhost:8378', + javascriptKey: 'test', + }; + if (masterKey) { + opts.masterKey = 'test'; + } + if (installationId) { + opts.installationId = installationId; + } + const client = new Parse.LiveQueryClient(opts); + client.open(); + const query = new Parse.Query(className); + const sub = client.subscribe(query); + await new Promise(resolve => sub.on('open', resolve)); + return { client, sub }; + } + + async function setupProtectedClass(className) { + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.addClassIfNotExists(className, { + secretField: { type: 'String' }, + publicField: { type: 'String' }, + }); + await schemaController.updateClass( + className, + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretField'] }, + } + ); + } + + it('should deliver protected fields to master key LiveQuery client', async () => { + const className = 'MasterKeyProtectedClass'; + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: [className] }, + liveQueryServerOptions: { + keyPairs: { masterKey: 'test', javascriptKey: 'test' }, + }, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent(className, () => {}); + await setupProtectedClass(className); + + const { client: masterClient, sub: masterSub } = await createSubscribedClient({ + className, + masterKey: true, + }); + + try { + const result = new Promise(resolve => { + masterSub.on('create', object => { + resolve({ + secretField: object.get('secretField'), + publicField: object.get('publicField'), + }); + }); + }); + + const obj = new Parse.Object(className); + obj.set('secretField', 'MASTER_VISIBLE'); + obj.set('publicField', 'public'); + await obj.save(null, { useMasterKey: true }); + + const received = await result; + + // Master key client must see protected fields + expect(received.secretField).toBe('MASTER_VISIBLE'); + expect(received.publicField).toBe('public'); + } finally { + masterClient.close(); + } + }); + + it('should not leak protected fields to regular client when master key client subscribes concurrently on update', async () => { + const className = 'RaceUpdateClass'; + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: [className] }, + liveQueryServerOptions: { + keyPairs: { masterKey: 'test', javascriptKey: 'test' }, + }, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent(className, () => {}); + await setupProtectedClass(className); + + const { client: masterClient, sub: masterSub } = await createSubscribedClient({ + className, + masterKey: true, + }); + const { client: regularClient, sub: regularSub } = await createSubscribedClient({ + className, + masterKey: false, + }); + + try { + const obj = new Parse.Object(className); + obj.set('secretField', 'TOP_SECRET'); + obj.set('publicField', 'visible'); + await obj.save(null, { useMasterKey: true }); + + const masterResult = new Promise(resolve => { + masterSub.on('update', object => { + resolve({ + secretField: object.get('secretField'), + publicField: object.get('publicField'), + }); + }); + }); + const regularResult = new Promise(resolve => { + regularSub.on('update', object => { + resolve({ + secretField: object.get('secretField'), + publicField: object.get('publicField'), + }); + }); + }); + + await obj.save({ publicField: 'updated' }, { useMasterKey: true }); + const [master, regular] = await Promise.all([masterResult, regularResult]); + // Regular client must NOT see the secret field + expect(regular.secretField).toBeUndefined(); + expect(regular.publicField).toBe('updated'); + // Master client must see the secret field + expect(master.secretField).toBe('TOP_SECRET'); + expect(master.publicField).toBe('updated'); + } finally { + masterClient.close(); + regularClient.close(); + } + }); + + it('should not leak protected fields to regular client when master key client subscribes concurrently on create', async () => { + const className = 'RaceCreateClass'; + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: [className] }, + liveQueryServerOptions: { + keyPairs: { masterKey: 'test', javascriptKey: 'test' }, + }, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent(className, () => {}); + await setupProtectedClass(className); + + const { client: masterClient, sub: masterSub } = await createSubscribedClient({ + className, + masterKey: true, + }); + const { client: regularClient, sub: regularSub } = await createSubscribedClient({ + className, + masterKey: false, + }); + + try { + const masterResult = new Promise(resolve => { + masterSub.on('create', object => { + resolve({ + secretField: object.get('secretField'), + publicField: object.get('publicField'), + }); + }); + }); + const regularResult = new Promise(resolve => { + regularSub.on('create', object => { + resolve({ + secretField: object.get('secretField'), + publicField: object.get('publicField'), + }); + }); + }); + + const newObj = new Parse.Object(className); + newObj.set('secretField', 'SECRET'); + newObj.set('publicField', 'public'); + await newObj.save(null, { useMasterKey: true }); + + const [master, regular] = await Promise.all([masterResult, regularResult]); + + expect(regular.secretField).toBeUndefined(); + expect(regular.publicField).toBe('public'); + expect(master.secretField).toBe('SECRET'); + expect(master.publicField).toBe('public'); + } finally { + masterClient.close(); + regularClient.close(); + } + }); + + it('should not leak protected fields to regular client when master key client subscribes concurrently on delete', async () => { + const className = 'RaceDeleteClass'; + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: [className] }, + liveQueryServerOptions: { + keyPairs: { masterKey: 'test', javascriptKey: 'test' }, + }, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent(className, () => {}); + await setupProtectedClass(className); + + const { client: masterClient, sub: masterSub } = await createSubscribedClient({ + className, + masterKey: true, + }); + const { client: regularClient, sub: regularSub } = await createSubscribedClient({ + className, + masterKey: false, + }); + + try { + const obj = new Parse.Object(className); + obj.set('secretField', 'SECRET'); + obj.set('publicField', 'public'); + await obj.save(null, { useMasterKey: true }); + + const masterResult = new Promise(resolve => { + masterSub.on('delete', object => { + resolve({ + secretField: object.get('secretField'), + publicField: object.get('publicField'), + }); + }); + }); + const regularResult = new Promise(resolve => { + regularSub.on('delete', object => { + resolve({ + secretField: object.get('secretField'), + publicField: object.get('publicField'), + }); + }); + }); + + await obj.destroy({ useMasterKey: true }); + const [master, regular] = await Promise.all([masterResult, regularResult]); + + expect(regular.secretField).toBeUndefined(); + expect(regular.publicField).toBe('public'); + expect(master.secretField).toBe('SECRET'); + expect(master.publicField).toBe('public'); + } finally { + masterClient.close(); + regularClient.close(); + } + }); + + it('should not corrupt object when afterEvent trigger modifies res.object for one client', async () => { + const className = 'TriggerRaceClass'; + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: [className] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent(className, req => { + if (req.object) { + req.object.set('injected', `for-${req.installationId}`); + } + }); + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.addClassIfNotExists(className, { + data: { type: 'String' }, + injected: { type: 'String' }, + }); + + const { client: client1, sub: sub1 } = await createSubscribedClient({ + className, + masterKey: false, + installationId: 'client-1', + }); + const { client: client2, sub: sub2 } = await createSubscribedClient({ + className, + masterKey: false, + installationId: 'client-2', + }); + + try { + const result1 = new Promise(resolve => { + sub1.on('create', object => { + resolve({ data: object.get('data'), injected: object.get('injected') }); + }); + }); + const result2 = new Promise(resolve => { + sub2.on('create', object => { + resolve({ data: object.get('data'), injected: object.get('injected') }); + }); + }); + + const newObj = new Parse.Object(className); + newObj.set('data', 'value'); + await newObj.save(null, { useMasterKey: true }); + + const [r1, r2] = await Promise.all([result1, result2]); + + expect(r1.data).toBe('value'); + expect(r2.data).toBe('value'); + expect(r1.injected).toBe('for-client-1'); + expect(r2.injected).toBe('for-client-2'); + expect(r1.injected).not.toBe(r2.injected); + } finally { + client1.close(); + client2.close(); + } + }); + }); + + describe('(GHSA-pfj7-wv7c-22pr) AuthData subset validation bypass with allowExpiredAuthDataToken', () => { + let validatorSpy; + + const testAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; + + beforeEach(async () => { + validatorSpy = spyOn(testAdapter, 'validateAuthData').and.resolveTo({}); + await reconfigureServer({ + auth: { testAdapter }, + allowExpiredAuthDataToken: true, + }); + }); + + it('validates authData on login when incoming data is a strict subset of stored data', async () => { + // Sign up a user with full authData (id + access_token) + const user = new Parse.User(); + await user.save({ + authData: { testAdapter: { id: 'user123', access_token: 'valid_token' } }, + }); + validatorSpy.calls.reset(); + + // Attempt to log in with only the id field (subset of stored data) + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + authData: { testAdapter: { id: 'user123' } }, + }), + }); + expect(res.data.objectId).toBe(user.id); + // The adapter MUST be called to validate the login attempt + expect(validatorSpy).toHaveBeenCalled(); + }); + + it('prevents account takeover via partial authData when allowExpiredAuthDataToken is enabled', async () => { + // Sign up a user with full authData + const user = new Parse.User(); + await user.save({ + authData: { testAdapter: { id: 'victim123', access_token: 'secret_token' } }, + }); + validatorSpy.calls.reset(); + + // Simulate an attacker sending only the provider ID (no access_token) + // The adapter should reject this because the token is missing + validatorSpy.and.rejectWith( + new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid credentials') + ); + + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + authData: { testAdapter: { id: 'victim123' } }, + }), + }).catch(e => e); + + // Login must be rejected — adapter validation must not be skipped + expect(res.status).toBe(400); + expect(validatorSpy).toHaveBeenCalled(); + }); + + it('validates authData on login even when authData is identical', async () => { + // Sign up with full authData + const user = new Parse.User(); + await user.save({ + authData: { testAdapter: { id: 'user456', access_token: 'expired_token' } }, + }); + validatorSpy.calls.reset(); + + // Log in with the exact same authData (all keys present, same values) + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + authData: { testAdapter: { id: 'user456', access_token: 'expired_token' } }, + }), + }); + expect(res.data.objectId).toBe(user.id); + // Auth providers are always validated on login regardless of allowExpiredAuthDataToken + expect(validatorSpy).toHaveBeenCalled(); + }); + + it('rejects login with identical but expired authData when adapter rejects', async () => { + // Sign up with authData that is initially valid + const user = new Parse.User(); + await user.save({ + authData: { testAdapter: { id: 'user_expired', access_token: 'token_now_expired' } }, + }); + validatorSpy.calls.reset(); + + // Simulate the token expiring on the provider side: the adapter now + // rejects the same token that was valid at signup time + validatorSpy.and.rejectWith( + new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Token expired') + ); + + // Attempt login with the exact same (now-expired) authData + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + authData: { testAdapter: { id: 'user_expired', access_token: 'token_now_expired' } }, + }), + }).catch(e => e); + + // Login must be rejected even though authData is identical to what's stored + expect(res.status).toBe(400); + expect(validatorSpy).toHaveBeenCalled(); + }); + + it('skips validation on update when authData is a subset of stored data', async () => { + // Sign up with full authData + const user = new Parse.User(); + await user.save({ + authData: { testAdapter: { id: 'user789', access_token: 'valid_token' } }, + }); + validatorSpy.calls.reset(); + + // Update the user with a subset of authData (simulates afterFind stripping fields) + await request({ + method: 'PUT', + url: `http://localhost:8378/1/users/${user.id}`, + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ + authData: { testAdapter: { id: 'user789' } }, + }), + }); + // On update with allowExpiredAuthDataToken: true, subset data skips validation + expect(validatorSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe('(GHSA-fph2-r4qg-9576) LiveQuery bypasses CLP pointer permission enforcement', () => { + const { sleep } = require('../lib/TestUtils'); + + beforeEach(() => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + }); + + afterEach(async () => { + try { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + } catch (e) { + // Ignore cleanup errors when client is not initialized + } + }); + + async function updateCLP(className, permissions) { + const response = await fetch(Parse.serverURL + '/schemas/' + className, { + method: 'PUT', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ classLevelPermissions: permissions }), + }); + const body = await response.json(); + if (body.error) { + throw body; + } + return body; + } + + it('should not deliver LiveQuery events to user not in readUserFields pointer', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['PrivateMessage'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + // Create users using master key to avoid session management issues + const userA = new Parse.User(); + userA.setUsername('userA_pointer'); + userA.setPassword('password123'); + await userA.signUp(); + await Parse.User.logOut(); + + // User B stays logged in for the subscription + const userB = new Parse.User(); + userB.setUsername('userB_pointer'); + userB.setPassword('password456'); + await userB.signUp(); + + // Create schema by saving an object with owner pointer, then set CLP + const seed = new Parse.Object('PrivateMessage'); + seed.set('owner', userA); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); + + await updateCLP('PrivateMessage', { + create: { '*': true }, + find: {}, + get: {}, + readUserFields: ['owner'], + }); + + // User B subscribes — should NOT receive events for User A's objects + const query = new Parse.Query('PrivateMessage'); + const subscription = await query.subscribe(userB.getSessionToken()); + + const createSpy = jasmine.createSpy('create'); + const enterSpy = jasmine.createSpy('enter'); + subscription.on('create', createSpy); + subscription.on('enter', enterSpy); + + // Create a message owned by User A + const msg = new Parse.Object('PrivateMessage'); + msg.set('content', 'secret message'); + msg.set('owner', userA); + await msg.save(null, { useMasterKey: true }); + + await sleep(500); + + // User B should NOT have received the create event + expect(createSpy).not.toHaveBeenCalled(); + expect(enterSpy).not.toHaveBeenCalled(); + }); + + it('should deliver LiveQuery events to user in readUserFields pointer', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['PrivateMessage2'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + // User A stays logged in for the subscription + const userA = new Parse.User(); + userA.setUsername('userA_owner'); + userA.setPassword('password123'); + await userA.signUp(); + + // Create schema by saving an object with owner pointer + const seed = new Parse.Object('PrivateMessage2'); + seed.set('owner', userA); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); + + await updateCLP('PrivateMessage2', { + create: { '*': true }, + find: {}, + get: {}, + readUserFields: ['owner'], + }); + + // User A subscribes — SHOULD receive events for their own objects + const query = new Parse.Query('PrivateMessage2'); + const subscription = await query.subscribe(userA.getSessionToken()); + + const createSpy = jasmine.createSpy('create'); + subscription.on('create', createSpy); + + // Create a message owned by User A + const msg = new Parse.Object('PrivateMessage2'); + msg.set('content', 'my own message'); + msg.set('owner', userA); + await msg.save(null, { useMasterKey: true }); + + await sleep(500); + + // User A SHOULD have received the create event + expect(createSpy).toHaveBeenCalledTimes(1); + }); + + it('should not deliver LiveQuery events when find uses pointerFields', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['PrivateDoc'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const userA = new Parse.User(); + userA.setUsername('userA_doc'); + userA.setPassword('password123'); + await userA.signUp(); + await Parse.User.logOut(); + + // User B stays logged in for the subscription + const userB = new Parse.User(); + userB.setUsername('userB_doc'); + userB.setPassword('password456'); + await userB.signUp(); + + // Create schema by saving an object with recipient pointer + const seed = new Parse.Object('PrivateDoc'); + seed.set('recipient', userA); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); + + // Set CLP with pointerFields instead of readUserFields + await updateCLP('PrivateDoc', { + create: { '*': true }, + find: { pointerFields: ['recipient'] }, + get: { pointerFields: ['recipient'] }, + }); + + // User B subscribes + const query = new Parse.Query('PrivateDoc'); + const subscription = await query.subscribe(userB.getSessionToken()); + + const createSpy = jasmine.createSpy('create'); + subscription.on('create', createSpy); + + // Create doc with recipient = User A (not User B) + const doc = new Parse.Object('PrivateDoc'); + doc.set('title', 'confidential'); + doc.set('recipient', userA); + await doc.save(null, { useMasterKey: true }); + + await sleep(500); + + // User B should NOT receive events for User A's document + expect(createSpy).not.toHaveBeenCalled(); + }); + + it('should not deliver LiveQuery events to unauthenticated users for pointer-protected classes', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['SecureItem'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const userA = new Parse.User(); + userA.setUsername('userA_secure'); + userA.setPassword('password123'); + await userA.signUp(); + await Parse.User.logOut(); + + // Create schema + const seed = new Parse.Object('SecureItem'); + seed.set('owner', userA); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); + + await updateCLP('SecureItem', { + create: { '*': true }, + find: {}, + get: {}, + readUserFields: ['owner'], + }); + + // Unauthenticated subscription + const query = new Parse.Query('SecureItem'); + const subscription = await query.subscribe(); + + const createSpy = jasmine.createSpy('create'); + subscription.on('create', createSpy); + + const item = new Parse.Object('SecureItem'); + item.set('data', 'private'); + item.set('owner', userA); + await item.save(null, { useMasterKey: true }); + + await sleep(500); + + expect(createSpy).not.toHaveBeenCalled(); + }); + + it('should handle readUserFields with array of pointers', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['SharedDoc'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const userA = new Parse.User(); + userA.setUsername('userA_shared'); + userA.setPassword('password123'); + await userA.signUp(); + await Parse.User.logOut(); + + // User B — don't log out, session must remain valid + const userB = new Parse.User(); + userB.setUsername('userB_shared'); + userB.setPassword('password456'); + await userB.signUp(); + const userBSessionToken = userB.getSessionToken(); + + // User C — signUp changes current user to C, but B's session stays valid + const userC = new Parse.User(); + userC.setUsername('userC_shared'); + userC.setPassword('password789'); + await userC.signUp(); + const userCSessionToken = userC.getSessionToken(); + + // Create schema with array field + const seed = new Parse.Object('SharedDoc'); + seed.set('collaborators', [userA]); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); + + await updateCLP('SharedDoc', { + create: { '*': true }, + find: {}, + get: {}, + readUserFields: ['collaborators'], + }); + + // User B subscribes — is in the collaborators array + const queryB = new Parse.Query('SharedDoc'); + const subscriptionB = await queryB.subscribe(userBSessionToken); + const createSpyB = jasmine.createSpy('createB'); + subscriptionB.on('create', createSpyB); + + // User C subscribes — is NOT in the collaborators array + const queryC = new Parse.Query('SharedDoc'); + const subscriptionC = await queryC.subscribe(userCSessionToken); + const createSpyC = jasmine.createSpy('createC'); + subscriptionC.on('create', createSpyC); + + // Create doc with collaborators = [userA, userB] (not userC) + const doc = new Parse.Object('SharedDoc'); + doc.set('title', 'team doc'); + doc.set('collaborators', [userA, userB]); + await doc.save(null, { useMasterKey: true }); + + await sleep(500); + + // User B SHOULD receive the event (in collaborators array) + expect(createSpyB).toHaveBeenCalledTimes(1); + // User C should NOT receive the event + expect(createSpyC).not.toHaveBeenCalled(); + }); + }); + + describe('(GHSA-qpc3-fg4j-8hgm) Protected field change detection oracle via LiveQuery watch parameter', () => { + const { sleep } = require('../lib/TestUtils'); + let obj; + + beforeEach(async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['SecretClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.addClassIfNotExists('SecretClass', { + secretObj: { type: 'Object' }, + publicField: { type: 'String' }, + }); + await schemaController.updateClass( + 'SecretClass', + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretObj'] }, + } + ); + + obj = new Parse.Object('SecretClass'); + obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 }); + obj.set('publicField', 'visible'); + await obj.save(null, { useMasterKey: true }); + }); + + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + }); + + it('should reject LiveQuery subscription with protected field in watch', async () => { + const query = new Parse.Query('SecretClass'); + query.watch('secretObj'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with dot-notation on protected field in watch', async () => { + const query = new Parse.Query('SecretClass'); + query.watch('secretObj.apiKey'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with deeply nested dot-notation on protected field in watch', async () => { + const query = new Parse.Query('SecretClass'); + query.watch('secretObj.nested.deep.key'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should allow LiveQuery subscription with non-protected field in watch', async () => { + const query = new Parse.Query('SecretClass'); + query.watch('publicField'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('update', object => { + expect(object.get('secretObj')).toBeUndefined(); + expect(object.get('publicField')).toBe('updated'); + resolve(); + }); + }), + obj.save({ publicField: 'updated' }, { useMasterKey: true }), + ]); + }); + + it('should not deliver update event when only non-watched field changes', async () => { + const query = new Parse.Query('SecretClass'); + query.watch('publicField'); + const subscription = await query.subscribe(); + const updateSpy = jasmine.createSpy('update'); + subscription.on('update', updateSpy); + + // Change a field that is NOT in the watch list + obj.set('secretObj', { apiKey: 'ROTATED_KEY', score: 99 }); + await obj.save(null, { useMasterKey: true }); + await sleep(500); + expect(updateSpy).not.toHaveBeenCalled(); + }); + + describe('(GHSA-8pjv-59c8-44p8) SSRF via Webhook URL requires master key', () => { + const expectMasterKeyRequired = async promise => { + try { + await promise; + fail('Expected request to be rejected'); + } catch (error) { + expect(error.status).toBe(403); + } + }; + + it('rejects registering a webhook function with internal URL without master key', async () => { + await expectMasterKeyRequired( + request({ + method: 'POST', + url: Parse.serverURL + '/hooks/functions', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + functionName: 'ssrf_probe', + url: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/', + }), + }) + ); + }); + + it('rejects updating a webhook function URL to internal address without master key', async () => { + // Seed a legitimate webhook first so the PUT hits auth, not "not found" + await request({ + method: 'POST', + url: Parse.serverURL + '/hooks/functions', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + functionName: 'ssrf_probe', + url: 'https://example.com/webhook', + }), + }); + await expectMasterKeyRequired( + request({ + method: 'PUT', + url: Parse.serverURL + '/hooks/functions/ssrf_probe', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + url: 'http://169.254.169.254/latest/meta-data/', + }), + }) + ); + }); + + it('rejects registering a webhook trigger with internal URL without master key', async () => { + await expectMasterKeyRequired( + request({ + method: 'POST', + url: Parse.serverURL + '/hooks/triggers', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + className: 'TestClass', + triggerName: 'beforeSave', + url: 'http://127.0.0.1:8080/admin/status', + }), + }) + ); + }); + + it('rejects registering a webhook with internal URL using JavaScript key', async () => { + await expectMasterKeyRequired( + request({ + method: 'POST', + url: Parse.serverURL + '/hooks/functions', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-JavaScript-Key': 'test', + }, + body: JSON.stringify({ + functionName: 'ssrf_probe', + url: 'http://10.0.0.1:3000/internal-api', + }), + }) + ); + }); + }); + + }); + + describe('(GHSA-6qh5-m6g3-xhq6) LiveQuery query depth DoS via deeply nested subscription', () => { + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + }); + + it('should reject LiveQuery subscription with deeply nested $or when queryDepth is set', async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + requestComplexity: { queryDepth: 10 }, + }); + const query = new Parse.Query('TestClass'); + let where = { field: 'value' }; + for (let i = 0; i < 15; i++) { + where = { $or: [where] }; + } + query._where = where; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.INVALID_QUERY, + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); + }); + + it('should reject LiveQuery subscription with deeply nested $and when queryDepth is set', async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + requestComplexity: { queryDepth: 10 }, + }); + const query = new Parse.Query('TestClass'); + let where = { field: 'value' }; + for (let i = 0; i < 50; i++) { + where = { $and: [where] }; + } + query._where = where; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.INVALID_QUERY, + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); + }); + + it('should reject LiveQuery subscription with deeply nested $nor when queryDepth is set', async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + requestComplexity: { queryDepth: 10 }, + }); + const query = new Parse.Query('TestClass'); + let where = { field: 'value' }; + for (let i = 0; i < 50; i++) { + where = { $nor: [where] }; + } + query._where = where; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.INVALID_QUERY, + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); + }); + + it('should allow LiveQuery subscription within the depth limit', async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + requestComplexity: { queryDepth: 10 }, + }); + const query = new Parse.Query('TestClass'); + let where = { field: 'value' }; + for (let i = 0; i < 5; i++) { + where = { $or: [where] }; + } + query._where = where; + const subscription = await query.subscribe(); + expect(subscription).toBeDefined(); + }); + + it('should allow LiveQuery subscription when queryDepth is disabled', async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + requestComplexity: { queryDepth: -1 }, + }); + const query = new Parse.Query('TestClass'); + let where = { field: 'value' }; + for (let i = 0; i < 15; i++) { + where = { $or: [where] }; + } + query._where = where; + const subscription = await query.subscribe(); + expect(subscription).toBeDefined(); + }); + }); + + describe('(GHSA-g4cf-xj29-wqqr) DoS via unindexed database query for unconfigured auth providers', () => { + it('should not query database for unconfigured auth provider on signup', async () => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; + const spy = spyOn(databaseAdapter, 'find').and.callThrough(); + await expectAsync( + new Parse.User().save({ authData: { nonExistentProvider: { id: 'test123' } } }) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.UNSUPPORTED_SERVICE, 'This authentication method is unsupported.') + ); + const authDataQueries = spy.calls.all().filter(call => { + const query = call.args[2]; + return query?.$or?.some(q => q['authData.nonExistentProvider.id']); + }); + expect(authDataQueries.length).toBe(0); + }); + + it('should not query database for unconfigured auth provider on challenge', async () => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; + const spy = spyOn(databaseAdapter, 'find').and.callThrough(); + await expectAsync( + request({ + method: 'POST', + url: Parse.serverURL + '/challenge', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + authData: { nonExistentProvider: { id: 'test123' } }, + challengeData: { nonExistentProvider: { token: 'abc' } }, + }), + }) + ).toBeRejected(); + const authDataQueries = spy.calls.all().filter(call => { + const query = call.args[2]; + return query?.$or?.some(q => q['authData.nonExistentProvider.id']); + }); + expect(authDataQueries.length).toBe(0); + }); + + it('should still query database for configured auth provider', async () => { + await reconfigureServer({ + auth: { + myConfiguredProvider: { + module: { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }, + }, + }, + }); + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; + const spy = spyOn(databaseAdapter, 'find').and.callThrough(); + const user = new Parse.User(); + await user.save({ authData: { myConfiguredProvider: { id: 'validId', token: 'validToken' } } }); + const authDataQueries = spy.calls.all().filter(call => { + const query = call.args[2]; + return query?.$or?.some(q => q['authData.myConfiguredProvider.id']); + }); + expect(authDataQueries.length).toBeGreaterThan(0); + }); + }); + + describe('(GHSA-2299-ghjr-6vjp) MFA recovery code reuse via concurrent requests', () => { + const mfaHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + + beforeEach(async () => { + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + }); + }); + + it('rejects concurrent logins using the same MFA recovery code', async () => { + const OTPAuth = require('otpauth'); + const user = await Parse.User.signUp('mfauser', 'password123'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken: user.getSessionToken() } + ); + + // Get recovery codes from stored auth data + await user.fetch({ useMasterKey: true }); + const recoveryCode = user.get('authData').mfa.recovery[0]; + expect(recoveryCode).toBeDefined(); + + // Send concurrent login requests with the same recovery code + const loginWithRecovery = () => + request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: mfaHeaders, + body: JSON.stringify({ + username: 'mfauser', + password: 'password123', + authData: { + mfa: { + token: recoveryCode, + }, + }, + }), + }); + + const results = await Promise.allSettled(Array(10).fill().map(() => loginWithRecovery())); + + const succeeded = results.filter(r => r.status === 'fulfilled'); + const failed = results.filter(r => r.status === 'rejected'); + + // Exactly one request should succeed; all others should fail + expect(succeeded.length).toBe(1); + expect(failed.length).toBe(9); + + // Verify the recovery code has been consumed + await user.fetch({ useMasterKey: true }); + const remainingRecovery = user.get('authData').mfa.recovery; + expect(remainingRecovery).not.toContain(recoveryCode); + }); + }); + + describe('(GHSA-w73w-g5xw-rwhf) MFA recovery code reuse via concurrent authData-only login', () => { + const mfaHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + + let fakeProvider; + + beforeEach(async () => { + fakeProvider = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; + await reconfigureServer({ + auth: { + fakeProvider, + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + }); + }); + + it('rejects concurrent authData-only logins using the same MFA recovery code', async () => { + const OTPAuth = require('otpauth'); + + // Create user via authData login with fake provider + const user = await Parse.User.logInWith('fakeProvider', { + authData: { id: 'user1', token: 'fakeToken' }, + }); + + // Enable MFA for this user + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken: user.getSessionToken() } + ); + + // Get recovery codes from stored auth data + await user.fetch({ useMasterKey: true }); + const recoveryCode = user.get('authData').mfa.recovery[0]; + expect(recoveryCode).toBeDefined(); + + // Send concurrent authData-only login requests with the same recovery code + const loginWithRecovery = () => + request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: mfaHeaders, + body: JSON.stringify({ + authData: { + fakeProvider: { id: 'user1', token: 'fakeToken' }, + mfa: { token: recoveryCode }, + }, + }), + }); + + const results = await Promise.allSettled(Array(10).fill().map(() => loginWithRecovery())); + + const succeeded = results.filter(r => r.status === 'fulfilled'); + const failed = results.filter(r => r.status === 'rejected'); + + // Exactly one request should succeed; all others should fail + expect(succeeded.length).toBe(1); + expect(failed.length).toBe(9); + + // Verify the recovery code has been consumed + await user.fetch({ useMasterKey: true }); + const remainingRecovery = user.get('authData').mfa.recovery; + expect(remainingRecovery).not.toContain(recoveryCode); + }); + }); + + describe('(GHSA-jpq4-7fmq-q5fj) SMS MFA single-use token reuse via concurrent requests', () => { + const mfaHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + + let sentToken; + + beforeEach(async () => { + sentToken = null; + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['SMS'], + algorithm: 'SHA1', + digits: 6, + period: 30, + sendSMS: token => { + sentToken = token; + }, + }, + }, + }); + }); + + async function setupSmsMfaUser() { + const user = await Parse.User.signUp('smsmfauser', 'password123'); + // Enroll SMS MFA + await request({ + method: 'PUT', + url: `http://localhost:8378/1/users/${user.id}`, + headers: { + ...mfaHeaders, + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ + authData: { mfa: { mobile: '+15551234567' } }, + }), + }); + const enrollToken = sentToken; + // Confirm enrollment with the received OTP + await request({ + method: 'PUT', + url: `http://localhost:8378/1/users/${user.id}`, + headers: { + ...mfaHeaders, + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ + authData: { mfa: { mobile: '+15551234567', token: enrollToken } }, + }), + }); + sentToken = null; + return user; + } + + async function requestLoginOtp(username, password) { + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: mfaHeaders, + body: JSON.stringify({ + username, + password, + authData: { mfa: { token: 'request' } }, + }), + }); + } catch (_err) { + // Expected: adapter throws "Please enter the token" + } + return sentToken; + } + + it('rejects concurrent logins using the same SMS MFA OTP', async () => { + const user = await setupSmsMfaUser(); + const otp = await requestLoginOtp('smsmfauser', 'password123'); + expect(otp).toBeDefined(); + + const loginWithOtp = () => + request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: mfaHeaders, + body: JSON.stringify({ + username: 'smsmfauser', + password: 'password123', + authData: { mfa: { token: otp } }, + }), + }); + + const results = await Promise.allSettled(Array(10).fill().map(() => loginWithOtp())); + + const succeeded = results.filter(r => r.status === 'fulfilled'); + const failed = results.filter(r => r.status === 'rejected'); + + // Exactly one request should succeed; all others should fail + expect(succeeded.length).toBe(1); + expect(failed.length).toBe(9); + + // Verify the OTP has been consumed + await user.fetch({ useMasterKey: true }); + const mfa = user.get('authData').mfa; + expect(mfa.token).toBeUndefined(); + }); + }); + + describe('(GHSA-p2w6-rmh7-w8q3) SQL Injection via aggregate and distinct field names in PostgreSQL adapter', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + }; + const serverURL = 'http://localhost:8378/1'; + + beforeEach(async () => { + const obj = new Parse.Object('TestClass'); + obj.set('playerName', 'Alice'); + obj.set('score', 100); + obj.set('metadata', { tag: 'hello' }); + await obj.save(null, { useMasterKey: true }); + }); + + describe('aggregate $group._id SQL injection', () => { + it_only_db('postgres')('rejects $group._id field value containing double quotes', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + pipeline: JSON.stringify([ + { + $group: { + _id: { + alias: '$playerName" OR 1=1 --', + }, + }, + }, + ]), + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it_only_db('postgres')('rejects $group._id field value containing semicolons', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + pipeline: JSON.stringify([ + { + $group: { + _id: { + alias: '$playerName"; DROP TABLE "TestClass" --', + }, + }, + }, + ]), + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it_only_db('postgres')('rejects $group._id date operation field value containing double quotes', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + pipeline: JSON.stringify([ + { + $group: { + _id: { + day: { $dayOfMonth: '$createdAt" OR 1=1 --' }, + }, + }, + }, + ]), + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it_only_db('postgres')('allows legitimate $group._id with field reference', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + pipeline: JSON.stringify([ + { + $group: { + _id: { + name: '$playerName', + }, + count: { $sum: 1 }, + }, + }, + ]), + }, + }); + expect(response.data?.results?.length).toBeGreaterThan(0); + }); + + it_only_db('postgres')('allows legitimate $group._id with date extraction', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + pipeline: JSON.stringify([ + { + $group: { + _id: { + day: { $dayOfMonth: '$_created_at' }, + }, + count: { $sum: 1 }, + }, + }, + ]), + }, + }); + expect(response.data?.results?.length).toBeGreaterThan(0); + }); + }); + + describe('distinct dot-notation SQL injection', () => { + it_only_db('postgres')('rejects distinct field name containing double quotes in dot notation', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + distinct: 'metadata" FROM pg_tables; --.tag', + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it_only_db('postgres')('rejects distinct field name containing semicolons in dot notation', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + distinct: 'metadata; DROP TABLE "TestClass" --.tag', + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it_only_db('postgres')('rejects distinct field name containing single quotes in dot notation', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + distinct: "metadata' OR '1'='1.tag", + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it_only_db('postgres')('allows legitimate distinct with dot notation', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + distinct: 'metadata.tag', + }, + }); + expect(response.data?.results).toEqual(['hello']); + }); + + it_only_db('postgres')('allows legitimate distinct without dot notation', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + distinct: 'playerName', + }, + }); + expect(response.data?.results).toEqual(['Alice']); + }); + }); + + describe('(GHSA-37mj-c2wf-cx96) /users/me leaks raw authData via master context', () => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + + it('does not leak raw MFA authData via /users/me', async () => { + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + }); + const user = await Parse.User.signUp('username', 'password'); + const sessionToken = user.getSessionToken(); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + // Enable MFA + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken } + ); + // Verify MFA data is stored (master key) + await user.fetch({ useMasterKey: true }); + expect(user.get('authData').mfa.secret).toBe(secret.base32); + expect(user.get('authData').mfa.recovery).toBeDefined(); + // GET /users/me should NOT include raw MFA data + const response = await request({ + headers: { + ...headers, + 'X-Parse-Session-Token': sessionToken, + }, + method: 'GET', + url: 'http://localhost:8378/1/users/me', + }); + expect(response.data.authData?.mfa?.secret).toBeUndefined(); + expect(response.data.authData?.mfa?.recovery).toBeUndefined(); + expect(response.data.authData?.mfa).toEqual({ status: 'enabled' }); + }); + + it('returns same authData from /users/me and /users/:id', async () => { + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + }); + const user = await Parse.User.signUp('username', 'password'); + const sessionToken = user.getSessionToken(); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + await user.save( + { authData: { mfa: { secret: secret.base32, token: totp.generate() } } }, + { sessionToken } + ); + // Fetch via /users/me + const meResponse = await request({ + headers: { + ...headers, + 'X-Parse-Session-Token': sessionToken, + }, + method: 'GET', + url: 'http://localhost:8378/1/users/me', + }); + // Fetch via /users/:id + const idResponse = await request({ + headers: { + ...headers, + 'X-Parse-Session-Token': sessionToken, + }, + method: 'GET', + url: `http://localhost:8378/1/users/${user.id}`, + }); + // Both should return the same sanitized authData + expect(meResponse.data.authData).toEqual(idResponse.data.authData); + expect(meResponse.data.authData?.mfa).toEqual({ status: 'enabled' }); + }); + }); + + describe('(GHSA-wp76-gg32-8258) /verifyPassword leaks raw authData via missing afterFind', () => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + + it('does not leak raw MFA authData via /verifyPassword', async () => { + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + verifyUserEmails: false, + }); + const user = await Parse.User.signUp('username', 'password'); + const sessionToken = user.getSessionToken(); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + // Enable MFA + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken } + ); + // Verify MFA data is stored (master key) + await user.fetch({ useMasterKey: true }); + expect(user.get('authData').mfa.secret).toBe(secret.base32); + expect(user.get('authData').mfa.recovery).toBeDefined(); + // POST /verifyPassword should NOT include raw MFA data + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/verifyPassword', + body: JSON.stringify({ username: 'username', password: 'password' }), + }); + expect(response.data.authData?.mfa?.secret).toBeUndefined(); + expect(response.data.authData?.mfa?.recovery).toBeUndefined(); + expect(response.data.authData?.mfa).toEqual({ status: 'enabled' }); + }); + + it('does not leak raw MFA authData via GET /verifyPassword', async () => { + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + verifyUserEmails: false, + }); + const user = await Parse.User.signUp('username', 'password'); + const sessionToken = user.getSessionToken(); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + await user.save( + { authData: { mfa: { secret: secret.base32, token: totp.generate() } } }, + { sessionToken } + ); + // GET /verifyPassword should NOT include raw MFA data + const response = await request({ + headers, + method: 'GET', + url: `http://localhost:8378/1/verifyPassword?username=username&password=password`, + }); + expect(response.data.authData?.mfa?.secret).toBeUndefined(); + expect(response.data.authData?.mfa?.recovery).toBeUndefined(); + expect(response.data.authData?.mfa).toEqual({ status: 'enabled' }); + }); + }); + + describe('(GHSA-q3p6-g7c4-829c) GraphQL endpoint ignores allowOrigin server option', () => { + let httpServer; + const gqlPort = 13398; + + const gqlHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'Content-Type': 'application/json', + }; + + async function setupGraphQLServer(serverOptions = {}) { + if (httpServer) { + await new Promise(resolve => httpServer.close(resolve)); + } + const server = await reconfigureServer(serverOptions); + const expressApp = express(); + httpServer = http.createServer(expressApp); + expressApp.use('/parse', server.app); + const parseGraphQLServer = new ParseGraphQLServer(server, { + graphQLPath: '/graphql', + }); + parseGraphQLServer.applyGraphQL(expressApp); + await new Promise(resolve => httpServer.listen({ port: gqlPort }, resolve)); + return parseGraphQLServer; + } + + afterEach(async () => { + if (httpServer) { + await new Promise(resolve => httpServer.close(resolve)); + httpServer = null; + } + }); + + it('should reflect allowed origin when allowOrigin is configured', async () => { + await setupGraphQLServer({ allowOrigin: 'https://example.com' }); + const response = await fetch(`http://localhost:${gqlPort}/graphql`, { + method: 'POST', + headers: { ...gqlHeaders, Origin: 'https://example.com' }, + body: JSON.stringify({ query: '{ health }' }), + }); + expect(response.status).toBe(200); + expect(response.headers.get('access-control-allow-origin')).toBe('https://example.com'); + }); + + it('should not reflect unauthorized origin when allowOrigin is configured', async () => { + await setupGraphQLServer({ allowOrigin: 'https://example.com' }); + const response = await fetch(`http://localhost:${gqlPort}/graphql`, { + method: 'POST', + headers: { ...gqlHeaders, Origin: 'https://unauthorized.example.net' }, + body: JSON.stringify({ query: '{ health }' }), + }); + expect(response.headers.get('access-control-allow-origin')).not.toBe('https://unauthorized.example.net'); + expect(response.headers.get('access-control-allow-origin')).toBe('https://example.com'); + }); + + it('should support multiple allowed origins', async () => { + await setupGraphQLServer({ allowOrigin: ['https://a.example.com', 'https://b.example.com'] }); + const responseA = await fetch(`http://localhost:${gqlPort}/graphql`, { + method: 'POST', + headers: { ...gqlHeaders, Origin: 'https://a.example.com' }, + body: JSON.stringify({ query: '{ health }' }), + }); + expect(responseA.headers.get('access-control-allow-origin')).toBe('https://a.example.com'); + + const responseB = await fetch(`http://localhost:${gqlPort}/graphql`, { + method: 'POST', + headers: { ...gqlHeaders, Origin: 'https://b.example.com' }, + body: JSON.stringify({ query: '{ health }' }), + }); + expect(responseB.headers.get('access-control-allow-origin')).toBe('https://b.example.com'); + + const responseUnauthorized = await fetch(`http://localhost:${gqlPort}/graphql`, { + method: 'POST', + headers: { ...gqlHeaders, Origin: 'https://unauthorized.example.net' }, + body: JSON.stringify({ query: '{ health }' }), + }); + expect(responseUnauthorized.headers.get('access-control-allow-origin')).not.toBe('https://unauthorized.example.net'); + expect(responseUnauthorized.headers.get('access-control-allow-origin')).toBe('https://a.example.com'); + }); + + it('should default to wildcard when allowOrigin is not configured', async () => { + await setupGraphQLServer(); + const response = await fetch(`http://localhost:${gqlPort}/graphql`, { + method: 'POST', + headers: { ...gqlHeaders, Origin: 'https://example.com' }, + body: JSON.stringify({ query: '{ health }' }), + }); + expect(response.headers.get('access-control-allow-origin')).toBe('*'); + }); + + it('should handle OPTIONS preflight with configured allowOrigin', async () => { + await setupGraphQLServer({ allowOrigin: 'https://example.com' }); + const response = await fetch(`http://localhost:${gqlPort}/graphql`, { + method: 'OPTIONS', + headers: { + Origin: 'https://example.com', + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'X-Parse-Application-Id, Content-Type', + }, + }); + expect(response.status).toBe(200); + expect(response.headers.get('access-control-allow-origin')).toBe('https://example.com'); + }); + + it('should not reflect unauthorized origin in OPTIONS preflight', async () => { + await setupGraphQLServer({ allowOrigin: 'https://example.com' }); + const response = await fetch(`http://localhost:${gqlPort}/graphql`, { + method: 'OPTIONS', + headers: { + Origin: 'https://unauthorized.example.net', + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'X-Parse-Application-Id, Content-Type', + }, + }); + expect(response.headers.get('access-control-allow-origin')).not.toBe('https://unauthorized.example.net'); + expect(response.headers.get('access-control-allow-origin')).toBe('https://example.com'); + }); + }); + }); + + describe('(GHSA-445j-ww4h-339m) Cloud Code trigger context prototype poisoning via X-Parse-Cloud-Context header', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('accepts __proto__ in X-Parse-Cloud-Context header', async () => { + // Context is client-controlled metadata for Cloud Code triggers and is not subject + // to requestKeywordDenylist. The __proto__ key is allowed but must not cause + // prototype pollution (verified by separate tests below). + Parse.Cloud.beforeSave('ContextTest', () => {}); + const response = await request({ + headers: { + ...headers, + 'X-Parse-Cloud-Context': JSON.stringify( + JSON.parse('{"__proto__": {"isAdmin": true}}') + ), + }, + method: 'POST', + url: 'http://localhost:8378/1/classes/ContextTest', + body: JSON.stringify({ foo: 'bar' }), + }).catch(e => e); + expect(response.status).toBe(201); + }); + + it('accepts constructor in X-Parse-Cloud-Context header', async () => { + Parse.Cloud.beforeSave('ContextTest', () => {}); + const response = await request({ + headers: { + ...headers, + 'X-Parse-Cloud-Context': JSON.stringify({ constructor: { prototype: { dummy: 0 } } }), + }, + method: 'POST', + url: 'http://localhost:8378/1/classes/ContextTest', + body: JSON.stringify({ foo: 'bar' }), + }).catch(e => e); + expect(response.status).toBe(201); + expect(Object.prototype.dummy).toBeUndefined(); + }); + + it('accepts __proto__ in _context body field', async () => { + Parse.Cloud.beforeSave('ContextTest', () => {}); + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/ContextTest', + headers: { + 'X-Parse-REST-API-Key': 'rest', + }, + body: { + foo: 'bar', + _ApplicationId: 'test', + _context: JSON.stringify(JSON.parse('{"__proto__": {"isAdmin": true}}')), + }, + }).catch(e => e); + expect(response.status).toBe(201); + }); + + it('does not pollute request.context prototype via X-Parse-Cloud-Context header', async () => { + let contextInTrigger; + Parse.Cloud.beforeSave('ContextTest', req => { + contextInTrigger = req.context; + }); + const response = await request({ + headers: { + ...headers, + 'X-Parse-Cloud-Context': JSON.stringify( + JSON.parse('{"__proto__": {"isAdmin": true}}') + ), + }, + method: 'POST', + url: 'http://localhost:8378/1/classes/ContextTest', + body: JSON.stringify({ foo: 'bar' }), + }).catch(e => e); + expect(response.status).toBe(201); + expect(contextInTrigger).toBeDefined(); + expect(contextInTrigger.isAdmin).toBeUndefined(); + expect(Object.getPrototypeOf(contextInTrigger)).not.toEqual( + jasmine.objectContaining({ isAdmin: true }) + ); + }); + + it('does not pollute request.context prototype via _context body field', async () => { + let contextInTrigger; + Parse.Cloud.beforeSave('ContextTest', req => { + contextInTrigger = req.context; + }); + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/ContextTest', + headers: { + 'X-Parse-REST-API-Key': 'rest', + }, + body: { + foo: 'bar', + _ApplicationId: 'test', + _context: JSON.stringify(JSON.parse('{"__proto__": {"isAdmin": true}}')), + }, + }).catch(e => e); + expect(response.status).toBe(201); + expect(contextInTrigger).toBeDefined(); + expect(contextInTrigger.isAdmin).toBeUndefined(); + expect(Object.getPrototypeOf(contextInTrigger)).not.toEqual( + jasmine.objectContaining({ isAdmin: true }) + ); + }); + + it('does not allow prototype-polluted properties to survive deletion in trigger context', async () => { + // This test verifies that __proto__ pollution cannot bypass context property deletion. + // When a developer deletes a context property, prototype-polluted properties would + // survive the deletion (unlike directly set properties), creating a security gap. + let contextAfterDelete; + Parse.Cloud.beforeSave('ContextTest', req => { + delete req.context.isAdmin; + contextAfterDelete = { isAdmin: req.context.isAdmin }; + }); + const response = await request({ + headers: { + ...headers, + 'X-Parse-Cloud-Context': JSON.stringify( + JSON.parse('{"__proto__": {"isAdmin": true}}') + ), + }, + method: 'POST', + url: 'http://localhost:8378/1/classes/ContextTest', + body: JSON.stringify({ foo: 'bar' }), + }).catch(e => e); + expect(response.status).toBe(201); + expect(contextAfterDelete).toBeDefined(); + expect(contextAfterDelete.isAdmin).toBeUndefined(); + }); + }); + + describe('(GHSA-hpm8-9qx6-jvwv) Ranged file download bypasses afterFind(Parse.File) trigger and validators', () => { + it_only_db('mongo')('enforces afterFind requireUser validator on streaming file download', async () => { + const file = new Parse.File('secret.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + Parse.Cloud.afterFind(Parse.File, () => {}, { requireUser: true }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Range': 'bytes=0-2', + }, + }).catch(e => e); + expect(response.status).toBe(403); + }); + + it('enforces afterFind requireUser validator on non-streaming file download', async () => { + const file = new Parse.File('secret.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + Parse.Cloud.afterFind(Parse.File, () => {}, { requireUser: true }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }).catch(e => e); + expect(response.status).toBe(403); + }); + + it_only_db('mongo')('allows streaming file download when afterFind requireUser validator passes', async () => { + const file = new Parse.File('secret.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const user = await Parse.User.signUp('username', 'password'); + Parse.Cloud.afterFind(Parse.File, () => {}, { requireUser: true }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + 'Range': 'bytes=0-2', + }, + }).catch(e => e); + expect(response.status).toBe(206); + }); + + it_only_db('mongo')('enforces afterFind custom authorization on streaming file download', async () => { + const file = new Parse.File('secret.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + Parse.Cloud.afterFind(Parse.File, () => { + throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Access denied'); + }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Range': 'bytes=0-2', + }, + }).catch(e => e); + expect(response.status).toBe(403); + }); + }); + + describe('(GHSA-g4v2-qx3q-4p64) /sessions/me bypasses _Session protectedFields', () => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + + it('should not return protected fields on GET /sessions/me', async () => { + await reconfigureServer({ + protectedFields: { + _Session: { '*': ['createdWith'] }, + }, + }); + const user = new Parse.User(); + user.setUsername('session-pf-user'); + user.setPassword('password123'); + user.set('email', 'session-pf@example.com'); + await user.signUp(); + const sessionToken = user.getSessionToken(); + + // Normal GET /sessions should strip createdWith + const sessionsResponse = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions', + headers: { ...headers, 'X-Parse-Session-Token': sessionToken }, + }); + expect(sessionsResponse.data.results[0].createdWith).toBeUndefined(); + + // GET /sessions/me should also strip createdWith + const meResponse = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { ...headers, 'X-Parse-Session-Token': sessionToken }, + }); + expect(meResponse.data.createdWith).toBeUndefined(); + }); + + it('should return non-protected fields on GET /sessions/me', async () => { + await reconfigureServer({ + protectedFields: { + _Session: { '*': ['createdWith'] }, + }, + }); + const user = new Parse.User(); + user.setUsername('session-pf-user2'); + user.setPassword('password123'); + user.set('email', 'session-pf2@example.com'); + await user.signUp(); + const sessionToken = user.getSessionToken(); + + const meResponse = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { ...headers, 'X-Parse-Session-Token': sessionToken }, + }); + expect(meResponse.data.sessionToken).toBe(sessionToken); + expect(meResponse.data.objectId).toBeDefined(); + expect(meResponse.data.user).toBeDefined(); + }); + + it('should return protected fields on GET /sessions/me with master key', async () => { + await reconfigureServer({ + protectedFields: { + _Session: { '*': ['createdWith'] }, + }, + }); + const user = new Parse.User(); + user.setUsername('session-pf-mk'); + user.setPassword('password123'); + user.set('email', 'session-pf-mk@example.com'); + await user.signUp(); + const sessionToken = user.getSessionToken(); + + const meResponse = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + ...headers, + 'X-Parse-Session-Token': sessionToken, + 'X-Parse-Master-Key': 'test', + }, + }); + expect(meResponse.data.createdWith).toBeDefined(); + expect(meResponse.data.sessionToken).toBe(sessionToken); + }); + }); +}); diff --git a/src/AccountLockout.js b/src/AccountLockout.js new file mode 100644 index 0000000000..f8371ff436 --- /dev/null +++ b/src/AccountLockout.js @@ -0,0 +1,156 @@ +// This class handles the Account Lockout Policy settings. +import Parse from 'parse/node'; + +export class AccountLockout { + constructor(user, config) { + this._user = user; + this._config = config; + } + + /** + * set _failed_login_count to value + */ + _setFailedLoginCount(value) { + const query = { + username: this._user.username, + }; + + const updateFields = { + _failed_login_count: value, + }; + + return this._config.database.update('_User', query, updateFields); + } + + /** + * increment _failed_login_count by 1 and return the updated document + */ + _incrementFailedLoginCount() { + const query = { + username: this._user.username, + }; + + const updateFields = { + _failed_login_count: { __op: 'Increment', amount: 1 }, + }; + + return this._config.database.update('_User', query, updateFields); + } + + /** + * if the failed login count is greater than the threshold + * then sets lockout expiration to 'currenttime + accountPolicy.duration', i.e., account is locked out for the next 'accountPolicy.duration' minutes + * else do nothing + */ + _setLockoutExpiration() { + const query = { + username: this._user.username, + _failed_login_count: { $gte: this._config.accountLockout.threshold }, + }; + + const now = new Date(); + + const updateFields = { + _account_lockout_expires_at: Parse._encode( + new Date(now.getTime() + this._config.accountLockout.duration * 60 * 1000) + ), + }; + + return this._config.database.update('_User', query, updateFields).catch(err => { + if ( + err && + err.code && + err.message && + err.code === Parse.Error.OBJECT_NOT_FOUND && + err.message === 'Object not found.' + ) { + return; // nothing to update so we are good + } else { + throw err; // unknown error + } + }); + } + + /** + * if _account_lockout_expires_at > current_time and _failed_login_count > threshold + * reject with account locked error + * else + * resolve + */ + _notLocked() { + const query = { + username: this._user.username, + _account_lockout_expires_at: { $gt: Parse._encode(new Date()) }, + _failed_login_count: { $gte: this._config.accountLockout.threshold }, + }; + + return this._config.database.find('_User', query).then(users => { + if (Array.isArray(users) && users.length > 0) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Your account is locked due to multiple failed login attempts. Please try again after ' + + this._config.accountLockout.duration + + ' minute(s)' + ); + } + }); + } + + /** + * Atomically increment _failed_login_count and enforce lockout threshold. + * Uses the atomic increment result to determine the exact post-increment + * count, eliminating the TOCTOU race between checking and updating. + */ + _handleFailedLoginAttempt() { + return this._incrementFailedLoginCount().then(result => { + const count = result._failed_login_count; + if (count >= this._config.accountLockout.threshold) { + return this._setLockoutExpiration().then(() => { + if (count > this._config.accountLockout.threshold) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Your account is locked due to multiple failed login attempts. Please try again after ' + + this._config.accountLockout.duration + + ' minute(s)' + ); + } + }); + } + }); + } + + /** + * handle login attempt if the Account Lockout Policy is enabled + */ + handleLoginAttempt(loginSuccessful) { + if (!this._config.accountLockout) { + return Promise.resolve(); + } + return this._notLocked().then(() => { + if (loginSuccessful) { + return this._setFailedLoginCount(0); + } else { + return this._handleFailedLoginAttempt(); + } + }); + } + + /** + * Removes the account lockout. + */ + unlockAccount() { + if (!this._config.accountLockout || !this._config.accountLockout.unlockOnPasswordReset) { + return Promise.resolve(); + } + return this._config.database.update( + '_User', + { username: this._user.username }, + { + _failed_login_count: { __op: 'Delete' }, + _account_lockout_expires_at: { __op: 'Delete' }, + } + ); + } +} + +export default AccountLockout; diff --git a/src/Adapters/AdapterLoader.js b/src/Adapters/AdapterLoader.js index d720fb99da..61f7690f57 100644 --- a/src/Adapters/AdapterLoader.js +++ b/src/Adapters/AdapterLoader.js @@ -1,14 +1,25 @@ -export function loadAdapter(adapter, defaultAdapter, options) { +/** + * @module AdapterLoader + */ +/** + * @static + * Attempt to load an adapter or fallback to the default. + * @param {Adapter} adapter an adapter + * @param {Adapter} defaultAdapter the default adapter to load + * @param {any} options options to pass to the contstructor + * @returns {Object} the loaded adapter + */ +export function loadAdapter(adapter, defaultAdapter, options): T { if (!adapter) { if (!defaultAdapter) { return options; } // Load from the default adapter when no adapter is set return loadAdapter(defaultAdapter, undefined, options); - } else if (typeof adapter === "function") { + } else if (typeof adapter === 'function') { try { return adapter(options); - } catch(e) { + } catch (e) { if (e.name === 'TypeError') { var Adapter = adapter; return new Adapter(options); @@ -16,7 +27,7 @@ export function loadAdapter(adapter, defaultAdapter, options) { throw e; } } - } else if (typeof adapter === "string") { + } else if (typeof adapter === 'string') { adapter = require(adapter); // If it's define as a module, get the default if (adapter.default) { @@ -34,4 +45,9 @@ export function loadAdapter(adapter, defaultAdapter, options) { return adapter; } +export async function loadModule(modulePath) { + const module = await import(modulePath); + return module?.default || module; +} + export default loadAdapter; diff --git a/src/Adapters/Analytics/AnalyticsAdapter.js b/src/Adapters/Analytics/AnalyticsAdapter.js index 48dd272b3c..380d869a5e 100644 --- a/src/Adapters/Analytics/AnalyticsAdapter.js +++ b/src/Adapters/Analytics/AnalyticsAdapter.js @@ -1,8 +1,22 @@ +/* eslint-disable unused-imports/no-unused-vars */ +/** + * @interface AnalyticsAdapter + * @module Adapters + */ export class AnalyticsAdapter { + /** + @param {any} parameters: the analytics request body, analytics info will be in the dimensions property + @param {Request} req: the original http request + */ appOpened(parameters, req) { return Promise.resolve({}); } + /** + @param {String} eventName: the name of the custom eventName + @param {any} parameters: the analytics request body, analytics info will be in the dimensions property + @param {Request} req: the original http request + */ trackEvent(eventName, parameters, req) { return Promise.resolve({}); } diff --git a/src/Adapters/Auth/AuthAdapter.js b/src/Adapters/Auth/AuthAdapter.js new file mode 100644 index 0000000000..ebcea4d3d8 --- /dev/null +++ b/src/Adapters/Auth/AuthAdapter.js @@ -0,0 +1,127 @@ +/* eslint-disable unused-imports/no-unused-vars */ + +/** + * @interface ParseAuthResponse + * @property {Boolean} [doNotSave] If true, Parse Server will not save provided authData. + * @property {Object} [response] If set, Parse Server will send the provided response to the client under authDataResponse + * @property {Object} [save] If set, Parse Server will save the object provided into this key, instead of client provided authData + */ + +/** + * AuthPolicy + * default: can be combined with ONE additional auth provider if additional configured on user + * additional: could be only used with a default policy auth provider + * solo: Will ignore ALL additional providers if additional configured on user + * @typedef {"default" | "additional" | "solo"} AuthPolicy + */ + +export class AuthAdapter { + constructor() { + /** + * Usage policy + * @type {AuthPolicy} + */ + if (!this.policy) { + this.policy = 'default'; + } + } + /** + * @param appIds The specified app IDs in the configuration + * @param {Object} authData The client provided authData + * @param {Object} options additional adapter options + * @param {Parse.Cloud.TriggerRequest} request + * @returns {(Promise|void|undefined)} resolves or returns if the applicationId is valid + */ + validateAppId(appIds, authData, options, request) { + return Promise.resolve({}); + } + + /** + * Legacy usage, if provided it will be triggered when authData related to this provider is touched (signup/update/login) + * otherwise you should implement validateSetup, validateLogin and validateUpdate + * @param {Object} authData The client provided authData + * @param {Object} options additional adapter options + * @param {Parse.Cloud.TriggerRequest} request + * @returns {Promise} + */ + validateAuthData(authData, options, request) { + return Promise.resolve({}); + } + + /** + * Triggered when user provide for the first time this auth provider + * could be a register or the user adding a new auth service + * @param {Object} authData The client provided authData + * @param {Object} options additional adapter options + * @param {Parse.Cloud.TriggerRequest} request + * @returns {Promise} + */ + validateSetUp(authData, options, req) { + return Promise.resolve({}); + } + + /** + * Triggered when user provide authData related to this provider + * The user is not logged in and has already set this provider before + * @param {Object} authData The client provided authData + * @param {Object} options additional adapter options + * @param {Parse.Cloud.TriggerRequest} request + * @returns {Promise} + */ + validateLogin(authData, options, req) { + return Promise.resolve({}); + } + + /** + * Triggered when user provide authData related to this provider + * the user is logged in and has already set this provider before + * @param {Object} authData The client provided authData + * @param {Object} options additional adapter options + * @param {Parse.Cloud.TriggerRequest} request + * @returns {Promise} + */ + validateUpdate(authData, options, req) { + return Promise.resolve({}); + } + + /** + * Triggered when user is looked up by authData with this provider. Override the `id` field if needed. + * @param {Object} authData The client provided authData + */ + beforeFind(authData) { + + } + + /** + * Triggered in pre authentication process if needed (like webauthn, SMS OTP) + * @param {Object} challengeData Data provided by the client + * @param {(Object|undefined)} authData Auth data provided by the client, can be used for validation + * @param {Object} options additional adapter options + * @param {Parse.Cloud.TriggerRequest} request + * @returns {Promise} A promise that resolves, resolved value will be added to challenge response under challenge key + */ + challenge(challengeData, authData, options, request) { + return Promise.resolve({}); + } + + /** + * Triggered when auth data is fetched + * @param {Object} authData authData + * @param {Object} options additional adapter options + * @param {Parse.Cloud.TriggerRequest} request + * @returns {Promise} Any overrides required to authData + */ + afterFind(authData, options, request) { + return Promise.resolve({}); + } + + /** + * Triggered when the adapter is first attached to Parse Server + * @param {Object} options Adapter Options + */ + validateOptions(options) { + /* */ + } +} + +export default AuthAdapter; diff --git a/src/Adapters/Auth/BaseCodeAuthAdapter.js b/src/Adapters/Auth/BaseCodeAuthAdapter.js new file mode 100644 index 0000000000..52f12f8703 --- /dev/null +++ b/src/Adapters/Auth/BaseCodeAuthAdapter.js @@ -0,0 +1,127 @@ +// abstract class for auth code adapters +import AuthAdapter from './AuthAdapter'; +export default class BaseAuthCodeAdapter extends AuthAdapter { + constructor(adapterName) { + super(); + this.adapterName = adapterName; + } + validateOptions(options) { + + if (!options) { + throw new Error(`${this.adapterName} options are required.`); + } + + this.enableInsecureAuth = options.enableInsecureAuth; + if (this.enableInsecureAuth) { + return; + } + + this.clientId = options.clientId; + this.clientSecret = options.clientSecret; + + if (!this.clientId) { + throw new Error(`${this.adapterName} clientId is required.`); + } + + if (!this.clientSecret) { + throw new Error(`${this.adapterName} clientSecret is required.`); + } + } + + async beforeFind(authData) { + if (this.enableInsecureAuth && !authData?.code) { + if (!authData?.access_token) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${this.adapterName} auth is invalid for this user.`); + } + + const user = await this.getUserFromAccessToken(authData.access_token, authData); + + if (user.id !== authData.id) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${this.adapterName} auth is invalid for this user.`); + } + + return; + } + + if (!authData?.code) { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, `${this.adapterName} code is required.`); + } + + const access_token = await this.getAccessTokenFromCode(authData); + const user = await this.getUserFromAccessToken(access_token, authData); + + if (authData.id && user.id !== authData.id) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${this.adapterName} auth is invalid for this user.`); + } + + authData.access_token = access_token; + authData.id = user.id; + + delete authData.code; + delete authData.redirect_uri; + + } + + async getUserFromAccessToken() { + // abstract method + throw new Error('getUserFromAccessToken is not implemented'); + } + + async getAccessTokenFromCode() { + // abstract method + throw new Error('getAccessTokenFromCode is not implemented'); + } + + /** + * Validates auth data on login. In the standard auth flows (login, signup, + * update), `beforeFind` runs first and validates credentials, so no + * additional credential check is needed here. + */ + validateLogin(authData) { + return { + id: authData.id, + } + } + + /** + * Validates auth data on first setup or when linking a new provider. + * In the standard auth flows, `beforeFind` runs first and validates + * credentials, so no additional credential check is needed here. + */ + validateSetUp(authData) { + return { + id: authData.id, + } + } + + /** + * Returns the auth data to expose to the client after a query. + */ + afterFind(authData) { + return { + id: authData.id, + } + } + + /** + * Validates auth data on update. In the standard auth flows, `beforeFind` + * runs first for any changed auth data and validates credentials, so no + * additional credential check is needed here. Unchanged (echoed-back) data + * skips both `beforeFind` and validation entirely. + */ + validateUpdate(authData) { + return { + id: authData.id, + } + } + + parseResponseData(data) { + const startPos = data.indexOf('('); + const endPos = data.indexOf(')'); + if (startPos === -1 || endPos === -1) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${this.adapterName} auth is invalid for this user.`); + } + const jsonData = data.substring(startPos + 1, endPos); + return JSON.parse(jsonData); + } +} diff --git a/src/Adapters/Auth/OAuth1Client.js b/src/Adapters/Auth/OAuth1Client.js new file mode 100644 index 0000000000..fec508ba8b --- /dev/null +++ b/src/Adapters/Auth/OAuth1Client.js @@ -0,0 +1,231 @@ +var https = require('https'), + crypto = require('crypto'); +var Parse = require('parse/node').Parse; + +var OAuth = function (options) { + if (!options) { + throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'No options passed to OAuth'); + } + this.consumer_key = options.consumer_key; + this.consumer_secret = options.consumer_secret; + this.auth_token = options.auth_token; + this.auth_token_secret = options.auth_token_secret; + this.host = options.host; + this.oauth_params = options.oauth_params || {}; +}; + +OAuth.prototype.send = function (method, path, params, body) { + var request = this.buildRequest(method, path, params, body); + // Encode the body properly, the current Parse Implementation don't do it properly + return new Promise(function (resolve, reject) { + var httpRequest = https + .request(request, function (res) { + var data = ''; + res.on('data', function (chunk) { + data += chunk; + }); + res.on('end', function () { + data = JSON.parse(data); + resolve(data); + }); + }) + .on('error', function () { + reject('Failed to make an OAuth request'); + }); + if (request.body) { + httpRequest.write(request.body); + } + httpRequest.end(); + }); +}; + +OAuth.prototype.buildRequest = function (method, path, params, body) { + if (path.indexOf('/') != 0) { + path = '/' + path; + } + if (params && Object.keys(params).length > 0) { + path += '?' + OAuth.buildParameterString(params); + } + + var request = { + host: this.host, + path: path, + method: method.toUpperCase(), + }; + + var oauth_params = this.oauth_params || {}; + oauth_params.oauth_consumer_key = this.consumer_key; + if (this.auth_token) { + oauth_params['oauth_token'] = this.auth_token; + } + + request = OAuth.signRequest(request, oauth_params, this.consumer_secret, this.auth_token_secret); + + if (body && Object.keys(body).length > 0) { + request.body = OAuth.buildParameterString(body); + } + return request; +}; + +OAuth.prototype.get = function (path, params) { + return this.send('GET', path, params); +}; + +OAuth.prototype.post = function (path, params, body) { + return this.send('POST', path, params, body); +}; + +/* + Proper string %escape encoding +*/ +OAuth.encode = function (str) { + // discuss at: http://phpjs.org/functions/rawurlencode/ + // original by: Brett Zamir (http://brett-zamir.me) + // input by: travc + // input by: Brett Zamir (http://brett-zamir.me) + // input by: Michael Grier + // input by: Ratheous + // bugfixed by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) + // bugfixed by: Brett Zamir (http://brett-zamir.me) + // bugfixed by: Joris + // reimplemented by: Brett Zamir (http://brett-zamir.me) + // reimplemented by: Brett Zamir (http://brett-zamir.me) + // note: This reflects PHP 5.3/6.0+ behavior + // note: Please be aware that this function expects to encode into UTF-8 encoded strings, as found on + // note: pages served as UTF-8 + // example 1: rawurlencode('Kevin van Zonneveld!'); + // returns 1: 'Kevin%20van%20Zonneveld%21' + // example 2: rawurlencode('http://kevin.vanzonneveld.net/'); + // returns 2: 'http%3A%2F%2Fkevin.vanzonneveld.net%2F' + // example 3: rawurlencode('http://www.google.nl/search?q=php.js&ie=utf-8&oe=utf-8&aq=t&rls=com.ubuntu:en-US:unofficial&client=firefox-a'); + // returns 3: 'http%3A%2F%2Fwww.google.nl%2Fsearch%3Fq%3Dphp.js%26ie%3Dutf-8%26oe%3Dutf-8%26aq%3Dt%26rls%3Dcom.ubuntu%3Aen-US%3Aunofficial%26client%3Dfirefox-a' + + str = (str + '').toString(); + + // Tilde should be allowed unescaped in future versions of PHP (as reflected below), but if you want to reflect current + // PHP behavior, you would need to add ".replace(/~/g, '%7E');" to the following. + return encodeURIComponent(str) + .replace(/!/g, '%21') + .replace(/'/g, '%27') + .replace(/\(/g, '%28') + .replace(/\)/g, '%29') + .replace(/\*/g, '%2A'); +}; + +OAuth.signatureMethod = 'HMAC-SHA1'; +OAuth.version = '1.0'; + +/* + Generate a nonce +*/ +OAuth.nonce = function () { + var text = ''; + var possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + + for (var i = 0; i < 30; i++) { text += possible.charAt(Math.floor(Math.random() * possible.length)); } + + return text; +}; + +OAuth.buildParameterString = function (obj) { + // Sort keys and encode values + if (obj) { + var keys = Object.keys(obj).sort(); + + // Map key=value, join them by & + return keys + .map(function (key) { + return key + '=' + OAuth.encode(obj[key]); + }) + .join('&'); + } + + return ''; +}; + +/* + Build the signature string from the object +*/ + +OAuth.buildSignatureString = function (method, url, parameters) { + return [method.toUpperCase(), OAuth.encode(url), OAuth.encode(parameters)].join('&'); +}; + +/* + Retuns encoded HMAC-SHA1 from key and text +*/ +OAuth.signature = function (text, key) { + crypto = require('crypto'); + return OAuth.encode(crypto.createHmac('sha1', key).update(text).digest('base64')); +}; + +OAuth.signRequest = function (request, oauth_parameters, consumer_secret, auth_token_secret) { + oauth_parameters = oauth_parameters || {}; + + // Set default values + if (!oauth_parameters.oauth_nonce) { + oauth_parameters.oauth_nonce = OAuth.nonce(); + } + if (!oauth_parameters.oauth_timestamp) { + oauth_parameters.oauth_timestamp = Math.floor(new Date().getTime() / 1000); + } + if (!oauth_parameters.oauth_signature_method) { + oauth_parameters.oauth_signature_method = OAuth.signatureMethod; + } + if (!oauth_parameters.oauth_version) { + oauth_parameters.oauth_version = OAuth.version; + } + + if (!auth_token_secret) { + auth_token_secret = ''; + } + // Force GET method if unset + if (!request.method) { + request.method = 'GET'; + } + + // Collect all the parameters in one signatureParameters object + var signatureParams = {}; + var parametersToMerge = [request.params, request.body, oauth_parameters]; + for (var i in parametersToMerge) { + var parameters = parametersToMerge[i]; + for (var k in parameters) { + signatureParams[k] = parameters[k]; + } + } + + // Create a string based on the parameters + var parameterString = OAuth.buildParameterString(signatureParams); + + // Build the signature string + var url = 'https://' + request.host + '' + request.path; + + var signatureString = OAuth.buildSignatureString(request.method, url, parameterString); + // Hash the signature string + var signatureKey = [OAuth.encode(consumer_secret), OAuth.encode(auth_token_secret)].join('&'); + + var signature = OAuth.signature(signatureString, signatureKey); + + // Set the signature in the params + oauth_parameters.oauth_signature = signature; + if (!request.headers) { + request.headers = {}; + } + + // Set the authorization header + var authHeader = Object.keys(oauth_parameters) + .sort() + .map(function (key) { + var value = oauth_parameters[key]; + return key + '="' + value + '"'; + }) + .join(', '); + + request.headers.Authorization = 'OAuth ' + authHeader; + + // Set the content type header + request.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + return request; +}; + +module.exports = OAuth; diff --git a/src/Adapters/Auth/apple.js b/src/Adapters/Auth/apple.js new file mode 100644 index 0000000000..35cb59291f --- /dev/null +++ b/src/Adapters/Auth/apple.js @@ -0,0 +1,135 @@ +/** + * Parse Server authentication adapter for Apple. + * + * @class AppleAdapter + * @param {Object} options - Configuration options for the adapter. + * @param {string} options.clientId - Your Apple App ID. + * + * @param {Object} authData - The authentication data provided by the client. + * @param {string} authData.id - The user ID obtained from Apple. + * @param {string} authData.token - The token obtained from Apple. + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Apple authentication, use the following structure: + * ```json + * { + * "auth": { + * "apple": { + * "clientId": "12345" + * } + * } + * } + * ``` + * + * ## Expected `authData` from the Client + * The adapter expects the client to provide the following `authData` payload: + * - `authData.id` (**string**, required): The user ID obtained from Apple. + * - `authData.token` (**string**, required): The token obtained from Apple. + * + * Parse Server stores the required authentication data in the database. + * + * ### Example AuthData from Apple + * ```json + * { + * "apple": { + * "id": "1234567", + * "token": "xxxxx.yyyyy.zzzzz" + * } + * } + * ``` + * + * @see {@link https://developer.apple.com/documentation/signinwithapplerestapi Sign in with Apple REST API Documentation} + */ + +// Apple SignIn Auth +// https://developer.apple.com/documentation/signinwithapplerestapi + +const Parse = require('parse/node').Parse; +const jwksClient = require('jwks-rsa'); +const jwt = require('jsonwebtoken'); +const authUtils = require('./utils'); + +const TOKEN_ISSUER = 'https://appleid.apple.com'; + +const getAppleKeyByKeyId = async (keyId, cacheMaxEntries, cacheMaxAge) => { + const client = jwksClient({ + jwksUri: `${TOKEN_ISSUER}/auth/keys`, + cache: true, + cacheMaxEntries, + cacheMaxAge, + }); + + let key; + try { + key = await authUtils.getSigningKey(client, keyId); + } catch { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `Unable to find matching key for Key ID: ${keyId}` + ); + } + return key; +}; + +const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMaxAge }) => { + if (!clientId) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Apple auth is not configured.' + ); + } + + if (!token) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `id token is invalid for this user.`); + } + + const { kid: keyId } = authUtils.getHeaderFromToken(token); + const ONE_HOUR_IN_MS = 3600000; + let jwtClaims; + + cacheMaxAge = cacheMaxAge || ONE_HOUR_IN_MS; + cacheMaxEntries = cacheMaxEntries || 5; + + const appleKey = await getAppleKeyByKeyId(keyId, cacheMaxEntries, cacheMaxAge); + const signingKey = appleKey.publicKey || appleKey.rsaPublicKey; + + try { + jwtClaims = jwt.verify(token, signingKey, { + algorithms: ['RS256'], + // the audience can be checked against a string, a regular expression or a list of strings and/or regular expressions. + audience: clientId, + }); + } catch (exception) { + const message = exception.message; + + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${message}`); + } + + if (jwtClaims.iss !== TOKEN_ISSUER) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `id token not issued by correct OpenID provider - expected: ${TOKEN_ISSUER} | from: ${jwtClaims.iss}` + ); + } + + if (jwtClaims.sub !== id) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `auth data is invalid for this user.`); + } + return jwtClaims; +}; + +// Returns a promise that fulfills if this id token is valid +function validateAuthData(authData, options = {}) { + return verifyIdToken(authData, options); +} + +// Returns a promise that fulfills if this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +module.exports = { + validateAppId, + validateAuthData, +}; diff --git a/src/Adapters/Auth/facebook.js b/src/Adapters/Auth/facebook.js new file mode 100644 index 0000000000..10adcd810b --- /dev/null +++ b/src/Adapters/Auth/facebook.js @@ -0,0 +1,208 @@ +/** + * Parse Server authentication adapter for Facebook. + * + * @class FacebookAdapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.appSecret - Your Facebook App Secret. Required for secure authentication. + * @param {string[]} options.appIds - An array of Facebook App IDs. Required for validating the app. + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Facebook authentication, use the following structure: + * ```json + * { + * "auth": { + * "facebook": { + * "appSecret": "your-app-secret", + * "appIds": ["your-app-id"] + * } + * } + * } + * ``` + * + * The adapter supports the following authentication methods: + * - **Standard Login**: Requires `id` and `access_token`. + * - **Limited Login**: Requires `id` and `token`. + * + * ## Auth Payloads + * ### Standard Login Payload + * ```json + * { + * "facebook": { + * "id": "1234567", + * "access_token": "abc123def456ghi789" + * } + * } + * ``` + * + * ### Limited Login Payload + * ```json + * { + * "facebook": { + * "id": "1234567", + * "token": "xxxxx.yyyyy.zzzzz" + * } + * } + * ``` + * + * ## Notes + * - **Standard Login**: Use `id` and `access_token` for full functionality. + * - **Limited Login**: Use `id` and `token` (JWT) when tracking is opted out (e.g., via Apple's App Tracking Transparency). + * - Supported Parse Server versions: + * - `>= 6.5.6 < 7` + * - `>= 7.0.1` + * + * @see {@link https://developers.facebook.com/docs/facebook-login/limited-login/ Facebook Limited Login} + * @see {@link https://developers.facebook.com/docs/facebook-login/facebook-login-for-business/ Facebook Login for Business} + */ + +// Helper functions for accessing the Facebook Graph API. +const Parse = require('parse/node').Parse; +const crypto = require('crypto'); +const jwksClient = require('jwks-rsa'); +const jwt = require('jsonwebtoken'); +const httpsRequest = require('./httpsRequest'); +const authUtils = require('./utils'); + +const TOKEN_ISSUER = 'https://www.facebook.com'; + +function getAppSecretPath(authData, options = {}) { + const appSecret = options.appSecret; + if (!appSecret) { + return ''; + } + const appsecret_proof = crypto + .createHmac('sha256', appSecret) + .update(authData.access_token) + .digest('hex'); + + return `&appsecret_proof=${appsecret_proof}`; +} + +function validateGraphToken(authData, options = {}) { + if (!Array.isArray(options.appIds) || !options.appIds.length) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Facebook auth is not configured.'); + } + return graphRequest( + 'me?fields=id&access_token=' + authData.access_token + getAppSecretPath(authData, options) + ).then(data => { + if ((data && data.id == authData.id) || (process.env.TESTING && authData.id === 'test')) { + return; + } + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Facebook auth is invalid for this user.'); + }); +} + +async function validateGraphAppId(appIds, authData, options) { + var access_token = authData.access_token; + if (process.env.TESTING && access_token === 'test') { + return; + } + if (!Array.isArray(appIds)) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'appIds must be an array.'); + } + if (!appIds.length) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Facebook auth is not configured.'); + } + const data = await graphRequest( + `app?access_token=${access_token}${getAppSecretPath(authData, options)}` + ); + if (!data || !appIds.includes(data.id)) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Facebook auth is invalid for this user.'); + } +} + +const getFacebookKeyByKeyId = async (keyId, cacheMaxEntries, cacheMaxAge) => { + const client = jwksClient({ + jwksUri: `${TOKEN_ISSUER}/.well-known/oauth/openid/jwks/`, + cache: true, + cacheMaxEntries, + cacheMaxAge, + }); + + let key; + try { + key = await authUtils.getSigningKey(client, keyId); + } catch { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `Unable to find matching key for Key ID: ${keyId}` + ); + } + return key; +}; + +const verifyIdToken = async ({ token, id }, { appIds, cacheMaxEntries, cacheMaxAge }) => { + if (!Array.isArray(appIds) || !appIds.length) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Facebook auth is not configured.' + ); + } + + if (!token) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'id token is invalid for this user.'); + } + + const { kid: keyId } = authUtils.getHeaderFromToken(token); + const ONE_HOUR_IN_MS = 3600000; + let jwtClaims; + + cacheMaxAge = cacheMaxAge || ONE_HOUR_IN_MS; + cacheMaxEntries = cacheMaxEntries || 5; + + const facebookKey = await getFacebookKeyByKeyId(keyId, cacheMaxEntries, cacheMaxAge); + const signingKey = facebookKey.publicKey || facebookKey.rsaPublicKey; + + try { + jwtClaims = jwt.verify(token, signingKey, { + algorithms: ['RS256'], + // the audience can be checked against a string, a regular expression or a list of strings and/or regular expressions. + audience: appIds, + }); + } catch (exception) { + const message = exception.message; + + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${message}`); + } + + if (jwtClaims.iss !== TOKEN_ISSUER) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `id token not issued by correct OpenID provider - expected: ${TOKEN_ISSUER} | from: ${jwtClaims.iss}` + ); + } + + if (jwtClaims.sub !== id) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'auth data is invalid for this user.'); + } + return jwtClaims; +}; + +// Returns a promise that fulfills iff this user id is valid. +function validateAuthData(authData, options) { + if (authData.token) { + return verifyIdToken(authData, options); + } else { + return validateGraphToken(authData, options); + } +} + +// Returns a promise that fulfills iff this app id is valid. +function validateAppId(appIds, authData, options) { + if (authData.token) { + return Promise.resolve(); + } else { + return validateGraphAppId(appIds, authData, options); + } +} + +// A promisey wrapper for FB graph requests. +function graphRequest(path) { + return httpsRequest.get('https://graph.facebook.com/' + path); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData, +}; diff --git a/src/Adapters/Auth/gcenter.js b/src/Adapters/Auth/gcenter.js new file mode 100644 index 0000000000..d53643df8b --- /dev/null +++ b/src/Adapters/Auth/gcenter.js @@ -0,0 +1,239 @@ +/** + * Parse Server authentication adapter for Apple Game Center. + * + * @class AppleGameCenterAdapter + * @param {Object} options - Configuration options for the adapter. + * @param {string} options.bundleId - Your Apple Game Center bundle ID. Required for secure authentication. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * + * @param {Object} authData - The authentication data provided by the client. + * @param {string} authData.id - The user ID obtained from Apple Game Center. + * @param {string} authData.publicKeyUrl - The public key URL obtained from Apple Game Center. + * @param {string} authData.timestamp - The timestamp obtained from Apple Game Center. + * @param {string} authData.signature - The signature obtained from Apple Game Center. + * @param {string} authData.salt - The salt obtained from Apple Game Center. + * @param {string} [authData.bundleId] - **[DEPRECATED]** The bundle ID obtained from Apple Game Center (required for insecure authentication). + * + * @description + * ## Parse Server Configuration + * The following `authData` fields are required: + * `id`, `publicKeyUrl`, `timestamp`, `signature`, and `salt`. These fields are validated against the configured `bundleId` for additional security. + * + * To configure Parse Server for Apple Game Center authentication, use the following structure: + * ```json + * { + * "auth": { + * "gcenter": { + * "bundleId": "com.valid.app" + * } + * } + * ``` + * + * ## Insecure Authentication (Not Recommended) + * The following `authData` fields are required for insecure authentication: + * `id`, `publicKeyUrl`, `timestamp`, `signature`, `salt`, and `bundleId` (**[DEPRECATED]**). This flow is insecure and poses potential security risks. + * + * To configure Parse Server for insecure authentication, use the following structure: + * ```json + * { + * "auth": { + * "gcenter": { + * "enableInsecureAuth": true + * } + * } + * ``` + * + * ### Deprecation Notice + * The `enableInsecureAuth` option and `authData.bundleId` parameter are deprecated and may be removed in future releases. Use secure authentication with the `bundleId` configured in the `options` object instead. + * + * + * @example Secure Authentication Example + * // Example authData for secure authentication: + * const authData = { + * gcenter: { + * id: "1234567", + * publicKeyUrl: "https://valid.apple.com/public/timeout.cer", + * timestamp: 1460981421303, + * salt: "saltST==", + * signature: "PoDwf39DCN464B49jJCU0d9Y0J" + * } + * }; + * + * @example Insecure Authentication Example (Not Recommended) + * // Example authData for insecure authentication: + * const authData = { + * gcenter: { + * id: "1234567", + * publicKeyUrl: "https://valid.apple.com/public/timeout.cer", + * timestamp: 1460981421303, + * salt: "saltST==", + * signature: "PoDwf39DCN464B49jJCU0d9Y0J", + * bundleId: "com.valid.app" // Deprecated. + * } + * }; + * + * @see {@link https://developer.apple.com/documentation/gamekit/gklocalplayer/3516283-fetchitems Apple Game Center Documentation} + */ +/* global BigInt */ + +import crypto from 'crypto'; +import { asn1, pki } from 'node-forge'; +import AuthAdapter from './AuthAdapter'; +class GameCenterAuth extends AuthAdapter { + constructor() { + super(); + this.ca = { cert: null, url: null }; + this.cache = {}; + this.bundleId = ''; + } + + validateOptions(options) { + if (!options) { + throw new Error('Game center auth options are required.'); + } + + if (!this.loadingPromise) { + this.loadingPromise = this.loadCertificate(options); + } + + this.enableInsecureAuth = options.enableInsecureAuth; + this.bundleId = options.bundleId; + + if (!this.enableInsecureAuth && !this.bundleId) { + throw new Error('bundleId is required for secure auth.'); + } + } + + async loadCertificate(options) { + const rootCertificateUrl = + options.rootCertificateUrl || + 'https://cacerts.digicert.com/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem'; + + if (this.ca.url === rootCertificateUrl) { + return rootCertificateUrl; + } + + const { certificate, headers } = await this.fetchCertificate(rootCertificateUrl); + + if ( + headers.get('content-type') !== 'application/x-pem-file' || + !headers.get('content-length') || + parseInt(headers.get('content-length'), 10) > 10000 + ) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid rootCertificateURL.'); + } + + this.ca.cert = pki.certificateFromPem(certificate); + this.ca.url = rootCertificateUrl; + + return rootCertificateUrl; + } + + verifyPublicKeyUrl(publicKeyUrl) { + const regex = /^https:\/\/(?:[-_A-Za-z0-9]+\.){0,}apple\.com\/.*\.cer$/; + return regex.test(publicKeyUrl); + } + + async fetchCertificate(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch certificate: ${url}`); + } + + const contentType = response.headers.get('content-type'); + const isPem = contentType?.includes('application/x-pem-file'); + + if (isPem) { + const certificate = await response.text(); + return { certificate, headers: response.headers }; + } + + const data = await response.arrayBuffer(); + const binaryData = Buffer.from(data); + + const asn1Cert = asn1.fromDer(binaryData.toString('binary')); + const forgeCert = pki.certificateFromAsn1(asn1Cert); + const certificate = pki.certificateToPem(forgeCert); + + return { certificate, headers: response.headers }; + } + + async getAppleCertificate(publicKeyUrl) { + if (!this.verifyPublicKeyUrl(publicKeyUrl)) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `Invalid publicKeyUrl: ${publicKeyUrl}`); + } + + if (this.cache[publicKeyUrl]) { + return this.cache[publicKeyUrl]; + } + + const { certificate, headers } = await this.fetchCertificate(publicKeyUrl); + const cacheControl = headers.get('cache-control'); + const expire = cacheControl?.match(/max-age=([0-9]+)/); + + this.verifyPublicKeyIssuer(certificate, publicKeyUrl); + + if (expire) { + this.cache[publicKeyUrl] = certificate; + setTimeout(() => delete this.cache[publicKeyUrl], parseInt(expire[1], 10) * 1000); + } + + return certificate; + } + + verifyPublicKeyIssuer(cert, publicKeyUrl) { + const publicKeyCert = pki.certificateFromPem(cert); + + if (!this.ca.cert) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Root certificate is invalid or missing.' + ); + } + + if (!this.ca.cert.verify(publicKeyCert)) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `Invalid publicKeyUrl: ${publicKeyUrl}`); + } + } + + verifySignature(publicKey, authData) { + const bundleId = this.bundleId || (this.enableInsecureAuth && authData.bundleId); + + const verifier = crypto.createVerify('sha256'); + verifier.update(Buffer.from(authData.id, 'utf8')); + verifier.update(Buffer.from(bundleId, 'utf8')); + verifier.update(this.convertTimestampToBigEndian(authData.timestamp)); + verifier.update(Buffer.from(authData.salt, 'base64')); + + if (!verifier.verify(publicKey, authData.signature, 'base64')) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid signature.'); + } + } + + async validateAuthData(authData) { + + const requiredKeys = ['id', 'publicKeyUrl', 'timestamp', 'signature', 'salt']; + if (this.enableInsecureAuth) { + requiredKeys.push('bundleId'); + } + + for (const key of requiredKeys) { + if (!authData[key]) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `AuthData ${key} is missing.`); + } + } + + await this.loadingPromise; + + const publicKey = await this.getAppleCertificate(authData.publicKeyUrl); + this.verifySignature(publicKey, authData); + } + + convertTimestampToBigEndian(timestamp) { + const buffer = Buffer.alloc(8); + buffer.writeBigUInt64BE(BigInt(timestamp)); + return buffer; + } +} + +export default new GameCenterAuth(); diff --git a/src/Adapters/Auth/github.js b/src/Adapters/Auth/github.js new file mode 100644 index 0000000000..7aa842f03b --- /dev/null +++ b/src/Adapters/Auth/github.js @@ -0,0 +1,127 @@ +/** + * Parse Server authentication adapter for GitHub. + * @class GitHubAdapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.clientId - The GitHub App Client ID. Required for secure authentication. + * @param {string} options.clientSecret - The GitHub App Client Secret. Required for secure authentication. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * + * @param {Object} authData - The authentication data provided by the client. + * @param {string} authData.code - The authorization code from GitHub. Required for secure authentication. + * @param {string} [authData.id] - **[DEPRECATED]** The GitHub user ID (required for insecure authentication). + * @param {string} [authData.access_token] - **[DEPRECATED]** The GitHub access token (required for insecure authentication). + * + * @description + * ## Parse Server Configuration + * * To configure Parse Server for GitHub authentication, use the following structure: + * ```json + * { + * "auth": { + * "github": { + * "clientId": "12345", + * "clientSecret": "abcde" + * } + * } + * ``` + * + * The GitHub adapter exchanges the `authData.code` provided by the client for an access token using GitHub's OAuth API. The following `authData` field is required: + * - `code` + * + * ## Insecure Authentication (Not Recommended) + * Insecure authentication uses the `authData.id` and `authData.access_token` provided by the client. This flow is insecure, deprecated, and poses potential security risks. The following `authData` fields are required: + * - `id` (**[DEPRECATED]**): The GitHub user ID. + * - `access_token` (**[DEPRECATED]**): The GitHub access token. + * To configure Parse Server for insecure authentication, use the following structure: + * ```json + * { + * "auth": { + * "github": { + * "enableInsecureAuth": true + * } + * } + * ``` + * + * ### Deprecation Notice + * The `enableInsecureAuth` option and insecure `authData` fields (`id`, `access_token`) are deprecated and will be removed in future versions. Use secure authentication with `clientId` and `clientSecret`. + * + * @example Secure Authentication Example + * // Example authData for secure authentication: + * const authData = { + * github: { + * code: "abc123def456ghi789" + * } + * }; + * + * @example Insecure Authentication Example (Not Recommended) + * // Example authData for insecure authentication: + * const authData = { + * github: { + * id: "1234567", + * access_token: "abc123def456ghi789" // Deprecated. + * } + * }; + * + * @note `enableInsecureAuth` will be removed in future versions. Use secure authentication with `clientId` and `clientSecret`. + * @note Secure authentication exchanges the `code` provided by the client for an access token using GitHub's OAuth API. + * + * @see {@link https://docs.github.com/en/developers/apps/authorizing-oauth-apps GitHub OAuth Documentation} + */ + +import BaseCodeAuthAdapter from './BaseCodeAuthAdapter'; +class GitHubAdapter extends BaseCodeAuthAdapter { + constructor() { + super('GitHub'); + } + async getAccessTokenFromCode(authData) { + const tokenUrl = 'https://github.com/login/oauth/access_token'; + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + client_id: this.clientId, + client_secret: this.clientSecret, + code: authData.code, + }), + }); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, `Failed to exchange code for token: ${response.statusText}`); + } + + const data = await response.json(); + if (data.error) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, data.error_description || data.error); + } + + return data.access_token; + } + + async getUserFromAccessToken(accessToken) { + const userApiUrl = 'https://api.github.com/user'; + const response = await fetch(userApiUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, `Failed to fetch GitHub user: ${response.statusText}`); + } + + const userData = await response.json(); + if (!userData.id || !userData.login) { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, 'Invalid GitHub user data received.'); + } + + return userData; + } + +} + +export default new GitHubAdapter(); + diff --git a/src/Adapters/Auth/google.js b/src/Adapters/Auth/google.js new file mode 100644 index 0000000000..f1a47f6c1c --- /dev/null +++ b/src/Adapters/Auth/google.js @@ -0,0 +1,135 @@ +/** + * Parse Server authentication adapter for Google. + * + * @class GoogleAdapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.clientId - Your Google application Client ID. + * @param {number} [options.cacheMaxEntries] - Maximum number of JWKS cache entries. Default: 5. + * @param {number} [options.cacheMaxAge] - Maximum age of JWKS cache entries in ms. Default: 3600000 (1 hour). + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Google authentication, use the following structure: + * ```json + * { + * "auth": { + * "google": { + * "clientId": "your-client-id" + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - **id**: The Google user ID. + * - **id_token**: The Google ID token. + * + * ## Auth Payload + * ### Example Auth Data Payload + * ```json + * { + * "google": { + * "id": "1234567", + * "id_token": "xxxxx.yyyyy.zzzzz" + * } + * } + * ``` + * + * ## Notes + * - Ensure your Google Client ID is configured properly in the Parse Server configuration. + * - The `id_token` is validated against Google's authentication services. + * + * @see {@link https://developers.google.com/identity/sign-in/web/backend-auth Google Authentication Documentation} + */ + +'use strict'; + +var Parse = require('parse/node').Parse; + +const jwksClient = require('jwks-rsa'); +const jwt = require('jsonwebtoken'); +const authUtils = require('./utils'); + +const TOKEN_ISSUER = 'accounts.google.com'; +const HTTPS_TOKEN_ISSUER = 'https://accounts.google.com'; + +const getGoogleKeyByKeyId = async (keyId, cacheMaxEntries, cacheMaxAge) => { + const client = jwksClient({ + jwksUri: 'https://www.googleapis.com/oauth2/v3/certs', + cache: true, + cacheMaxEntries, + cacheMaxAge, + }); + + let key; + try { + key = await authUtils.getSigningKey(client, keyId); + } catch { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `Unable to find matching key for Key ID: ${keyId}` + ); + } + return key; +}; + +async function verifyIdToken({ id_token: token, id }, { clientId, cacheMaxEntries, cacheMaxAge }) { + if (!clientId) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Google auth is not configured.' + ); + } + + if (!token) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `id token is invalid for this user.`); + } + + const { kid: keyId } = authUtils.getHeaderFromToken(token); + const ONE_HOUR_IN_MS = 3600000; + let jwtClaims; + + cacheMaxAge = cacheMaxAge || ONE_HOUR_IN_MS; + cacheMaxEntries = cacheMaxEntries || 5; + + const googleKey = await getGoogleKeyByKeyId(keyId, cacheMaxEntries, cacheMaxAge); + const signingKey = googleKey.publicKey || googleKey.rsaPublicKey; + + try { + jwtClaims = jwt.verify(token, signingKey, { + algorithms: ['RS256'], + audience: clientId, + }); + } catch (exception) { + const message = exception.message; + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${message}`); + } + + if (jwtClaims.iss !== TOKEN_ISSUER && jwtClaims.iss !== HTTPS_TOKEN_ISSUER) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `id token not issued by correct provider - expected: ${TOKEN_ISSUER} or ${HTTPS_TOKEN_ISSUER} | from: ${jwtClaims.iss}` + ); + } + + if (jwtClaims.sub !== id) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `auth data is invalid for this user.`); + } + + return jwtClaims; +} + +// Returns a promise that fulfills if this user id is valid. +function validateAuthData(authData, options = {}) { + return verifyIdToken(authData, options); +} + +// Returns a promise that fulfills if this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData, +}; diff --git a/src/Adapters/Auth/gpgames.js b/src/Adapters/Auth/gpgames.js new file mode 100644 index 0000000000..01b1cec7cf --- /dev/null +++ b/src/Adapters/Auth/gpgames.js @@ -0,0 +1,139 @@ +/** + * Parse Server authentication adapter for Google Play Games Services. + * + * @class GooglePlayGamesServicesAdapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.clientId - Your Google Play Games Services App Client ID. Required for secure authentication. + * @param {string} options.clientSecret - Your Google Play Games Services App Client Secret. Required for secure authentication. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Google Play Games Services authentication, use the following structure: + * ```json + * { + * "auth": { + * "gpgames": { + * "clientId": "your-client-id", + * "clientSecret": "your-client-secret" + * } + * } + * } + * ``` + * ### Insecure Configuration (Not Recommended) + * ```json + * { + * "auth": { + * "gpgames": { + * "enableInsecureAuth": true + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - **Secure Authentication**: `code`, `redirect_uri`. + * - **Insecure Authentication (Not Recommended)**: `id`, `access_token`. + * + * ## Auth Payloads + * ### Secure Authentication Payload + * ```json + * { + * "gpgames": { + * "code": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + * "redirect_uri": "https://example.com/callback" + * } + * } + * ``` + * + * ### Insecure Authentication Payload (Not Recommended) + * ```json + * { + * "gpgames": { + * "id": "123456789", + * "access_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + * } + * } + * ``` + * + * ## Notes + * - `enableInsecureAuth` is **not recommended** and may be removed in future versions. Use secure authentication with `code` and `redirect_uri`. + * - Secure authentication exchanges the `code` provided by the client for an access token using Google Play Games Services' OAuth API. + * + * @see {@link https://developers.google.com/games/services/console/enabling Google Play Games Services Authentication Documentation} + */ + +import BaseCodeAuthAdapter from './BaseCodeAuthAdapter'; +class GooglePlayGamesServicesAdapter extends BaseCodeAuthAdapter { + constructor() { + super("gpgames"); + } + + async getAccessTokenFromCode(authData) { + const tokenUrl = 'https://oauth2.googleapis.com/token'; + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + client_id: this.clientId, + client_secret: this.clientSecret, + code: authData.code, + redirect_uri: authData.redirectUri, + grant_type: 'authorization_code', + }), + }); + + if (!response.ok) { + throw new Parse.Error( + Parse.Error.VALIDATION_ERROR, + `Failed to exchange code for token: ${response.statusText}` + ); + } + + const data = await response.json(); + if (data.error) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + data.error_description || data.error + ); + } + + return data.access_token; + } + + async getUserFromAccessToken(accessToken, authData) { + const userApiUrl = `https://www.googleapis.com/games/v1/players/${authData.id}`; + const response = await fetch(userApiUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Parse.Error( + Parse.Error.VALIDATION_ERROR, + `Failed to fetch Google Play Games Services user: ${response.statusText}` + ); + } + + const userData = await response.json(); + if (!userData.playerId || userData.playerId !== authData.id) { + throw new Parse.Error( + Parse.Error.VALIDATION_ERROR, + 'Invalid Google Play Games Services user data received.' + ); + } + + return { + id: userData.playerId + }; + } + +} + +export default new GooglePlayGamesServicesAdapter(); diff --git a/src/Adapters/Auth/httpsRequest.js b/src/Adapters/Auth/httpsRequest.js new file mode 100644 index 0000000000..a198fbd318 --- /dev/null +++ b/src/Adapters/Auth/httpsRequest.js @@ -0,0 +1,39 @@ +const https = require('https'); + +function makeCallback(resolve, reject, noJSON) { + return function (res) { + let data = ''; + res.on('data', chunk => { + data += chunk; + }); + res.on('end', () => { + if (noJSON) { + return resolve(data); + } + try { + data = JSON.parse(data); + } catch (e) { + return reject(e); + } + resolve(data); + }); + res.on('error', reject); + }; +} + +function get(options, noJSON = false) { + return new Promise((resolve, reject) => { + https.get(options, makeCallback(resolve, reject, noJSON)).on('error', reject); + }); +} + +function request(options, postData) { + return new Promise((resolve, reject) => { + const req = https.request(options, makeCallback(resolve, reject)); + req.on('error', reject); + req.write(postData); + req.end(); + }); +} + +module.exports = { get, request }; diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js new file mode 100755 index 0000000000..51dd5342ef --- /dev/null +++ b/src/Adapters/Auth/index.js @@ -0,0 +1,281 @@ +import loadAdapter from '../AdapterLoader'; +import Parse from 'parse/node'; +import AuthAdapter from './AuthAdapter'; + +const apple = require('./apple'); +const digits = require('./twitter'); // digits tokens are validated by twitter +const facebook = require('./facebook'); +import gcenter from './gcenter'; +import github from './github'; +const google = require('./google'); +import gpgames from './gpgames'; +import instagram from './instagram'; +const janraincapture = require('./janraincapture'); +const janrainengage = require('./janrainengage'); +const keycloak = require('./keycloak'); +const ldap = require('./ldap'); +import line from './line'; +import linkedin from './linkedin'; +const meetup = require('./meetup'); +import mfa from './mfa'; +import microsoft from './microsoft'; +import oauth2 from './oauth2'; +const phantauth = require('./phantauth'); +import qq from './qq'; +import spotify from './spotify'; +import twitter from './twitter'; +const vkontakte = require('./vkontakte'); +import wechat from './wechat'; +import weibo from './weibo'; + + +const anonymous = { + validateAuthData: () => { + return Promise.resolve(); + }, + validateAppId: () => { + return Promise.resolve(); + }, +}; + +const providers = { + apple, + gcenter, + gpgames, + facebook, + instagram, + linkedin, + meetup, + mfa, + google, + github, + twitter, + spotify, + anonymous, + digits, + janrainengage, + janraincapture, + line, + vkontakte, + qq, + wechat, + weibo, + phantauth, + microsoft, + keycloak, + ldap, +}; + +// Indexed auth policies +const authAdapterPolicies = { + default: true, + solo: true, + additional: true, +}; + +function authDataValidator(provider, adapter, appIds, options) { + return async function (authData, req, user, requestObject) { + if (appIds && typeof adapter.validateAppId === 'function') { + await Promise.resolve(adapter.validateAppId(appIds, authData, options, requestObject)); + } + if ( + adapter.policy && + !authAdapterPolicies[adapter.policy] && + typeof adapter.policy !== 'function' + ) { + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + 'AuthAdapter policy is not configured correctly. The value must be either "solo", "additional", "default" or undefined (will be handled as "default")' + ); + } + if (typeof adapter.validateAuthData === 'function') { + return adapter.validateAuthData(authData, options, requestObject); + } + if ( + typeof adapter.validateSetUp !== 'function' || + typeof adapter.validateLogin !== 'function' || + typeof adapter.validateUpdate !== 'function' + ) { + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + 'Adapter is not configured. Implement either validateAuthData or all of the following: validateSetUp, validateLogin and validateUpdate' + ); + } + // When masterKey is detected, we should trigger a logged in user + const isLoggedIn = + (req.auth.user && user && req.auth.user.id === user.id) || (user && req.auth.isMaster); + let hasAuthDataConfigured = false; + + if (user && user.get('authData') && user.get('authData')[provider]) { + hasAuthDataConfigured = true; + } + + if (isLoggedIn) { + // User is updating their authData + if (hasAuthDataConfigured) { + return { + method: 'validateUpdate', + validator: () => adapter.validateUpdate(authData, options, requestObject), + }; + } + // Set up if the user does not have the provider configured + return { + method: 'validateSetUp', + validator: () => adapter.validateSetUp(authData, options, requestObject), + }; + } + + // Not logged in and authData is configured on the user + if (hasAuthDataConfigured) { + return { + method: 'validateLogin', + validator: () => adapter.validateLogin(authData, options, requestObject), + }; + } + + // User not logged in and the provider is not set up, for example when a new user + // signs up or an existing user uses a new auth provider + return { + method: 'validateSetUp', + validator: () => adapter.validateSetUp(authData, options, requestObject), + }; + }; +} + +function loadAuthAdapter(provider, authOptions) { + // providers are auth providers implemented by default + let defaultAdapter = providers[provider]; + // authOptions can contain complete custom auth adapters or + // a default auth adapter like Facebook + const providerOptions = authOptions[provider]; + if ( + providerOptions && + Object.prototype.hasOwnProperty.call(providerOptions, 'oauth2') && + providerOptions['oauth2'] === true + ) { + defaultAdapter = oauth2; + } + + // Default provider not found and a custom auth provider was not provided + if (!defaultAdapter && !providerOptions) { + return; + } + + const adapter = + defaultAdapter instanceof AuthAdapter ? new defaultAdapter.constructor() : Object.assign({}, defaultAdapter); + const keys = [ + 'validateAuthData', + 'validateAppId', + 'validateSetUp', + 'validateLogin', + 'validateUpdate', + 'challenge', + 'validateOptions', + 'policy', + 'afterFind', + ]; + const defaultAuthAdapter = new AuthAdapter(); + keys.forEach(key => { + const existing = adapter?.[key]; + if ( + existing && + typeof existing === 'function' && + existing.toString() === defaultAuthAdapter[key].toString() + ) { + adapter[key] = null; + } + }); + const appIds = providerOptions ? providerOptions.appIds : undefined; + + // Try the configuration methods + if (providerOptions) { + const optionalAdapter = loadAdapter(providerOptions, undefined, providerOptions); + if (optionalAdapter) { + keys.forEach(key => { + if (optionalAdapter[key]) { + adapter[key] = optionalAdapter[key]; + } + }); + } + } + if (adapter.validateOptions) { + adapter.validateOptions(providerOptions); + } + + return { adapter, appIds, providerOptions }; +} + +module.exports = function (authOptions = {}, enableAnonymousUsers = true) { + let _enableAnonymousUsers = enableAnonymousUsers; + const setEnableAnonymousUsers = function (enable) { + _enableAnonymousUsers = enable; + }; + // To handle the test cases on configuration + const getValidatorForProvider = function (provider) { + if (provider === 'anonymous' && !_enableAnonymousUsers) { + return { validator: undefined }; + } + const authAdapter = loadAuthAdapter(provider, authOptions); + if (!authAdapter) { return; } + const { adapter, appIds, providerOptions } = authAdapter; + return { validator: authDataValidator(provider, adapter, appIds, providerOptions), adapter }; + }; + + const runAfterFind = async (req, authData) => { + if (!authData) { + return; + } + const adapters = Object.keys(authData); + await Promise.all( + adapters.map(async provider => { + const authAdapter = getValidatorForProvider(provider); + if (!authAdapter) { + return; + } + const { adapter, providerOptions } = authAdapter; + const afterFind = adapter.afterFind; + if (afterFind && typeof afterFind === 'function') { + const requestObject = { + ip: req.config.ip, + user: req.auth.user, + master: req.auth.isMaster, + }; + const result = afterFind.call( + adapter, + authData[provider], + providerOptions, + requestObject, + ); + if (result) { + authData[provider] = result; + } + } + }) + ); + }; + + // Returns the list of auth provider names that have a valid adapter configured. + // This includes both built-in providers and custom providers from authOptions. + const getProviders = function () { + const allProviders = new Set([...Object.keys(providers), ...Object.keys(authOptions)]); + if (!_enableAnonymousUsers) { + allProviders.delete('anonymous'); + } + return [...allProviders].filter(provider => { + try { + return !!loadAuthAdapter(provider, authOptions); + } catch { + return false; + } + }); + }; + + return Object.freeze({ + getValidatorForProvider, + getProviders, + setEnableAnonymousUsers, + runAfterFind, + }); +}; + +module.exports.loadAuthAdapter = loadAuthAdapter; diff --git a/src/Adapters/Auth/instagram.js b/src/Adapters/Auth/instagram.js new file mode 100644 index 0000000000..e1921597dd --- /dev/null +++ b/src/Adapters/Auth/instagram.js @@ -0,0 +1,120 @@ +/** + * Parse Server authentication adapter for Instagram. + * + * @class InstagramAdapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.clientId - Your Instagram App Client ID. Required for secure authentication. + * @param {string} options.clientSecret - Your Instagram App Client Secret. Required for secure authentication. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Instagram authentication, use the following structure: + * ```json + * { + * "auth": { + * "instagram": { + * "clientId": "your-client-id", + * "clientSecret": "your-client-secret" + * } + * } + * } + * ``` + * ### Insecure Configuration (Not Recommended) + * ```json + * { + * "auth": { + * "instagram": { + * "enableInsecureAuth": true + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - **Secure Authentication**: `code`, `redirect_uri`. + * - **Insecure Authentication (Deprecated)**: `id`, `access_token`. + * + * ## Auth Payloads + * ### Secure Authentication Payload + * ```json + * { + * "instagram": { + * "code": "lmn789opq012rst345uvw", + * "redirect_uri": "https://example.com/callback" + * } + * } + * ``` + * + * ### Insecure Authentication Payload (Deprecated) + * ```json + * { + * "instagram": { + * "id": "1234567", + * "access_token": "AQXNnd2hIT6z9bHFzZz2Kp1ghiMz_RtyuvwXYZ123abc" + * } + * } + * ``` + * + * ## Notes + * - `enableInsecureAuth` is **deprecated** and will be removed in future versions. Use secure authentication with `code` and `redirect_uri`. + * - Secure authentication exchanges the `code` and `redirect_uri` provided by the client for an access token using Instagram's OAuth flow. + * + * @see {@link https://developers.facebook.com/docs/instagram-basic-display-api/getting-started Instagram Basic Display API - Getting Started} + */ + + +import BaseAuthCodeAdapter from './BaseCodeAuthAdapter'; +class InstagramAdapter extends BaseAuthCodeAdapter { + constructor() { + super('Instagram'); + } + + async getAccessTokenFromCode(authData) { + const response = await fetch('https://api.instagram.com/oauth/access_token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: this.clientId, + client_secret: this.clientSecret, + grant_type: 'authorization_code', + redirect_uri: this.redirectUri, + code: authData.code + }) + }); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Instagram API request failed.'); + } + + const data = await response.json(); + if (data.error) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, data.error_description || data.error); + } + + return data.access_token; + } + + async getUserFromAccessToken(accessToken, authData) { + const apiURL = 'https://graph.instagram.com/'; + const path = `${apiURL}me?fields=id&access_token=${accessToken}`; + + const response = await fetch(path); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Instagram API request failed.'); + } + + const user = await response.json(); + if (user?.id !== authData.id) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Instagram auth is invalid for this user.'); + } + + return { + id: user.id, + } + + } +} + +export default new InstagramAdapter(); diff --git a/src/Adapters/Auth/janraincapture.js b/src/Adapters/Auth/janraincapture.js new file mode 100644 index 0000000000..ca55df7da8 --- /dev/null +++ b/src/Adapters/Auth/janraincapture.js @@ -0,0 +1,85 @@ +/** + * Parse Server authentication adapter for Janrain Capture API. + * + * @class JanrainCapture + * @param {Object} options - The adapter configuration options. + * @param {String} options.janrain_capture_host - The Janrain Capture API host. + * + * @param {Object} authData - The authentication data provided by the client. + * @param {String} authData.id - The Janrain Capture user ID. + * @param {String} authData.access_token - The Janrain Capture access token. + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Janrain Capture authentication, use the following structure: + * ```json + * { + * "auth": { + * "janrain": { + * "janrain_capture_host": "your-janrain-capture-host" + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - `id`: The Janrain Capture user ID. + * - `access_token`: An authorized Janrain Capture access token for the user. + * + * ## Auth Payload Example + * ```json + * { + * "janrain": { + * "id": "user's Janrain Capture ID as a string", + * "access_token": "an authorized Janrain Capture access token for the user" + * } + * } + * ``` + * + * ## Notes + * Parse Server validates the provided `authData` using the Janrain Capture API. + * + * @see {@link https://docs.janrain.com/api/registration/entity/#entity Janrain Capture API Documentation} + */ + + +// Helper functions for accessing the Janrain Capture API. +var Parse = require('parse/node').Parse; +var querystring = require('querystring'); +const httpsRequest = require('./httpsRequest'); + +// Returns a promise that fulfills iff this user id is valid. +function validateAuthData(authData, options) { + return request(options.janrain_capture_host, authData.access_token).then(data => { + //successful response will have a "stat" (status) of 'ok' and a result node that stores the uuid, because that's all we asked for + //see: https://docs.janrain.com/api/registration/entity/#entity + if (data && data.stat == 'ok' && data.result == authData.id) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Janrain capture auth is invalid for this user.' + ); + }); +} + +// Returns a promise that fulfills iff this app id is valid. +function validateAppId() { + //no-op + return Promise.resolve(); +} + +// A promisey wrapper for api requests +function request(host, access_token) { + var query_string_data = querystring.stringify({ + access_token: access_token, + attribute_name: 'uuid', // we only need to pull the uuid for this access token to make sure it matches + }); + + return httpsRequest.get({ host: host, path: '/entity?' + query_string_data }); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData, +}; diff --git a/src/Adapters/Auth/janrainengage.js b/src/Adapters/Auth/janrainengage.js new file mode 100644 index 0000000000..782cbb121a --- /dev/null +++ b/src/Adapters/Auth/janrainengage.js @@ -0,0 +1,60 @@ +// Helper functions for accessing the Janrain Engage API. +var httpsRequest = require('./httpsRequest'); +var Parse = require('parse/node').Parse; +var querystring = require('querystring'); +import Config from '../../Config'; +import Deprecator from '../../Deprecator/Deprecator'; + +// Returns a promise that fulfills iff this user id is valid. +function validateAuthData(authData, options) { + const config = Config.get(Parse.applicationId); + + Deprecator.logRuntimeDeprecation({ usage: 'janrainengage adapter' }); + if (!config?.auth?.janrainengage?.enableInsecureAuth || !config.enableInsecureAuthAdapters) { + throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'janrainengage adapter only works with enableInsecureAuth: true'); + } + + return apiRequest(options.api_key, authData.auth_token).then(data => { + //successful response will have a "stat" (status) of 'ok' and a profile node with an identifier + //see: http://developers.janrain.com/overview/social-login/identity-providers/user-profile-data/#normalized-user-profile-data + if (data && data.stat == 'ok' && data.profile.identifier == authData.id) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Janrain engage auth is invalid for this user.' + ); + }); +} + +// Returns a promise that fulfills iff this app id is valid. +function validateAppId() { + //no-op + return Promise.resolve(); +} + +// A promisey wrapper for api requests +function apiRequest(api_key, auth_token) { + var post_data = querystring.stringify({ + token: auth_token, + apiKey: api_key, + format: 'json', + }); + + var post_options = { + host: 'rpxnow.com', + path: '/api/v2/auth_info', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': post_data.length, + }, + }; + + return httpsRequest.request(post_options, post_data); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData, +}; diff --git a/src/Adapters/Auth/keycloak.js b/src/Adapters/Auth/keycloak.js new file mode 100644 index 0000000000..f3b4db37f9 --- /dev/null +++ b/src/Adapters/Auth/keycloak.js @@ -0,0 +1,184 @@ +/** + * Parse Server authentication adapter for Keycloak. + * + * @class KeycloakAdapter + * @param {Object} options - The adapter configuration options. + * @param {Object} options.config - The Keycloak configuration object, typically loaded from a JSON file. + * @param {String} options.config.auth-server-url - The Keycloak authentication server URL. + * @param {String} options.config.realm - The Keycloak realm name. + * @param {String} options.config.client-id - The Keycloak client ID. + * + * @param {Object} authData - The authentication data provided by the client. + * @param {String} authData.access_token - The Keycloak access token retrieved during client authentication. + * @param {String} authData.id - The user ID retrieved from Keycloak during client authentication. + * @param {Array} [authData.roles] - The roles assigned to the user in Keycloak (optional). + * @param {Array} [authData.groups] - The groups assigned to the user in Keycloak (optional). + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Keycloak authentication, use the following structure: + * ```javascript + * { + * "auth": { + * "keycloak": { + * "config": require('./auth/keycloak.json') + * } + * } + * } + * ``` + * Ensure the `keycloak.json` configuration file is generated from Keycloak's setup guide and includes: + * - `auth-server-url`: The Keycloak authentication server URL. + * - `realm`: The Keycloak realm name. + * - `client-id`: The Keycloak client ID. + * + * ## Auth Data + * The adapter requires the following `authData` fields: + * - `access_token`: The Keycloak access token retrieved during client authentication. + * - `id`: The user ID retrieved from Keycloak during client authentication. + * - `roles` (optional): The roles assigned to the user in Keycloak. + * - `groups` (optional): The groups assigned to the user in Keycloak. + * + * ## Auth Payload Example + * ### Example Auth Data + * ```json + * { + * "keycloak": { + * "access_token": "an authorized Keycloak access token for the user", + * "id": "user's Keycloak ID as a string", + * "roles": ["admin", "user"], + * "groups": ["group1", "group2"] + * } + * } + * ``` + * + * ## Notes + * - Parse Server validates the provided `authData` by making a `userinfo` call to Keycloak and ensures the attributes match those returned by Keycloak. + * + * ## Keycloak Configuration + * To configure Keycloak, copy the JSON configuration file generated from Keycloak's setup guide: + * - [Keycloak Securing Apps Documentation](https://www.keycloak.org/docs/latest/securing_apps/index.html#_javascript_adapter) + * + * Place the configuration file on your server, for example: + * - `auth/keycloak.json` + * + * For more information on Keycloak authentication, see: + * - [Securing Apps Documentation](https://www.keycloak.org/docs/latest/securing_apps/) + * - [Server Administration Documentation](https://www.keycloak.org/docs/latest/server_admin/) + */ + +const { Parse } = require('parse/node'); +const jwksClient = require('jwks-rsa'); +const jwt = require('jsonwebtoken'); +const authUtils = require('./utils'); + +const arraysEqual = (_arr1, _arr2) => { + if (!Array.isArray(_arr1) || !Array.isArray(_arr2) || _arr1.length !== _arr2.length) { return false; } + + var arr1 = _arr1.concat().sort(); + var arr2 = _arr2.concat().sort(); + + for (var i = 0; i < arr1.length; i++) { + if (arr1[i] !== arr2[i]) { return false; } + } + + return true; +}; + +const getKeycloakKeyByKeyId = async (keyId, jwksUri, cacheMaxEntries, cacheMaxAge) => { + const client = jwksClient({ + jwksUri, + cache: true, + cacheMaxEntries, + cacheMaxAge, + }); + + let key; + try { + key = await authUtils.getSigningKey(client, keyId); + } catch { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `Unable to find matching key for Key ID: ${keyId}` + ); + } + return key; +}; + +const verifyAccessToken = async ( + { access_token, id, roles, groups } = {}, + { config, cacheMaxEntries, cacheMaxAge } = {} +) => { + if (!(access_token && id)) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Missing access token and/or User id'); + } + if (!config || !(config['auth-server-url'] && config['realm'])) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Missing keycloak configuration'); + } + if (!config['client-id']) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Keycloak auth is not configured. Missing client-id.' + ); + } + + const expectedIssuer = `${config['auth-server-url']}/realms/${config['realm']}`; + const jwksUri = `${config['auth-server-url']}/realms/${config['realm']}/protocol/openid-connect/certs`; + + const { kid: keyId } = authUtils.getHeaderFromToken(access_token); + const ONE_HOUR_IN_MS = 3600000; + + cacheMaxAge = cacheMaxAge || ONE_HOUR_IN_MS; + cacheMaxEntries = cacheMaxEntries || 5; + + const keycloakKey = await getKeycloakKeyByKeyId(keyId, jwksUri, cacheMaxEntries, cacheMaxAge); + const signingKey = keycloakKey.publicKey || keycloakKey.rsaPublicKey; + + let jwtClaims; + try { + jwtClaims = jwt.verify(access_token, signingKey, { + algorithms: ['RS256'], + }); + } catch (exception) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${exception.message}`); + } + + if (jwtClaims.iss !== expectedIssuer) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `access token not issued by correct provider - expected: ${expectedIssuer} | from: ${jwtClaims.iss}` + ); + } + + if (jwtClaims.azp !== config['client-id']) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `access token is not authorized for this client - expected: ${config['client-id']} | from: ${jwtClaims.azp}` + ); + } + + if (jwtClaims.sub !== id) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'auth data is invalid for this user.'); + } + + const rolesMatch = jwtClaims.roles === roles || arraysEqual(jwtClaims.roles, roles); + const groupsMatch = jwtClaims.groups === groups || arraysEqual(jwtClaims.groups, groups); + + if (!rolesMatch || !groupsMatch) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid authentication'); + } + + return jwtClaims; +}; + +function validateAuthData(authData, options = {}) { + return verifyAccessToken(authData, options); +} + +function validateAppId() { + return Promise.resolve(); +} + +module.exports = { + validateAppId, + validateAuthData, +}; diff --git a/src/Adapters/Auth/ldap.js b/src/Adapters/Auth/ldap.js new file mode 100644 index 0000000000..7312aea67b --- /dev/null +++ b/src/Adapters/Auth/ldap.js @@ -0,0 +1,226 @@ +/** + * Parse Server authentication adapter for LDAP. + * + * @class LDAP + * @param {Object} options - The adapter configuration options. + * @param {String} options.url - The LDAP server URL. Must start with `ldap://` or `ldaps://`. + * @param {String} options.suffix - The LDAP suffix for user distinguished names (DN). + * @param {String} [options.dn] - The distinguished name (DN) template for user authentication. Replace `{{id}}` with the username. + * @param {Object} [options.tlsOptions] - Options for LDAPS TLS connections. + * @param {String} [options.groupCn] - The common name (CN) of the group to verify user membership. + * @param {String} [options.groupFilter] - The LDAP search filter for groups, with `{{id}}` replaced by the username. + * + * @param {Object} authData - The authentication data provided by the client. + * @param {String} authData.id - The user's LDAP username. + * @param {String} authData.password - The user's LDAP password. + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for LDAP authentication, use the following structure: + * ```javascript + * { + * auth: { + * ldap: { + * url: 'ldaps://ldap.example.com', + * suffix: 'ou=users,dc=example,dc=com', + * groupCn: 'admins', + * groupFilter: '(memberUid={{id}})', + * tlsOptions: { + * rejectUnauthorized: false + * } + * } + * } + * } + * ``` + * + * ## Authentication Process + * 1. Validates the provided `authData` using an LDAP bind operation. + * 2. Optionally, verifies that the user belongs to a specific group by performing an LDAP search using the provided `groupCn` or `groupFilter`. + * + * ## Auth Payload + * The adapter requires the following `authData` fields: + * - `id`: The user's LDAP username. + * - `password`: The user's LDAP password. + * + * ### Example Auth Payload + * ```json + * { + * "ldap": { + * "id": "jdoe", + * "password": "password123" + * } + * } + * ``` + * + * @example Configuration Example + * // Example Parse Server configuration: + * const config = { + * auth: { + * ldap: { + * url: 'ldaps://ldap.example.com', + * suffix: 'ou=users,dc=example,dc=com', + * groupCn: 'admins', + * groupFilter: '(memberUid={{id}})', + * tlsOptions: { + * rejectUnauthorized: false + * } + * } + * } + * }; + * + * @see {@link https://ldap.com/ LDAP Basics} + * @see {@link https://ldap.com/ldap-filters/ LDAP Filters} + */ + + +const ldapjs = require('ldapjs'); +const Parse = require('parse/node').Parse; + +// Escape LDAP DN special characters per RFC 4514 +// https://datatracker.ietf.org/doc/html/rfc4514#section-2.4 +function escapeDN(value) { + let escaped = value + .replace(/\\/g, '\\\\') + .replace(/,/g, '\\,') + .replace(/=/g, '\\=') + .replace(/\+/g, '\\+') + .replace(//g, '\\>') + .replace(/#/g, '\\#') + .replace(/;/g, '\\;') + .replace(/"/g, '\\"'); + if (escaped.startsWith(' ')) { + escaped = '\\ ' + escaped.slice(1); + } + if (escaped.endsWith(' ')) { + escaped = escaped.slice(0, -1) + '\\ '; + } + return escaped; +} + +// Escape LDAP filter special characters per RFC 4515 +// https://datatracker.ietf.org/doc/html/rfc4515#section-3 +function escapeFilter(value) { + // eslint-disable-next-line no-control-regex + return value.replace(/[\\*()\x00]/g, ch => + '\\' + ch.charCodeAt(0).toString(16).padStart(2, '0') + ); +} + +function validateAuthData(authData, options) { + if (!optionsAreValid(options)) { + return new Promise((_, reject) => { + reject(new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'LDAP auth configuration missing')); + }); + } + const clientOptions = options.url.startsWith('ldaps://') + ? { url: options.url, tlsOptions: options.tlsOptions } + : { url: options.url }; + + if (typeof authData.id !== 'string') { + return Promise.reject( + new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'LDAP: Wrong username or password') + ); + } + const client = ldapjs.createClient(clientOptions); + const escapedId = escapeDN(authData.id); + const userCn = + typeof options.dn === 'string' + ? options.dn.replace('{{id}}', escapedId) + : `uid=${escapedId},${options.suffix}`; + + return new Promise((resolve, reject) => { + client.bind(userCn, authData.password, ldapError => { + delete authData.password; + if (ldapError) { + let error; + switch (ldapError.code) { + case 49: + error = new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'LDAP: Wrong username or password' + ); + break; + case 'DEPTH_ZERO_SELF_SIGNED_CERT': + error = new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'LDAPS: Certificate mismatch'); + break; + default: + error = new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'LDAP: Somthing went wrong (' + ldapError.code + ')' + ); + } + reject(error); + client.destroy(ldapError); + return; + } + + if (typeof options.groupCn === 'string' && typeof options.groupFilter === 'string') { + searchForGroup(client, options, authData.id, resolve, reject); + } else { + client.unbind(); + client.destroy(); + resolve(); + } + }); + }); +} + +function optionsAreValid(options) { + return ( + typeof options === 'object' && + typeof options.suffix === 'string' && + typeof options.url === 'string' && + (options.url.startsWith('ldap://') || + (options.url.startsWith('ldaps://') && typeof options.tlsOptions === 'object')) + ); +} + +function searchForGroup(client, options, id, resolve, reject) { + const filter = options.groupFilter.replace(/{{id}}/gi, escapeFilter(id)); + const opts = { + scope: 'sub', + filter: filter, + }; + let found = false; + client.search(options.suffix, opts, (searchError, res) => { + if (searchError) { + client.unbind(); + client.destroy(); + return reject(new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'LDAP group search failed')); + } + res.on('searchEntry', entry => { + if (entry.pojo.attributes.find(obj => obj.type === 'cn').values.includes(options.groupCn)) { + found = true; + client.unbind(); + client.destroy(); + return resolve(); + } + }); + res.on('end', () => { + if (!found) { + client.unbind(); + client.destroy(); + return reject( + new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'LDAP: User not in group') + ); + } + }); + res.on('error', () => { + client.unbind(); + client.destroy(); + return reject(new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'LDAP group search failed')); + }); + }); +} + +function validateAppId() { + return Promise.resolve(); +} + +module.exports = { + validateAppId, + validateAuthData, + escapeDN, + escapeFilter, +}; diff --git a/src/Adapters/Auth/line.js b/src/Adapters/Auth/line.js new file mode 100644 index 0000000000..7551db817d --- /dev/null +++ b/src/Adapters/Auth/line.js @@ -0,0 +1,143 @@ +/** + * Parse Server authentication adapter for Line. + * + * @class LineAdapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.clientId - Your Line App Client ID. Required for secure authentication. + * @param {string} options.clientSecret - Your Line App Client Secret. Required for secure authentication. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Line authentication, use the following structure: + * ### Secure Configuration + * ```json + * { + * "auth": { + * "line": { + * "clientId": "your-client-id", + * "clientSecret": "your-client-secret" + * } + * } + * } + * ``` + * ### Insecure Configuration (Not Recommended) + * ```json + * { + * "auth": { + * "line": { + * "enableInsecureAuth": true + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - **Secure Authentication**: `code`, `redirect_uri`. + * - **Insecure Authentication (Not Recommended)**: `id`, `access_token`. + * + * ## Auth Payloads + * ### Secure Authentication Payload + * ```json + * { + * "line": { + * "code": "xxxxxxxxx", + * "redirect_uri": "https://example.com/callback" + * } + * } + * ``` + * + * ### Insecure Authentication Payload (Not Recommended) + * ```json + * { + * "line": { + * "id": "1234567", + * "access_token": "xxxxxxxxx" + * } + * } + * ``` + * + * ## Notes + * - `enableInsecureAuth` is **not recommended** and will be removed in future versions. Use secure authentication with `clientId` and `clientSecret`. + * - Secure authentication exchanges the `code` and `redirect_uri` provided by the client for an access token using Line's OAuth flow. + * + * @see {@link https://developers.line.biz/en/docs/line-login/integrate-line-login/ Line Login Documentation} + */ + +import BaseCodeAuthAdapter from './BaseCodeAuthAdapter'; + +class LineAdapter extends BaseCodeAuthAdapter { + constructor() { + super('Line'); + } + + async getAccessTokenFromCode(authData) { + if (!authData.code) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Line auth is invalid for this user.' + ); + } + + const tokenUrl = 'https://api.line.me/oauth2/v2.1/token'; + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: this.clientId, + client_secret: this.clientSecret, + grant_type: 'authorization_code', + redirect_uri: authData.redirect_uri, + code: authData.code, + }), + }); + + if (!response.ok) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `Failed to exchange code for token: ${response.statusText}` + ); + } + + const data = await response.json(); + if (data.error) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + data.error_description || data.error + ); + } + + return data.access_token; + } + + async getUserFromAccessToken(accessToken) { + const userApiUrl = 'https://api.line.me/v2/profile'; + const response = await fetch(userApiUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `Failed to fetch Line user: ${response.statusText}` + ); + } + + const userData = await response.json(); + if (!userData?.userId) { + throw new Parse.Error( + Parse.Error.VALIDATION_ERROR, + 'Invalid Line user data received.' + ); + } + + return userData; + } +} + +export default new LineAdapter(); diff --git a/src/Adapters/Auth/linkedin.js b/src/Adapters/Auth/linkedin.js new file mode 100644 index 0000000000..2d74166783 --- /dev/null +++ b/src/Adapters/Auth/linkedin.js @@ -0,0 +1,115 @@ +/** + * Parse Server authentication adapter for LinkedIn. + * + * @class LinkedInAdapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.clientId - Your LinkedIn App Client ID. Required for secure authentication. + * @param {string} options.clientSecret - Your LinkedIn App Client Secret. Required for secure authentication. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for LinkedIn authentication, use the following structure: + * ### Secure Configuration + * ```json + * { + * "auth": { + * "linkedin": { + * "clientId": "your-client-id", + * "clientSecret": "your-client-secret" + * } + * } + * } + * ``` + * ### Insecure Configuration (Not Recommended) + * ```json + * { + * "auth": { + * "linkedin": { + * "enableInsecureAuth": true + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - **Secure Authentication**: `code`, `redirect_uri`, and optionally `is_mobile_sdk`. + * - **Insecure Authentication (Not Recommended)**: `id`, `access_token`, and optionally `is_mobile_sdk`. + * + * ## Auth Payloads + * ### Secure Authentication Payload + * ```json + * { + * "linkedin": { + * "code": "lmn789opq012rst345uvw", + * "redirect_uri": "https://your-redirect-uri.com/callback", + * "is_mobile_sdk": true + * } + * } + * ``` + * + * ### Insecure Authentication Payload (Not Recommended) + * ```json + * { + * "linkedin": { + * "id": "7654321", + * "access_token": "AQXNnd2hIT6z9bHFzZz2Kp1ghiMz_RtyuvwXYZ123abc", + * "is_mobile_sdk": true + * } + * } + * ``` + * + * ## Notes + * - Secure authentication exchanges the `code` and `redirect_uri` provided by the client for an access token using LinkedIn's OAuth API. + * - Insecure authentication validates the user ID and access token directly, bypassing OAuth flows. This method is **not recommended** and may introduce security vulnerabilities. + * - `enableInsecureAuth` is **deprecated** and may be removed in future versions. + * + * @see {@link https://learn.microsoft.com/en-us/linkedin/shared/authentication/authentication LinkedIn Authentication Documentation} + */ + +import BaseAuthCodeAdapter from './BaseCodeAuthAdapter'; +class LinkedInAdapter extends BaseAuthCodeAdapter { + constructor() { + super('LinkedIn'); + } + async getUserFromAccessToken(access_token, authData) { + const response = await fetch('https://api.linkedin.com/v2/me', { + headers: { + Authorization: `Bearer ${access_token}`, + 'x-li-format': 'json', + 'x-li-src': authData?.is_mobile_sdk ? 'msdk' : undefined, + }, + }); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'LinkedIn API request failed.'); + } + + return response.json(); + } + + async getAccessTokenFromCode(authData) { + const response = await fetch('https://www.linkedin.com/oauth/v2/accessToken', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: authData.code, + redirect_uri: authData.redirect_uri, + client_id: this.clientId, + client_secret: this.clientSecret, + }), + }); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'LinkedIn API request failed.'); + } + + const json = await response.json(); + return json.access_token; + } +} + +export default new LinkedInAdapter(); diff --git a/src/Adapters/Auth/meetup.js b/src/Adapters/Auth/meetup.js new file mode 100644 index 0000000000..33ec63d36e --- /dev/null +++ b/src/Adapters/Auth/meetup.js @@ -0,0 +1,43 @@ +// Helper functions for accessing the meetup API. +var Parse = require('parse/node').Parse; +const httpsRequest = require('./httpsRequest'); +import Config from '../../Config'; +import Deprecator from '../../Deprecator/Deprecator'; + +// Returns a promise that fulfills iff this user id is valid. +async function validateAuthData(authData) { + const config = Config.get(Parse.applicationId); + const meetupConfig = config.auth.meetup; + + Deprecator.logRuntimeDeprecation({ usage: 'meetup adapter' }); + + if (!meetupConfig?.enableInsecureAuth) { + throw new Parse.Error('Meetup only works with enableInsecureAuth: true'); + } + + const data = await request('member/self', authData.access_token); + if (data?.id !== authData.id) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Meetup auth is invalid for this user.'); + } +} + +// Returns a promise that fulfills iff this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +// A promisey wrapper for api requests +function request(path, access_token) { + return httpsRequest.get({ + host: 'api.meetup.com', + path: '/2/' + path, + headers: { + Authorization: 'bearer ' + access_token, + }, + }); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData, +}; diff --git a/src/Adapters/Auth/mfa.js b/src/Adapters/Auth/mfa.js new file mode 100644 index 0000000000..9af8601c47 --- /dev/null +++ b/src/Adapters/Auth/mfa.js @@ -0,0 +1,298 @@ +/** + * Parse Server authentication adapter for Multi-Factor Authentication (MFA). + * + * @class MFAAdapter + * @param {Object} options - The adapter options. + * @param {Array} options.options - Supported MFA methods. Must include `"SMS"` or `"TOTP"`. + * @param {Number} [options.digits=6] - The number of digits for the one-time password (OTP). Must be between 4 and 10. + * @param {Number} [options.period=30] - The validity period of the OTP in seconds. Must be greater than 10. + * @param {String} [options.algorithm="SHA1"] - The algorithm used for TOTP generation. Defaults to `"SHA1"`. + * @param {Function} [options.sendSMS] - A callback function for sending SMS OTPs. Required if `"SMS"` is included in `options`. + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for MFA, use the following structure: + * ```javascript + * { + * auth: { + * mfa: { + * options: ["SMS", "TOTP"], + * digits: 6, + * period: 30, + * algorithm: "SHA1", + * sendSMS: (token, mobile) => { + * // Send the SMS using your preferred SMS provider. + * console.log(`Sending SMS to ${mobile} with token: ${token}`); + * } + * } + * } + * } + * ``` + * + * ## MFA Methods + * - **SMS**: + * - Requires a valid mobile number. + * - Sends a one-time password (OTP) via SMS for login or verification. + * - Uses the `sendSMS` callback for sending the OTP. + * + * - **TOTP**: + * - Requires a secret key for setup. + * - Validates the user's OTP against a time-based one-time password (TOTP) generated using the secret key. + * - Supports configurable digits, period, and algorithm for TOTP generation. + * - Generates two single-use recovery codes during enrollment. Each recovery code can be used once + * in place of a TOTP token and is consumed after use. + * + * ## MFA Payload + * The adapter requires the following `authData` fields: + * - **For SMS-based MFA**: + * - `mobile`: The user's mobile number (required for setup). + * - `token`: The OTP provided by the user for login or verification. + * - **For TOTP-based MFA**: + * - `secret`: The TOTP secret key for the user (required for setup). + * - `token`: The OTP provided by the user for login or verification. + * + * ## Example Payloads + * ### SMS Setup Payload + * ```json + * { + * "mobile": "+1234567890" + * } + * ``` + * + * ### TOTP Setup Payload + * ```json + * { + * "secret": "BASE32ENCODEDSECRET", + * "token": "123456" + * } + * ``` + * + * ### Login Payload + * ```json + * { + * "token": "123456" + * } + * ``` + * + * @see {@link https://en.wikipedia.org/wiki/Time-based_One-Time_Password_algorithm Time-based One-Time Password Algorithm (TOTP)} + * @see {@link https://tools.ietf.org/html/rfc6238 RFC 6238: TOTP: Time-Based One-Time Password Algorithm} + */ + +import { TOTP, Secret } from 'otpauth'; +import { randomString } from '../../cryptoUtils'; +import AuthAdapter from './AuthAdapter'; +class MFAAdapter extends AuthAdapter { + validateOptions(opts) { + const validOptions = opts.options; + if (!Array.isArray(validOptions)) { + throw 'mfa.options must be an array'; + } + this.sms = validOptions.includes('SMS'); + this.totp = validOptions.includes('TOTP'); + if (!this.sms && !this.totp) { + throw 'mfa.options must include SMS or TOTP'; + } + const digits = opts.digits || 6; + const period = opts.period || 30; + if (typeof digits !== 'number') { + throw 'mfa.digits must be a number'; + } + if (typeof period !== 'number') { + throw 'mfa.period must be a number'; + } + if (digits < 4 || digits > 10) { + throw 'mfa.digits must be between 4 and 10'; + } + if (period < 10) { + throw 'mfa.period must be greater than 10'; + } + const sendSMS = opts.sendSMS; + if (this.sms && typeof sendSMS !== 'function') { + throw 'mfa.sendSMS callback must be defined when using SMS OTPs'; + } + this.smsCallback = sendSMS; + this.digits = digits; + this.period = period; + this.algorithm = opts.algorithm || 'SHA1'; + } + validateSetUp(mfaData) { + if (mfaData.mobile && this.sms) { + return this.setupMobileOTP(mfaData.mobile); + } + if (this.totp) { + return this.setupTOTP(mfaData); + } + throw 'Invalid MFA data'; + } + async validateLogin(loginData, _, req) { + const saveResponse = { + doNotSave: true, + }; + const token = loginData.token; + const auth = req.original.get('authData') || {}; + const { secret, recovery, mobile, token: saved, expiry } = auth.mfa || {}; + if (this.sms && mobile) { + if (token === 'request') { + const { token: sendToken, expiry } = await this.sendSMS(mobile); + auth.mfa.token = sendToken; + auth.mfa.expiry = expiry; + req.object.set('authData', auth); + await req.object.save(null, { useMasterKey: true }); + throw 'Please enter the token'; + } + if (!saved || token !== saved) { + throw 'Invalid MFA token 1'; + } + if (new Date() > expiry) { + throw 'Invalid MFA token 2'; + } + delete auth.mfa.token; + delete auth.mfa.expiry; + return { + save: auth.mfa, + }; + } + if (this.totp) { + if (typeof token !== 'string') { + throw 'Invalid MFA token'; + } + if (!secret) { + return saveResponse; + } + const recoveryIndex = recovery?.indexOf(token) ?? -1; + if (recoveryIndex >= 0) { + const updatedRecovery = [...recovery]; + updatedRecovery.splice(recoveryIndex, 1); + return { + save: { ...auth.mfa, recovery: updatedRecovery }, + }; + } + const totp = new TOTP({ + algorithm: this.algorithm, + digits: this.digits, + period: this.period, + secret: Secret.fromBase32(secret), + }); + const valid = totp.validate({ + token, + }); + if (valid === null) { + throw 'Invalid MFA token'; + } + } + return saveResponse; + } + async validateUpdate(authData, _, req) { + if (req.master) { + return; + } + if (authData.mobile && this.sms) { + if (!authData.token) { + throw 'MFA is already set up on this account'; + } + return this.confirmSMSOTP(authData, req.original.get('authData')?.mfa || {}); + } + if (this.totp) { + await this.validateLogin({ token: authData.old }, null, req); + return this.validateSetUp(authData); + } + throw 'Invalid MFA data'; + } + afterFind(authData, options, req) { + if (req.master) { + return; + } + if (this.totp && authData.secret) { + return { + status: 'enabled', + }; + } + if (this.sms && authData.mobile) { + return { + status: 'enabled', + }; + } + return { + status: 'disabled', + }; + } + + policy(req, auth) { + if (this.sms && auth?.pending && Object.keys(auth).length === 1) { + return 'default'; + } + return 'additional'; + } + + async setupMobileOTP(mobile) { + const { token, expiry } = await this.sendSMS(mobile); + return { + save: { + pending: { + [mobile]: { + token, + expiry, + }, + }, + }, + }; + } + + async sendSMS(mobile) { + if (!/^[+]*[(]{0,1}[0-9]{1,3}[)]{0,1}[-\s\./0-9]*$/g.test(mobile)) { + throw 'Invalid mobile number.'; + } + let token = ''; + while (token.length < this.digits) { + token += randomString(10).replace(/\D/g, ''); + } + token = token.substring(0, this.digits); + await Promise.resolve(this.smsCallback(token, mobile)); + const expiry = new Date(new Date().getTime() + this.period * 1000); + return { token, expiry }; + } + + async confirmSMSOTP(inputData, authData) { + const { mobile, token } = inputData; + if (!authData.pending?.[mobile]) { + throw 'This number is not pending'; + } + const pendingData = authData.pending[mobile]; + if (token !== pendingData.token) { + throw 'Invalid MFA token'; + } + if (new Date() > pendingData.expiry) { + throw 'Invalid MFA token'; + } + delete authData.pending[mobile]; + authData.mobile = mobile; + return { + save: authData, + }; + } + + setupTOTP(mfaData) { + const { secret, token } = mfaData; + if (!secret || !token || secret.length < 20) { + throw 'Invalid MFA data'; + } + const totp = new TOTP({ + algorithm: this.algorithm, + digits: this.digits, + period: this.period, + secret: Secret.fromBase32(secret), + }); + const valid = totp.validate({ + token, + }); + if (valid === null) { + throw 'Invalid MFA token'; + } + const recovery = [randomString(30), randomString(30)]; + return { + response: { recovery: recovery.join(', ') }, + save: { secret, recovery }, + }; + } +} +export default new MFAAdapter(); diff --git a/src/Adapters/Auth/microsoft.js b/src/Adapters/Auth/microsoft.js new file mode 100644 index 0000000000..a2e17ef4a5 --- /dev/null +++ b/src/Adapters/Auth/microsoft.js @@ -0,0 +1,109 @@ +/** + * Parse Server authentication adapter for Microsoft. + * + * @class MicrosoftAdapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.clientId - Your Microsoft App Client ID. Required for secure authentication. + * @param {string} options.clientSecret - Your Microsoft App Client Secret. Required for secure authentication. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Microsoft authentication, use the following structure: + * ### Secure Configuration + * ```json + * { + * "auth": { + * "microsoft": { + * "clientId": "your-client-id", + * "clientSecret": "your-client-secret" + * } + * } + * } + * ``` + * ### Insecure Configuration (Not Recommended) + * ```json + * { + * "auth": { + * "microsoft": { + * "enableInsecureAuth": true + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - **Secure Authentication**: `code`, `redirect_uri`. + * - **Insecure Authentication (Not Recommended)**: `id`, `access_token`. + * + * ## Auth Payloads + * ### Secure Authentication Payload + * ```json + * { + * "microsoft": { + * "code": "lmn789opq012rst345uvw", + * "redirect_uri": "https://your-redirect-uri.com/callback" + * } + * } + * ``` + * ### Insecure Authentication Payload (Not Recommended) + * ```json + * { + * "microsoft": { + * "id": "7654321", + * "access_token": "AQXNnd2hIT6z9bHFzZz2Kp1ghiMz_RtyuvwXYZ123abc" + * } + * } + * ``` + * + * ## Notes + * - Secure authentication exchanges the `code` and `redirect_uri` provided by the client for an access token using Microsoft's OAuth API. + * - **Insecure authentication** validates the user ID and access token directly, bypassing OAuth flows (not recommended). This method is deprecated and may be removed in future versions. + * + * @see {@link https://docs.microsoft.com/en-us/graph/auth/auth-concepts Microsoft Authentication Documentation} + */ + +import BaseAuthCodeAdapter from './BaseCodeAuthAdapter'; +class MicrosoftAdapter extends BaseAuthCodeAdapter { + constructor() { + super('Microsoft'); + } + async getUserFromAccessToken(access_token) { + const userResponse = await fetch('https://graph.microsoft.com/v1.0/me', { + headers: { + Authorization: 'Bearer ' + access_token, + }, + }); + + if (!userResponse.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Microsoft API request failed.'); + } + + return userResponse.json(); + } + + async getAccessTokenFromCode(authData) { + const response = await fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: this.clientId, + client_secret: this.clientSecret, + grant_type: 'authorization_code', + redirect_uri: authData.redirect_uri, + code: authData.code, + }), + }); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Microsoft API request failed.'); + } + + const json = await response.json(); + return json.access_token; + } +} + +export default new MicrosoftAdapter(); diff --git a/src/Adapters/Auth/oauth2.js b/src/Adapters/Auth/oauth2.js new file mode 100644 index 0000000000..8fe41d6f41 --- /dev/null +++ b/src/Adapters/Auth/oauth2.js @@ -0,0 +1,121 @@ +/** + * Parse Server authentication adapter for OAuth2 Token Introspection. + * + * @class OAuth2Adapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.tokenIntrospectionEndpointUrl - The URL of the token introspection endpoint. Required. + * @param {boolean} options.oauth2 - Indicates that the request should be handled by the OAuth2 adapter. Required. + * @param {string} [options.useridField='sub'] - The field in the introspection response that contains the user ID. Defaults to `sub` per RFC 7662. + * @param {string} [options.appidField] - The field in the introspection response that contains the app ID. Optional. + * @param {string[]} [options.appIds] - List of allowed app IDs. Required if `appidField` is defined. + * @param {string} [options.authorizationHeader] - The Authorization header value for the introspection request. Optional. + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for OAuth2 Token Introspection, use the following structure: + * ```json + * { + * "auth": { + * "oauth2Provider": { + * "tokenIntrospectionEndpointUrl": "https://provider.com/introspect", + * "useridField": "sub", + * "appidField": "aud", + * "appIds": ["my-app-id"], + * "authorizationHeader": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=", + * "oauth2": true + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - `id`: The user ID provided by the client. + * - `access_token`: The access token provided by the client. + * + * ## Auth Payload + * ### Example Auth Payload + * ```json + * { + * "oauth2": { + * "id": "user-id", + * "access_token": "access-token" + * } + * } + * ``` + * + * ## Notes + * - `tokenIntrospectionEndpointUrl` is mandatory and should point to a valid OAuth2 provider's introspection endpoint. + * - If `appidField` is defined, `appIds` must also be specified to validate the app ID in the introspection response. + * - `authorizationHeader` can be used to authenticate requests to the token introspection endpoint. + * + * @see {@link https://datatracker.ietf.org/doc/html/rfc7662 OAuth 2.0 Token Introspection Specification} + */ + + +import AuthAdapter from './AuthAdapter'; + +class OAuth2Adapter extends AuthAdapter { + validateOptions(options) { + super.validateOptions(options); + + if (!options.tokenIntrospectionEndpointUrl) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 token introspection endpoint URL is missing.'); + } + if (options.appidField && !options.appIds?.length) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 configuration is missing app IDs.'); + } + + this.tokenIntrospectionEndpointUrl = options.tokenIntrospectionEndpointUrl; + this.useridField = options.useridField || 'sub'; + this.appidField = options.appidField; + this.appIds = options.appIds; + this.authorizationHeader = options.authorizationHeader; + } + + async validateAppId(appIds, authData) { + if (!this.appidField) { + return; + } + + const response = await this.requestTokenInfo(authData.access_token); + + const appIdFieldValue = response[this.appidField]; + const isValidAppId = Array.isArray(appIdFieldValue) + ? appIdFieldValue.some(appId => this.appIds.includes(appId)) + : this.appIds.includes(appIdFieldValue); + + if (!isValidAppId) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2: Invalid app ID.'); + } + } + + async validateAuthData(authData) { + const response = await this.requestTokenInfo(authData.access_token); + + if (!response.active || (this.useridField && authData.id !== response[this.useridField])) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 access token is invalid for this user.'); + } + + return {}; + } + + async requestTokenInfo(accessToken) { + const response = await fetch(this.tokenIntrospectionEndpointUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(this.authorizationHeader && { Authorization: this.authorizationHeader }) + }, + body: new URLSearchParams({ token: accessToken }) + }); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 token introspection request failed.'); + } + + return response.json(); + } +} + +export default new OAuth2Adapter(); + diff --git a/src/Adapters/Auth/phantauth.js b/src/Adapters/Auth/phantauth.js new file mode 100644 index 0000000000..d9145c84ca --- /dev/null +++ b/src/Adapters/Auth/phantauth.js @@ -0,0 +1,50 @@ +/* + * PhantAuth was designed to simplify testing for applications using OpenID Connect + * authentication by making use of random generated users. + * + * To learn more, please go to: https://www.phantauth.net + */ + +const { Parse } = require('parse/node'); +const httpsRequest = require('./httpsRequest'); +import Config from '../../Config'; +import Deprecator from '../../Deprecator/Deprecator'; + +// Returns a promise that fulfills if this user id is valid. +async function validateAuthData(authData) { + const config = Config.get(Parse.applicationId); + + Deprecator.logRuntimeDeprecation({ usage: 'phantauth adapter' }); + + const phantauthConfig = config.auth.phantauth; + if (!phantauthConfig?.enableInsecureAuth) { + throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'PhantAuth only works with enableInsecureAuth: true'); + } + + const data = await request('auth/userinfo', authData.access_token); + if (data?.sub !== authData.id) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'PhantAuth auth is invalid for this user.'); + } +} + +// Returns a promise that fulfills if this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +// A promisey wrapper for api requests +function request(path, access_token) { + return httpsRequest.get({ + host: 'phantauth.net', + path: '/' + path, + headers: { + Authorization: 'bearer ' + access_token, + 'User-Agent': 'parse-server', + }, + }); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData, +}; diff --git a/src/Adapters/Auth/qq.js b/src/Adapters/Auth/qq.js new file mode 100644 index 0000000000..873e9071b8 --- /dev/null +++ b/src/Adapters/Auth/qq.js @@ -0,0 +1,112 @@ +/** + * Parse Server authentication adapter for QQ. + * + * @class QqAdapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.clientId - Your QQ App ID. Required for secure authentication. + * @param {string} options.clientSecret - Your QQ App Secret. Required for secure authentication. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for QQ authentication, use the following structure: + * ### Secure Configuration + * ```json + * { + * "auth": { + * "qq": { + * "clientId": "your-app-id", + * "clientSecret": "your-app-secret" + * } + * } + * } + * ``` + * ### Insecure Configuration (Not Recommended) + * ```json + * { + * "auth": { + * "qq": { + * "enableInsecureAuth": true + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - **Secure Authentication**: `code`, `redirect_uri`. + * - **Insecure Authentication (Not Recommended)**: `id`, `access_token`. + * + * ## Auth Payloads + * ### Secure Authentication Payload + * ```json + * { + * "qq": { + * "code": "abcd1234", + * "redirect_uri": "https://your-redirect-uri.com/callback" + * } + * } + * ``` + * ### Insecure Authentication Payload (Not Recommended) + * ```json + * { + * "qq": { + * "id": "1234567", + * "access_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + * } + * } + * ``` + * + * ## Notes + * - Secure authentication exchanges the `code` and `redirect_uri` provided by the client for an access token using QQ's OAuth API. + * - **Insecure authentication** validates the `id` and `access_token` directly, bypassing OAuth flows. This approach is not recommended and may be deprecated in future versions. + * + * @see {@link https://wiki.connect.qq.com/ QQ Authentication Documentation} + */ + +import BaseAuthCodeAdapter from './BaseCodeAuthAdapter'; +class QqAdapter extends BaseAuthCodeAdapter { + constructor() { + super('qq'); + } + + async getUserFromAccessToken(access_token) { + const response = await fetch('https://graph.qq.com/oauth2.0/me', { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'qq API request failed.'); + } + + const data = await response.text(); + return this.parseResponseData(data); + } + + async getAccessTokenFromCode(authData) { + const response = await fetch('https://graph.qq.com/oauth2.0/token', { + method: 'GET', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: this.clientId, + client_secret: this.clientSecret, + redirect_uri: authData.redirect_uri, + code: authData.code, + }).toString(), + }); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'qq API request failed.'); + } + + const text = await response.text(); + const data = this.parseResponseData(text); + return data.access_token; + } +} + +export default new QqAdapter(); diff --git a/src/Adapters/Auth/spotify.js b/src/Adapters/Auth/spotify.js new file mode 100644 index 0000000000..c3304c6348 --- /dev/null +++ b/src/Adapters/Auth/spotify.js @@ -0,0 +1,118 @@ +/** + * Parse Server authentication adapter for Spotify. + * + * @class SpotifyAdapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.clientId - Your Spotify application's Client ID. Required for secure authentication. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Spotify authentication, use the following structure: + * ### Secure Configuration + * ```json + * { + * "auth": { + * "spotify": { + * "clientId": "your-client-id" + * } + * } + * } + * ``` + * ### Insecure Configuration (Not Recommended) + * ```json + * { + * "auth": { + * "spotify": { + * "enableInsecureAuth": true + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - **Secure Authentication**: `code`, `redirect_uri`, and `code_verifier`. + * - **Insecure Authentication (Not Recommended)**: `id`, `access_token`. + * + * ## Auth Payloads + * ### Secure Authentication Payload + * ```json + * { + * "spotify": { + * "code": "abc123def456ghi789", + * "redirect_uri": "https://example.com/callback", + * "code_verifier": "secure-code-verifier" + * } + * } + * ``` + * ### Insecure Authentication Payload (Not Recommended) + * ```json + * { + * "spotify": { + * "id": "1234567", + * "access_token": "abc123def456ghi789" + * } + * } + * ``` + * + * ## Notes + * - `enableInsecureAuth` is **not recommended** and bypasses secure flows by validating the user ID and access token directly. This method is not suitable for production environments and may be removed in future versions. + * - Secure authentication exchanges the `code` provided by the client for an access token using Spotify's OAuth API. This method ensures greater security and is the recommended approach. + * + * @see {@link https://developer.spotify.com/documentation/web-api/tutorials/getting-started Spotify OAuth Documentation} + */ + +import BaseAuthCodeAdapter from './BaseCodeAuthAdapter'; +class SpotifyAdapter extends BaseAuthCodeAdapter { + constructor() { + super('spotify'); + } + + async getUserFromAccessToken(access_token) { + const response = await fetch('https://api.spotify.com/v1/me', { + headers: { + Authorization: 'Bearer ' + access_token, + }, + }); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Spotify API request failed.'); + } + + const user = await response.json(); + return { + id: user.id, + }; + } + + async getAccessTokenFromCode(authData) { + if (!authData.code || !authData.redirect_uri || !authData.code_verifier) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Spotify auth configuration authData.code and/or authData.redirect_uri and/or authData.code_verifier.' + ); + } + + const response = await fetch('https://accounts.spotify.com/api/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: authData.code, + redirect_uri: authData.redirect_uri, + code_verifier: authData.code_verifier, + client_id: this.clientId, + }), + }); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Spotify API request failed.'); + } + + return response.json(); + } +} + +export default new SpotifyAdapter(); diff --git a/src/Adapters/Auth/twitter.js b/src/Adapters/Auth/twitter.js new file mode 100644 index 0000000000..9a6881bd24 --- /dev/null +++ b/src/Adapters/Auth/twitter.js @@ -0,0 +1,244 @@ +/** + * Parse Server authentication adapter for Twitter. + * + * @class TwitterAdapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.consumerKey - The Twitter App Consumer Key. Required for secure authentication. + * @param {string} options.consumerSecret - The Twitter App Consumer Secret. Required for secure authentication. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Twitter authentication, use the following structure: + * ### Secure Configuration + * ```json + * { + * "auth": { + * "twitter": { + * "consumerKey": "your-consumer-key", + * "consumerSecret": "your-consumer-secret" + * } + * } + * } + * ``` + * ### Insecure Configuration (Not Recommended) + * ```json + * { + * "auth": { + * "twitter": { + * "enableInsecureAuth": true + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - **Secure Authentication**: `oauth_token`, `oauth_verifier`. + * - **Insecure Authentication (Not Recommended)**: `id`, `oauth_token`, `oauth_token_secret`. + * + * ## Auth Payloads + * ### Secure Authentication Payload + * ```json + * { + * "twitter": { + * "oauth_token": "1234567890-abc123def456", + * "oauth_verifier": "abc123def456" + * } + * } + * ``` + * + * ### Insecure Authentication Payload (Not Recommended) + * ```json + * { + * "twitter": { + * "id": "1234567890", + * "oauth_token": "1234567890-abc123def456", + * "oauth_token_secret": "1234567890-abc123def456" + * } + * } + * ``` + * + * ## Notes + * - **Deprecation Notice**: `enableInsecureAuth` and insecure fields (`id`, `oauth_token_secret`) are **deprecated** and may be removed in future versions. Use secure authentication with `consumerKey` and `consumerSecret`. + * - Secure authentication exchanges the `oauth_token` and `oauth_verifier` provided by the client for an access token using Twitter's OAuth API. + * + * @see {@link https://developer.twitter.com/en/docs/authentication/oauth-1-0a Twitter OAuth Documentation} + */ + +import Config from '../../Config'; +import querystring from 'querystring'; +import AuthAdapter from './AuthAdapter'; + +class TwitterAuthAdapter extends AuthAdapter { + validateOptions(options) { + if (!options) { + throw new Error('Twitter auth options are required.'); + } + + this.enableInsecureAuth = options.enableInsecureAuth; + + if (!this.enableInsecureAuth && (!options.consumer_key || !options.consumer_secret)) { + throw new Error('Consumer key and secret are required for secure Twitter auth.'); + } + } + + async validateAuthData(authData, options) { + const config = Config.get(Parse.applicationId); + const twitterConfig = config.auth.twitter; + + if (this.enableInsecureAuth && twitterConfig && config.enableInsecureAuthAdapters) { + return this.validateInsecureAuth(authData, options); + } + + if (!options.consumer_key || !options.consumer_secret) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Twitter auth configuration missing consumer_key and/or consumer_secret.' + ); + } + + const accessTokenData = await this.exchangeAccessToken(authData); + + if (accessTokenData?.oauth_token && accessTokenData?.user_id) { + authData.id = accessTokenData.user_id; + authData.auth_token = accessTokenData.oauth_token; + return; + } + + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Twitter auth is invalid for this user.' + ); + } + + async validateInsecureAuth(authData, options) { + if (!authData.oauth_token || !authData.oauth_token_secret) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Twitter insecure auth requires oauth_token and oauth_token_secret.' + ); + } + + options = this.handleMultipleConfigurations(authData, options); + + const data = await this.request(authData, options); + const parsedData = await data.json(); + + if (parsedData?.id === authData.id) { + return; + } + + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Twitter auth is invalid for this user.' + ); + } + + async exchangeAccessToken(authData) { + const accessTokenRequestOptions = { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: querystring.stringify({ + oauth_token: authData.oauth_token, + oauth_verifier: authData.oauth_verifier, + }), + }; + + const response = await fetch('https://api.twitter.com/oauth/access_token', accessTokenRequestOptions); + if (!response.ok) { + throw new Error('Failed to exchange access token.'); + } + + return response.json(); + } + + handleMultipleConfigurations(authData, options) { + if (Array.isArray(options)) { + const consumer_key = authData.consumer_key; + + if (!consumer_key) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Twitter auth is invalid for this user.' + ); + } + + options = options.filter(option => option.consumer_key === consumer_key); + + if (options.length === 0) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Twitter auth is invalid for this user.' + ); + } + + return options[0]; + } + + return options; + } + + async request(authData, options) { + const { consumer_key, consumer_secret } = options; + + const oauth = { + consumer_key, + consumer_secret, + auth_token: authData.oauth_token, + auth_token_secret: authData.oauth_token_secret, + }; + + const url = new URL('https://api.twitter.com/2/users/me'); + + const response = await fetch(url, { + headers: { + Authorization: 'Bearer ' + oauth.auth_token, + }, + body: JSON.stringify(oauth), + }); + + if (!response.ok) { + throw new Error('Failed to fetch user data.'); + } + + return response; + } + + async beforeFind(authData) { + if (this.enableInsecureAuth && !authData?.code) { + if (!authData?.access_token) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Twitter auth is invalid for this user.'); + } + + const user = await this.getUserFromAccessToken(authData.access_token, authData); + + if (user.id !== authData.id) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Twitter auth is invalid for this user.'); + } + + return; + } + + if (!authData?.code) { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, 'Twitter code is required.'); + } + + const access_token = await this.exchangeAccessToken(authData); + const user = await this.getUserFromAccessToken(access_token, authData); + + + authData.access_token = access_token; + authData.id = user.id; + + delete authData.code; + delete authData.redirect_uri; + } + + validateAppId() { + return Promise.resolve(); + } +} + +export default new TwitterAuthAdapter(); diff --git a/src/Adapters/Auth/utils.js b/src/Adapters/Auth/utils.js new file mode 100644 index 0000000000..0d4d7cd8a2 --- /dev/null +++ b/src/Adapters/Auth/utils.js @@ -0,0 +1,24 @@ +const jwt = require('jsonwebtoken'); +const util = require('util'); +const Parse = require('parse/node').Parse; +const getHeaderFromToken = token => { + const decodedToken = jwt.decode(token, { complete: true }); + if (!decodedToken) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `provided token does not decode as JWT`); + } + + return decodedToken.header; +}; + +/** + * Returns the signing key from a JWKS client. + * @param {Object} client The JWKS client. + * @param {String} key The kid. + */ +async function getSigningKey(client, key) { + return util.promisify(client.getSigningKey)(key); +} +module.exports = { + getHeaderFromToken, + getSigningKey, +}; diff --git a/src/Adapters/Auth/vkontakte.js b/src/Adapters/Auth/vkontakte.js new file mode 100644 index 0000000000..3b5b7a9bac --- /dev/null +++ b/src/Adapters/Auth/vkontakte.js @@ -0,0 +1,80 @@ +'use strict'; + +// Helper functions for accessing the vkontakte API. + +const httpsRequest = require('./httpsRequest'); +var Parse = require('parse/node').Parse; +import Config from '../../Config'; +import Deprecator from '../../Deprecator/Deprecator'; + +// Returns a promise that fulfills iff this user id is valid. +async function validateAuthData(authData, params) { + const config = Config.get(Parse.applicationId); + Deprecator.logRuntimeDeprecation({ usage: 'vkontakte adapter' }); + + const vkConfig = config.auth.vkontakte; + if (!vkConfig?.enableInsecureAuth || !config.enableInsecureAuthAdapters) { + throw new Parse.Error('Vk only works with enableInsecureAuth: true'); + } + + const response = await vkOAuth2Request(params); + if (!response?.access_token) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Vk appIds or appSecret is incorrect.'); + } + + const vkUser = await request( + 'api.vk.com', + `method/users.get?access_token=${authData.access_token}&v=${params.apiVersion}` + ); + + if (!vkUser?.response?.length || vkUser.response[0].id !== authData.id) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Vk auth is invalid for this user.'); + } +} + +function vkOAuth2Request(params) { + return new Promise(function (resolve) { + if ( + !params || + !params.appIds || + !params.appIds.length || + !params.appSecret || + !params.appSecret.length + ) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Vk auth is not configured. Missing appIds or appSecret.' + ); + } + if (!params.apiVersion) { + params.apiVersion = '5.124'; + } + resolve(); + }).then(function () { + return request( + 'oauth.vk.com', + 'access_token?client_id=' + + params.appIds + + '&client_secret=' + + params.appSecret + + '&v=' + + params.apiVersion + + '&grant_type=client_credentials' + ); + }); +} + +// Returns a promise that fulfills iff this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +// A promisey wrapper for api requests +function request(host, path) { + return httpsRequest.get('https://' + host + '/' + path); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData, +}; diff --git a/src/Adapters/Auth/wechat.js b/src/Adapters/Auth/wechat.js new file mode 100644 index 0000000000..d9c196f5a4 --- /dev/null +++ b/src/Adapters/Auth/wechat.js @@ -0,0 +1,120 @@ +/** + * Parse Server authentication adapter for WeChat. + * + * @class WeChatAdapter + * @param {Object} options - The adapter options object. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * @param {string} options.clientId - Your WeChat App ID. + * @param {string} options.clientSecret - Your WeChat App Secret. + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for WeChat authentication, use the following structure: + * ### Secure Configuration (Recommended) + * ```json + * { + * "auth": { + * "wechat": { + * "clientId": "your-client-id", + * "clientSecret": "your-client-secret" + * } + * } + * } + * ``` + * ### Insecure Configuration (Not Recommended) + * ```json + * { + * "auth": { + * "wechat": { + * "enableInsecureAuth": true + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - **With `enableInsecureAuth` (Not Recommended)**: `id`, `access_token`. + * - **Without `enableInsecureAuth`**: `code`. + * + * ## Auth Payloads + * ### Secure Authentication Payload (Recommended) + * ```json + * { + * "wechat": { + * "code": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + * } + * } + * ``` + * ### Insecure Authentication Payload (Not Recommended) + * ```json + * { + * "wechat": { + * "id": "1234567", + * "access_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + * } + * } + * ``` + * + * ## Notes + * - With `enableInsecureAuth`, the adapter directly validates the `id` and `access_token` sent by the client. + * - Without `enableInsecureAuth`, the adapter uses the `code` provided by the client to exchange for an access token via WeChat's OAuth API. + * - The `enableInsecureAuth` flag is **deprecated** and may be removed in future versions. Use secure authentication with the `code` field instead. + * + * @example Auth Data Example + * // Example authData provided by the client: + * const authData = { + * wechat: { + * code: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + * } + * }; + * + * @see {@link https://developers.weixin.qq.com/doc/offiaccount/en/OA_Web_Apps/Wechat_webpage_authorization.html WeChat Authentication Documentation} + */ + +import BaseAuthCodeAdapter from './BaseCodeAuthAdapter'; + +class WeChatAdapter extends BaseAuthCodeAdapter { + constructor() { + super('WeChat'); + } + + async getUserFromAccessToken(access_token, authData) { + const response = await fetch( + `https://api.weixin.qq.com/sns/auth?access_token=${access_token}&openid=${authData.id}` + ); + + const data = await response.json(); + + if (!response.ok || data.errcode !== 0) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'WeChat auth is invalid for this user.'); + } + + return data; + } + + async getAccessTokenFromCode(authData) { + if (!authData.code) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'WeChat auth requires a code to be sent.'); + } + + const appId = this.clientId; + const appSecret = this.clientSecret + + + const response = await fetch( + `https://api.weixin.qq.com/sns/oauth2/access_token?appid=${appId}&secret=${appSecret}&code=${authData.code}&grant_type=authorization_code` + ); + + const data = await response.json(); + + if (!response.ok || data.errcode) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'WeChat auth is invalid for this user.'); + } + + authData.id = data.openid; + + return data.access_token; + } +} + +export default new WeChatAdapter(); diff --git a/src/Adapters/Auth/weibo.js b/src/Adapters/Auth/weibo.js new file mode 100644 index 0000000000..86a761c653 --- /dev/null +++ b/src/Adapters/Auth/weibo.js @@ -0,0 +1,149 @@ +/** + * Parse Server authentication adapter for Weibo. + * + * @class WeiboAdapter + * @param {Object} options - The adapter configuration options. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * @param {string} options.clientId - Your Weibo client ID. + * @param {string} options.clientSecret - Your Weibo client secret. + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Weibo authentication, use the following structure: + * ### Secure Configuration + * ```json + * { + * "auth": { + * "weibo": { + * "clientId": "your-client-id", + * "clientSecret": "your-client-secret" + * } + * } + * } + * ``` + * ### Insecure Configuration (Not Recommended) + * ```json + * { + * "auth": { + * "weibo": { + * "enableInsecureAuth": true + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - **Secure Authentication**: `code`, `redirect_uri`. + * - **Insecure Authentication (Not Recommended)**: `id`, `access_token`. + * + * ## Auth Payloads + * ### Secure Authentication Payload + * ```json + * { + * "weibo": { + * "code": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + * "redirect_uri": "https://example.com/callback" + * } + * } + * ``` + * ### Insecure Authentication Payload (Not Recommended) + * ```json + * { + * "weibo": { + * "id": "1234567", + * "access_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + * } + * } + * ``` + * + * ## Notes + * - **Insecure Authentication**: When `enableInsecureAuth` is enabled, the adapter directly validates the `id` and `access_token` provided by the client. + * - **Secure Authentication**: When `enableInsecureAuth` is disabled, the adapter exchanges the `code` and `redirect_uri` for an access token using Weibo's OAuth API. + * - `enableInsecureAuth` is **deprecated** and may be removed in future versions. Use secure authentication with `code` and `redirect_uri`. + * + * @example Auth Data Example (Secure) + * const authData = { + * weibo: { + * code: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + * redirect_uri: "https://example.com/callback" + * } + * }; + * + * @example Auth Data Example (Insecure - Not Recommended) + * const authData = { + * weibo: { + * id: "1234567", + * access_token: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + * } + * }; + * + * @see {@link https://open.weibo.com/wiki/Oauth2/access_token Weibo Authentication Documentation} + */ + +import BaseAuthCodeAdapter from './BaseCodeAuthAdapter'; +import querystring from 'querystring'; + +class WeiboAdapter extends BaseAuthCodeAdapter { + constructor() { + super('Weibo'); + } + + async getUserFromAccessToken(access_token) { + const postData = querystring.stringify({ + access_token: access_token, + }); + + const response = await fetch('https://api.weibo.com/oauth2/get_token_info', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: postData, + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Weibo auth is invalid for this user.'); + } + + return { + id: data.uid, + } + } + + async getAccessTokenFromCode(authData) { + if (!authData?.code || !authData?.redirect_uri) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Weibo auth requires code and redirect_uri to be sent.' + ); + } + + const postData = querystring.stringify({ + client_id: this.clientId, + client_secret: this.clientSecret, + grant_type: 'authorization_code', + code: authData.code, + redirect_uri: authData.redirect_uri, + }); + + const response = await fetch('https://api.weibo.com/oauth2/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: postData, + }); + + const data = await response.json(); + + if (!response.ok || data.errcode) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Weibo auth is invalid for this user.'); + } + + return data.access_token; + } +} + +export default new WeiboAdapter(); diff --git a/src/Adapters/Cache/CacheAdapter.js b/src/Adapters/Cache/CacheAdapter.js index 7d65381763..9a84f89ec6 100644 --- a/src/Adapters/Cache/CacheAdapter.js +++ b/src/Adapters/Cache/CacheAdapter.js @@ -1,22 +1,27 @@ +/* eslint-disable unused-imports/no-unused-vars */ +/** + * @interface + * @memberof module:Adapters + */ export class CacheAdapter { /** * Get a value in the cache - * @param key Cache key to get - * @return Promise that will eventually resolve to the value in the cache. + * @param {String} key Cache key to get + * @return {Promise} that will eventually resolve to the value in the cache. */ get(key) {} /** * Set a value in the cache - * @param key Cache key to set - * @param value Value to set the key - * @param ttl Optional TTL + * @param {String} key Cache key to set + * @param {String} value Value to set the key + * @param {String} ttl Optional TTL */ put(key, value, ttl) {} /** * Remove a value from the cache. - * @param key Cache key to remove + * @param {String} key Cache key to remove */ del(key) {} diff --git a/src/Adapters/Cache/InMemoryCache.js b/src/Adapters/Cache/InMemoryCache.js index 2d44292a0a..c97f82e34d 100644 --- a/src/Adapters/Cache/InMemoryCache.js +++ b/src/Adapters/Cache/InMemoryCache.js @@ -1,16 +1,13 @@ const DEFAULT_CACHE_TTL = 5 * 1000; - export class InMemoryCache { - constructor({ - ttl = DEFAULT_CACHE_TTL - }) { + constructor({ ttl = DEFAULT_CACHE_TTL }) { this.ttl = ttl; this.cache = Object.create(null); } get(key) { - let record = this.cache[key]; + const record = this.cache[key]; if (record == null) { return null; } @@ -32,8 +29,8 @@ export class InMemoryCache { var record = { value: value, - expire: ttl + Date.now() - } + expire: ttl + Date.now(), + }; if (!isNaN(record.expire)) { record.timeout = setTimeout(() => { @@ -59,7 +56,6 @@ export class InMemoryCache { clear() { this.cache = Object.create(null); } - } export default InMemoryCache; diff --git a/src/Adapters/Cache/InMemoryCacheAdapter.js b/src/Adapters/Cache/InMemoryCacheAdapter.js index 09e1c12a11..e8036c51da 100644 --- a/src/Adapters/Cache/InMemoryCacheAdapter.js +++ b/src/Adapters/Cache/InMemoryCacheAdapter.js @@ -1,24 +1,20 @@ -import {InMemoryCache} from './InMemoryCache'; +import { LRUCache } from './LRUCache'; export class InMemoryCacheAdapter { - constructor(ctx) { - this.cache = new InMemoryCache(ctx) + this.cache = new LRUCache(ctx); } get(key) { - return new Promise((resolve, reject) => { - let record = this.cache.get(key); - if (record == null) { - return resolve(null); - } - - return resolve(JSON.parse(record)); - }) + const record = this.cache.get(key); + if (record === null) { + return Promise.resolve(null); + } + return Promise.resolve(record); } put(key, value, ttl) { - this.cache.put(key, JSON.stringify(value), ttl); + this.cache.put(key, value, ttl); return Promise.resolve(); } diff --git a/src/Adapters/Cache/LRUCache.js b/src/Adapters/Cache/LRUCache.js new file mode 100644 index 0000000000..129a006376 --- /dev/null +++ b/src/Adapters/Cache/LRUCache.js @@ -0,0 +1,29 @@ +import { LRUCache as LRU } from 'lru-cache'; +import defaults from '../../defaults'; + +export class LRUCache { + constructor({ ttl = defaults.cacheTTL, maxSize = defaults.cacheMaxSize }) { + this.cache = new LRU({ + max: maxSize, + ttl, + }); + } + + get(key) { + return this.cache.get(key) || null; + } + + put(key, value, ttl = this.ttl) { + this.cache.set(key, value, ttl); + } + + del(key) { + this.cache.delete(key); + } + + clear() { + this.cache.clear(); + } +} + +export default LRUCache; diff --git a/src/Adapters/Cache/NullCacheAdapter.js b/src/Adapters/Cache/NullCacheAdapter.js new file mode 100644 index 0000000000..812ee2ee38 --- /dev/null +++ b/src/Adapters/Cache/NullCacheAdapter.js @@ -0,0 +1,23 @@ +export class NullCacheAdapter { + constructor() {} + + get() { + return new Promise(resolve => { + return resolve(null); + }); + } + + put() { + return Promise.resolve(); + } + + del() { + return Promise.resolve(); + } + + clear() { + return Promise.resolve(); + } +} + +export default NullCacheAdapter; diff --git a/src/Adapters/Cache/RedisCacheAdapter.js b/src/Adapters/Cache/RedisCacheAdapter.js new file mode 100644 index 0000000000..7acab7fecf --- /dev/null +++ b/src/Adapters/Cache/RedisCacheAdapter.js @@ -0,0 +1,95 @@ +import { createClient } from 'redis'; +import logger from '../../logger'; +import { KeyPromiseQueue } from '../../KeyPromiseQueue'; + +const DEFAULT_REDIS_TTL = 30 * 1000; // 30 seconds in milliseconds +const FLUSH_DB_KEY = '__flush_db__'; + +function debug(...args: any) { + const message = ['RedisCacheAdapter: ' + arguments[0]].concat(args.slice(1, args.length)); + logger.debug.apply(logger, message); +} + +const isValidTTL = ttl => typeof ttl === 'number' && ttl > 0; + +export class RedisCacheAdapter { + constructor(redisCtx, ttl = DEFAULT_REDIS_TTL) { + this.ttl = isValidTTL(ttl) ? ttl : DEFAULT_REDIS_TTL; + this.client = createClient(redisCtx); + this.queue = new KeyPromiseQueue(); + this.client.on('error', err => { logger.error('RedisCacheAdapter client error', { error: err }) }); + this.client.on('connect', () => {}); + this.client.on('reconnecting', () => {}); + this.client.on('ready', () => {}); + } + + async connect() { + if (this.client.isOpen) { + return; + } + return await this.client.connect(); + } + + async handleShutdown() { + if (!this.client) { + return; + } + try { + await this.client.close(); + } catch (err) { + logger.error('RedisCacheAdapter error on shutdown', { error: err }); + } + } + + async get(key) { + debug('get', { key }); + try { + await this.queue.enqueue(key); + const res = await this.client.get(key); + if (!res) { + return null; + } + return JSON.parse(res); + } catch (err) { + logger.error('RedisCacheAdapter error on get', { error: err }); + } + } + + async put(key, value, ttl = this.ttl) { + value = JSON.stringify(value); + debug('put', { key, value, ttl }); + await this.queue.enqueue(key); + if (ttl === 0) { + // ttl of zero is a logical no-op, but redis cannot set expire time of zero + return; + } + + if (ttl === Infinity) { + return this.client.set(key, value); + } + + if (!isValidTTL(ttl)) { + ttl = this.ttl; + } + return this.client.set(key, value, { PX: ttl }); + } + + async del(key) { + debug('del', { key }); + await this.queue.enqueue(key); + return this.client.del(key); + } + + async clear() { + debug('clear'); + await this.queue.enqueue(FLUSH_DB_KEY); + return this.client.sendCommand(['FLUSHDB']); + } + + // Used for testing + getAllKeys() { + return this.client.keys('*'); + } +} + +export default RedisCacheAdapter; diff --git a/src/Adapters/Cache/SchemaCache.js b/src/Adapters/Cache/SchemaCache.js new file mode 100644 index 0000000000..f55edf0635 --- /dev/null +++ b/src/Adapters/Cache/SchemaCache.js @@ -0,0 +1,23 @@ +const SchemaCache = {}; + +export default { + all() { + return [...(SchemaCache.allClasses || [])]; + }, + + get(className) { + return this.all().find(cached => cached.className === className); + }, + + put(allSchema) { + SchemaCache.allClasses = allSchema; + }, + + del(className) { + this.put(this.all().filter(cached => cached.className !== className)); + }, + + clear() { + delete SchemaCache.allClasses; + }, +}; diff --git a/src/Adapters/Email/MailAdapter.js b/src/Adapters/Email/MailAdapter.js index 82ea8b34c3..10e77232b4 100644 --- a/src/Adapters/Email/MailAdapter.js +++ b/src/Adapters/Email/MailAdapter.js @@ -1,10 +1,12 @@ - -/* - Mail Adapter prototype - A MailAdapter should implement at least sendMail() +/* eslint-disable unused-imports/no-unused-vars */ +/** + * @interface + * @memberof module:Adapters + * Mail Adapter prototype + * A MailAdapter should implement at least sendMail() */ export class MailAdapter { - /* + /** * A method for sending mail * @param options would have the parameters * - to: the recipient @@ -12,7 +14,7 @@ export class MailAdapter { * - subject: the subject of the email */ sendMail(options) {} - + /* You can implement those methods if you want * to provide HTML templates etc... */ diff --git a/src/Adapters/Files/FilesAdapter.js b/src/Adapters/Files/FilesAdapter.js index cec1c2a4e2..57f816521b 100644 --- a/src/Adapters/Files/FilesAdapter.js +++ b/src/Adapters/Files/FilesAdapter.js @@ -1,34 +1,122 @@ +/* eslint-disable unused-imports/no-unused-vars */ // Files Adapter // // Allows you to change the file storage mechanism. // // Adapter classes must implement the following functions: -// * createFile(config, filename, data) -// * getFileData(config, filename) -// * getFileLocation(config, request, filename) +// * createFile(filename, data, contentType) +// * deleteFile(filename) +// * getFileData(filename) +// * getFileLocation(config, filename) +// Adapter classes should implement the following functions: +// * validateFilename(filename) +// * handleFileStream(filename, req, res, contentType) // -// Default is GridStoreAdapter, which requires mongo +// Default is GridFSBucketAdapter, which requires mongo // and for the API server to be using the DatabaseController with Mongo // database adapter. +import type { Config } from '../../Config'; +import Parse from 'parse/node'; +/** + * @interface + * @memberof module:Adapters + */ export class FilesAdapter { - /* this method is responsible to store the file in order to be retrived later by it's file name + /** Responsible for storing the file in order to be retrieved later by its filename * - * @param filename the filename to save - * @param data the buffer of data from the file - * @param contentType the supposed contentType + * @param {string} filename - the filename to save + * @param {Buffer|Readable} data - the file data as a Buffer, or a Readable stream if the adapter supports streaming (see supportsStreaming) + * @param {string} contentType - the supposed contentType * @discussion the contentType can be undefined if the controller was not able to determine it + * @param {object} options - (Optional) options to be passed to file adapter (S3 File Adapter Only) + * - tags: object containing key value pairs that will be stored with file + * - metadata: object containing key value pairs that will be sotred with file (https://docs.aws.amazon.com/AmazonS3/latest/user-guide/add-object-metadata.html) + * @discussion options are not supported by all file adapters. Check the your adapter's documentation for compatibility * - * @return a promise that should fail if the storage didn't succeed + * @return {Promise} a promise that should fail if the storage didn't succeed + */ + createFile(filename: string, data, contentType: string, options: Object): Promise {} + + /** Whether this adapter supports receiving Readable streams in createFile(). + * If false (default), streams are buffered to a Buffer before being passed. + * Override and return true to receive Readable streams directly. + * + * @return {boolean} + */ + get supportsStreaming() { + return false; + } + + /** Responsible for deleting the specified file + * + * @param {string} filename - the filename to delete + * + * @return {Promise} a promise that should fail if the deletion didn't succeed + */ + deleteFile(filename: string): Promise {} + + /** Responsible for retrieving the data of the specified file + * + * @param {string} filename - the name of file to retrieve + * + * @return {Promise} a promise that should pass with the file data or fail on error + */ + getFileData(filename: string): Promise {} + + /** Returns an absolute URL where the file can be accessed * + * @param {Config} config - server configuration + * @param {string} filename + * + * @return {string | Promise} Absolute URL */ - createFile(filename: string, data, contentType: string) { } + getFileLocation(config: Config, filename: string): string | Promise {} - deleteFile(filename) { } + /** Validate a filename for this adapter type + * + * @param {string} filename + * + * @returns {null|Parse.Error} null if there are no errors + */ + // validateFilename(filename: string): ?Parse.Error {} + + /** Handles Byte-Range Requests for Streaming + * + * @param {string} filename + * @param {object} req + * @param {object} res + * @param {string} contentType + * + * @returns {Promise} Data for byte range + */ + // handleFileStream(filename: string, res: any, req: any, contentType: string): Promise + + /** Responsible for retrieving metadata and tags + * + * @param {string} filename - the filename to retrieve metadata + * + * @return {Promise} a promise that should pass with metadata + */ + // getMetadata(filename: string): Promise {} +} - getFileData(filename) { } +/** + * Simple filename validation + * + * @param filename + * @returns {null|Parse.Error} + */ +export function validateFilename(filename): ?Parse.Error { + if (filename.length > 128) { + return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Filename too long.'); + } - getFileLocation(config, filename) { } + const regx = /^[_a-zA-Z0-9][a-zA-Z0-9@. ~_-]*$/; + if (!filename.match(regx)) { + return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Filename contains invalid characters.'); + } + return null; } export default FilesAdapter; diff --git a/src/Adapters/Files/GridFSBucketAdapter.js b/src/Adapters/Files/GridFSBucketAdapter.js new file mode 100644 index 0000000000..0236bec219 --- /dev/null +++ b/src/Adapters/Files/GridFSBucketAdapter.js @@ -0,0 +1,298 @@ +/** + GridFSBucketAdapter + Stores files in Mongo using GridFS + Requires the database adapter to be based on mongoclient + + @flow weak + */ + +// @flow-disable-next +import { MongoClient, GridFSBucket, Db } from 'mongodb'; +import { FilesAdapter, validateFilename } from './FilesAdapter'; +import defaults, { ParseServerDatabaseOptions } from '../../defaults'; +const crypto = require('crypto'); + +export class GridFSBucketAdapter extends FilesAdapter { + _databaseURI: string; + _connectionPromise: Promise; + _mongoOptions: Object; + _algorithm: string; + _clientMetadata: ?{ name: string, version: string }; + + constructor( + mongoDatabaseURI = defaults.DefaultMongoURI, + mongoOptions = {}, + encryptionKey = undefined + ) { + super(); + this._databaseURI = mongoDatabaseURI; + this._algorithm = 'aes-256-gcm'; + this._encryptionKey = + encryptionKey !== undefined + ? crypto + .createHash('sha256') + .update(String(encryptionKey)) + .digest('base64') + .substring(0, 32) + : null; + const defaultMongoOptions = {}; + const _mongoOptions = Object.assign(defaultMongoOptions, mongoOptions); + this._clientMetadata = mongoOptions.clientMetadata; + this._batchSize = mongoOptions.batchSize; + // Remove Parse Server-specific options that should not be passed to MongoDB client + for (const key of ParseServerDatabaseOptions) { + delete _mongoOptions[key]; + } + this._mongoOptions = _mongoOptions; + } + + get supportsStreaming() { + return true; + } + + _connect() { + if (!this._connectionPromise) { + // Only use driverInfo if clientMetadata option is set + const options = { ...this._mongoOptions }; + if (this._clientMetadata) { + options.driverInfo = { + name: this._clientMetadata.name, + version: this._clientMetadata.version + }; + } + + this._connectionPromise = MongoClient.connect(this._databaseURI, options).then( + client => { + this._client = client; + return client.db(client.s.options.dbName); + } + ); + } + return this._connectionPromise; + } + + _getBucket() { + return this._connect().then(database => new GridFSBucket(database)); + } + + // For a given config object, filename, and data, store a file + // Returns a promise + async createFile(filename: string, data, contentType, options = {}) { + const bucket = await this._getBucket(); + const stream = await bucket.openUploadStream(filename, { + metadata: options.metadata, + }); + + // If data is a stream and encryption is enabled, buffer first + // (AES-256-GCM needs complete data for format: [encrypted][IV][authTag]) + if (typeof data?.pipe === 'function' && this._encryptionKey !== null) { + data = await new Promise((resolve, reject) => { + const chunks = []; + data.on('data', chunk => chunks.push(chunk)); + data.on('end', () => resolve(Buffer.concat(chunks))); + data.on('error', reject); + }); + } + + if (typeof data?.pipe === 'function') { + // Pipe readable stream directly into GridFS upload stream + return new Promise((resolve, reject) => { + data.pipe(stream); + stream.on('finish', resolve); + stream.on('error', reject); + data.on('error', (err) => { + stream.destroy(err); + reject(err); + }); + }); + } + + // Buffer path (existing behavior) + if (this._encryptionKey !== null) { + try { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(this._algorithm, this._encryptionKey, iv); + const encryptedResult = Buffer.concat([ + cipher.update(data), + cipher.final(), + iv, + cipher.getAuthTag(), + ]); + await stream.write(encryptedResult); + } catch (err) { + return new Promise((resolve, reject) => { + return reject(err); + }); + } + } else { + await stream.write(data); + } + stream.end(); + return new Promise((resolve, reject) => { + stream.on('finish', resolve); + stream.on('error', reject); + }); + } + + async deleteFile(filename: string) { + const bucket = await this._getBucket(); + const documents = await bucket.find({ filename }, { batchSize: this._batchSize }).toArray(); + if (documents.length === 0) { + throw new Error('FileNotFound'); + } + return Promise.all( + documents.map(doc => { + return bucket.delete(doc._id); + }) + ); + } + + async getFileData(filename: string) { + const bucket = await this._getBucket(); + const stream = bucket.openDownloadStreamByName(filename); + stream.read(); + return new Promise((resolve, reject) => { + const chunks = []; + stream.on('data', data => { + chunks.push(data); + }); + stream.on('end', () => { + const data = Buffer.concat(chunks); + if (this._encryptionKey !== null) { + try { + const authTagLocation = data.length - 16; + const ivLocation = data.length - 32; + const authTag = data.slice(authTagLocation); + const iv = data.slice(ivLocation, authTagLocation); + const encrypted = data.slice(0, ivLocation); + const decipher = crypto.createDecipheriv(this._algorithm, this._encryptionKey, iv); + decipher.setAuthTag(authTag); + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); + return resolve(decrypted); + } catch (err) { + return reject(err); + } + } + resolve(data); + }); + stream.on('error', err => { + reject(err); + }); + }); + } + + async rotateEncryptionKey(options = {}) { + let fileNames = []; + let oldKeyFileAdapter = {}; + const bucket = await this._getBucket(); + if (options.oldKey !== undefined) { + oldKeyFileAdapter = new GridFSBucketAdapter( + this._databaseURI, + this._mongoOptions, + options.oldKey + ); + } else { + oldKeyFileAdapter = new GridFSBucketAdapter(this._databaseURI, this._mongoOptions); + } + if (options.fileNames !== undefined) { + fileNames = options.fileNames; + } else { + const fileNamesIterator = await bucket.find({}, { batchSize: this._batchSize }).toArray(); + fileNamesIterator.forEach(file => { + fileNames.push(file.filename); + }); + } + let fileNamesNotRotated = fileNames; + const fileNamesRotated = []; + for (const fileName of fileNames) { + try { + const plainTextData = await oldKeyFileAdapter.getFileData(fileName); + // Overwrite file with data encrypted with new key + await this.createFile(fileName, plainTextData); + fileNamesRotated.push(fileName); + fileNamesNotRotated = fileNamesNotRotated.filter(function (value) { + return value !== fileName; + }); + } catch { + continue; + } + } + return { rotated: fileNamesRotated, notRotated: fileNamesNotRotated }; + } + + getFileLocation(config, filename) { + const encodedFilename = filename.split('/').map(encodeURIComponent).join('/'); + return config.mount + '/files/' + config.applicationId + '/' + encodedFilename; + } + + async getMetadata(filename) { + const bucket = await this._getBucket(); + const files = await bucket.find({ filename }, { batchSize: this._batchSize }).toArray(); + if (files.length === 0) { + return {}; + } + const { metadata } = files[0]; + return { metadata }; + } + + async handleFileStream(filename: string, req, res, contentType) { + const bucket = await this._getBucket(); + const files = await bucket.find({ filename }, { batchSize: this._batchSize }).toArray(); + if (files.length === 0) { + throw new Error('FileNotFound'); + } + const parts = req + .get('Range') + .replace(/bytes=/, '') + .split('-'); + const partialstart = parts[0]; + const partialend = parts[1]; + + const fileLength = files[0].length; + const fileStart = parseInt(partialstart, 10); + const fileEnd = partialend ? parseInt(partialend, 10) : fileLength; + + let start = Math.min(fileStart || 0, fileEnd, fileLength); + let end = Math.max(fileStart || 0, fileEnd) + 1 || fileLength; + if (isNaN(fileStart)) { + start = fileLength - end + 1; + end = fileLength; + } + end = Math.min(end, fileLength); + start = Math.max(start, 0); + + res.status(206); + res.header('Accept-Ranges', 'bytes'); + res.header('Content-Length', end - start); + res.header('Content-Range', 'bytes ' + start + '-' + end + '/' + fileLength); + res.header('Content-Type', contentType); + const stream = bucket.openDownloadStreamByName(filename); + stream.start(start); + if (end) { + stream.end(end); + } + stream.on('data', chunk => { + res.write(chunk); + }); + stream.on('error', e => { + res.status(404); + res.send(e.message); + }); + stream.on('end', () => { + res.end(); + }); + } + + handleShutdown() { + if (!this._client) { + return Promise.resolve(); + } + return this._client.close(false); + } + + validateFilename(filename) { + return validateFilename(filename); + } +} + +export default GridFSBucketAdapter; diff --git a/src/Adapters/Files/GridStoreAdapter.js b/src/Adapters/Files/GridStoreAdapter.js index d7844a0b5f..39d1ca41c6 100644 --- a/src/Adapters/Files/GridStoreAdapter.js +++ b/src/Adapters/Files/GridStoreAdapter.js @@ -1,72 +1,4 @@ -/** - GridStoreAdapter - Stores files in Mongo using GridStore - Requires the database adapter to be based on mongoclient - - @flow weak - */ - -import { MongoClient, GridStore, Db} from 'mongodb'; -import { FilesAdapter } from './FilesAdapter'; - -const DefaultMongoURI = 'mongodb://localhost:27017/parse'; - -export class GridStoreAdapter extends FilesAdapter { - _databaseURI: string; - _connectionPromise: Promise; - - constructor(mongoDatabaseURI = DefaultMongoURI) { - super(); - this._databaseURI = mongoDatabaseURI; - this._connect(); - } - - _connect() { - if (!this._connectionPromise) { - this._connectionPromise = MongoClient.connect(this._databaseURI); - } - return this._connectionPromise; - } - - // For a given config object, filename, and data, store a file - // Returns a promise - createFile(filename: string, data, contentType) { - return this._connect().then(database => { - let gridStore = new GridStore(database, filename, 'w'); - return gridStore.open(); - }).then(gridStore => { - return gridStore.write(data); - }).then(gridStore => { - return gridStore.close(); - }); - } - - deleteFile(filename: string) { - return this._connect().then(database => { - let gridStore = new GridStore(database, filename, 'r'); - return gridStore.open(); - }).then((gridStore) => { - return gridStore.unlink(); - }).then((gridStore) => { - return gridStore.close(); - }); - } - - getFileData(filename: string) { - return this._connect().then(database => { - return GridStore.exist(database, filename) - .then(() => { - let gridStore = new GridStore(database, filename, 'r'); - return gridStore.open(); - }); - }).then(gridStore => { - return gridStore.read(); - }); - } - - getFileLocation(config, filename) { - return (config.mount + '/files/' + config.applicationId + '/' + encodeURIComponent(filename)); - } -} - -export default GridStoreAdapter; +// Note: GridStore was replaced by GridFSBucketAdapter by default in 2018 by @flovilmart +throw new Error( + 'GridStoreAdapter: GridStore is no longer supported by parse server and mongodb, use GridFSBucketAdapter instead.' +); diff --git a/src/Adapters/Logger/FileLoggerAdapter.js b/src/Adapters/Logger/FileLoggerAdapter.js deleted file mode 100644 index b197738c55..0000000000 --- a/src/Adapters/Logger/FileLoggerAdapter.js +++ /dev/null @@ -1,119 +0,0 @@ -// Logger -// -// Wrapper around Winston logging library with custom query -// -// expected log entry to be in the shape of: -// {"level":"info","message":"Your Message","timestamp":"2016-02-04T05:59:27.412Z"} -// -import { LoggerAdapter } from './LoggerAdapter'; -import winston from 'winston'; -import fs from 'fs'; -import { Parse } from 'parse/node'; -import { logger, configure } from '../../logger'; - -const MILLISECONDS_IN_A_DAY = 24 * 60 * 60 * 1000; -const CACHE_TIME = 1000 * 60; - -let LOGS_FOLDER = './logs/'; - -if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { - LOGS_FOLDER = './test_logs/' -} - -let currentDate = new Date(); - -let simpleCache = { - timestamp: null, - from: null, - until: null, - order: null, - data: [], - level: 'info', -}; - -// returns Date object rounded to nearest day -let _getNearestDay = (date) => { - return new Date(date.getFullYear(), date.getMonth(), date.getDate()); -} - -// returns Date object of previous day -let _getPrevDay = (date) => { - return new Date(date - MILLISECONDS_IN_A_DAY); -} - -// returns the iso formatted file name -let _getFileName = () => { - return _getNearestDay(currentDate).toISOString() -} - -// check for valid cache when both from and util match. -// cache valid for up to 1 minute -let _hasValidCache = (from, until, level) => { - if (String(from) === String(simpleCache.from) && - String(until) === String(simpleCache.until) && - new Date() - simpleCache.timestamp < CACHE_TIME && - level === simpleCache.level) { - return true; - } - return false; -} - -// check that log entry has valid time stamp based on query -let _isValidLogEntry = (from, until, entry) => { - var _entry = JSON.parse(entry), - timestamp = new Date(_entry.timestamp); - return timestamp >= from && timestamp <= until - ? true - : false -}; - -export class FileLoggerAdapter extends LoggerAdapter { - - info() { - return logger.info.apply(undefined, arguments); - } - - error() { - return logger.error.apply(undefined, arguments); - } - - // custom query as winston is currently limited - query(options, callback = () => {}) { - if (!options) { - options = {}; - } - // defaults to 7 days prior - let from = options.from || new Date(Date.now() - (7 * MILLISECONDS_IN_A_DAY)); - let until = options.until || new Date(); - let limit = options.size || 10; - let order = options.order || 'desc'; - let level = options.level || 'info'; - let roundedUntil = _getNearestDay(until); - let roundedFrom = _getNearestDay(from); - - var options = { - from, - until, - limit, - order - }; - - return new Promise((resolve, reject) => { - logger.query(options, (err, res) => { - if (err) { - callback(err); - return reject(err); - } - if (level == 'error') { - callback(res['parse-server-error']); - resolve(res['parse-server-error']); - } else { - callback(res['parse-server']); - resolve(res['parse-server']); - } - }) - }); - } -} - -export default FileLoggerAdapter; diff --git a/src/Adapters/Logger/LoggerAdapter.js b/src/Adapters/Logger/LoggerAdapter.js index b1fe31b8ab..957719be9b 100644 --- a/src/Adapters/Logger/LoggerAdapter.js +++ b/src/Adapters/Logger/LoggerAdapter.js @@ -1,17 +1,20 @@ -// Logger Adapter -// -// Allows you to change the logger mechanism -// -// Adapter classes must implement the following functions: -// * info(obj1 [, obj2, .., objN]) -// * error(obj1 [, obj2, .., objN]) -// * query(options, callback) -// Default is FileLoggerAdapter.js - +/* eslint-disable unused-imports/no-unused-vars */ +/** + * @interface + * @memberof module:Adapters + * Logger Adapter + * Allows you to change the logger mechanism + * Default is WinstonLoggerAdapter.js + */ export class LoggerAdapter { - info() {} - error() {} - query(options, callback) {} + constructor(options) {} + /** + * log + * @param {String} level + * @param {String} message + * @param {Object} metadata + */ + log(level, message /* meta */) {} } export default LoggerAdapter; diff --git a/src/Adapters/Logger/WinstonLogger.js b/src/Adapters/Logger/WinstonLogger.js new file mode 100644 index 0000000000..886a1394f3 --- /dev/null +++ b/src/Adapters/Logger/WinstonLogger.js @@ -0,0 +1,124 @@ +import winston, { format } from 'winston'; +import fs from 'fs'; +import path from 'path'; +import DailyRotateFile from 'winston-daily-rotate-file'; +import _ from 'lodash'; +import defaults from '../../defaults'; + +const logger = winston.createLogger(); + +function configureTransports(options) { + const transports = []; + if (options) { + const silent = options.silent; + delete options.silent; + + try { + if (!_.isNil(options.dirname)) { + const parseServer = new DailyRotateFile( + Object.assign( + { + filename: 'parse-server.info', + json: true, + format: format.combine(format.timestamp(), format.splat(), format.json()), + }, + options + ) + ); + parseServer.name = 'parse-server'; + transports.push(parseServer); + + const parseServerError = new DailyRotateFile( + Object.assign( + { + filename: 'parse-server.err', + json: true, + format: format.combine(format.timestamp(), format.splat(), format.json()), + }, + options, + { level: 'error' } + ) + ); + parseServerError.name = 'parse-server-error'; + transports.push(parseServerError); + } + } catch { + /* */ + } + + const consoleFormat = options.json ? format.json() : format.simple(); + const consoleOptions = Object.assign( + { + colorize: true, + name: 'console', + silent, + format: format.combine(format.splat(), consoleFormat), + }, + options + ); + + transports.push(new winston.transports.Console(consoleOptions)); + } + + logger.configure({ + transports, + }); +} + +export function configureLogger({ + logsFolder = defaults.logsFolder, + jsonLogs = defaults.jsonLogs, + logLevel = winston.level, + verbose = defaults.verbose, + silent = defaults.silent, + maxLogFiles, +} = {}) { + if (verbose) { + logLevel = 'verbose'; + } + + winston.level = logLevel; + const options = {}; + + if (logsFolder) { + if (!path.isAbsolute(logsFolder)) { + logsFolder = path.resolve(process.cwd(), logsFolder); + } + try { + fs.mkdirSync(logsFolder); + } catch { + /* */ + } + } + options.dirname = logsFolder; + options.level = logLevel; + options.silent = silent; + options.maxFiles = maxLogFiles; + + if (jsonLogs) { + options.json = true; + options.stringify = true; + } + configureTransports(options); +} + +export function addTransport(transport) { + // we will remove the existing transport + // before replacing it with a new one + removeTransport(transport.name); + + logger.add(transport); +} + +export function removeTransport(transport) { + const matchingTransport = logger.transports.find(t1 => { + return typeof transport === 'string' ? t1.name === transport : t1 === transport; + }); + + if (matchingTransport) { + logger.remove(matchingTransport); + } +} + +export { logger }; +export default logger; diff --git a/src/Adapters/Logger/WinstonLoggerAdapter.js b/src/Adapters/Logger/WinstonLoggerAdapter.js new file mode 100644 index 0000000000..ab866ee107 --- /dev/null +++ b/src/Adapters/Logger/WinstonLoggerAdapter.js @@ -0,0 +1,63 @@ +import { LoggerAdapter } from './LoggerAdapter'; +import { logger, addTransport, configureLogger } from './WinstonLogger'; + +const MILLISECONDS_IN_A_DAY = 24 * 60 * 60 * 1000; + +export class WinstonLoggerAdapter extends LoggerAdapter { + constructor(options) { + super(); + if (options) { + configureLogger(options); + } + } + + log() { + return logger.log.apply(logger, arguments); + } + + addTransport(transport) { + // Note that this is calling addTransport + // from logger. See import - confusing. + // but this is not recursive. + addTransport(transport); + } + + // custom query as winston is currently limited + query(options, callback = () => {}) { + if (!options) { + options = {}; + } + // defaults to 7 days prior + const from = options.from || new Date(Date.now() - 7 * MILLISECONDS_IN_A_DAY); + const until = options.until || new Date(); + const limit = options.size || 10; + const order = options.order || 'desc'; + const level = options.level || 'info'; + + const queryOptions = { + from, + until, + limit, + order, + }; + + return new Promise((resolve, reject) => { + logger.query(queryOptions, (err, res) => { + if (err) { + callback(err); + return reject(err); + } + + if (level === 'error') { + callback(res['parse-server-error']); + resolve(res['parse-server-error']); + } else { + callback(res['parse-server']); + resolve(res['parse-server']); + } + }); + }); + } +} + +export default WinstonLoggerAdapter; diff --git a/src/Adapters/MessageQueue/EventEmitterMQ.js b/src/Adapters/MessageQueue/EventEmitterMQ.js new file mode 100644 index 0000000000..1f0081aad5 --- /dev/null +++ b/src/Adapters/MessageQueue/EventEmitterMQ.js @@ -0,0 +1,63 @@ +import events from 'events'; + +const emitter = new events.EventEmitter(); +const subscriptions = new Map(); + +function unsubscribe(channel: string) { + if (!subscriptions.has(channel)) { + //console.log('No channel to unsub from'); + return; + } + //console.log('unsub ', channel); + emitter.removeListener(channel, subscriptions.get(channel)); + subscriptions.delete(channel); +} + +class Publisher { + emitter: any; + + constructor(emitter: any) { + this.emitter = emitter; + } + + publish(channel: string, message: string): void { + this.emitter.emit(channel, message); + } +} + +class Consumer extends events.EventEmitter { + emitter: any; + + constructor(emitter: any) { + super(); + this.emitter = emitter; + } + + subscribe(channel: string): void { + unsubscribe(channel); + const handler = message => { + this.emit('message', channel, message); + }; + subscriptions.set(channel, handler); + this.emitter.on(channel, handler); + } + + unsubscribe(channel: string): void { + unsubscribe(channel); + } +} + +function createPublisher(): any { + return new Publisher(emitter); +} + +function createSubscriber(): any { + return new Consumer(emitter); +} + +const EventEmitterMQ = { + createPublisher, + createSubscriber, +}; + +export { EventEmitterMQ }; diff --git a/src/LiveQuery/EventEmitterPubSub.js b/src/Adapters/PubSub/EventEmitterPubSub.js similarity index 72% rename from src/LiveQuery/EventEmitterPubSub.js rename to src/Adapters/PubSub/EventEmitterPubSub.js index 7318d082f0..277118a082 100644 --- a/src/LiveQuery/EventEmitterPubSub.js +++ b/src/Adapters/PubSub/EventEmitterPubSub.js @@ -1,6 +1,6 @@ import events from 'events'; -let emitter = new events.EventEmitter(); +const emitter = new events.EventEmitter(); class Publisher { emitter: any; @@ -25,9 +25,9 @@ class Subscriber extends events.EventEmitter { } subscribe(channel: string): void { - let handler = (message) => { + const handler = message => { this.emit('message', channel, message); - } + }; this.subscriptions.set(channel, handler); this.emitter.on(channel, handler); } @@ -46,14 +46,16 @@ function createPublisher(): any { } function createSubscriber(): any { + // createSubscriber is called once at live query server start + // to avoid max listeners warning, we should clean up the event emitter + // each time this function is called + emitter.removeAllListeners(); return new Subscriber(emitter); } -let EventEmitterPubSub = { +const EventEmitterPubSub = { createPublisher, - createSubscriber -} + createSubscriber, +}; -export { - EventEmitterPubSub -} +export { EventEmitterPubSub }; diff --git a/src/Adapters/PubSub/PubSubAdapter.js b/src/Adapters/PubSub/PubSubAdapter.js new file mode 100644 index 0000000000..b68d9bee5d --- /dev/null +++ b/src/Adapters/PubSub/PubSubAdapter.js @@ -0,0 +1,47 @@ +/* eslint-disable unused-imports/no-unused-vars */ +/** + * @interface + * @memberof module:Adapters + */ +export class PubSubAdapter { + /** + * @returns {PubSubAdapter.Publisher} + */ + static createPublisher() {} + /** + * @returns {PubSubAdapter.Subscriber} + */ + static createSubscriber() {} +} + +/** + * @interface Publisher + * @memberof PubSubAdapter + */ +interface Publisher { + /** + * @param {String} channel the channel in which to publish + * @param {String} message the message to publish + */ + publish(channel: string, message: string): void; +} + +/** + * @interface Subscriber + * @memberof PubSubAdapter + */ +interface Subscriber { + /** + * called when a new subscription the channel is required + * @param {String} channel the channel to subscribe + */ + subscribe(channel: string): void; + + /** + * called when the subscription from the channel should be stopped + * @param {String} channel + */ + unsubscribe(channel: string): void; +} + +export default PubSubAdapter; diff --git a/src/Adapters/PubSub/RedisPubSub.js b/src/Adapters/PubSub/RedisPubSub.js new file mode 100644 index 0000000000..d7d714f1b2 --- /dev/null +++ b/src/Adapters/PubSub/RedisPubSub.js @@ -0,0 +1,27 @@ +import { createClient } from 'redis'; +import { logger } from '../../logger'; + +function createPublisher({ redisURL, redisOptions = {} }): any { + const client = createClient({ url: redisURL, ...redisOptions }); + client.on('error', err => { logger.error('RedisPubSub Publisher client error', { error: err }) }); + client.on('connect', () => {}); + client.on('reconnecting', () => {}); + client.on('ready', () => {}); + return client; +} + +function createSubscriber({ redisURL, redisOptions = {} }): any { + const client = createClient({ url: redisURL, ...redisOptions }); + client.on('error', err => { logger.error('RedisPubSub Subscriber client error', { error: err }) }); + client.on('connect', () => {}); + client.on('reconnecting', () => {}); + client.on('ready', () => {}); + return client; +} + +const RedisPubSub = { + createPublisher, + createSubscriber, +}; + +export { RedisPubSub }; diff --git a/src/Adapters/Push/PushAdapter.js b/src/Adapters/Push/PushAdapter.js index 30cbed8f6a..6f89886b1b 100644 --- a/src/Adapters/Push/PushAdapter.js +++ b/src/Adapters/Push/PushAdapter.js @@ -1,3 +1,5 @@ +/* eslint-disable unused-imports/no-unused-vars */ +// @flow // Push Adapter // // Allows you to change the push notification mechanism. @@ -6,17 +8,29 @@ // * getValidPushTypes() // * send(devices, installations, pushStatus) // -// Default is ParsePushAdapter, which uses GCM for +// Default is ParsePushAdapter, which uses FCM for // android push and APNS for ios push. +/** + * @interface + * @memberof module:Adapters + */ export class PushAdapter { - send(devices, installations, pushStatus) { } + /** + * @param {any} body + * @param {Parse.Installation[]} installations + * @param {any} pushStatus + * @returns {Promise} + */ + send(body: any, installations: any[], pushStatus: any): ?Promise<*> {} /** * Get an array of valid push types. * @returns {Array} An array of valid push types */ - getValidPushTypes() {} + getValidPushTypes(): string[] { + return []; + } } export default PushAdapter; diff --git a/src/Adapters/Storage/Mongo/MongoCollection.js b/src/Adapters/Storage/Mongo/MongoCollection.js index 757be2091a..04f8ca1dc2 100644 --- a/src/Adapters/Storage/Mongo/MongoCollection.js +++ b/src/Adapters/Storage/Mongo/MongoCollection.js @@ -1,10 +1,10 @@ -let mongodb = require('mongodb'); -let Collection = mongodb.Collection; +const mongodb = require('mongodb'); +const Collection = mongodb.Collection; export default class MongoCollection { - _mongoCollection:Collection; + _mongoCollection: Collection; - constructor(mongoCollection:Collection) { + constructor(mongoCollection: Collection) { this._mongoCollection = mongoCollection; } @@ -13,73 +13,188 @@ export default class MongoCollection { // none, then build the geoindex. // This could be improved a lot but it's not clear if that's a good // idea. Or even if this behavior is a good idea. - find(query, { skip, limit, sort } = {}) { - return this._rawFind(query, { skip, limit, sort }) - .catch(error => { - // Check for "no geoindex" error - if (error.code != 17007 && !error.message.match(/unable to find index for .geoNear/)) { - throw error; - } - // Figure out what key needs an index - let key = error.message.match(/field=([A-Za-z_0-9]+) /)[1]; - if (!key) { - throw error; - } - - var index = {}; - index[key] = '2d'; - return this._mongoCollection.createIndex(index) + find( + query, + { + skip, + limit, + sort, + keys, + maxTimeMS, + batchSize, + readPreference, + hint, + caseInsensitive, + explain, + comment, + } = {} + ) { + // Support for Full Text Search - $text + if (keys && keys.$score) { + delete keys.$score; + keys.score = { $meta: 'textScore' }; + } + return this._rawFind(query, { + skip, + limit, + sort, + keys, + maxTimeMS, + batchSize, + readPreference, + hint, + caseInsensitive, + explain, + comment, + }).catch(error => { + // Check for "no geoindex" error + if (error.code != 17007 && !error.message.match(/unable to find index for .geoNear/)) { + throw error; + } + // Figure out what key needs an index + const key = error.message.match(/field=([A-Za-z_0-9]+) /)[1]; + if (!key) { + throw error; + } + + var index = {}; + index[key] = '2d'; + return ( + this._mongoCollection + .createIndex(index) // Retry, but just once. - .then(() => this._rawFind(query, { skip, limit, sort })); + .then(() => + this._rawFind(query, { + skip, + limit, + sort, + keys, + maxTimeMS, + batchSize, + readPreference, + hint, + caseInsensitive, + explain, + comment, + }) + ) + ); + }); + } + + /** + * Collation to support case insensitive queries + */ + static caseInsensitiveCollation() { + return { locale: 'en_US', strength: 2 }; + } + + _rawFind( + query, + { + skip, + limit, + sort, + keys, + maxTimeMS, + batchSize, + readPreference, + hint, + caseInsensitive, + explain, + comment, + } = {} + ) { + let findOperation = this._mongoCollection.find(query, { + skip, + limit, + sort, + readPreference, + hint, + comment, + batchSize, + }); + + if (keys) { + findOperation = findOperation.project(keys); + } + + if (caseInsensitive) { + findOperation = findOperation.collation(MongoCollection.caseInsensitiveCollation()); + } + + if (maxTimeMS) { + findOperation = findOperation.maxTimeMS(maxTimeMS); + } + + return explain ? findOperation.explain(explain) : findOperation.toArray(); + } + + count(query, { skip, limit, sort, maxTimeMS, readPreference, hint, comment } = {}) { + // If query is empty, then use estimatedDocumentCount instead. + // This is due to countDocuments performing a scan, + // which greatly increases execution time when being run on large collections. + // See https://github.com/Automattic/mongoose/issues/6713 for more info regarding this problem. + if (typeof query !== 'object' || !Object.keys(query).length) { + return this._mongoCollection.estimatedDocumentCount({ + maxTimeMS, }); + } + + const countOperation = this._mongoCollection.countDocuments(query, { + skip, + limit, + sort, + maxTimeMS, + readPreference, + hint, + comment, + }); + + return countOperation; } - _rawFind(query, { skip, limit, sort } = {}) { - return this._mongoCollection - .find(query, { skip, limit, sort }) - .toArray(); + distinct(field, query) { + return this._mongoCollection.distinct(field, query); } - count(query, { skip, limit, sort } = {}) { - return this._mongoCollection.count(query, { skip, limit, sort }); + aggregate(pipeline, { maxTimeMS, batchSize, readPreference, hint, explain, comment } = {}) { + return this._mongoCollection + .aggregate(pipeline, { maxTimeMS, batchSize, readPreference, hint, explain, comment }) + .toArray(); } - insertOne(object) { - return this._mongoCollection.insertOne(object); + insertOne(object, session) { + return this._mongoCollection.insertOne(object, { session }); } // Atomically updates data in the database for a single (first) object that matched the query // If there is nothing that matches the query - does insert // Postgres Note: `INSERT ... ON CONFLICT UPDATE` that is available since 9.5. - upsertOne(query, update) { - return this._mongoCollection.update(query, update, { upsert: true }) + upsertOne(query, update, session) { + return this._mongoCollection.updateOne(query, update, { + upsert: true, + session, + }); } updateOne(query, update) { return this._mongoCollection.updateOne(query, update); } - updateMany(query, update) { - return this._mongoCollection.updateMany(query, update); + updateMany(query, update, session) { + return this._mongoCollection.updateMany(query, update, { session }); } - deleteOne(query) { - return this._mongoCollection.deleteOne(query); - } - - deleteMany(query) { - return this._mongoCollection.deleteMany(query); + deleteMany(query, session) { + return this._mongoCollection.deleteMany(query, { session }); } _ensureSparseUniqueIndexInBackground(indexRequest) { - return new Promise((resolve, reject) => { - this._mongoCollection.ensureIndex(indexRequest, { unique: true, background: true, sparse: true }, (error, indexName) => { - if (error) { - reject(error); - } else { - resolve(); - } - }); + return this._mongoCollection.createIndex(indexRequest, { + unique: true, + background: true, + sparse: true, }); } diff --git a/src/Adapters/Storage/Mongo/MongoSchemaCollection.js b/src/Adapters/Storage/Mongo/MongoSchemaCollection.js index 12e0b14a41..45b27f7516 100644 --- a/src/Adapters/Storage/Mongo/MongoSchemaCollection.js +++ b/src/Adapters/Storage/Mongo/MongoSchemaCollection.js @@ -1,4 +1,5 @@ import MongoCollection from './MongoCollection'; +import Parse from 'parse/node'; function mongoFieldToParseSchemaField(type) { if (type[0] === '*') { @@ -14,16 +15,27 @@ function mongoFieldToParseSchemaField(type) { }; } switch (type) { - case 'number': return {type: 'Number'}; - case 'string': return {type: 'String'}; - case 'boolean': return {type: 'Boolean'}; - case 'date': return {type: 'Date'}; + case 'number': + return { type: 'Number' }; + case 'string': + return { type: 'String' }; + case 'boolean': + return { type: 'Boolean' }; + case 'date': + return { type: 'Date' }; case 'map': - case 'object': return {type: 'Object'}; - case 'array': return {type: 'Array'}; - case 'geopoint': return {type: 'GeoPoint'}; - case 'file': return {type: 'File'}; - case 'bytes': return {type: 'Bytes'}; + case 'object': + return { type: 'Object' }; + case 'array': + return { type: 'Array' }; + case 'geopoint': + return { type: 'GeoPoint' }; + case 'file': + return { type: 'File' }; + case 'bytes': + return { type: 'Bytes' }; + case 'polygon': + return { type: 'Polygon' }; } } @@ -31,48 +43,76 @@ const nonFieldSchemaKeys = ['_id', '_metadata', '_client_permissions']; function mongoSchemaFieldsToParseSchemaFields(schema) { var fieldNames = Object.keys(schema).filter(key => nonFieldSchemaKeys.indexOf(key) === -1); var response = fieldNames.reduce((obj, fieldName) => { - obj[fieldName] = mongoFieldToParseSchemaField(schema[fieldName]) + obj[fieldName] = mongoFieldToParseSchemaField(schema[fieldName]); + if ( + schema._metadata && + schema._metadata.fields_options && + schema._metadata.fields_options[fieldName] + ) { + obj[fieldName] = Object.assign( + {}, + obj[fieldName], + schema._metadata.fields_options[fieldName] + ); + } return obj; }, {}); - response.ACL = {type: 'ACL'}; - response.createdAt = {type: 'Date'}; - response.updatedAt = {type: 'Date'}; - response.objectId = {type: 'String'}; + response.ACL = { type: 'ACL' }; + response.createdAt = { type: 'Date' }; + response.updatedAt = { type: 'Date' }; + response.objectId = { type: 'String' }; return response; } const emptyCLPS = Object.freeze({ find: {}, + count: {}, get: {}, create: {}, update: {}, delete: {}, addField: {}, + protectedFields: {}, }); const defaultCLPS = Object.freeze({ - find: {'*': true}, - get: {'*': true}, - create: {'*': true}, - update: {'*': true}, - delete: {'*': true}, - addField: {'*': true}, + ACL: { + '*': { + read: true, + write: true, + }, + }, + find: { '*': true }, + count: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': [] }, }); function mongoSchemaToParseSchema(mongoSchema) { let clps = defaultCLPS; - if (mongoSchema._metadata && mongoSchema._metadata.class_permissions) { - clps = {...emptyCLPS, ...mongoSchema._metadata.class_permissions}; + let indexes = {}; + if (mongoSchema._metadata) { + if (mongoSchema._metadata.class_permissions) { + clps = { ...emptyCLPS, ...mongoSchema._metadata.class_permissions }; + } + if (mongoSchema._metadata.indexes) { + indexes = { ...mongoSchema._metadata.indexes }; + } } return { className: mongoSchema._id, fields: mongoSchemaFieldsToParseSchemaFields(mongoSchema), classLevelPermissions: clps, + indexes: indexes, }; } function _mongoSchemaQueryFromNameQuery(name: string, query) { - let object = { _id: name }; + const object = { _id: name }; if (query) { Object.keys(query).forEach(key => { object[key] = query[key]; @@ -81,22 +121,34 @@ function _mongoSchemaQueryFromNameQuery(name: string, query) { return object; } - - // Returns a type suitable for inserting into mongo _SCHEMA collection. // Does no validation. That is expected to be done in Parse Server. function parseFieldTypeToMongoFieldType({ type, targetClass }) { switch (type) { - case 'Pointer': return `*${targetClass}`; - case 'Relation': return `relation<${targetClass}>`; - case 'Number': return 'number'; - case 'String': return 'string'; - case 'Boolean': return 'boolean'; - case 'Date': return 'date'; - case 'Object': return 'object'; - case 'Array': return 'array'; - case 'GeoPoint': return 'geopoint'; - case 'File': return 'file'; + case 'Pointer': + return `*${targetClass}`; + case 'Relation': + return `relation<${targetClass}>`; + case 'Number': + return 'number'; + case 'String': + return 'string'; + case 'Boolean': + return 'boolean'; + case 'Date': + return 'date'; + case 'Object': + return 'object'; + case 'Array': + return 'array'; + case 'GeoPoint': + return 'geopoint'; + case 'File': + return 'file'; + case 'Bytes': + return 'bytes'; + case 'Polygon': + return 'polygon'; } } @@ -108,23 +160,38 @@ class MongoSchemaCollection { } _fetchAllSchemasFrom_SCHEMA() { - return this._collection._rawFind({}) - .then(schemas => schemas.map(mongoSchemaToParseSchema)); + return this._collection._rawFind({}).then(schemas => schemas.map(mongoSchemaToParseSchema)); } - _fechOneSchemaFrom_SCHEMA(name: string) { - return this._collection._rawFind(_mongoSchemaQueryFromNameQuery(name), { limit: 1 }).then(results => { - if (results.length === 1) { - return mongoSchemaToParseSchema(results[0]); - } else { - throw undefined; - } - }); + _fetchOneSchemaFrom_SCHEMA(name: string) { + return this._collection + ._rawFind(_mongoSchemaQueryFromNameQuery(name), { limit: 1 }) + .then(results => { + if (results.length === 1) { + return mongoSchemaToParseSchema(results[0]); + } else { + throw undefined; + } + }); } // Atomically find and delete an object based on query. findAndDeleteSchema(name: string) { - return this._collection._mongoCollection.findAndRemove(_mongoSchemaQueryFromNameQuery(name), []); + return this._collection._mongoCollection.findOneAndDelete(_mongoSchemaQueryFromNameQuery(name)); + } + + insertSchema(schema: any) { + return this._collection + .insertOne(schema) + .then(() => mongoSchemaToParseSchema(schema)) + .catch(error => { + if (error.code === 11000) { + //Mongo's duplicate key error + throw new Parse.Error(Parse.Error.DUPLICATE_VALUE, 'Class already exists.'); + } else { + throw error; + } + }); } updateSchema(name: string, update) { @@ -146,40 +213,94 @@ class MongoSchemaCollection { // Support additional types that Mongo doesn't, like Money, or something. // TODO: don't spend an extra query on finding the schema if the type we are trying to add isn't a GeoPoint. - addFieldIfNotExists(className: string, fieldName: string, type: string) { - return this._fechOneSchemaFrom_SCHEMA(className) - .then(schema => { - // The schema exists. Check for existing GeoPoints. - if (type.type === 'GeoPoint') { - // Make sure there are not other geopoint fields - if (Object.keys(schema.fields).some(existingField => schema.fields[existingField].type === 'GeoPoint')) { - throw new Parse.Error(Parse.Error.INCORRECT_TYPE, 'MongoDB only supports one GeoPoint field in a class.'); + addFieldIfNotExists(className: string, fieldName: string, fieldType: string) { + return this._fetchOneSchemaFrom_SCHEMA(className) + .then( + schema => { + // If a field with this name already exists, it will be handled elsewhere. + if (schema.fields[fieldName] !== undefined) { + return; + } + // The schema exists. Check for existing GeoPoints. + if (fieldType.type === 'GeoPoint') { + // Make sure there are not other geopoint fields + if ( + Object.keys(schema.fields).some( + existingField => schema.fields[existingField].type === 'GeoPoint' + ) + ) { + throw new Parse.Error( + Parse.Error.INCORRECT_TYPE, + 'MongoDB only supports one GeoPoint field in a class.' + ); + } + } + return; + }, + error => { + // If error is undefined, the schema doesn't exist, and we can create the schema with the field. + // If some other error, reject with it. + if (error === undefined) { + return; + } + throw error; } + ) + .then(() => { + const { type, targetClass, ...fieldOptions } = fieldType; + // We use $exists and $set to avoid overwriting the field type if it + // already exists. (it could have added inbetween the last query and the update) + if (fieldOptions && Object.keys(fieldOptions).length > 0) { + return this.upsertSchema( + className, + { [fieldName]: { $exists: false } }, + { + $set: { + [fieldName]: parseFieldTypeToMongoFieldType({ + type, + targetClass, + }), + [`_metadata.fields_options.${fieldName}`]: fieldOptions, + }, + } + ); + } else { + return this.upsertSchema( + className, + { [fieldName]: { $exists: false } }, + { + $set: { + [fieldName]: parseFieldTypeToMongoFieldType({ + type, + targetClass, + }), + }, + } + ); + } + }); + } + + async updateFieldOptions(className: string, fieldName: string, fieldType: any) { + const { ...fieldOptions } = fieldType; + delete fieldOptions.type; + delete fieldOptions.targetClass; + + await this.upsertSchema( + className, + { [fieldName]: { $exists: true } }, + { + $set: { + [`_metadata.fields_options.${fieldName}`]: fieldOptions, + }, } - return; - }, error => { - // If error is undefined, the schema doesn't exist, and we can create the schema with the field. - // If some other error, reject with it. - if (error === undefined) { - return; - } - throw error; - }) - .then(() => { - // We use $exists and $set to avoid overwriting the field type if it - // already exists. (it could have added inbetween the last query and the update) - return this.upsertSchema( - className, - { [fieldName]: { '$exists': false } }, - { '$set' : { [fieldName]: parseFieldTypeToMongoFieldType(type) } } - ); - }); + ); } } // Exported for testing reasons and because we haven't moved all mongo schema format // related logic into the database adapter yet. -MongoSchemaCollection._TESTmongoSchemaToParseSchema = mongoSchemaToParseSchema -MongoSchemaCollection.parseFieldTypeToMongoFieldType = parseFieldTypeToMongoFieldType +MongoSchemaCollection._TESTmongoSchemaToParseSchema = mongoSchemaToParseSchema; +MongoSchemaCollection.parseFieldTypeToMongoFieldType = parseFieldTypeToMongoFieldType; -export default MongoSchemaCollection +export default MongoSchemaCollection; diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 4bdb575b12..9c17b2a18b 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -1,40 +1,80 @@ -import MongoCollection from './MongoCollection'; +// @flow +import { format as formatUrl, parse as parseUrl } from '../../../vendor/mongodbUrl'; +import type { QueryOptions, QueryType, SchemaType, StorageClass } from '../StorageAdapter'; +import { StorageAdapter } from '../StorageAdapter'; +import Utils from '../../../Utils'; +import MongoCollection from './MongoCollection'; import MongoSchemaCollection from './MongoSchemaCollection'; import { - parse as parseUrl, - format as formatUrl, -} from '../../../vendor/mongodbUrl'; -import { - parseObjectToMongoObjectForCreate, mongoObjectToParseObject, + parseObjectToMongoObjectForCreate, transformKey, - transformWhere, + transformPointerString, transformUpdate, + transformWhere, } from './MongoTransform'; -import _ from 'lodash'; +// @flow-disable-next +import Parse from 'parse/node'; +// @flow-disable-next +import _ from 'lodash'; +import { EJSON } from 'bson'; +import defaults, { ParseServerDatabaseOptions } from '../../../defaults'; +import logger from '../../../logger'; -let mongodb = require('mongodb'); -let MongoClient = mongodb.MongoClient; +// @flow-disable-next +const mongodb = require('mongodb'); +const MongoClient = mongodb.MongoClient; +const ReadPreference = mongodb.ReadPreference; const MongoSchemaCollectionName = '_SCHEMA'; -const DefaultMongoURI = 'mongodb://localhost:27017/parse'; + +/** + * Determines if a MongoDB error is a transient infrastructure error + * (connection pool, network, server selection) as opposed to a query-level error. + */ +function isTransientError(error) { + if (!error) { + return false; + } + + // Connection pool, network, and server selection errors + const transientErrorNames = [ + 'MongoWaitQueueTimeoutError', + 'MongoServerSelectionError', + 'MongoNetworkTimeoutError', + 'MongoNetworkError', + ]; + if (transientErrorNames.includes(error.name)) { + return true; + } + + // Check for MongoDB's transient transaction error label + if (typeof error.hasErrorLabel === 'function') { + if (error.hasErrorLabel('TransientTransactionError')) { + return true; + } + } + + return false; +} const storageAdapterAllCollections = mongoAdapter => { - return mongoAdapter.connect() - .then(() => mongoAdapter.database.collections()) - .then(collections => { - return collections.filter(collection => { - if (collection.namespace.match(/\.system\./)) { - return false; - } - // TODO: If you have one app with a collection prefix that happens to be a prefix of another - // apps prefix, this will go very very badly. We should fix that somehow. - return (collection.collectionName.indexOf(mongoAdapter._collectionPrefix) == 0); + return mongoAdapter + .connect() + .then(() => mongoAdapter.database.collections()) + .then(collections => { + return collections.filter(collection => { + if (collection.namespace.match(/\.system\./)) { + return false; + } + // TODO: If you have one app with a collection prefix that happens to be a prefix of another + // apps prefix, this will go very very badly. We should fix that somehow. + return collection.collectionName.indexOf(mongoAdapter._collectionPrefix) == 0; + }); }); - }); -} +}; -const convertParseSchemaToMongoSchema = ({...schema}) => { +const convertParseSchemaToMongoSchema = ({ ...schema }) => { delete schema.fields._rperm; delete schema.fields._wperm; @@ -47,24 +87,39 @@ const convertParseSchemaToMongoSchema = ({...schema}) => { } return schema; -} +}; // Returns { code, error } if invalid, or { result }, an object // suitable for inserting into _SCHEMA collection, otherwise. -const mongoSchemaFromFieldsAndClassNameAndCLP = (fields, className, classLevelPermissions) => { - let mongoObject = { +const mongoSchemaFromFieldsAndClassNameAndCLP = ( + fields, + className, + classLevelPermissions, + indexes +) => { + const mongoObject = { _id: className, objectId: 'string', updatedAt: 'string', - createdAt: 'string' + createdAt: 'string', + _metadata: undefined, }; - for (let fieldName in fields) { - mongoObject[fieldName] = MongoSchemaCollection.parseFieldTypeToMongoFieldType(fields[fieldName]); + for (const fieldName in fields) { + const { type, targetClass, ...fieldOptions } = fields[fieldName]; + mongoObject[fieldName] = MongoSchemaCollection.parseFieldTypeToMongoFieldType({ + type, + targetClass, + }); + if (fieldOptions && Object.keys(fieldOptions).length > 0) { + mongoObject._metadata = mongoObject._metadata || {}; + mongoObject._metadata.fields_options = mongoObject._metadata.fields_options || {}; + mongoObject._metadata.fields_options[fieldName] = fieldOptions; + } } if (typeof classLevelPermissions !== 'undefined') { - mongoObject._metadata = mongoObject._metadata || {}; + mongoObject._metadata = mongoObject._metadata || {}; if (!classLevelPermissions) { delete mongoObject._metadata.class_permissions; } else { @@ -72,27 +127,84 @@ const mongoSchemaFromFieldsAndClassNameAndCLP = (fields, className, classLevelPe } } + if (indexes && typeof indexes === 'object' && Object.keys(indexes).length > 0) { + mongoObject._metadata = mongoObject._metadata || {}; + mongoObject._metadata.indexes = indexes; + } + + if (!mongoObject._metadata) { + // cleanup the unused _metadata + delete mongoObject._metadata; + } + return mongoObject; -} +}; +function validateExplainValue(explain) { + if (explain) { + // The list of allowed explain values is from node-mongodb-native/lib/explain.js + const explainAllowedValues = [ + 'queryPlanner', + 'queryPlannerExtended', + 'executionStats', + 'allPlansExecution', + false, + true, + ]; + if (!explainAllowedValues.includes(explain)) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Invalid value for explain'); + } + } +} -export class MongoStorageAdapter { +export class MongoStorageAdapter implements StorageAdapter { // Private _uri: string; _collectionPrefix: string; _mongoOptions: Object; + _onchange: any; + _stream: any; + _logClientEvents: ?Array; + _clientMetadata: ?{ name: string, version: string }; // Public - connectionPromise; - database; - - constructor({ - uri = DefaultMongoURI, - collectionPrefix = '', - mongoOptions = {}, - }) { + connectionPromise: ?Promise; + database: any; + client: MongoClient; + _maxTimeMS: ?number; + _batchSize: ?number; + canSortOnJoinTables: boolean; + enableSchemaHooks: boolean; + schemaCacheTtl: ?number; + disableIndexFieldValidation: boolean; + + constructor({ uri = defaults.DefaultMongoURI, collectionPrefix = '', mongoOptions = {} }: any) { this._uri = uri; this._collectionPrefix = collectionPrefix; - this._mongoOptions = mongoOptions; + this._onchange = () => {}; + + // MaxTimeMS is not a global MongoDB client option, it is applied per operation. + this._maxTimeMS = mongoOptions.maxTimeMS; + // BatchSize is not a global MongoDB client option, it is applied per cursor operation. + this._batchSize = mongoOptions.batchSize; + this.canSortOnJoinTables = true; + this.enableSchemaHooks = !!mongoOptions.enableSchemaHooks; + this.schemaCacheTtl = mongoOptions.schemaCacheTtl; + this.disableIndexFieldValidation = !!mongoOptions.disableIndexFieldValidation; + this._logClientEvents = mongoOptions.logClientEvents; + this._clientMetadata = mongoOptions.clientMetadata; + + // Create a copy of mongoOptions and remove Parse Server-specific options that should not + // be passed to MongoDB client. Note: We only delete from this._mongoOptions, not from the + // original mongoOptions object, because other components (like DatabaseController) need + // access to these options. + this._mongoOptions = { ...mongoOptions }; + for (const key of ParseServerDatabaseOptions) { + delete this._mongoOptions[key]; + } + } + + watch(callback: () => void): void { + this._onchange = callback; } connect() { @@ -104,95 +216,280 @@ export class MongoStorageAdapter { // encoded const encodedUri = formatUrl(parseUrl(this._uri)); - this.connectionPromise = MongoClient.connect(encodedUri, this._mongoOptions).then(database => { - if (!database) { - delete this.connectionPromise; - return; - } - database.on('error', (error) => { - delete this.connectionPromise; - }); - database.on('close', (error) => { + // Only use driverInfo if clientMetadata option is set + const options = { ...this._mongoOptions }; + if (this._clientMetadata) { + options.driverInfo = { + name: this._clientMetadata.name, + version: this._clientMetadata.version + }; + } + + this.connectionPromise = MongoClient.connect(encodedUri, options) + .then(client => { + // Starting mongoDB 3.0, the MongoClient.connect don't return a DB anymore but a client + // Fortunately, we can get back the options and use them to select the proper DB. + // https://github.com/mongodb/node-mongodb-native/blob/2c35d76f08574225b8db02d7bef687123e6bb018/lib/mongo_client.js#L885 + const options = client.s.options; + const database = client.db(options.dbName); + if (!database) { + delete this.connectionPromise; + return; + } + client.on('error', () => { + delete this.connectionPromise; + }); + client.on('close', () => { + delete this.connectionPromise; + }); + + // Set up client event logging if configured + if (this._logClientEvents && Array.isArray(this._logClientEvents)) { + this._logClientEvents.forEach(eventConfig => { + client.on(eventConfig.name, event => { + let logData = {}; + if (!eventConfig.keys || eventConfig.keys.length === 0) { + logData = event; + } else { + eventConfig.keys.forEach(keyPath => { + logData[keyPath] = _.get(event, keyPath); + }); + } + + // Validate log level exists, fallback to 'info' + const logLevel = typeof logger[eventConfig.logLevel] === 'function' ? eventConfig.logLevel : 'info'; + + // Safe JSON serialization with Map/Set and circular reference support + const logMessage = `MongoDB client event ${eventConfig.name}: ${JSON.stringify(logData, Utils.getCircularReplacer())}`; + + logger[logLevel](logMessage); + }); + }); + } + + this.client = client; + this.database = database; + }) + .catch(err => { delete this.connectionPromise; + return Promise.reject(err); }); - this.database = database; - }).catch((err) => { - delete this.connectionPromise; - return Promise.reject(err); - }); return this.connectionPromise; } + handleError(error: ?(Error | Parse.Error)): Promise { + if (error && error.code === 13) { + // Unauthorized error + delete this.client; + delete this.database; + delete this.connectionPromise; + logger.error('Received unauthorized error', { error: error }); + } + + // Transform infrastructure/transient errors into Parse.Error.INTERNAL_SERVER_ERROR + if (isTransientError(error)) { + logger.error('Database transient error', error); + throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Database error'); + } + + throw error; + } + + async handleShutdown() { + if (!this.client) { + return; + } + await this.client.close(false); + delete this.connectionPromise; + } + _adaptiveCollection(name: string) { return this.connect() .then(() => this.database.collection(this._collectionPrefix + name)) - .then(rawCollection => new MongoCollection(rawCollection)); + .then(rawCollection => new MongoCollection(rawCollection)) + .catch(err => this.handleError(err)); } - _schemaCollection() { + _schemaCollection(): Promise { return this.connect() .then(() => this._adaptiveCollection(MongoSchemaCollectionName)) - .then(collection => new MongoSchemaCollection(collection)); + .then(collection => { + if (!this._stream && this.enableSchemaHooks) { + this._stream = collection._mongoCollection.watch(); + this._stream.on('change', () => this._onchange()); + } + return new MongoSchemaCollection(collection); + }); } - classExists(name) { - return this.connect().then(() => { - return this.database.listCollections({ name: this._collectionPrefix + name }).toArray(); - }).then(collections => { - return collections.length > 0; - }); + classExists(name: string) { + return this.connect() + .then(() => { + return this.database.listCollections({ name: this._collectionPrefix + name }).toArray(); + }) + .then(collections => { + return collections.length > 0; + }) + .catch(err => this.handleError(err)); } - setClassLevelPermissions(className, CLPs) { + setClassLevelPermissions(className: string, CLPs: any): Promise { return this._schemaCollection() - .then(schemaCollection => schemaCollection.updateSchema(className, { - $set: { _metadata: { class_permissions: CLPs } } - })); + .then(schemaCollection => + schemaCollection.updateSchema(className, { + $set: { '_metadata.class_permissions': CLPs }, + }) + ) + .catch(err => this.handleError(err)); } - createClass(className, schema) { - schema = convertParseSchemaToMongoSchema(schema); - let mongoObject = mongoSchemaFromFieldsAndClassNameAndCLP(schema.fields, className, schema.classLevelPermissions); - mongoObject._id = className; - return this._schemaCollection() - .then(schemaCollection => schemaCollection._collection.insertOne(mongoObject)) - .then(result => MongoSchemaCollection._TESTmongoSchemaToParseSchema(result.ops[0])) - .catch(error => { - if (error.code === 11000) { //Mongo's duplicate key error - throw new Parse.Error(Parse.Error.DUPLICATE_VALUE, 'Class already exists.'); + setIndexesWithSchemaFormat( + className: string, + submittedIndexes: any, + existingIndexes: any = {}, + fields: any + ): Promise { + if (submittedIndexes === undefined) { + return Promise.resolve(); + } + if (Object.keys(existingIndexes).length === 0) { + existingIndexes = { _id_: { _id: 1 } }; + } + const deletePromises = []; + const insertedIndexes = []; + Object.keys(submittedIndexes).forEach(name => { + const field = submittedIndexes[name]; + if (existingIndexes[name] && field.__op !== 'Delete') { + throw new Parse.Error(Parse.Error.INVALID_QUERY, `Index ${name} exists, cannot update.`); + } + if (!existingIndexes[name] && field.__op === 'Delete') { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Index ${name} does not exist, cannot delete.` + ); + } + if (field.__op === 'Delete') { + const promise = this.dropIndex(className, name); + deletePromises.push(promise); + delete existingIndexes[name]; } else { - throw error; + Object.keys(field).forEach(key => { + if ( + !this.disableIndexFieldValidation && + !Object.prototype.hasOwnProperty.call( + fields, + key.indexOf('_p_') === 0 ? key.replace('_p_', '') : key + ) + ) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Field ${key} does not exist, cannot add index.` + ); + } + }); + existingIndexes[name] = field; + insertedIndexes.push({ + key: field, + name, + }); } - }) + }); + let insertPromise = Promise.resolve(); + if (insertedIndexes.length > 0) { + insertPromise = this.createIndexes(className, insertedIndexes); + } + return Promise.all(deletePromises) + .then(() => insertPromise) + .then(() => this._schemaCollection()) + .then(schemaCollection => + schemaCollection.updateSchema(className, { + $set: { '_metadata.indexes': existingIndexes }, + }) + ) + .catch(err => this.handleError(err)); } - addFieldIfNotExists(className, fieldName, type) { + setIndexesFromMongo(className: string) { + return this.getIndexes(className) + .then(indexes => { + indexes = indexes.reduce((obj, index) => { + if (index.key._fts) { + delete index.key._fts; + delete index.key._ftsx; + for (const field in index.weights) { + index.key[field] = 'text'; + } + } + obj[index.name] = index.key; + return obj; + }, {}); + return this._schemaCollection().then(schemaCollection => + schemaCollection.updateSchema(className, { + $set: { '_metadata.indexes': indexes }, + }) + ); + }) + .catch(err => this.handleError(err)) + .catch(() => { + // Ignore if collection not found + return Promise.resolve(); + }); + } + + createClass(className: string, schema: SchemaType): Promise { + schema = convertParseSchemaToMongoSchema(schema); + const mongoObject = mongoSchemaFromFieldsAndClassNameAndCLP( + schema.fields, + className, + schema.classLevelPermissions, + schema.indexes + ); + mongoObject._id = className; + return this.setIndexesWithSchemaFormat(className, schema.indexes, {}, schema.fields) + .then(() => this._schemaCollection()) + .then(schemaCollection => schemaCollection.insertSchema(mongoObject)) + .catch(err => this.handleError(err)); + } + + async updateFieldOptions(className: string, fieldName: string, type: any) { + const schemaCollection = await this._schemaCollection(); + await schemaCollection.updateFieldOptions(className, fieldName, type); + } + + addFieldIfNotExists(className: string, fieldName: string, type: any): Promise { return this._schemaCollection() - .then(schemaCollection => schemaCollection.addFieldIfNotExists(className, fieldName, type)); + .then(schemaCollection => schemaCollection.addFieldIfNotExists(className, fieldName, type)) + .then(() => this.createIndexesIfNeeded(className, fieldName, type)) + .catch(err => this.handleError(err)); } // Drops a collection. Resolves with true if it was a Parse Schema (eg. _User, Custom, etc.) // and resolves with false if it wasn't (eg. a join table). Rejects if deletion was impossible. - deleteClass(className) { - return this._adaptiveCollection(className) - .then(collection => collection.drop()) - .catch(error => { - // 'ns not found' means collection was already gone. Ignore deletion attempt. - if (error.message == 'ns not found') { - return; - } - throw error; - }) - // We've dropped the collection, now remove the _SCHEMA document - .then(() => this._schemaCollection()) - .then(schemaCollection => schemaCollection.findAndDeleteSchema(className)) + deleteClass(className: string) { + return ( + this._adaptiveCollection(className) + .then(collection => collection.drop()) + .catch(error => { + // 'ns not found' means collection was already gone. Ignore deletion attempt. + if (error.message == 'ns not found') { + return; + } + throw error; + }) + // We've dropped the collection, now remove the _SCHEMA document + .then(() => this._schemaCollection()) + .then(schemaCollection => schemaCollection.findAndDeleteSchema(className)) + .catch(err => this.handleError(err)) + ); } - // Delete all data known to this adatper. Used for testing. - deleteAllClasses() { - return storageAdapterAllCollections(this) - .then(collections => Promise.all(collections.map(collection => collection.drop()))); + deleteAllClasses(fast: boolean) { + return storageAdapterAllCollections(this).then(collections => + Promise.all( + collections.map(collection => (fast ? collection.deleteMany({}) : collection.drop())) + ) + ); } // Remove the column and all the data. For Relations, the _Join collection is handled @@ -206,7 +503,7 @@ export class MongoStorageAdapter { // Pointer field names are passed for legacy reasons: the original mongo // format stored pointer field names differently in the database, and therefore // needed to know the type of the field before it could delete it. Future database - // adatpers should ignore the pointerFieldNames argument. All the field names are in + // adapters should ignore the pointerFieldNames argument. All the field names are in // fieldNames, they show up additionally in the pointerFieldNames database for use // by the mongo adapter, which deals with the legacy mongo format. @@ -215,119 +512,313 @@ export class MongoStorageAdapter { // may do so. // Returns a Promise. - deleteFields(className, schema, fieldNames) { + deleteFields(className: string, schema: SchemaType, fieldNames: string[]) { const mongoFormatNames = fieldNames.map(fieldName => { if (schema.fields[fieldName].type === 'Pointer') { - return `_p_${fieldName}` + return `_p_${fieldName}`; } else { return fieldName; } }); - const collectionUpdate = { '$unset' : {} }; + const collectionUpdate = { $unset: {} }; mongoFormatNames.forEach(name => { collectionUpdate['$unset'][name] = null; }); - const schemaUpdate = { '$unset' : {} }; + const collectionFilter = { $or: [] }; + mongoFormatNames.forEach(name => { + collectionFilter['$or'].push({ [name]: { $exists: true } }); + }); + + const schemaUpdate = { $unset: {} }; fieldNames.forEach(name => { schemaUpdate['$unset'][name] = null; + schemaUpdate['$unset'][`_metadata.fields_options.${name}`] = null; }); return this._adaptiveCollection(className) - .then(collection => collection.updateMany({}, collectionUpdate)) - .then(() => this._schemaCollection()) - .then(schemaCollection => schemaCollection.updateSchema(className, schemaUpdate)); + .then(collection => collection.updateMany(collectionFilter, collectionUpdate)) + .then(() => this._schemaCollection()) + .then(schemaCollection => schemaCollection.updateSchema(className, schemaUpdate)) + .catch(err => this.handleError(err)); } // Return a promise for all schemas known to this adapter, in Parse format. In case the // schemas cannot be retrieved, returns a promise that rejects. Requirements for the // rejection reason are TBD. - getAllClasses() { - return this._schemaCollection().then(schemasCollection => schemasCollection._fetchAllSchemasFrom_SCHEMA()); + getAllClasses(): Promise { + return this._schemaCollection() + .then(schemasCollection => schemasCollection._fetchAllSchemasFrom_SCHEMA()) + .catch(err => this.handleError(err)); } // Return a promise for the schema with the given name, in Parse format. If // this adapter doesn't know about the schema, return a promise that rejects with // undefined as the reason. - getClass(className) { + getClass(className: string): Promise { return this._schemaCollection() - .then(schemasCollection => schemasCollection._fechOneSchemaFrom_SCHEMA(className)) + .then(schemasCollection => schemasCollection._fetchOneSchemaFrom_SCHEMA(className)) + .catch(err => this.handleError(err)); } // TODO: As yet not particularly well specified. Creates an object. Maybe shouldn't even need the schema, // and should infer from the type. Or maybe does need the schema for validations. Or maybe needs - // the schem only for the legacy mongo format. We'll figure that out later. - createObject(className, schema, object) { + // the schema only for the legacy mongo format. We'll figure that out later. + createObject(className: string, schema: SchemaType, object: any, transactionalSession: ?any) { schema = convertParseSchemaToMongoSchema(schema); const mongoObject = parseObjectToMongoObjectForCreate(className, object, schema); return this._adaptiveCollection(className) - .then(collection => collection.insertOne(mongoObject)) - .catch(error => { - if (error.code === 11000) { // Duplicate value - throw new Parse.Error(Parse.Error.DUPLICATE_VALUE, - 'A duplicate value for a field with unique values was provided'); - } - throw error; - }); + .then(collection => collection.insertOne(mongoObject, transactionalSession)) + .then(() => ({ ops: [mongoObject] })) + .catch(error => { + if (error.code === 11000) { + logger.error('Duplicate key error:', error.message); + const err = new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'A duplicate value for a field with unique values was provided' + ); + err.underlyingError = error; + if (error.message) { + const matches = error.message.match(/index:[\sa-zA-Z0-9_\-\.]+\$?([a-zA-Z_-]+)_1/); + if (matches && Array.isArray(matches)) { + err.userInfo = { duplicated_field: matches[1] }; + } + // Check for authData unique index violations + if (!err.userInfo) { + const authDataMatch = error.message.match(/index:\s+(_auth_data_[a-zA-Z0-9_]+_id)/); + if (authDataMatch) { + err.userInfo = { duplicated_field: authDataMatch[1] }; + } + } + } + throw err; + } + throw error; + }) + .catch(err => this.handleError(err)); } // Remove all objects that match the given Parse Query. // If no objects match, reject with OBJECT_NOT_FOUND. If objects are found and deleted, resolve with undefined. // If there is some other error, reject with INTERNAL_SERVER_ERROR. - deleteObjectsByQuery(className, schema, query) { + deleteObjectsByQuery( + className: string, + schema: SchemaType, + query: QueryType, + transactionalSession: ?any + ) { schema = convertParseSchemaToMongoSchema(schema); return this._adaptiveCollection(className) - .then(collection => { - let mongoWhere = transformWhere(className, query, schema); - return collection.deleteMany(mongoWhere) - }) - .then(({ result }) => { - if (result.n === 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); - } - return Promise.resolve(); - }, error => { - throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Database adapter error'); - }); + .then(collection => { + const mongoWhere = transformWhere(className, query, schema); + return collection.deleteMany(mongoWhere, transactionalSession); + }) + .catch(err => this.handleError(err)) + .then( + ({ deletedCount }) => { + if (deletedCount === 0) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); + } + return Promise.resolve(); + }, + () => { + throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Database adapter error'); + } + ); } // Apply the update to all objects that match the given Parse Query. - updateObjectsByQuery(className, schema, query, update) { + updateObjectsByQuery( + className: string, + schema: SchemaType, + query: QueryType, + update: any, + transactionalSession: ?any + ) { schema = convertParseSchemaToMongoSchema(schema); const mongoUpdate = transformUpdate(className, update, schema); const mongoWhere = transformWhere(className, query, schema); return this._adaptiveCollection(className) - .then(collection => collection.updateMany(mongoWhere, mongoUpdate)); + .then(collection => collection.updateMany(mongoWhere, mongoUpdate, transactionalSession)) + .catch(err => this.handleError(err)); } // Atomically finds and updates an object based on query. // Return value not currently well specified. - findOneAndUpdate(className, schema, query, update) { + findOneAndUpdate( + className: string, + schema: SchemaType, + query: QueryType, + update: any, + transactionalSession: ?any + ) { schema = convertParseSchemaToMongoSchema(schema); const mongoUpdate = transformUpdate(className, update, schema); const mongoWhere = transformWhere(className, query, schema); return this._adaptiveCollection(className) - .then(collection => collection._mongoCollection.findAndModify(mongoWhere, [], mongoUpdate, { new: true })) - .then(result => result.value); + .then(collection => + collection._mongoCollection.findOneAndUpdate(mongoWhere, mongoUpdate, { + returnDocument: 'after', + session: transactionalSession || undefined, + }) + ) + .then(result => mongoObjectToParseObject(className, result, schema)) + .catch(error => { + if (error.code === 11000) { + logger.error('Duplicate key error:', error.message); + const err = new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'A duplicate value for a field with unique values was provided' + ); + err.underlyingError = error; + if (error.message) { + const matches = error.message.match( + /index:[\sa-zA-Z0-9_\-\.]+\$?([a-zA-Z_-]+)_1/ + ); + if (matches && Array.isArray(matches)) { + err.userInfo = { duplicated_field: matches[1] }; + } + if (!err.userInfo) { + const authDataMatch = error.message.match(/index:\s+(_auth_data_[a-zA-Z0-9_]+_id)/); + if (authDataMatch) { + err.userInfo = { duplicated_field: authDataMatch[1] }; + } + } + } + throw err; + } + throw error; + }) + .catch(err => this.handleError(err)); } // Hopefully we can get rid of this. It's only used for config and hooks. - upsertOneObject(className, schema, query, update) { + upsertOneObject( + className: string, + schema: SchemaType, + query: QueryType, + update: any, + transactionalSession: ?any + ) { schema = convertParseSchemaToMongoSchema(schema); const mongoUpdate = transformUpdate(className, update, schema); const mongoWhere = transformWhere(className, query, schema); return this._adaptiveCollection(className) - .then(collection => collection.upsertOne(mongoWhere, mongoUpdate)); + .then(collection => collection.upsertOne(mongoWhere, mongoUpdate, transactionalSession)) + .catch(err => this.handleError(err)); } // Executes a find. Accepts: className, query in Parse format, and { skip, limit, sort }. - find(className, schema, query, { skip, limit, sort }) { + find( + className: string, + schema: SchemaType, + query: QueryType, + { + skip, + limit, + sort, + keys, + readPreference, + hint, + caseInsensitive, + explain, + comment, + }: QueryOptions + ): Promise { + validateExplainValue(explain); + schema = convertParseSchemaToMongoSchema(schema); + const mongoWhere = transformWhere(className, query, schema); + const mongoSort = _.mapKeys(sort, (value, fieldName) => + transformKey(className, fieldName, schema) + ); + const mongoKeys = _.reduce( + keys, + (memo, key) => { + if (key === 'ACL') { + memo['_rperm'] = 1; + memo['_wperm'] = 1; + } else { + memo[transformKey(className, key, schema)] = 1; + } + return memo; + }, + {} + ); + + // If we aren't requesting the `_id` field, we need to explicitly opt out + // of it. Doing so in parse-server is unusual, but it can allow us to + // optimize some queries with covering indexes. + if (keys && !mongoKeys._id) { + mongoKeys._id = 0; + } + + readPreference = this._parseReadPreference(readPreference); + return this.createTextIndexesIfNeeded(className, query, schema) + .then(() => this._adaptiveCollection(className)) + .then(collection => + collection.find(mongoWhere, { + skip, + limit, + sort: mongoSort, + keys: mongoKeys, + maxTimeMS: this._maxTimeMS, + batchSize: this._batchSize, + readPreference, + hint, + caseInsensitive, + explain, + comment, + }) + ) + .then(objects => { + if (explain) { + return objects; + } + return objects.map(object => mongoObjectToParseObject(className, object, schema)); + }) + .catch(err => this.handleError(err)); + } + + ensureIndex( + className: string, + schema: SchemaType, + fieldNames: string[], + indexName: ?string, + caseInsensitive: boolean = false, + options?: Object = {} + ): Promise { schema = convertParseSchemaToMongoSchema(schema); - let mongoWhere = transformWhere(className, query, schema); - let mongoSort = _.mapKeys(sort, (value, fieldName) => transformKey(className, fieldName, schema)); + const indexCreationRequest = {}; + const mongoFieldNames = fieldNames.map(fieldName => transformKey(className, fieldName, schema)); + mongoFieldNames.forEach(fieldName => { + 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 sparseOptions: Object = options.sparse !== undefined ? { sparse: options.sparse } : {}; + const caseInsensitiveOptions: Object = caseInsensitive + ? { collation: MongoCollection.caseInsensitiveCollation() } + : {}; + const partialFilterOptions: Object = + options.partialFilterExpression !== undefined + ? { partialFilterExpression: options.partialFilterExpression } + : {}; + const indexOptions: Object = { + ...defaultOptions, + ...caseInsensitiveOptions, + ...indexNameOptions, + ...ttlOptions, + ...sparseOptions, + ...partialFilterOptions, + }; + return this._adaptiveCollection(className) - .then(collection => collection.find(mongoWhere, { skip, limit, sort: mongoSort })) - .then(objects => objects.map(object => mongoObjectToParseObject(className, object, schema))) + .then(collection => + collection._mongoCollection.createIndex(indexCreationRequest, indexOptions) + ) + .catch(err => this.handleError(err)); } // Create a unique index. Unique indexes on nullable fields are not allowed. Since we don't @@ -335,36 +826,480 @@ export class MongoStorageAdapter { // As such, we shouldn't expose this function to users of parse until we have an out-of-band // Way of determining if a field is nullable. Undefined doesn't count against uniqueness, // which is why we use sparse indexes. - ensureUniqueness(className, schema, fieldNames) { + ensureUniqueness(className: string, schema: SchemaType, fieldNames: string[]) { schema = convertParseSchemaToMongoSchema(schema); - let indexCreationRequest = {}; - let mongoFieldNames = fieldNames.map(fieldName => transformKey(className, fieldName, schema)); + const indexCreationRequest = {}; + const mongoFieldNames = fieldNames.map(fieldName => transformKey(className, fieldName, schema)); mongoFieldNames.forEach(fieldName => { indexCreationRequest[fieldName] = 1; }); return this._adaptiveCollection(className) - .then(collection => collection._ensureSparseUniqueIndexInBackground(indexCreationRequest)) - .catch(error => { - if (error.code === 11000) { - throw new Parse.Error(Parse.Error.DUPLICATE_VALUE, 'Tried to ensure field uniqueness for a class that already has duplicates.'); - } else { + .then(collection => collection._ensureSparseUniqueIndexInBackground(indexCreationRequest)) + .catch(error => { + if (error.code === 11000) { + throw new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'Tried to ensure field uniqueness for a class that already has duplicates.' + ); + } throw error; - } - }); + }) + .catch(err => this.handleError(err)); + } + + // Creates a unique sparse index on _auth_data_.id to prevent + // race conditions during concurrent signups with the same authData. + ensureAuthDataUniqueness(provider: string) { + return this._adaptiveCollection('_User') + .then(collection => + collection._mongoCollection.createIndex( + { [`_auth_data_${provider}.id`]: 1 }, + { unique: true, sparse: true, background: true, name: `_auth_data_${provider}_id` } + ) + ) + .catch(error => { + if (error.code === 11000) { + throw new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'Tried to ensure field uniqueness for a class that already has duplicates.' + ); + } + // Ignore "index already exists with same name" or "index already exists with different options" + if (error.code === 85 || error.code === 86) { + return; + } + throw error; + }) + .catch(err => this.handleError(err)); } // Used in tests - _rawFind(className, query) { - return this._adaptiveCollection(className).then(collection => collection.find(query)); + _rawFind(className: string, query: QueryType) { + return this._adaptiveCollection(className) + .then(collection => + collection.find(query, { + maxTimeMS: this._maxTimeMS, + batchSize: this._batchSize, + }) + ) + .catch(err => this.handleError(err)); + } + + // Executes a count. + count( + className: string, + schema: SchemaType, + query: QueryType, + readPreference: ?string, + _estimate: ?boolean, + hint: ?mixed, + comment: ?string + ) { + schema = convertParseSchemaToMongoSchema(schema); + readPreference = this._parseReadPreference(readPreference); + return this._adaptiveCollection(className) + .then(collection => + collection.count(transformWhere(className, query, schema, true), { + maxTimeMS: this._maxTimeMS, + readPreference, + hint, + comment, + }) + ) + .catch(err => this.handleError(err)); } - // Executs a count. - count(className, schema, query) { + distinct(className: string, schema: SchemaType, query: QueryType, fieldName: string) { schema = convertParseSchemaToMongoSchema(schema); + const isPointerField = schema.fields[fieldName] && schema.fields[fieldName].type === 'Pointer'; + const transformField = transformKey(className, fieldName, schema); + + return this._adaptiveCollection(className) + .then(collection => + collection.distinct(transformField, transformWhere(className, query, schema)) + ) + .then(objects => { + objects = objects.filter(obj => obj != null); + return objects.map(object => { + if (isPointerField) { + return transformPointerString(schema, fieldName, object); + } + return mongoObjectToParseObject(className, object, schema); + }); + }) + .catch(err => this.handleError(err)); + } + + aggregate( + className: string, + schema: any, + pipeline: any, + readPreference: ?string, + hint: ?mixed, + explain?: boolean, + comment: ?string, + rawValues?: boolean, + rawFieldNames?: boolean + ) { + validateExplainValue(explain); + if (rawValues) { + pipeline = EJSON.deserialize(pipeline); + } + let isPointerField = false; + pipeline = pipeline.map(stage => { + if (stage.$group) { + stage.$group = this._parseAggregateGroupArgs(schema, stage.$group, rawFieldNames); + if ( + stage.$group._id && + typeof stage.$group._id === 'string' && + stage.$group._id.indexOf('$_p_') >= 0 + ) { + isPointerField = true; + } + } + if (stage.$match) { + stage.$match = this._parseAggregateArgs(schema, stage.$match, rawValues, rawFieldNames); + } + if (stage.$project) { + stage.$project = this._parseAggregateProjectArgs(schema, stage.$project, rawValues, rawFieldNames); + } + if (stage.$geoNear && stage.$geoNear.query) { + stage.$geoNear.query = this._parseAggregateArgs(schema, stage.$geoNear.query, rawValues, rawFieldNames); + } + return stage; + }); + readPreference = this._parseReadPreference(readPreference); + return this._adaptiveCollection(className) + .then(collection => + collection.aggregate(pipeline, { + readPreference, + maxTimeMS: this._maxTimeMS, + batchSize: this._batchSize, + hint, + explain, + comment, + }) + ) + .then(results => { + if (rawFieldNames) { + return results; + } + results.forEach(result => { + if (Object.prototype.hasOwnProperty.call(result, '_id')) { + if (isPointerField && result._id) { + result._id = result._id.split('$')[1]; + } + if ( + result._id == null || + result._id == undefined || + (['object', 'string'].includes(typeof result._id) && _.isEmpty(result._id)) + ) { + result._id = null; + } + result.objectId = result._id; + delete result._id; + } + }); + return results; + }) + .then(objects => { + if (rawValues) { + return objects.map(obj => EJSON.serialize(obj)); + } + if (rawFieldNames) { + return objects; + } + return objects.map(object => mongoObjectToParseObject(className, object, schema)); + }) + .catch(err => this.handleError(err)); + } + + // This function will recursively traverse the pipeline and convert any Pointer or Date columns. + // If we detect a pointer column we will rename the column being queried for to match the column + // in the database. We also modify the value to what we expect the value to be in the database + // as well. + // For dates, the driver expects a Date object, but we have a string coming in. So we'll convert + // the string to a Date so the driver can perform the necessary comparison. + // + // The goal of this method is to look for the "leaves" of the pipeline and determine if it needs + // to be converted. The pipeline can have a few different forms. For more details, see: + // https://docs.mongodb.com/manual/reference/operator/aggregation/ + // + // If the pipeline is an array, it means we are probably parsing an '$and' or '$or' operator. In + // that case we need to loop through all of it's children to find the columns being operated on. + // If the pipeline is an object, then we'll loop through the keys checking to see if the key name + // matches one of the schema columns. If it does match a column and the column is a Pointer or + // a Date, then we'll convert the value as described above. + // + // As much as I hate recursion...this seemed like a good fit for it. We're essentially traversing + // down a tree to find a "leaf node" and checking to see if it needs to be converted. + _parseAggregateArgs(schema: any, pipeline: any, rawValues?: boolean, rawFieldNames?: boolean): any { + if (pipeline === null) { + return null; + } else if (Utils.isDate(pipeline)) { + return pipeline; + } else if (Array.isArray(pipeline)) { + return pipeline.map(value => this._parseAggregateArgs(schema, value, rawValues, rawFieldNames)); + } else if (typeof pipeline === 'object') { + const returnValue = {}; + for (const field in pipeline) { + if (!rawFieldNames && schema.fields[field] && schema.fields[field].type === 'Pointer') { + if (typeof pipeline[field] === 'object') { + returnValue[`_p_${field}`] = pipeline[field]; + } else if (rawValues) { + returnValue[`_p_${field}`] = pipeline[field]; + } else { + returnValue[`_p_${field}`] = `${schema.fields[field].targetClass}$${pipeline[field]}`; + } + } else if (schema.fields[field] && schema.fields[field].type === 'Date' && !rawValues) { + returnValue[field] = this._convertToDate(pipeline[field]); + } else { + returnValue[field] = this._parseAggregateArgs(schema, pipeline[field], rawValues, rawFieldNames); + } + + if (!rawFieldNames) { + if (field === 'objectId') { + returnValue['_id'] = returnValue[field]; + delete returnValue[field]; + } else if (field === 'createdAt') { + returnValue['_created_at'] = returnValue[field]; + delete returnValue[field]; + } else if (field === 'updatedAt') { + returnValue['_updated_at'] = returnValue[field]; + delete returnValue[field]; + } + } + } + return returnValue; + } + return pipeline; + } + + // This function is slightly different than the one above. Rather than trying to combine these + // two functions and making the code even harder to understand, I decided to split it up. The + // difference with this function is we are not transforming the values, only the keys of the + // pipeline. + _parseAggregateProjectArgs(schema: any, pipeline: any, rawValues?: boolean, rawFieldNames?: boolean): any { + const returnValue = {}; + for (const field in pipeline) { + if (!rawFieldNames && schema.fields[field] && schema.fields[field].type === 'Pointer') { + returnValue[`_p_${field}`] = pipeline[field]; + } else { + returnValue[field] = this._parseAggregateArgs(schema, pipeline[field], rawValues, rawFieldNames); + } + + if (!rawFieldNames) { + if (field === 'objectId') { + returnValue['_id'] = returnValue[field]; + delete returnValue[field]; + } else if (field === 'createdAt') { + returnValue['_created_at'] = returnValue[field]; + delete returnValue[field]; + } else if (field === 'updatedAt') { + returnValue['_updated_at'] = returnValue[field]; + delete returnValue[field]; + } + } + } + return returnValue; + } + + // This function is slightly different than the two above. MongoDB $group aggregate looks like: + // { $group: { _id: , : { : }, ... } } + // The could be a column name, prefixed with the '$' character. We'll look for + // these and check to see if it is a 'Pointer' or if it's one of createdAt, + // updatedAt or objectId and change it accordingly. + _parseAggregateGroupArgs(schema: any, pipeline: any, rawFieldNames?: boolean): any { + if (Array.isArray(pipeline)) { + return pipeline.map(value => this._parseAggregateGroupArgs(schema, value, rawFieldNames)); + } else if (typeof pipeline === 'object') { + const returnValue = {}; + for (const field in pipeline) { + returnValue[field] = this._parseAggregateGroupArgs(schema, pipeline[field], rawFieldNames); + } + return returnValue; + } else if (typeof pipeline === 'string' && !rawFieldNames) { + const field = pipeline.substring(1); + if (schema.fields[field] && schema.fields[field].type === 'Pointer') { + return `$_p_${field}`; + } else if (field == 'createdAt') { + return '$_created_at'; + } else if (field == 'updatedAt') { + return '$_updated_at'; + } + } + return pipeline; + } + + /** + * Recursively converts values to Date objects. Since the passed object is part of an aggregation + * pipeline and can contain various logic operators (like $gt, $lt, etc), this function will + * traverse the object and convert any strings that can be parsed as dates into Date objects. + * @param {any} value The value to convert. + * @returns {any} The original value if not convertible to Date, or a Date object if it is. + */ + _convertToDate(value: any): any { + if (Utils.isDate(value)) { + return value; + } + if (typeof value === 'string') { + return isNaN(Date.parse(value)) ? value : new Date(value); + } + if (typeof value === 'object') { + const returnValue = {}; + for (const field in value) { + returnValue[field] = this._convertToDate(value[field]); + } + return returnValue; + } + return value; + } + + _parseReadPreference(readPreference: ?string): ?string { + if (readPreference) { + readPreference = readPreference.toUpperCase(); + } + switch (readPreference) { + case 'PRIMARY': + readPreference = ReadPreference.PRIMARY; + break; + case 'PRIMARY_PREFERRED': + readPreference = ReadPreference.PRIMARY_PREFERRED; + break; + case 'SECONDARY': + readPreference = ReadPreference.SECONDARY; + break; + case 'SECONDARY_PREFERRED': + readPreference = ReadPreference.SECONDARY_PREFERRED; + break; + case 'NEAREST': + readPreference = ReadPreference.NEAREST; + break; + case undefined: + case null: + case '': + break; + default: + throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Not supported read preference.'); + } + return readPreference; + } + + performInitialization(): Promise { + return Promise.resolve(); + } + + createIndex(className: string, index: any) { + return this._adaptiveCollection(className) + .then(collection => collection._mongoCollection.createIndex(index)) + .catch(err => this.handleError(err)); + } + + createIndexes(className: string, indexes: any) { return this._adaptiveCollection(className) - .then(collection => collection.count(transformWhere(className, query, schema))); + .then(collection => collection._mongoCollection.createIndexes(indexes)) + .catch(err => this.handleError(err)); + } + + createIndexesIfNeeded(className: string, fieldName: string, type: any) { + if (type && type.type === 'Polygon') { + const index = { + [fieldName]: '2dsphere', + }; + return this.createIndex(className, index); + } + return Promise.resolve(); + } + + createTextIndexesIfNeeded(className: string, query: QueryType, schema: any): Promise { + for (const fieldName in query) { + if (!query[fieldName] || !query[fieldName].$text) { + continue; + } + const existingIndexes = schema.indexes; + for (const key in existingIndexes) { + const index = existingIndexes[key]; + if (Object.prototype.hasOwnProperty.call(index, fieldName)) { + return Promise.resolve(); + } + } + const indexName = `${fieldName}_text`; + const textIndex = { + [indexName]: { [fieldName]: 'text' }, + }; + return this.setIndexesWithSchemaFormat( + className, + textIndex, + existingIndexes, + schema.fields + ).catch(error => { + if (error.code === 85) { + // Index exist with different options + return this.setIndexesFromMongo(className); + } + throw error; + }); + } + return Promise.resolve(); + } + + getIndexes(className: string) { + return this._adaptiveCollection(className) + .then(collection => collection._mongoCollection.indexes()) + .catch(err => this.handleError(err)); + } + + dropIndex(className: string, index: any) { + return this._adaptiveCollection(className) + .then(collection => collection._mongoCollection.dropIndex(index)) + .catch(err => this.handleError(err)); + } + + dropAllIndexes(className: string) { + return this._adaptiveCollection(className) + .then(collection => collection._mongoCollection.dropIndexes()) + .catch(err => this.handleError(err)); + } + + updateSchemaWithIndexes(): Promise { + return this.getAllClasses() + .then(classes => { + const promises = classes.map(schema => { + return this.setIndexesFromMongo(schema.className); + }); + return Promise.all(promises); + }) + .catch(err => this.handleError(err)); + } + + createTransactionalSession(): Promise { + const transactionalSection = this.client.startSession(); + transactionalSection.startTransaction(); + return Promise.resolve(transactionalSection); + } + + commitTransactionalSession(transactionalSection: any): Promise { + const commit = retries => { + return transactionalSection + .commitTransaction() + .catch(error => { + if (error && error.hasErrorLabel('TransientTransactionError') && retries > 0) { + return commit(retries - 1); + } + throw error; + }) + .then(() => { + transactionalSection.endSession(); + }); + }; + return commit(5); + } + + abortTransactionalSession(transactionalSection: any): Promise { + return transactionalSection.abortTransaction().then(() => { + transactionalSection.endSession(); + }); } } export default MongoStorageAdapter; -module.exports = MongoStorageAdapter; // Required for tests diff --git a/src/Adapters/Storage/Mongo/MongoTransform.js b/src/Adapters/Storage/Mongo/MongoTransform.js index cd7408f1fb..0fd13017b3 100644 --- a/src/Adapters/Storage/Mongo/MongoTransform.js +++ b/src/Adapters/Storage/Mongo/MongoTransform.js @@ -1,103 +1,208 @@ import log from '../../../logger'; -import _ from 'lodash'; +import _ from 'lodash'; var mongodb = require('mongodb'); var Parse = require('parse/node').Parse; +const Utils = require('../../../Utils'); const transformKey = (className, fieldName, schema) => { // Check if the schema is known since it's a built-in field. - switch(fieldName) { - case 'objectId': return '_id'; - case 'createdAt': return '_created_at'; - case 'updatedAt': return '_updated_at'; - case 'sessionToken': return '_session_token'; + switch (fieldName) { + case 'objectId': + return '_id'; + case 'createdAt': + return '_created_at'; + case 'updatedAt': + return '_updated_at'; + case 'sessionToken': + return '_session_token'; + case 'lastUsed': + return '_last_used'; + case 'timesUsed': + return 'times_used'; } if (schema.fields[fieldName] && schema.fields[fieldName].__type == 'Pointer') { fieldName = '_p_' + fieldName; + } else if (schema.fields[fieldName] && schema.fields[fieldName].type == 'Pointer') { + fieldName = '_p_' + fieldName; } return fieldName; -} +}; const transformKeyValueForUpdate = (className, restKey, restValue, parseFormatSchema) => { // Check if the schema is known since it's a built-in field. var key = restKey; var timeField = false; - switch(key) { - case 'objectId': - case '_id': - key = '_id'; - break; - case 'createdAt': - case '_created_at': - key = '_created_at'; - timeField = true; - break; - case 'updatedAt': - case '_updated_at': - key = '_updated_at'; - timeField = true; - break; - case 'sessionToken': - case '_session_token': - key = '_session_token'; - break; - case 'expiresAt': - case '_expiresAt': - key = 'expiresAt'; - timeField = true; - break; - case '_email_verify_token_expires_at': - key = '_email_verify_token_expires_at'; - timeField = true; - break; - case '_rperm': - case '_wperm': - return {key: key, value: restValue}; - break; - } - - if ((parseFormatSchema.fields[key] && parseFormatSchema.fields[key].type === 'Pointer') || (!parseFormatSchema.fields[key] && restValue && restValue.__type == 'Pointer')) { + switch (key) { + case 'objectId': + case '_id': + if (['_GlobalConfig', '_GraphQLConfig'].includes(className)) { + return { + key: key, + value: parseInt(restValue), + }; + } + key = '_id'; + break; + case 'createdAt': + case '_created_at': + key = '_created_at'; + timeField = true; + break; + case 'updatedAt': + case '_updated_at': + key = '_updated_at'; + timeField = true; + break; + case 'sessionToken': + case '_session_token': + key = '_session_token'; + break; + case 'expiresAt': + case '_expiresAt': + key = 'expiresAt'; + timeField = true; + break; + case '_email_verify_token_expires_at': + key = '_email_verify_token_expires_at'; + timeField = true; + break; + case '_account_lockout_expires_at': + key = '_account_lockout_expires_at'; + timeField = true; + break; + case '_failed_login_count': + key = '_failed_login_count'; + break; + case '_perishable_token_expires_at': + key = '_perishable_token_expires_at'; + timeField = true; + break; + case '_password_changed_at': + key = '_password_changed_at'; + timeField = true; + break; + case '_rperm': + case '_wperm': + return { key: key, value: restValue }; + case 'lastUsed': + case '_last_used': + key = '_last_used'; + timeField = true; + break; + case 'timesUsed': + case 'times_used': + key = 'times_used'; + timeField = true; + break; + } + + if ( + (parseFormatSchema.fields[key] && parseFormatSchema.fields[key].type === 'Pointer') || + (!key.includes('.') && + !parseFormatSchema.fields[key] && + restValue && + restValue.__type == 'Pointer') // Do not use the _p_ prefix for pointers inside nested documents + ) { key = '_p_' + key; } // Handle atomic values var value = transformTopLevelAtom(restValue); if (value !== CannotTransform) { - if (timeField && (typeof value === 'string')) { + if (timeField && typeof value === 'string') { value = new Date(value); } - return {key, value}; + if (restKey.indexOf('.') > 0) { + return { key, value: restValue }; + } + return { key, value }; } // Handle arrays - if (restValue instanceof Array) { + if (Array.isArray(restValue)) { value = restValue.map(transformInteriorValue); - return {key, value}; + return { key, value }; } // Handle update operators if (typeof restValue === 'object' && '__op' in restValue) { - return {key, value: transformUpdateOperator(restValue, false)}; + return { key, value: transformUpdateOperator(restValue, false) }; } // Handle normal objects by recursing - value = _.mapValues(restValue, transformInteriorValue); - return {key, value}; -} + value = mapValues(restValue, transformInteriorValue); + return { key, value }; +}; + +const isRegex = value => { + return value && Utils.isRegExp(value); +}; + +const isStartsWithRegex = value => { + if (!isRegex(value)) { + return false; + } + + const matches = value.toString().match(/\/\^\\Q.*\\E\//); + return !!matches; +}; + +const isAllValuesRegexOrNone = values => { + if (!values || !Array.isArray(values) || values.length === 0) { + return true; + } + + const firstValuesIsRegex = isStartsWithRegex(values[0]); + if (values.length === 1) { + return firstValuesIsRegex; + } + + for (let i = 1, length = values.length; i < length; ++i) { + if (firstValuesIsRegex !== isStartsWithRegex(values[i])) { + return false; + } + } + + return true; +}; + +const isAnyValueRegex = values => { + return values.some(function (value) { + return isRegex(value); + }); +}; const transformInteriorValue = restValue => { - if (restValue !== null && typeof restValue === 'object' && Object.keys(restValue).some(key => key.includes('$') || key.includes('.'))) { - throw new Parse.Error(Parse.Error.INVALID_NESTED_KEY, "Nested keys should not contain the '$' or '.' characters"); + if ( + restValue !== null && + typeof restValue === 'object' && + Object.keys(restValue).some(key => key.includes('$') || key.includes('.')) + ) { + throw new Parse.Error( + Parse.Error.INVALID_NESTED_KEY, + "Nested keys should not contain the '$' or '.' characters" + ); } // Handle atomic values var value = transformInteriorAtom(restValue); if (value !== CannotTransform) { + if (value && typeof value === 'object') { + if (Utils.isDate(value)) { + return value; + } + if (Array.isArray(value)) { + value = value.map(transformInteriorValue); + } else { + value = mapValues(value, transformInteriorValue); + } + } return value; } // Handle arrays - if (restValue instanceof Array) { + if (Array.isArray(restValue)) { return restValue.map(transformInteriorValue); } @@ -107,100 +212,157 @@ const transformInteriorValue = restValue => { } // Handle normal objects by recursing - return _.mapValues(restValue, transformInteriorValue); -} + return mapValues(restValue, transformInteriorValue); +}; const valueAsDate = value => { if (typeof value === 'string') { return new Date(value); - } else if (value instanceof Date) { + } else if (Utils.isDate(value)) { return value; } return false; -} +}; -function transformQueryKeyValue(className, key, value, schema) { - switch(key) { - case 'createdAt': - if (valueAsDate(value)) { - return {key: '_created_at', value: valueAsDate(value)} - } - key = '_created_at'; - break; - case 'updatedAt': - if (valueAsDate(value)) { - return {key: '_updated_at', value: valueAsDate(value)} - } - key = '_updated_at'; - break; - case 'expiresAt': - if (valueAsDate(value)) { - return {key: 'expiresAt', value: valueAsDate(value)} - } - break; - case '_email_verify_token_expires_at': - if (valueAsDate(value)) { - return {key: '_email_verify_token_expires_at', value: valueAsDate(value)} +function transformQueryKeyValue(className, key, value, schema, count = false) { + switch (key) { + case 'createdAt': + if (valueAsDate(value)) { + return { key: '_created_at', value: valueAsDate(value) }; + } + key = '_created_at'; + break; + case 'updatedAt': + if (valueAsDate(value)) { + return { key: '_updated_at', value: valueAsDate(value) }; + } + key = '_updated_at'; + break; + case 'expiresAt': + if (valueAsDate(value)) { + return { key: 'expiresAt', value: valueAsDate(value) }; + } + break; + case '_email_verify_token_expires_at': + if (valueAsDate(value)) { + return { + key: '_email_verify_token_expires_at', + value: valueAsDate(value), + }; + } + break; + case 'objectId': { + if (['_GlobalConfig', '_GraphQLConfig'].includes(className)) { + value = parseInt(value); + } + return { key: '_id', value }; } - break; - case 'objectId': return {key: '_id', value} - case 'sessionToken': return {key: '_session_token', value} - case '_rperm': - case '_wperm': - case '_perishable_token': - case '_email_verify_token': return {key, value} - case '$or': - return {key: '$or', value: value.map(subQuery => transformWhere(className, subQuery, schema))}; - case '$and': - return {key: '$and', value: value.map(subQuery => transformWhere(className, subQuery, schema))}; - default: - // Other auth data - const authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)\.id$/); - if (authDataMatch) { - const provider = authDataMatch[1]; - // Special-case auth data. - return {key: `_auth_data_${provider}.id`, value}; + case '_account_lockout_expires_at': + if (valueAsDate(value)) { + return { + key: '_account_lockout_expires_at', + value: valueAsDate(value), + }; + } + break; + case '_failed_login_count': + return { key, value }; + case 'sessionToken': + return { key: '_session_token', value }; + case '_perishable_token_expires_at': + if (valueAsDate(value)) { + return { + key: '_perishable_token_expires_at', + value: valueAsDate(value), + }; + } + break; + case '_password_changed_at': + if (valueAsDate(value)) { + return { key: '_password_changed_at', value: valueAsDate(value) }; + } + break; + case '_rperm': + case '_wperm': + case '_perishable_token': + case '_email_verify_token': + return { key, value }; + case '$or': + case '$and': + case '$nor': + return { + key: key, + value: value.map(subQuery => transformWhere(className, subQuery, schema, count)), + }; + case 'lastUsed': + if (valueAsDate(value)) { + return { key: '_last_used', value: valueAsDate(value) }; + } + key = '_last_used'; + break; + case 'timesUsed': + return { key: 'times_used', value: value }; + default: { + // Other auth data + const authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)(\.(.+))?$/); + if (authDataMatch && className === '_User') { + const provider = authDataMatch[1]; + const subField = authDataMatch[3]; + return { key: `_auth_data_${provider}${subField ? `.${subField}` : ''}`, value }; + } } } - const expectedTypeIsArray = - schema && - schema.fields[key] && - schema.fields[key].type === 'Array'; + const expectedTypeIsArray = schema && schema.fields[key] && schema.fields[key].type === 'Array'; const expectedTypeIsPointer = - schema && - schema.fields[key] && - schema.fields[key].type === 'Pointer'; + schema && schema.fields[key] && schema.fields[key].type === 'Pointer'; - if (expectedTypeIsPointer || !schema && value && value.__type === 'Pointer') { + const field = schema && schema.fields[key]; + if ( + expectedTypeIsPointer || + (!schema && !key.includes('.') && value && value.__type === 'Pointer') + ) { key = '_p_' + key; } // Handle query constraints - if (transformConstraint(value, expectedTypeIsArray) !== CannotTransform) { - return {key, value: transformConstraint(value, expectedTypeIsArray)}; + const transformedConstraint = transformConstraint(value, field, key, count); + if (transformedConstraint !== CannotTransform) { + if (transformedConstraint.$text) { + return { key: '$text', value: transformedConstraint.$text }; + } + if (transformedConstraint.$elemMatch) { + return { key: '$nor', value: [{ [key]: transformedConstraint }] }; + } + return { key, value: transformedConstraint }; } - if (expectedTypeIsArray && !(value instanceof Array)) { - return {key, value: { '$all' : [value] }}; + if (expectedTypeIsArray && !Array.isArray(value)) { + return { key, value: { $all: [transformInteriorAtom(value)] } }; } // Handle atomic values - if (transformTopLevelAtom(value) !== CannotTransform) { - return {key, value: transformTopLevelAtom(value)}; + const transformRes = key.includes('.') + ? transformInteriorAtom(value) + : transformTopLevelAtom(value); + if (transformRes !== CannotTransform) { + return { key, value: transformRes }; } else { - throw new Parse.Error(Parse.Error.INVALID_JSON, `You cannot use ${value} as a query parameter.`); + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `You cannot use ${value} as a query parameter.` + ); } } // Main exposed method to help run queries. // restWhere is the "where" clause in REST API form. // Returns the mongo form of the query. -function transformWhere(className, restWhere, schema) { - let mongoWhere = {}; - for (let restKey in restWhere) { - let out = transformQueryKeyValue(className, restKey, restWhere[restKey], schema); +function transformWhere(className, restWhere, schema, count = false) { + const mongoWhere = {}; + for (const restKey in restWhere) { + const out = transformQueryKeyValue(className, restKey, restWhere[restKey], schema, count); mongoWhere[out.key] = out.value; } return mongoWhere; @@ -210,37 +372,61 @@ const parseObjectKeyValueToMongoObjectKeyValue = (restKey, restValue, schema) => // Check if the schema is known since it's a built-in field. let transformedValue; let coercedToDate; - switch(restKey) { - case 'objectId': return {key: '_id', value: restValue}; - case 'expiresAt': - transformedValue = transformTopLevelAtom(restValue); - coercedToDate = typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue - return {key: 'expiresAt', value: coercedToDate}; - case '_email_verify_token_expires_at': - transformedValue = transformTopLevelAtom(restValue); - coercedToDate = typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue - return {key: '_email_verify_token_expires_at', value: coercedToDate}; - case '_rperm': - case '_wperm': - case '_email_verify_token': - case '_hashed_password': - case '_perishable_token': return {key: restKey, value: restValue}; - case 'sessionToken': return {key: '_session_token', value: restValue}; - default: - // Auth data should have been transformed already - if (restKey.match(/^authData\.([a-zA-Z0-9_]+)\.id$/)) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'can only query on ' + restKey); - } - // Trust that the auth data has been transformed and save it directly - if (restKey.match(/^_auth_data_[a-zA-Z0-9_]+$/)) { - return {key: restKey, value: restValue}; - } + switch (restKey) { + case 'objectId': + return { key: '_id', value: restValue }; + case 'expiresAt': + transformedValue = transformTopLevelAtom(restValue); + coercedToDate = + typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue; + return { key: 'expiresAt', value: coercedToDate }; + case '_email_verify_token_expires_at': + transformedValue = transformTopLevelAtom(restValue); + coercedToDate = + typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue; + return { key: '_email_verify_token_expires_at', value: coercedToDate }; + case '_account_lockout_expires_at': + transformedValue = transformTopLevelAtom(restValue); + coercedToDate = + typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue; + return { key: '_account_lockout_expires_at', value: coercedToDate }; + case '_perishable_token_expires_at': + transformedValue = transformTopLevelAtom(restValue); + coercedToDate = + typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue; + return { key: '_perishable_token_expires_at', value: coercedToDate }; + case '_password_changed_at': + transformedValue = transformTopLevelAtom(restValue); + coercedToDate = + typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue; + return { key: '_password_changed_at', value: coercedToDate }; + case '_failed_login_count': + case '_rperm': + case '_wperm': + case '_email_verify_token': + case '_hashed_password': + case '_perishable_token': + return { key: restKey, value: restValue }; + case 'sessionToken': + return { key: '_session_token', value: restValue }; + default: + // Auth data should have been transformed already + if (restKey.match(/^authData\.([a-zA-Z0-9_]+)\.id$/)) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'can only query on ' + restKey); + } + // Trust that the auth data has been transformed and save it directly + if (restKey.match(/^_auth_data_[a-zA-Z0-9_]+$/)) { + return { key: restKey, value: restValue }; + } } //skip straight to transformTopLevelAtom for Bytes, they don't show up in the schema for some reason if (restValue && restValue.__type !== 'Bytes') { //Note: We may not know the type of a field here, as the user could be saving (null) to a field //That never existed before, meaning we can't infer the type. - if (schema.fields[restKey] && schema.fields[restKey].type == 'Pointer' || restValue.__type == 'Pointer') { + if ( + (schema.fields[restKey] && schema.fields[restKey].type == 'Pointer') || + restValue.__type == 'Pointer' + ) { restKey = '_p_' + restKey; } } @@ -248,7 +434,7 @@ const parseObjectKeyValueToMongoObjectKeyValue = (restKey, restValue, schema) => // Handle atomic values var value = transformTopLevelAtom(restValue); if (value !== CannotTransform) { - return {key: restKey, value: value}; + return { key: restKey, value: value }; } // ACLs are handled before this method is called @@ -258,24 +444,31 @@ const parseObjectKeyValueToMongoObjectKeyValue = (restKey, restValue, schema) => } // Handle arrays - if (restValue instanceof Array) { + if (Array.isArray(restValue)) { value = restValue.map(transformInteriorValue); - return {key: restKey, value: value}; + return { key: restKey, value: value }; } // Handle normal objects by recursing if (Object.keys(restValue).some(key => key.includes('$') || key.includes('.'))) { - throw new Parse.Error(Parse.Error.INVALID_NESTED_KEY, "Nested keys should not contain the '$' or '.' characters"); + throw new Parse.Error( + Parse.Error.INVALID_NESTED_KEY, + "Nested keys should not contain the '$' or '.' characters" + ); } - value = _.mapValues(restValue, transformInteriorValue); - return {key: restKey, value}; -} + value = mapValues(restValue, transformInteriorValue); + + return { key: restKey, value }; +}; const parseObjectToMongoObjectForCreate = (className, restCreate, schema) => { restCreate = addLegacyACL(restCreate); - let mongoCreate = {} - for (let restKey in restCreate) { - let { key, value } = parseObjectKeyValueToMongoObjectKeyValue( + const mongoCreate = {}; + for (const restKey in restCreate) { + if (restCreate[restKey] && restCreate[restKey].__type === 'Relation') { + continue; + } + const { key, value } = parseObjectKeyValueToMongoObjectKeyValue( restKey, restCreate[restKey], schema @@ -296,13 +489,13 @@ const parseObjectToMongoObjectForCreate = (className, restCreate, schema) => { } return mongoCreate; -} +}; // Main exposed method to help update old objects. const transformUpdate = (className, restUpdate, parseFormatSchema) => { - let mongoUpdate = {}; - let acl = addLegacyACL(restUpdate)._acl; - if (acl) { + const mongoUpdate = {}; + const acl = addLegacyACL(restUpdate); + if (acl._rperm || acl._wperm || acl._acl) { mongoUpdate.$set = {}; if (acl._rperm) { mongoUpdate.$set._rperm = acl._rperm; @@ -315,7 +508,15 @@ const transformUpdate = (className, restUpdate, parseFormatSchema) => { } } for (var restKey in restUpdate) { - var out = transformKeyValueForUpdate(className, restKey, restUpdate[restKey], parseFormatSchema); + if (restUpdate[restKey] && restUpdate[restKey].__type === 'Relation') { + continue; + } + var out = transformKeyValueForUpdate( + className, + restKey, + restUpdate[restKey], + parseFormatSchema + ); // If the output value is an object with any $ keys, it's an // operator that needs to be lifted onto the top level update @@ -330,17 +531,18 @@ const transformUpdate = (className, restUpdate, parseFormatSchema) => { } return mongoUpdate; -} +}; // Add the legacy _acl format. const addLegacyACL = restObject => { - let restObjectCopy = {...restObject}; - let _acl = {}; + const restObjectCopy = { ...restObject }; + const _acl = {}; if (restObject._wperm) { restObject._wperm.forEach(entry => { _acl[entry] = { w: true }; }); + restObjectCopy._acl = _acl; } if (restObject._rperm) { @@ -351,15 +553,11 @@ const addLegacyACL = restObject => { _acl[entry].r = true; } }); - } - - if (Object.keys(_acl).length > 0) { restObjectCopy._acl = _acl; } return restObjectCopy; -} - +}; // A sentinel value that helper transformations return when they // cannot perform a transformation @@ -367,11 +565,11 @@ function CannotTransform() {} const transformInteriorAtom = atom => { // TODO: check validity harder for the __type-defined types - if (typeof atom === 'object' && atom && !(atom instanceof Date) && atom.__type === 'Pointer') { + if (typeof atom === 'object' && atom && !Utils.isDate(atom) && atom.__type === 'Pointer') { return { __type: 'Pointer', className: atom.className, - objectId: atom.objectId + objectId: atom.objectId, }; } else if (typeof atom === 'function' || typeof atom === 'symbol') { throw new Parse.Error(Parse.Error.INVALID_JSON, `cannot transform value: ${atom}`); @@ -379,10 +577,12 @@ const transformInteriorAtom = atom => { return DateCoder.JSONToDatabase(atom); } else if (BytesCoder.isValidJSON(atom)) { return BytesCoder.JSONToDatabase(atom); + } else if (typeof atom === 'object' && atom && atom.$regex !== undefined) { + return new RegExp(atom.$regex); } else { return atom; } -} +}; // Helper function to transform an atom from REST format to Mongo format. // An atom is anything that can't contain other expressions. So it @@ -391,49 +591,58 @@ const transformInteriorAtom = atom => { // or arrays with generic stuff inside. // Raises an error if this cannot possibly be valid REST format. // Returns CannotTransform if it's just not an atom -function transformTopLevelAtom(atom) { - switch(typeof atom) { - case 'string': - case 'number': - case 'boolean': - return atom; - case 'undefined': - return atom; - case 'symbol': - case 'function': - throw new Parse.Error(Parse.Error.INVALID_JSON, `cannot transform value: ${atom}`); - case 'object': - if (atom instanceof Date) { - // Technically dates are not rest format, but, it seems pretty - // clear what they should be transformed to, so let's just do it. +function transformTopLevelAtom(atom, field) { + switch (typeof atom) { + case 'number': + case 'boolean': + case 'undefined': return atom; - } - - if (atom === null) { + case 'string': + if (field && field.type === 'Pointer') { + return `${field.targetClass}$${atom}`; + } return atom; - } + case 'symbol': + case 'function': + throw new Parse.Error(Parse.Error.INVALID_JSON, `cannot transform value: ${atom}`); + case 'object': + if (Utils.isDate(atom)) { + // Technically dates are not rest format, but, it seems pretty + // clear what they should be transformed to, so let's just do it. + return atom; + } - // TODO: check validity harder for the __type-defined types - if (atom.__type == 'Pointer') { - return `${atom.className}$${atom.objectId}`; - } - if (DateCoder.isValidJSON(atom)) { - return DateCoder.JSONToDatabase(atom); - } - if (BytesCoder.isValidJSON(atom)) { - return BytesCoder.JSONToDatabase(atom); - } - if (GeoPointCoder.isValidJSON(atom)) { - return GeoPointCoder.JSONToDatabase(atom); - } - if (FileCoder.isValidJSON(atom)) { - return FileCoder.JSONToDatabase(atom); - } - return CannotTransform; + if (atom === null) { + return atom; + } + + // TODO: check validity harder for the __type-defined types + if (atom.__type == 'Pointer') { + return `${atom.className}$${atom.objectId}`; + } + if (DateCoder.isValidJSON(atom)) { + return DateCoder.JSONToDatabase(atom); + } + if (BytesCoder.isValidJSON(atom)) { + return BytesCoder.JSONToDatabase(atom); + } + if (GeoPointCoder.isValidJSON(atom)) { + return GeoPointCoder.JSONToDatabase(atom); + } + if (PolygonCoder.isValidJSON(atom)) { + return PolygonCoder.JSONToDatabase(atom); + } + if (FileCoder.isValidJSON(atom)) { + return FileCoder.JSONToDatabase(atom); + } + return CannotTransform; - default: - // I don't think typeof can ever let us get here - throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, `really did not expect value: ${atom}`); + default: + // I don't think typeof can ever let us get here + throw new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + `really did not expect value: ${atom}` + ); } } @@ -442,11 +651,22 @@ function transformTopLevelAtom(atom) { // If it is not a valid constraint but it could be a valid something // else, return CannotTransform. // inArray is whether this is an array field. -function transformConstraint(constraint, inArray) { +function transformConstraint(constraint, field, queryKey, count = false) { + const inArray = field && field.type && field.type === 'Array'; + // Check wether the given key has `.` + const isNestedKey = queryKey.indexOf('.') > -1; if (typeof constraint !== 'object' || !constraint) { return CannotTransform; } - + // For inArray or nested key, we need to transform the interior atom + const transformFunction = (inArray || isNestedKey) ? transformInteriorAtom : transformTopLevelAtom; + const transformer = atom => { + const result = transformFunction(atom, field); + if (result === CannotTransform) { + throw new Parse.Error(Parse.Error.INVALID_JSON, `bad atom: ${JSON.stringify(atom)}`); + } + return result; + }; // keys is the constraints in reverse alphabetical order. // This is a hack so that: // $regex is handled before $options @@ -454,105 +674,288 @@ function transformConstraint(constraint, inArray) { var keys = Object.keys(constraint).sort().reverse(); var answer = {}; for (var key of keys) { - switch(key) { - case '$lt': - case '$lte': - case '$gt': - case '$gte': - case '$exists': - case '$ne': - case '$eq': - answer[key] = inArray ? transformInteriorAtom(constraint[key]) : transformTopLevelAtom(constraint[key]); - if (answer[key] === CannotTransform) { - throw new Parse.Error(Parse.Error.INVALID_JSON, `bad atom: ${atom}`); - } - break; + switch (key) { + case '$lt': + case '$lte': + case '$gt': + case '$gte': + case '$exists': + case '$ne': + case '$eq': { + const val = constraint[key]; + if (val && typeof val === 'object' && val.$relativeTime) { + if (field && field.type !== 'Date') { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + '$relativeTime can only be used with Date field' + ); + } - case '$in': - case '$nin': - var arr = constraint[key]; - if (!(arr instanceof Array)) { - throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad ' + key + ' value'); - } - answer[key] = arr.map(value => { - let result = inArray ? transformInteriorAtom(value) : transformTopLevelAtom(value); - if (result === CannotTransform) { - throw new Parse.Error(Parse.Error.INVALID_JSON, `bad atom: ${atom}`); + switch (key) { + case '$exists': + case '$ne': + case '$eq': + throw new Parse.Error( + Parse.Error.INVALID_JSON, + '$relativeTime can only be used with the $lt, $lte, $gt, and $gte operators' + ); + } + + const parserResult = Utils.relativeTimeToDate(val.$relativeTime); + if (parserResult.status === 'success') { + answer[key] = parserResult.result; + break; + } + + log.info('Error while parsing relative date', parserResult); + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bad $relativeTime (${key}) value. ${parserResult.info}` + ); } - return result; - }); - break; - case '$all': - var arr = constraint[key]; - if (!(arr instanceof Array)) { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'bad ' + key + ' value'); + answer[key] = transformer(val); + break; } - answer[key] = arr.map(transformInteriorAtom); - break; - case '$regex': - var s = constraint[key]; - if (typeof s !== 'string') { - throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad regex: ' + s); + case '$in': + case '$nin': { + const arr = constraint[key]; + if (!Array.isArray(arr)) { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad ' + key + ' value'); + } + answer[key] = _.flatMap(arr, value => { + return (atom => { + if (Array.isArray(atom)) { + return value.map(transformer); + } else { + return transformer(atom); + } + })(value); + }); + break; } - answer[key] = s; - break; - - case '$options': - answer[key] = constraint[key]; - break; - - case '$nearSphere': - var point = constraint[key]; - answer[key] = [point.longitude, point.latitude]; - break; + case '$all': { + const arr = constraint[key]; + if (!Array.isArray(arr)) { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad ' + key + ' value'); + } + answer[key] = arr.map(transformInteriorAtom); + + const values = answer[key]; + if (isAnyValueRegex(values) && !isAllValuesRegexOrNone(values)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'All $all values must be of regex type or none: ' + values + ); + } - case '$maxDistance': - answer[key] = constraint[key]; - break; + break; + } + case '$regex': + var s = constraint[key]; + if (typeof s !== 'string') { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad regex: ' + s); + } + answer[key] = s; + break; - // The SDKs don't seem to use these but they are documented in the - // REST API docs. - case '$maxDistanceInRadians': - answer['$maxDistance'] = constraint[key]; - break; - case '$maxDistanceInMiles': - answer['$maxDistance'] = constraint[key] / 3959; - break; - case '$maxDistanceInKilometers': - answer['$maxDistance'] = constraint[key] / 6371; - break; + case '$containedBy': { + const arr = constraint[key]; + if (!Array.isArray(arr)) { + throw new Parse.Error(Parse.Error.INVALID_JSON, `bad $containedBy: should be an array`); + } + answer.$elemMatch = { + $nin: arr.map(transformer), + }; + break; + } + case '$options': + answer[key] = constraint[key]; + break; - case '$select': - case '$dontSelect': - throw new Parse.Error( - Parse.Error.COMMAND_UNAVAILABLE, - 'the ' + key + ' constraint is not supported yet'); + case '$text': { + const search = constraint[key].$search; + if (typeof search !== 'object') { + throw new Parse.Error(Parse.Error.INVALID_JSON, `bad $text: $search, should be object`); + } + if (!search.$term || typeof search.$term !== 'string') { + throw new Parse.Error(Parse.Error.INVALID_JSON, `bad $text: $term, should be string`); + } else { + answer[key] = { + $search: search.$term, + }; + } + if (search.$language && typeof search.$language !== 'string') { + throw new Parse.Error(Parse.Error.INVALID_JSON, `bad $text: $language, should be string`); + } else if (search.$language) { + answer[key].$language = search.$language; + } + if (search.$caseSensitive && typeof search.$caseSensitive !== 'boolean') { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bad $text: $caseSensitive, should be boolean` + ); + } else if (search.$caseSensitive) { + answer[key].$caseSensitive = search.$caseSensitive; + } + if (search.$diacriticSensitive && typeof search.$diacriticSensitive !== 'boolean') { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bad $text: $diacriticSensitive, should be boolean` + ); + } else if (search.$diacriticSensitive) { + answer[key].$diacriticSensitive = search.$diacriticSensitive; + } + break; + } + case '$nearSphere': { + const point = constraint[key]; + if (count) { + answer.$geoWithin = { + $centerSphere: [[point.longitude, point.latitude], constraint.$maxDistance], + }; + } else { + answer[key] = [point.longitude, point.latitude]; + } + break; + } + case '$maxDistance': { + if (count) { + break; + } + answer[key] = constraint[key]; + break; + } + // The SDKs don't seem to use these but they are documented in the + // REST API docs. + case '$maxDistanceInRadians': + answer['$maxDistance'] = constraint[key]; + break; + case '$maxDistanceInMiles': + answer['$maxDistance'] = constraint[key] / 3959; + break; + case '$maxDistanceInKilometers': + answer['$maxDistance'] = constraint[key] / 6371; + break; - case '$within': - var box = constraint[key]['$box']; - if (!box || box.length != 2) { + case '$select': + case '$dontSelect': throw new Parse.Error( - Parse.Error.INVALID_JSON, - 'malformatted $within arg'); - } - answer[key] = { - '$box': [ - [box[0].longitude, box[0].latitude], - [box[1].longitude, box[1].latitude] - ] - }; - break; + Parse.Error.COMMAND_UNAVAILABLE, + 'the ' + key + ' constraint is not supported yet' + ); + + case '$within': + var box = constraint[key]['$box']; + if (!box || box.length != 2) { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'malformatted $within arg'); + } + answer[key] = { + $box: [ + [box[0].longitude, box[0].latitude], + [box[1].longitude, box[1].latitude], + ], + }; + break; - default: - if (key.match(/^\$+/)) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - 'bad constraint: ' + key); + case '$geoWithin': { + const polygon = constraint[key]['$polygon']; + const centerSphere = constraint[key]['$centerSphere']; + if (polygon !== undefined) { + let points; + if (typeof polygon === 'object' && polygon.__type === 'Polygon') { + if (!polygon.coordinates || polygon.coordinates.length < 3) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoWithin value; Polygon.coordinates should contain at least 3 lon/lat pairs' + ); + } + points = polygon.coordinates; + } else if (Array.isArray(polygon)) { + if (polygon.length < 3) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoWithin value; $polygon should contain at least 3 GeoPoints' + ); + } + points = polygon; + } else { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + "bad $geoWithin value; $polygon should be Polygon object or Array of Parse.GeoPoint's" + ); + } + points = points.map(point => { + if (Array.isArray(point) && point.length === 2) { + Parse.GeoPoint._validate(point[1], point[0]); + return point; + } + if (!GeoPointCoder.isValidJSON(point)) { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad $geoWithin value'); + } else { + Parse.GeoPoint._validate(point.latitude, point.longitude); + } + return [point.longitude, point.latitude]; + }); + answer[key] = { + $polygon: points, + }; + } else if (centerSphere !== undefined) { + if (!Array.isArray(centerSphere) || centerSphere.length < 2) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoWithin value; $centerSphere should be an array of Parse.GeoPoint and distance' + ); + } + // Get point, convert to geo point if necessary and validate + let point = centerSphere[0]; + if (Array.isArray(point) && point.length === 2) { + point = new Parse.GeoPoint(point[1], point[0]); + } else if (!GeoPointCoder.isValidJSON(point)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoWithin value; $centerSphere geo point invalid' + ); + } + Parse.GeoPoint._validate(point.latitude, point.longitude); + // Get distance and validate + const distance = centerSphere[1]; + if (isNaN(distance) || distance < 0) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoWithin value; $centerSphere distance invalid' + ); + } + answer[key] = { + $centerSphere: [[point.longitude, point.latitude], distance], + }; + } + break; } - return CannotTransform; + case '$geoIntersects': { + const point = constraint[key]['$point']; + if (!GeoPointCoder.isValidJSON(point)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoIntersect value; $point should be GeoPoint' + ); + } else { + Parse.GeoPoint._validate(point.latitude, point.longitude); + } + answer[key] = { + $geometry: { + type: 'Point', + coordinates: [point.longitude, point.latitude], + }, + }; + break; + } + default: + if (key.match(/^\$+/)) { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad constraint: ' + key); + } + return CannotTransform; } } return answer; @@ -568,238 +971,326 @@ function transformConstraint(constraint, inArray) { // The output for a flattened operator is just a value. // Returns undefined if this should be a no-op. -function transformUpdateOperator({ - __op, - amount, - objects, -}, flatten) { - switch(__op) { - case 'Delete': - if (flatten) { - return undefined; - } else { - return {__op: '$unset', arg: ''}; - } +function transformUpdateOperator({ __op, amount, objects }, flatten) { + switch (__op) { + case 'Delete': + if (flatten) { + return undefined; + } else { + return { __op: '$unset', arg: '' }; + } - case 'Increment': - if (typeof amount !== 'number') { - throw new Parse.Error(Parse.Error.INVALID_JSON, 'incrementing must provide a number'); - } - if (flatten) { - return amount; - } else { - return {__op: '$inc', arg: amount}; - } + case 'Increment': + if (typeof amount !== 'number') { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'incrementing must provide a number'); + } + if (flatten) { + return amount; + } else { + return { __op: '$inc', arg: amount }; + } - case 'Add': - case 'AddUnique': - if (!(objects instanceof Array)) { - throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to add must be an array'); - } - var toAdd = objects.map(transformInteriorAtom); - if (flatten) { - return toAdd; - } else { - var mongoOp = { - Add: '$push', - AddUnique: '$addToSet' - }[__op]; - return {__op: mongoOp, arg: {'$each': toAdd}}; - } + case 'SetOnInsert': + if (flatten) { + return amount; + } else { + return { __op: '$setOnInsert', arg: amount }; + } - case 'Remove': - if (!(objects instanceof Array)) { - throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to remove must be an array'); - } - var toRemove = objects.map(transformInteriorAtom); - if (flatten) { - return []; - } else { - return {__op: '$pullAll', arg: toRemove}; - } + case 'Add': + case 'AddUnique': + if (!Array.isArray(objects)) { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to add must be an array'); + } + var toAdd = objects.map(transformInteriorAtom); + if (flatten) { + return toAdd; + } else { + var mongoOp = { + Add: '$push', + AddUnique: '$addToSet', + }[__op]; + return { __op: mongoOp, arg: { $each: toAdd } }; + } - default: - throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, `The ${__op} operator is not supported yet.`); + case 'Remove': + if (!Array.isArray(objects)) { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to remove must be an array'); + } + var toRemove = objects.map(transformInteriorAtom); + if (flatten) { + return []; + } else { + return { __op: '$pullAll', arg: toRemove }; + } + + default: + throw new Parse.Error( + Parse.Error.COMMAND_UNAVAILABLE, + `The ${__op} operator is not supported yet.` + ); } } +function mapValues(object, iterator) { + const result = {}; + Object.keys(object).forEach(key => { + result[key] = iterator(object[key]); + }); + return result; +} const nestedMongoObjectToNestedParseObject = mongoObject => { - switch(typeof mongoObject) { - case 'string': - case 'number': - case 'boolean': - return mongoObject; - case 'undefined': - case 'symbol': - case 'function': - throw 'bad value in mongoObjectToParseObject'; - case 'object': - if (mongoObject === null) { - return null; - } - if (mongoObject instanceof Array) { - return mongoObject.map(nestedMongoObjectToNestedParseObject); - } + switch (typeof mongoObject) { + case 'string': + case 'number': + case 'boolean': + case 'undefined': + return mongoObject; + case 'symbol': + case 'function': + throw 'bad value in nestedMongoObjectToNestedParseObject'; + case 'object': + if (mongoObject === null) { + return null; + } + if (Array.isArray(mongoObject)) { + return mongoObject.map(nestedMongoObjectToNestedParseObject); + } - if (mongoObject instanceof Date) { - return Parse._encode(mongoObject); - } + if (Utils.isDate(mongoObject)) { + return Parse._encode(mongoObject); + } - if (mongoObject instanceof mongodb.Long) { - return mongoObject.toNumber(); - } + if (mongoObject instanceof mongodb.Long) { + return mongoObject.toNumber(); + } - if (mongoObject instanceof mongodb.Double) { - return mongoObject.value; - } + if (mongoObject instanceof mongodb.Double) { + return mongoObject.value; + } - if (BytesCoder.isValidDatabaseObject(mongoObject)) { - return BytesCoder.databaseToJSON(mongoObject); - } + if (BytesCoder.isValidDatabaseObject(mongoObject)) { + return BytesCoder.databaseToJSON(mongoObject); + } - return _.mapValues(mongoObject, nestedMongoObjectToNestedParseObject); - default: - throw 'unknown js type'; + if ( + Object.prototype.hasOwnProperty.call(mongoObject, '__type') && + mongoObject.__type == 'Date' && + Utils.isDate(mongoObject.iso) + ) { + mongoObject.iso = mongoObject.iso.toJSON(); + return mongoObject; + } + + return mapValues(mongoObject, nestedMongoObjectToNestedParseObject); + default: + throw 'unknown js type'; } -} +}; + +const transformPointerString = (schema, field, pointerString) => { + const objData = pointerString.split('$'); + if (objData[0] !== schema.fields[field].targetClass) { + throw 'pointer to incorrect className'; + } + return { + __type: 'Pointer', + className: objData[0], + objectId: objData[1], + }; +}; // Converts from a mongo-format object to a REST-format object. // Does not strip out anything based on a lack of authentication. const mongoObjectToParseObject = (className, mongoObject, schema) => { - switch(typeof mongoObject) { - case 'string': - case 'number': - case 'boolean': - return mongoObject; - case 'undefined': - case 'symbol': - case 'function': - throw 'bad value in mongoObjectToParseObject'; - case 'object': - if (mongoObject === null) { - return null; - } - if (mongoObject instanceof Array) { - return mongoObject.map(nestedMongoObjectToNestedParseObject); - } - - if (mongoObject instanceof Date) { - return Parse._encode(mongoObject); - } + switch (typeof mongoObject) { + case 'string': + case 'number': + case 'boolean': + case 'undefined': + return mongoObject; + case 'symbol': + case 'function': + throw 'bad value in mongoObjectToParseObject'; + case 'object': { + if (mongoObject === null) { + return null; + } + if (Array.isArray(mongoObject)) { + return mongoObject.map(nestedMongoObjectToNestedParseObject); + } - if (mongoObject instanceof mongodb.Long) { - return mongoObject.toNumber(); - } + if (Utils.isDate(mongoObject)) { + return Parse._encode(mongoObject); + } - if (mongoObject instanceof mongodb.Double) { - return mongoObject.value; - } + if (mongoObject instanceof mongodb.Long) { + return mongoObject.toNumber(); + } - if (BytesCoder.isValidDatabaseObject(mongoObject)) { - return BytesCoder.databaseToJSON(mongoObject); - } + if (mongoObject instanceof mongodb.Double) { + return mongoObject.value; + } - let restObject = {}; - if (mongoObject._rperm || mongoObject._wperm) { - restObject._rperm = mongoObject._rperm || []; - restObject._wperm = mongoObject._wperm || []; - delete mongoObject._rperm; - delete mongoObject._wperm; - } + if (BytesCoder.isValidDatabaseObject(mongoObject)) { + return BytesCoder.databaseToJSON(mongoObject); + } - for (var key in mongoObject) { - switch(key) { - case '_id': - restObject['objectId'] = '' + mongoObject[key]; - break; - case '_hashed_password': - restObject._hashed_password = mongoObject[key]; - break; - case '_acl': - case '_email_verify_token': - case '_perishable_token': - case '_tombstone': - case '_email_verify_token_expires_at': - break; - case '_session_token': - restObject['sessionToken'] = mongoObject[key]; - break; - case 'updatedAt': - case '_updated_at': - restObject['updatedAt'] = Parse._encode(new Date(mongoObject[key])).iso; - break; - case 'createdAt': - case '_created_at': - restObject['createdAt'] = Parse._encode(new Date(mongoObject[key])).iso; - break; - case 'expiresAt': - case '_expiresAt': - restObject['expiresAt'] = Parse._encode(new Date(mongoObject[key])); - break; - default: - // Check other auth data keys - var authDataMatch = key.match(/^_auth_data_([a-zA-Z0-9_]+)$/); - if (authDataMatch) { - var provider = authDataMatch[1]; - restObject['authData'] = restObject['authData'] || {}; - restObject['authData'][provider] = mongoObject[key]; - break; - } + const restObject = {}; + if (mongoObject._rperm || mongoObject._wperm) { + restObject._rperm = mongoObject._rperm || []; + restObject._wperm = mongoObject._wperm || []; + delete mongoObject._rperm; + delete mongoObject._wperm; + } - if (key.indexOf('_p_') == 0) { - var newKey = key.substring(3); - if (!schema.fields[newKey]) { - log.info('transform.js', 'Found a pointer column not in the schema, dropping it.', className, newKey); + for (var key in mongoObject) { + switch (key) { + case '_id': + restObject['objectId'] = '' + mongoObject[key]; break; - } - if (schema.fields[newKey].type !== 'Pointer') { - log.info('transform.js', 'Found a pointer in a non-pointer column, dropping it.', className, key); + case '_hashed_password': + restObject._hashed_password = mongoObject[key]; break; - } - if (mongoObject[key] === null) { + case '_acl': break; - } - var objData = mongoObject[key].split('$'); - if (objData[0] !== schema.fields[newKey].targetClass) { - throw 'pointer to incorrect className'; - } - restObject[newKey] = { - __type: 'Pointer', - className: objData[0], - objectId: objData[1] - }; - break; - } else if (key[0] == '_' && key != '__type') { - throw ('bad key in untransform: ' + key); - } else { - var value = mongoObject[key]; - if (schema.fields[key] && schema.fields[key].type === 'File' && FileCoder.isValidDatabaseObject(value)) { - restObject[key] = FileCoder.databaseToJSON(value); + case '_email_verify_token': + case '_perishable_token': + case '_perishable_token_expires_at': + case '_password_changed_at': + case '_tombstone': + case '_email_verify_token_expires_at': + case '_account_lockout_expires_at': + case '_failed_login_count': + case '_password_history': + // Those keys will be deleted if needed in the DB Controller + restObject[key] = mongoObject[key]; break; - } - if (schema.fields[key] && schema.fields[key].type === 'GeoPoint' && GeoPointCoder.isValidDatabaseObject(value)) { - restObject[key] = GeoPointCoder.databaseToJSON(value); + case '_session_token': + restObject['sessionToken'] = mongoObject[key]; break; - } + case 'updatedAt': + case '_updated_at': + restObject['updatedAt'] = Parse._encode(new Date(mongoObject[key])).iso; + break; + case 'createdAt': + case '_created_at': + restObject['createdAt'] = Parse._encode(new Date(mongoObject[key])).iso; + break; + case 'expiresAt': + case '_expiresAt': + restObject['expiresAt'] = Parse._encode(new Date(mongoObject[key])); + break; + case 'lastUsed': + case '_last_used': + restObject['lastUsed'] = Parse._encode(new Date(mongoObject[key])).iso; + break; + case 'timesUsed': + case 'times_used': + restObject['timesUsed'] = mongoObject[key]; + break; + case 'authData': + if (className === '_User') { + log.warn( + 'ignoring authData in _User as this key is reserved to be synthesized of `_auth_data_*` keys' + ); + } else { + restObject['authData'] = mongoObject[key]; + } + break; + default: + // Check other auth data keys + var authDataMatch = key.match(/^_auth_data_([a-zA-Z0-9_]+)$/); + if (authDataMatch && className === '_User') { + var provider = authDataMatch[1]; + restObject['authData'] = restObject['authData'] || {}; + restObject['authData'][provider] = mongoObject[key]; + break; + } + + if (key.indexOf('_p_') == 0) { + var newKey = key.substring(3); + if (!schema.fields[newKey]) { + log.info( + 'transform.js', + 'Found a pointer column not in the schema, dropping it.', + className, + newKey + ); + break; + } + if (schema.fields[newKey].type !== 'Pointer') { + log.info( + 'transform.js', + 'Found a pointer in a non-pointer column, dropping it.', + className, + key + ); + break; + } + if (mongoObject[key] === null) { + break; + } + restObject[newKey] = transformPointerString(schema, newKey, mongoObject[key]); + break; + } else if (key[0] == '_' && key != '__type') { + throw 'bad key in untransform: ' + key; + } else { + var value = mongoObject[key]; + if ( + schema.fields[key] && + schema.fields[key].type === 'File' && + FileCoder.isValidDatabaseObject(value) + ) { + restObject[key] = FileCoder.databaseToJSON(value); + break; + } + if ( + schema.fields[key] && + schema.fields[key].type === 'GeoPoint' && + GeoPointCoder.isValidDatabaseObject(value) + ) { + restObject[key] = GeoPointCoder.databaseToJSON(value); + break; + } + if ( + schema.fields[key] && + schema.fields[key].type === 'Polygon' && + PolygonCoder.isValidDatabaseObject(value) + ) { + restObject[key] = PolygonCoder.databaseToJSON(value); + break; + } + if ( + schema.fields[key] && + schema.fields[key].type === 'Bytes' && + BytesCoder.isValidDatabaseObject(value) + ) { + restObject[key] = BytesCoder.databaseToJSON(value); + break; + } + } + restObject[key] = nestedMongoObjectToNestedParseObject(mongoObject[key]); } - restObject[key] = nestedMongoObjectToNestedParseObject(mongoObject[key]); } - } - const relationFieldNames = Object.keys(schema.fields).filter(fieldName => schema.fields[fieldName].type === 'Relation'); - let relationFields = {}; - relationFieldNames.forEach(relationFieldName => { - relationFields[relationFieldName] = { - __type: 'Relation', - className: schema.fields[relationFieldName].targetClass, - } - }); + const relationFieldNames = Object.keys(schema.fields).filter( + fieldName => schema.fields[fieldName].type === 'Relation' + ); + const relationFields = {}; + relationFieldNames.forEach(relationFieldName => { + relationFields[relationFieldName] = { + __type: 'Relation', + className: schema.fields[relationFieldName].targetClass, + }; + }); - return { ...restObject, ...relationFields }; - default: - throw 'unknown js type'; + return { ...restObject, ...relationFields }; + } + default: + throw 'unknown js type'; } -} +}; var DateCoder = { JSONToDatabase(json) { @@ -807,35 +1298,43 @@ var DateCoder = { }, isValidJSON(value) { - return (typeof value === 'object' && - value !== null && - value.__type === 'Date' - ); - } + return typeof value === 'object' && value !== null && value.__type === 'Date'; + }, }; var BytesCoder = { + base64Pattern: new RegExp('^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$'), + isBase64Value(object) { + if (typeof object !== 'string') { + return false; + } + return this.base64Pattern.test(object); + }, + databaseToJSON(object) { + let value; + if (this.isBase64Value(object)) { + value = object; + } else { + value = object.buffer.toString('base64'); + } return { __type: 'Bytes', - base64: object.buffer.toString('base64') + base64: value, }; }, isValidDatabaseObject(object) { - return (object instanceof mongodb.Binary); + return object instanceof mongodb.Binary || this.isBase64Value(object); }, JSONToDatabase(json) { - return new mongodb.Binary(new Buffer(json.base64, 'base64')); + return new mongodb.Binary(Buffer.from(json.base64, 'base64')); }, isValidJSON(value) { - return (typeof value === 'object' && - value !== null && - value.__type === 'Bytes' - ); - } + return typeof value === 'object' && value !== null && value.__type === 'Bytes'; + }, }; var GeoPointCoder = { @@ -843,38 +1342,98 @@ var GeoPointCoder = { return { __type: 'GeoPoint', latitude: object[1], - longitude: object[0] - } + longitude: object[0], + }; }, isValidDatabaseObject(object) { - return (object instanceof Array && - object.length == 2 - ); + return Array.isArray(object) && object.length == 2; }, JSONToDatabase(json) { - return [ json.longitude, json.latitude ]; + return [json.longitude, json.latitude]; }, isValidJSON(value) { - return (typeof value === 'object' && - value !== null && - value.__type === 'GeoPoint' - ); - } + return typeof value === 'object' && value !== null && value.__type === 'GeoPoint'; + }, +}; + +var PolygonCoder = { + databaseToJSON(object) { + // Convert lng/lat -> lat/lng + const coords = object.coordinates[0].map(coord => { + return [coord[1], coord[0]]; + }); + return { + __type: 'Polygon', + coordinates: coords, + }; + }, + + isValidDatabaseObject(object) { + const coords = object.coordinates[0]; + if (object.type !== 'Polygon' || !Array.isArray(coords)) { + return false; + } + for (let i = 0; i < coords.length; i++) { + const point = coords[i]; + if (!GeoPointCoder.isValidDatabaseObject(point)) { + return false; + } + Parse.GeoPoint._validate(parseFloat(point[1]), parseFloat(point[0])); + } + return true; + }, + + JSONToDatabase(json) { + let coords = json.coordinates; + // Add first point to the end to close polygon + if ( + coords[0][0] !== coords[coords.length - 1][0] || + coords[0][1] !== coords[coords.length - 1][1] + ) { + coords.push(coords[0]); + } + const unique = coords.filter((item, index, ar) => { + let foundIndex = -1; + for (let i = 0; i < ar.length; i += 1) { + const pt = ar[i]; + if (pt[0] === item[0] && pt[1] === item[1]) { + foundIndex = i; + break; + } + } + return foundIndex === index; + }); + if (unique.length < 3) { + throw new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + 'GeoJSON: Loop must have at least 3 different vertices' + ); + } + // Convert lat/long -> long/lat + coords = coords.map(coord => { + return [coord[1], coord[0]]; + }); + return { type: 'Polygon', coordinates: [coords] }; + }, + + isValidJSON(value) { + return typeof value === 'object' && value !== null && value.__type === 'Polygon'; + }, }; var FileCoder = { databaseToJSON(object) { return { __type: 'File', - name: object - } + name: object, + }; }, isValidDatabaseObject(object) { - return (typeof object === 'string'); + return typeof object === 'string'; }, JSONToDatabase(json) { @@ -882,11 +1441,8 @@ var FileCoder = { }, isValidJSON(value) { - return (typeof value === 'object' && - value !== null && - value.__type === 'File' - ); - } + return typeof value === 'object' && value !== null && value.__type === 'File'; + }, }; module.exports = { @@ -895,4 +1451,6 @@ module.exports = { transformUpdate, transformWhere, mongoObjectToParseObject, + transformConstraint, + transformPointerString, }; diff --git a/src/Adapters/Storage/Postgres/PostgresClient.js b/src/Adapters/Storage/Postgres/PostgresClient.js new file mode 100644 index 0000000000..16a9564c29 --- /dev/null +++ b/src/Adapters/Storage/Postgres/PostgresClient.js @@ -0,0 +1,36 @@ +const parser = require('./PostgresConfigParser'); + +export function createClient(uri, databaseOptions) { + let dbOptions = {}; + databaseOptions = databaseOptions || {}; + + if (uri) { + dbOptions = parser.getDatabaseOptionsFromURI(uri); + } + + for (const key in databaseOptions) { + dbOptions[key] = databaseOptions[key]; + } + + const initOptions = dbOptions.initOptions || {}; + initOptions.noWarnings = process && process.env.TESTING; + + const pgp = require('pg-promise')(initOptions); + const client = pgp(dbOptions); + + if (process.env.PARSE_SERVER_LOG_LEVEL === 'debug') { + const monitor = require('pg-monitor'); + if (monitor.isAttached()) { + monitor.detach(); + } + monitor.attach(initOptions); + } + + if (dbOptions.pgOptions) { + for (const key in dbOptions.pgOptions) { + pgp.pg.defaults[key] = dbOptions.pgOptions[key]; + } + } + + return { client, pgp }; +} diff --git a/src/Adapters/Storage/Postgres/PostgresConfigParser.js b/src/Adapters/Storage/Postgres/PostgresConfigParser.js new file mode 100644 index 0000000000..64e4752913 --- /dev/null +++ b/src/Adapters/Storage/Postgres/PostgresConfigParser.js @@ -0,0 +1,93 @@ +const fs = require('fs'); +function getDatabaseOptionsFromURI(uri) { + const databaseOptions = {}; + + const parsedURI = new URL(uri); + const queryParams = parseQueryParams(parsedURI.searchParams.toString()); + + databaseOptions.host = parsedURI.hostname || 'localhost'; + databaseOptions.port = parsedURI.port ? parseInt(parsedURI.port) : 5432; + databaseOptions.database = parsedURI.pathname ? parsedURI.pathname.substr(1) : undefined; + + databaseOptions.user = parsedURI.username; + databaseOptions.password = parsedURI.password; + + if (queryParams.ssl && queryParams.ssl.toLowerCase() === 'true') { + databaseOptions.ssl = true; + } + + if ( + queryParams.ca || + queryParams.pfx || + queryParams.cert || + queryParams.key || + queryParams.passphrase || + queryParams.rejectUnauthorized || + queryParams.secureOptions + ) { + databaseOptions.ssl = {}; + if (queryParams.ca) { + databaseOptions.ssl.ca = fs.readFileSync(queryParams.ca).toString(); + } + if (queryParams.pfx) { + databaseOptions.ssl.pfx = fs.readFileSync(queryParams.pfx).toString(); + } + if (queryParams.cert) { + databaseOptions.ssl.cert = fs.readFileSync(queryParams.cert).toString(); + } + if (queryParams.key) { + databaseOptions.ssl.key = fs.readFileSync(queryParams.key).toString(); + } + if (queryParams.passphrase) { + databaseOptions.ssl.passphrase = queryParams.passphrase; + } + if (queryParams.rejectUnauthorized) { + databaseOptions.ssl.rejectUnauthorized = + queryParams.rejectUnauthorized.toLowerCase() === 'true' ? true : false; + } + if (queryParams.secureOptions) { + databaseOptions.ssl.secureOptions = parseInt(queryParams.secureOptions); + } + } + + databaseOptions.binary = + queryParams.binary && queryParams.binary.toLowerCase() === 'true' ? true : false; + + databaseOptions.client_encoding = queryParams.client_encoding; + databaseOptions.application_name = queryParams.application_name; + databaseOptions.fallback_application_name = queryParams.fallback_application_name; + + if (queryParams.poolSize) { + databaseOptions.max = parseInt(queryParams.poolSize) || 10; + } + if (queryParams.max) { + databaseOptions.max = parseInt(queryParams.max) || 10; + } + if (queryParams.query_timeout) { + databaseOptions.query_timeout = parseInt(queryParams.query_timeout); + } + if (queryParams.idleTimeoutMillis) { + databaseOptions.idleTimeoutMillis = parseInt(queryParams.idleTimeoutMillis); + } + if (queryParams.keepAlive) { + databaseOptions.keepAlive = queryParams.keepAlive.toLowerCase() === 'true' ? true : false; + } + + return databaseOptions; +} + +function parseQueryParams(queryString) { + queryString = queryString || ''; + + return queryString.split('&').reduce((p, c) => { + const parts = c.split('='); + p[decodeURIComponent(parts[0])] = + parts.length > 1 ? decodeURIComponent(parts.slice(1).join('=')) : ''; + return p; + }, {}); +} + +module.exports = { + parseQueryParams: parseQueryParams, + getDatabaseOptionsFromURI: getDatabaseOptionsFromURI, +}; diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index cf886cdcd8..08f8c647f4 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -1,52 +1,486 @@ -const pgp = require('pg-promise')(); +// @flow +import { createClient } from './PostgresClient'; +// @flow-disable-next +import Parse from 'parse/node'; +// @flow-disable-next +import _ from 'lodash'; +// @flow-disable-next +import { randomUUID } from 'crypto'; +import sql from './sql'; +import { StorageAdapter } from '../StorageAdapter'; +import type { SchemaType, QueryType, QueryOptions } from '../StorageAdapter'; +const Utils = require('../../../Utils'); const PostgresRelationDoesNotExistError = '42P01'; const PostgresDuplicateRelationError = '42P07'; const PostgresDuplicateColumnError = '42701'; +const PostgresMissingColumnError = '42703'; const PostgresUniqueIndexViolationError = '23505'; +const logger = require('../../../logger'); + +const debug = function (...args: any) { + args = ['PG: ' + arguments[0]].concat(args.slice(1, args.length)); + const log = logger.getLogger(); + log.debug.apply(log, args); +}; const parseTypeToPostgresType = type => { switch (type.type) { - case 'String': return 'text'; - case 'Date': return 'timestamp'; - case 'Object': return 'jsonb'; - case 'Boolean': return 'boolean'; - case 'Pointer': return 'char(10)'; - case 'Number': return 'double precision'; + case 'String': + return 'text'; + case 'Date': + return 'timestamp with time zone'; + case 'Object': + return 'jsonb'; + case 'File': + return 'text'; + case 'Boolean': + return 'boolean'; + case 'Pointer': + return 'text'; + case 'Number': + return 'double precision'; + case 'GeoPoint': + return 'point'; + case 'Bytes': + return 'jsonb'; + case 'Polygon': + return 'polygon'; case 'Array': if (type.contents && type.contents.type === 'String') { return 'text[]'; } else { return 'jsonb'; } - default: throw `no type for ${JSON.stringify(type)} yet`; + default: + throw `no type for ${JSON.stringify(type)} yet`; + } +}; + +const ParseToPosgresComparator = { + $gt: '>', + $lt: '<', + $gte: '>=', + $lte: '<=', +}; + +const mongoAggregateToPostgres = { + $dayOfMonth: 'DAY', + $dayOfWeek: 'DOW', + $dayOfYear: 'DOY', + $isoDayOfWeek: 'ISODOW', + $isoWeekYear: 'ISOYEAR', + $hour: 'HOUR', + $minute: 'MINUTE', + $second: 'SECOND', + $millisecond: 'MILLISECONDS', + $month: 'MONTH', + $week: 'WEEK', + $year: 'YEAR', +}; + +const toPostgresValue = value => { + if (typeof value === 'object') { + if (value.__type === 'Date') { + return value.iso; + } + if (value.__type === 'File') { + return value.name; + } + } + return value; +}; + +const toPostgresValueCastType = value => { + const postgresValue = toPostgresValue(value); + let castType; + switch (typeof postgresValue) { + case 'number': + castType = 'double precision'; + break; + case 'boolean': + castType = 'boolean'; + break; + default: + castType = undefined; + } + return castType; +}; + +const transformValue = value => { + if (typeof value === 'object' && value.__type === 'Pointer') { + return value.objectId; } + return value; }; -const buildWhereClause = ({ schema, query, index }) => { - let patterns = []; +// Duplicate from then mongo adapter... +const emptyCLPS = Object.freeze({ + find: {}, + get: {}, + count: {}, + create: {}, + update: {}, + delete: {}, + addField: {}, + protectedFields: {}, +}); + +const defaultCLPS = Object.freeze({ + ACL: { + '*': { + read: true, + write: true, + }, + }, + find: { '*': true }, + get: { '*': true }, + count: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': [] }, +}); + +const toParseSchema = schema => { + if (schema.className === '_User') { + delete schema.fields._hashed_password; + } + if (schema.fields) { + delete schema.fields._wperm; + delete schema.fields._rperm; + } + let clps = defaultCLPS; + if (schema.classLevelPermissions) { + clps = { ...emptyCLPS, ...schema.classLevelPermissions }; + } + let indexes = {}; + if (schema.indexes) { + indexes = { ...schema.indexes }; + } + return { + className: schema.className, + fields: schema.fields, + classLevelPermissions: clps, + indexes, + }; +}; + +const toPostgresSchema = schema => { + if (!schema) { + return schema; + } + schema.fields = schema.fields || {}; + schema.fields._wperm = { type: 'Array', contents: { type: 'String' } }; + schema.fields._rperm = { type: 'Array', contents: { type: 'String' } }; + if (schema.className === '_User') { + schema.fields._hashed_password = { type: 'String' }; + schema.fields._password_history = { type: 'Array' }; + } + return schema; +}; + +const isArrayIndex = (arrayIndex) => Array.from(arrayIndex).every(c => c >= '0' && c <= '9'); + +const handleDotFields = object => { + Object.keys(object).forEach(fieldName => { + if (fieldName.indexOf('.') > -1) { + const components = fieldName.split('.'); + const first = components.shift(); + object[first] = object[first] || {}; + let currentObj = object[first]; + let next; + let value = object[fieldName]; + if (value && value.__op === 'Delete') { + value = undefined; + } + while ((next = components.shift())) { + currentObj[next] = currentObj[next] || {}; + if (components.length === 0) { + currentObj[next] = value; + } + currentObj = currentObj[next]; + } + delete object[fieldName]; + } + }); + return object; +}; + +const escapeSqlString = value => value.replace(/'/g, "''"); +const escapeJsonString = value => JSON.stringify(value).slice(1, -1); + +const transformDotFieldToComponents = fieldName => { + return fieldName.split('.').map((cmpt, index) => { + if (index === 0) { + return `"${cmpt.replace(/"/g, '""')}"`; + } + if (isArrayIndex(cmpt)) { + return Number(cmpt); + } else { + return `'${escapeSqlString(cmpt)}'`; + } + }); +}; + +const transformDotField = fieldName => { + if (fieldName.indexOf('.') === -1) { + return `"${fieldName.replace(/"/g, '""')}"`; + } + const components = transformDotFieldToComponents(fieldName); + let name = components.slice(0, components.length - 1).join('->'); + name += '->>' + components[components.length - 1]; + return name; +}; + +const validateAggregateFieldName = name => { + if (typeof name !== 'string' || !name.match(/^[a-zA-Z][a-zA-Z0-9_]*$/)) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${name}`); + } +}; + +const transformAggregateField = fieldName => { + if (typeof fieldName !== 'string') { + return fieldName; + } + if (fieldName === '$_created_at') { + return 'createdAt'; + } + if (fieldName === '$_updated_at') { + return 'updatedAt'; + } + if (!fieldName.startsWith('$')) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}`); + } + const name = fieldName.substring(1); + validateAggregateFieldName(name); + return name; +}; + +const validateKeys = object => { + if (typeof object == 'object') { + for (const key in object) { + if (typeof object[key] == 'object') { + validateKeys(object[key]); + } + + if (key.includes('$') || key.includes('.')) { + throw new Parse.Error( + Parse.Error.INVALID_NESTED_KEY, + "Nested keys should not contain the '$' or '.' characters" + ); + } + } + } +}; + +// Returns the list of join tables on a schema +const joinTablesForSchema = schema => { + const list = []; + if (schema) { + Object.keys(schema.fields).forEach(field => { + if (schema.fields[field].type === 'Relation') { + list.push(`_Join:${field}:${schema.className}`); + } + }); + } + return list; +}; + +interface WhereClause { + pattern: string; + values: Array; + sorts: Array; +} + +const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClause => { + const patterns = []; let values = []; - for (let fieldName in query) { - let fieldValue = query[fieldName]; - if (typeof fieldValue === 'string') { + const sorts = []; + + schema = toPostgresSchema(schema); + for (const fieldName in query) { + const isArrayField = + schema.fields && schema.fields[fieldName] && schema.fields[fieldName].type === 'Array'; + const initialPatternsLength = patterns.length; + const fieldValue = query[fieldName]; + + // nothing in the schema, it's gonna blow up + if (!schema.fields[fieldName]) { + // as it won't exist + if (fieldValue && fieldValue.$exists === false) { + continue; + } + } + const authDataMatch = fieldName.match(/^_auth_data_([a-zA-Z0-9_]+)$/); + if (authDataMatch) { + // TODO: Handle querying by _auth_data_provider, authData is stored in authData field + continue; + } else if (caseInsensitive && (fieldName === 'username' || fieldName === 'email')) { + patterns.push(`LOWER($${index}:name) = LOWER($${index + 1})`); + values.push(fieldName, fieldValue); + index += 2; + } else if (fieldName.indexOf('.') >= 0) { + let name = transformDotField(fieldName); + if (fieldValue === null) { + patterns.push(`$${index}:raw IS NULL`); + values.push(name); + index += 1; + continue; + } else { + if (fieldValue.$in) { + name = transformDotFieldToComponents(fieldName).join('->'); + patterns.push(`($${index}:raw)::jsonb @> $${index + 1}::jsonb`); + values.push(name, JSON.stringify(fieldValue.$in)); + index += 2; + } else if (fieldValue.$regex) { + // Handle later + } else if (typeof fieldValue !== 'object') { + patterns.push(`$${index}:raw = $${index + 1}::text`); + values.push(name, fieldValue); + index += 2; + } else if ( + typeof fieldValue === 'object' && + !Object.keys(fieldValue).some(key => key.startsWith('$')) + ) { + name = transformDotFieldToComponents(fieldName).join('->'); + patterns.push(`($${index}:raw)::jsonb = $${index + 1}::jsonb`); + values.push(name, JSON.stringify(fieldValue)); + index += 2; + } + } + } else if (fieldValue === null || fieldValue === undefined) { + patterns.push(`$${index}:name IS NULL`); + values.push(fieldName); + index += 1; + continue; + } else if (typeof fieldValue === 'string') { patterns.push(`$${index}:name = $${index + 1}`); values.push(fieldName, fieldValue); index += 2; - } else if (fieldValue.$ne) { - patterns.push(`$${index}:name <> $${index + 1}`); - values.push(fieldName, fieldValue.$ne); + } else if (typeof fieldValue === 'boolean') { + patterns.push(`$${index}:name = $${index + 1}`); + // Can't cast boolean to double precision + if (schema.fields[fieldName] && schema.fields[fieldName].type === 'Number') { + // Should always return zero results + const MAX_INT_PLUS_ONE = 9223372036854775808; + values.push(fieldName, MAX_INT_PLUS_ONE); + } else { + values.push(fieldName, fieldValue); + } + index += 2; + } else if (typeof fieldValue === 'number') { + patterns.push(`$${index}:name = $${index + 1}`); + values.push(fieldName, fieldValue); index += 2; - } else if (fieldName === '$or') { - fieldValue.map(subQuery => buildWhereClause({ schema, query: subQuery, index })).forEach(result => { - patterns.push(result.pattern); - values.push(...result.values); + } else if (['$or', '$nor', '$and'].includes(fieldName)) { + const clauses = []; + const clauseValues = []; + fieldValue.forEach(subQuery => { + const clause = buildWhereClause({ + schema, + query: subQuery, + index, + caseInsensitive, + }); + if (clause.pattern.length > 0) { + clauses.push(clause.pattern); + clauseValues.push(...clause.values); + index += clause.values.length; + } }); - } else if (Array.isArray(fieldValue.$in) && schema.fields[fieldName].type === 'Array') { - let inPatterns = []; + + const orOrAnd = fieldName === '$and' ? ' AND ' : ' OR '; + const not = fieldName === '$nor' ? ' NOT ' : ''; + + patterns.push(`${not}(${clauses.join(orOrAnd)})`); + values.push(...clauseValues); + } + + if (fieldValue.$ne !== undefined) { + if (isArrayField) { + fieldValue.$ne = JSON.stringify([fieldValue.$ne]); + patterns.push(`NOT array_contains($${index}:name, $${index + 1})`); + } else { + if (fieldValue.$ne === null) { + patterns.push(`$${index}:name IS NOT NULL`); + values.push(fieldName); + index += 1; + continue; + } else { + // if not null, we need to manually exclude null + if (fieldValue.$ne.__type === 'GeoPoint') { + patterns.push( + `($${index}:name <> POINT($${index + 1}, $${index + 2}) OR $${index}:name IS NULL)` + ); + } else { + if (fieldName.indexOf('.') >= 0) { + const castType = toPostgresValueCastType(fieldValue.$ne); + const constraintFieldName = castType + ? `CAST ((${transformDotField(fieldName)}) AS ${castType})` + : transformDotField(fieldName); + patterns.push( + `(${constraintFieldName} <> $${index + 1} OR ${constraintFieldName} IS NULL)` + ); + } else if (typeof fieldValue.$ne === 'object' && fieldValue.$ne.$relativeTime) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + '$relativeTime can only be used with the $lt, $lte, $gt, and $gte operators' + ); + } else { + patterns.push(`($${index}:name <> $${index + 1} OR $${index}:name IS NULL)`); + } + } + } + } + if (fieldValue.$ne.__type === 'GeoPoint') { + const point = fieldValue.$ne; + values.push(fieldName, point.longitude, point.latitude); + index += 3; + } else { + // TODO: support arrays + values.push(fieldName, fieldValue.$ne); + index += 2; + } + } + if (fieldValue.$eq !== undefined) { + if (fieldValue.$eq === null) { + patterns.push(`$${index}:name IS NULL`); + values.push(fieldName); + index += 1; + } else { + if (fieldName.indexOf('.') >= 0) { + const castType = toPostgresValueCastType(fieldValue.$eq); + const constraintFieldName = castType + ? `CAST ((${transformDotField(fieldName)}) AS ${castType})` + : transformDotField(fieldName); + values.push(fieldValue.$eq); + patterns.push(`${constraintFieldName} = $${index++}`); + } else if (typeof fieldValue.$eq === 'object' && fieldValue.$eq.$relativeTime) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + '$relativeTime can only be used with the $lt, $lte, $gt, and $gte operators' + ); + } else { + values.push(fieldName, fieldValue.$eq); + patterns.push(`$${index}:name = $${index + 1}`); + index += 2; + } + } + } + const isInOrNin = Array.isArray(fieldValue.$in) || Array.isArray(fieldValue.$nin); + if ( + Array.isArray(fieldValue.$in) && + isArrayField && + schema.fields[fieldName].contents && + schema.fields[fieldName].contents.type === 'String' + ) { + const inPatterns = []; let allowNull = false; values.push(fieldName); fieldValue.$in.forEach((listElem, listIndex) => { - if (listElem === null ) { + if (listElem === null) { allowNull = true; } else { values.push(listElem); @@ -54,141 +488,818 @@ const buildWhereClause = ({ schema, query, index }) => { } }); if (allowNull) { - patterns.push(`($${index}:name IS NULL OR $${index}:name && ARRAY[${inPatterns.join(',')}])`); + patterns.push(`($${index}:name IS NULL OR $${index}:name && ARRAY[${inPatterns.join()}])`); } else { - patterns.push(`$${index}:name && ARRAY[${inPatterns.join(',')}]`); + patterns.push(`$${index}:name && ARRAY[${inPatterns.join()}]`); } index = index + 1 + inPatterns.length; - } else if (Array.isArray(fieldValue.$in) && schema.fields[fieldName].type === 'String') { - let inPatterns = []; + } else if (isInOrNin) { + var createConstraint = (baseArray, notIn) => { + const not = notIn ? ' NOT ' : ''; + if (baseArray.length > 0) { + if (isArrayField) { + patterns.push(`${not} array_contains($${index}:name, $${index + 1})`); + values.push(fieldName, JSON.stringify(baseArray)); + index += 2; + } else { + // Handle Nested Dot Notation Above + if (fieldName.indexOf('.') >= 0) { + return; + } + const fieldType = schema.fields[fieldName]?.type; + if (fieldType === 'String') { + const operatorName = notIn ? '$nin' : '$in'; + for (const elem of baseArray) { + if (elem != null && typeof elem !== 'string') { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `${operatorName} element type mismatch: expected string for field "${fieldName}"` + ); + } + } + } + const inPatterns = []; + values.push(fieldName); + baseArray.forEach((listElem, listIndex) => { + if (listElem != null) { + values.push(listElem); + inPatterns.push(`$${index + 1 + listIndex}`); + } + }); + patterns.push(`$${index}:name ${not} IN (${inPatterns.join()})`); + index = index + 1 + inPatterns.length; + } + } else if (!notIn) { + values.push(fieldName); + patterns.push(`$${index}:name IS NULL`); + index = index + 1; + } else { + // Handle empty array + if (notIn) { + patterns.push('1 = 1'); // Return all values + } else { + patterns.push('1 = 2'); // Return no values + } + } + }; + if (fieldValue.$in) { + createConstraint( + _.flatMap(fieldValue.$in, elt => elt), + false + ); + } + if (fieldValue.$nin) { + createConstraint( + _.flatMap(fieldValue.$nin, elt => elt), + true + ); + } + } else if (typeof fieldValue.$in !== 'undefined') { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad $in value'); + } else if (typeof fieldValue.$nin !== 'undefined') { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad $nin value'); + } + + if (Array.isArray(fieldValue.$all) && isArrayField) { + if (isAnyValueRegexStartsWith(fieldValue.$all)) { + if (!isAllValuesRegexOrNone(fieldValue.$all)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'All $all values must be of regex type or none: ' + fieldValue.$all + ); + } + + for (let i = 0; i < fieldValue.$all.length; i += 1) { + const value = processRegexPattern(fieldValue.$all[i].$regex); + fieldValue.$all[i] = value.substring(1) + '%'; + } + patterns.push(`array_contains_all_regex($${index}:name, $${index + 1}::jsonb)`); + } else { + patterns.push(`array_contains_all($${index}:name, $${index + 1}::jsonb)`); + } + values.push(fieldName, JSON.stringify(fieldValue.$all)); + index += 2; + } else if (Array.isArray(fieldValue.$all)) { + if (fieldValue.$all.length === 1) { + patterns.push(`$${index}:name = $${index + 1}`); + values.push(fieldName, fieldValue.$all[0].objectId); + index += 2; + } + } + + if (typeof fieldValue.$exists !== 'undefined') { + if (typeof fieldValue.$exists === 'object' && fieldValue.$exists.$relativeTime) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + '$relativeTime can only be used with the $lt, $lte, $gt, and $gte operators' + ); + } else if (fieldValue.$exists) { + patterns.push(`$${index}:name IS NOT NULL`); + } else { + patterns.push(`$${index}:name IS NULL`); + } values.push(fieldName); - fieldValue.$in.forEach((listElem, listIndex) => { - values.push(listElem); - inPatterns.push(`$${index + 1 + listIndex}`); - }); - patterns.push(`$${index}:name IN (${inPatterns.join(',')})`); - index = index + 1 + inPatterns.length; - } else if (fieldValue.__type === 'Pointer') { + index += 1; + } + + if (fieldValue.$containedBy) { + const arr = fieldValue.$containedBy; + if (!Array.isArray(arr)) { + throw new Parse.Error(Parse.Error.INVALID_JSON, `bad $containedBy: should be an array`); + } + + patterns.push(`$${index}:name <@ $${index + 1}::jsonb`); + values.push(fieldName, JSON.stringify(arr)); + index += 2; + } + + if (fieldValue.$text) { + const search = fieldValue.$text.$search; + let language = 'english'; + if (typeof search !== 'object') { + throw new Parse.Error(Parse.Error.INVALID_JSON, `bad $text: $search, should be object`); + } + if (!search.$term || typeof search.$term !== 'string') { + throw new Parse.Error(Parse.Error.INVALID_JSON, `bad $text: $term, should be string`); + } + if (search.$language && typeof search.$language !== 'string') { + throw new Parse.Error(Parse.Error.INVALID_JSON, `bad $text: $language, should be string`); + } else if (search.$language) { + language = search.$language; + } + if (search.$caseSensitive && typeof search.$caseSensitive !== 'boolean') { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bad $text: $caseSensitive, should be boolean` + ); + } else if (search.$caseSensitive) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bad $text: $caseSensitive not supported, please use $regex or create a separate lower case column.` + ); + } + if (search.$diacriticSensitive && typeof search.$diacriticSensitive !== 'boolean') { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bad $text: $diacriticSensitive, should be boolean` + ); + } else if (search.$diacriticSensitive === false) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bad $text: $diacriticSensitive - false not supported, install Postgres Unaccent Extension` + ); + } + patterns.push( + `to_tsvector($${index}, $${index + 1}:name) @@ to_tsquery($${index + 2}, $${index + 3})` + ); + values.push(language, fieldName, language, search.$term); + index += 4; + } + + if (fieldValue.$nearSphere) { + const point = fieldValue.$nearSphere; + const distance = fieldValue.$maxDistance; + const distanceInKM = distance * 6371 * 1000; + patterns.push( + `ST_DistanceSphere($${index}:name::geometry, POINT($${index + 1}, $${index + 2 + })::geometry) <= $${index + 3}` + ); + sorts.push( + `ST_DistanceSphere($${index}:name::geometry, POINT($${index + 1}, $${index + 2 + })::geometry) ASC` + ); + values.push(fieldName, point.longitude, point.latitude, distanceInKM); + index += 4; + } + + if (fieldValue.$within && fieldValue.$within.$box) { + const box = fieldValue.$within.$box; + const left = box[0].longitude; + const bottom = box[0].latitude; + const right = box[1].longitude; + const top = box[1].latitude; + + patterns.push(`$${index}:name::point <@ $${index + 1}::box`); + values.push(fieldName, `((${left}, ${bottom}), (${right}, ${top}))`); + index += 2; + } + + if (fieldValue.$geoWithin && fieldValue.$geoWithin.$centerSphere) { + const centerSphere = fieldValue.$geoWithin.$centerSphere; + if (!Array.isArray(centerSphere) || centerSphere.length < 2) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoWithin value; $centerSphere should be an array of Parse.GeoPoint and distance' + ); + } + // Get point, convert to geo point if necessary and validate + let point = centerSphere[0]; + if (Array.isArray(point) && point.length === 2) { + point = new Parse.GeoPoint(point[1], point[0]); + } else if (!GeoPointCoder.isValidJSON(point)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoWithin value; $centerSphere geo point invalid' + ); + } + Parse.GeoPoint._validate(point.latitude, point.longitude); + // Get distance and validate + const distance = centerSphere[1]; + if (isNaN(distance) || distance < 0) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoWithin value; $centerSphere distance invalid' + ); + } + const distanceInKM = distance * 6371 * 1000; + patterns.push( + `ST_DistanceSphere($${index}:name::geometry, POINT($${index + 1}, $${index + 2 + })::geometry) <= $${index + 3}` + ); + values.push(fieldName, point.longitude, point.latitude, distanceInKM); + index += 4; + } + + if (fieldValue.$geoWithin && fieldValue.$geoWithin.$polygon) { + const polygon = fieldValue.$geoWithin.$polygon; + let points; + if (typeof polygon === 'object' && polygon.__type === 'Polygon') { + if (!polygon.coordinates || polygon.coordinates.length < 3) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoWithin value; Polygon.coordinates should contain at least 3 lon/lat pairs' + ); + } + points = polygon.coordinates; + } else if (Array.isArray(polygon)) { + if (polygon.length < 3) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoWithin value; $polygon should contain at least 3 GeoPoints' + ); + } + points = polygon; + } else { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + "bad $geoWithin value; $polygon should be Polygon object or Array of Parse.GeoPoint's" + ); + } + points = points + .map(point => { + if (Array.isArray(point) && point.length === 2) { + Parse.GeoPoint._validate(point[1], point[0]); + return `(${point[0]}, ${point[1]})`; + } + if (typeof point !== 'object' || point.__type !== 'GeoPoint') { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad $geoWithin value'); + } else { + Parse.GeoPoint._validate(point.latitude, point.longitude); + } + return `(${point.longitude}, ${point.latitude})`; + }) + .join(', '); + + patterns.push(`$${index}:name::point <@ $${index + 1}::polygon`); + values.push(fieldName, `(${points})`); + index += 2; + } + if (fieldValue.$geoIntersects && fieldValue.$geoIntersects.$point) { + const point = fieldValue.$geoIntersects.$point; + if (typeof point !== 'object' || point.__type !== 'GeoPoint') { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad $geoIntersect value; $point should be GeoPoint' + ); + } else { + Parse.GeoPoint._validate(point.latitude, point.longitude); + } + patterns.push(`$${index}:name::polygon @> $${index + 1}::point`); + values.push(fieldName, `(${point.longitude}, ${point.latitude})`); + index += 2; + } + + if (fieldValue.$regex) { + let regex = fieldValue.$regex; + let operator = '~'; + const opts = fieldValue.$options; + if (opts) { + if (opts.indexOf('i') >= 0) { + operator = '~*'; + } + if (opts.indexOf('x') >= 0) { + regex = removeWhiteSpace(regex); + } + } + + regex = processRegexPattern(regex); + + if (fieldName.indexOf('.') >= 0) { + const name = transformDotField(fieldName); + patterns.push(`$${index}:raw ${operator} '$${index + 1}:raw'`); + values.push(name, regex); + } else { + patterns.push(`$${index}:name ${operator} '$${index + 1}:raw'`); + values.push(fieldName, regex); + } + index += 2; + } + + if (fieldValue.__type === 'Pointer') { + if (isArrayField) { + patterns.push(`array_contains($${index}:name, $${index + 1})`); + values.push(fieldName, JSON.stringify([fieldValue])); + index += 2; + } else { + patterns.push(`$${index}:name = $${index + 1}`); + values.push(fieldName, fieldValue.objectId); + index += 2; + } + } + + if (fieldValue.__type === 'Date') { patterns.push(`$${index}:name = $${index + 1}`); - values.push(fieldName, fieldValue.objectId); + values.push(fieldName, fieldValue.iso); index += 2; - } else { - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, `Postgres doesn't support this query type yet`); + } + + if (fieldValue.__type === 'GeoPoint') { + patterns.push(`$${index}:name ~= POINT($${index + 1}, $${index + 2})`); + values.push(fieldName, fieldValue.longitude, fieldValue.latitude); + index += 3; + } + + if (fieldValue.__type === 'Polygon') { + const value = convertPolygonToSQL(fieldValue.coordinates); + patterns.push(`$${index}:name ~= $${index + 1}::polygon`); + values.push(fieldName, value); + index += 2; + } + + Object.keys(ParseToPosgresComparator).forEach(cmp => { + if (fieldValue[cmp] || fieldValue[cmp] === 0) { + const pgComparator = ParseToPosgresComparator[cmp]; + let constraintFieldName; + let postgresValue = toPostgresValue(fieldValue[cmp]); + + if (fieldName.indexOf('.') >= 0) { + const castType = toPostgresValueCastType(fieldValue[cmp]); + constraintFieldName = castType + ? `CAST ((${transformDotField(fieldName)}) AS ${castType})` + : transformDotField(fieldName); + } else { + if (typeof postgresValue === 'object' && postgresValue.$relativeTime) { + if (schema.fields[fieldName].type !== 'Date') { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + '$relativeTime can only be used with Date field' + ); + } + const parserResult = Utils.relativeTimeToDate(postgresValue.$relativeTime); + if (parserResult.status === 'success') { + postgresValue = toPostgresValue(parserResult.result); + } else { + // eslint-disable-next-line no-console + console.error('Error while parsing relative date', parserResult); + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bad $relativeTime (${postgresValue.$relativeTime}) value. ${parserResult.info}` + ); + } + } + constraintFieldName = `$${index++}:name`; + values.push(fieldName); + } + values.push(postgresValue); + patterns.push(`${constraintFieldName} ${pgComparator} $${index++}`); + } + }); + + if (initialPatternsLength === patterns.length) { + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + `Postgres doesn't support this query type yet ${JSON.stringify(fieldValue)}` + ); } } - return { pattern: patterns.join(' AND '), values }; -} + values = values.map(transformValue); + return { pattern: patterns.join(' AND '), values, sorts }; +}; + +export class PostgresStorageAdapter implements StorageAdapter { + canSortOnJoinTables: boolean; + enableSchemaHooks: boolean; -export class PostgresStorageAdapter { // Private _collectionPrefix: string; - _client; + _client: any; + _onchange: any; + _pgp: any; + _stream: any; + _uuid: any; + schemaCacheTtl: ?number; + disableIndexFieldValidation: boolean; - constructor({ - uri, - collectionPrefix = '', - }) { + constructor({ uri, collectionPrefix = '', databaseOptions = {} }: any) { + const options = { ...databaseOptions }; this._collectionPrefix = collectionPrefix; - this._client = pgp(uri); + this.enableSchemaHooks = !!databaseOptions.enableSchemaHooks; + this.disableIndexFieldValidation = !!databaseOptions.disableIndexFieldValidation; + + this.schemaCacheTtl = databaseOptions.schemaCacheTtl; + for (const key of ['enableSchemaHooks', 'schemaCacheTtl', 'disableIndexFieldValidation']) { + delete options[key]; + } + + const { client, pgp } = createClient(uri, options); + this._client = client; + this._onchange = () => { }; + this._pgp = pgp; + this._uuid = randomUUID(); + this.canSortOnJoinTables = false; } - _ensureSchemaCollectionExists() { - return this._client.query('CREATE TABLE "_SCHEMA" ( "className" varChar(120), "schema" jsonb, "isParseClass" bool, PRIMARY KEY ("className") )') - .catch(error => { - if (error.code === PostgresDuplicateRelationError) { - // Table already exists, must have been created by a different request. Ignore error. - } else { + watch(callback: () => void): void { + this._onchange = callback; + } + + //Note that analyze=true will run the query, executing INSERTS, DELETES, etc. + createExplainableQuery(query: string, analyze: boolean = false) { + if (analyze) { + return 'EXPLAIN (ANALYZE, FORMAT JSON) ' + query; + } else { + return 'EXPLAIN (FORMAT JSON) ' + query; + } + } + + handleShutdown() { + if (this._stream) { + this._stream.done(); + delete this._stream; + } + if (!this._client) { + return; + } + this._client.$pool.end(); + } + + async _listenToSchema() { + if (!this._stream && this.enableSchemaHooks) { + this._stream = await this._client.connect({ direct: true }); + this._stream.client.on('notification', data => { + const payload = JSON.parse(data.payload); + if (payload.senderId !== this._uuid) { + this._onchange(); + } + }); + await this._stream.none('LISTEN $1~', 'schema.change'); + } + } + + _notifySchemaChange() { + if (this._stream) { + this._stream + .none('NOTIFY $1~, $2', ['schema.change', { senderId: this._uuid }]) + .catch(error => { + // eslint-disable-next-line no-console + console.log('Failed to Notify:', error); // unlikely to ever happen + }); + } + } + + async _ensureSchemaCollectionExists(conn: any) { + conn = conn || this._client; + await conn + .none( + 'CREATE TABLE IF NOT EXISTS "_SCHEMA" ( "className" varChar(120), "schema" jsonb, "isParseClass" bool, PRIMARY KEY ("className") )' + ) + .catch(error => { throw error; - } + }); + } + + async classExists(name: string) { + return this._client.one( + 'SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = $1)', + [name], + a => a.exists + ); + } + + async setClassLevelPermissions(className: string, CLPs: any) { + await this._client.task('set-class-level-permissions', async t => { + const values = [className, 'schema', 'classLevelPermissions', JSON.stringify(CLPs)]; + await t.none( + `UPDATE "_SCHEMA" SET $2:name = json_object_set_key($2:name, $3::text, $4::jsonb) WHERE "className" = $1`, + values + ); }); - }; + this._notifySchemaChange(); + } - classExists(name) { - return notImplemented(); + async setIndexesWithSchemaFormat( + className: string, + submittedIndexes: any, + existingIndexes: any = {}, + fields: any, + conn: ?any + ): Promise { + conn = conn || this._client; + const self = this; + if (submittedIndexes === undefined) { + return Promise.resolve(); + } + if (Object.keys(existingIndexes).length === 0) { + existingIndexes = { _id_: { _id: 1 } }; + } + const deletedIndexes = []; + const insertedIndexes = []; + Object.keys(submittedIndexes).forEach(name => { + const field = submittedIndexes[name]; + if (existingIndexes[name] && field.__op !== 'Delete') { + throw new Parse.Error(Parse.Error.INVALID_QUERY, `Index ${name} exists, cannot update.`); + } + if (!existingIndexes[name] && field.__op === 'Delete') { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Index ${name} does not exist, cannot delete.` + ); + } + if (field.__op === 'Delete') { + deletedIndexes.push(name); + delete existingIndexes[name]; + } else { + Object.keys(field).forEach(key => { + if ( + !this.disableIndexFieldValidation && + !Object.prototype.hasOwnProperty.call(fields, key) + ) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Field ${key} does not exist, cannot add index.` + ); + } + }); + existingIndexes[name] = field; + insertedIndexes.push({ + key: field, + name, + }); + } + }); + await conn.tx('set-indexes-with-schema-format', async t => { + try { + if (insertedIndexes.length > 0) { + await self.createIndexes(className, insertedIndexes, t); + } + } catch (e) { + // pg-promise use Batch error see https://github.com/vitaly-t/spex/blob/e572030f261be1a8e9341fc6f637e36ad07f5231/src/errors/batch.js#L59 + const columnDoesNotExistError = e.getErrors && e.getErrors()[0] && e.getErrors()[0].code === '42703'; + // Specific case when the column does not exist + if (columnDoesNotExistError) { + // If the disableIndexFieldValidation is true, we should ignore the error + if (!this.disableIndexFieldValidation) { + throw e; + } + } else { + throw e; + } + } + if (deletedIndexes.length > 0) { + await self.dropIndexes(className, deletedIndexes, t); + } + await t.none( + 'UPDATE "_SCHEMA" SET $2:name = json_object_set_key($2:name, $3::text, $4::jsonb) WHERE "className" = $1', + [className, 'schema', 'indexes', JSON.stringify(existingIndexes)] + ); + }); + this._notifySchemaChange(); } - setClassLevelPermissions(className, CLPs) { - return notImplemented(); + async createClass(className: string, schema: SchemaType, conn: ?any) { + conn = conn || this._client; + const parseSchema = await conn + .tx('create-class', async t => { + await this.createTable(className, schema, t); + await t.none( + 'INSERT INTO "_SCHEMA" ("className", "schema", "isParseClass") VALUES ($, $, true)', + { className, schema } + ); + await this.setIndexesWithSchemaFormat(className, schema.indexes, {}, schema.fields, t); + return toParseSchema(schema); + }) + .catch(err => { + if (err.code === PostgresUniqueIndexViolationError && err.detail.includes(className)) { + throw new Parse.Error(Parse.Error.DUPLICATE_VALUE, `Class ${className} already exists.`); + } + throw err; + }); + this._notifySchemaChange(); + return parseSchema; } - createClass(className, schema) { - let valuesArray = []; - let patternsArray = []; - Object.keys(schema.fields).forEach((fieldName, index) => { - valuesArray.push(fieldName); - let parseType = schema.fields[fieldName]; - if (['_rperm', '_wperm'].includes(fieldName)) { + // Just create a table, do not insert in schema + async createTable(className: string, schema: SchemaType, conn: any) { + conn = conn || this._client; + debug('createTable'); + const valuesArray = []; + const patternsArray = []; + const fields = Object.assign({}, schema.fields); + if (className === '_User') { + fields._email_verify_token_expires_at = { type: 'Date' }; + fields._email_verify_token = { type: 'String' }; + fields._account_lockout_expires_at = { type: 'Date' }; + fields._failed_login_count = { type: 'Number' }; + fields._perishable_token = { type: 'String' }; + fields._perishable_token_expires_at = { type: 'Date' }; + fields._password_changed_at = { type: 'Date' }; + fields._password_history = { type: 'Array' }; + } + let index = 2; + const relations = []; + Object.keys(fields).forEach(fieldName => { + const parseType = fields[fieldName]; + // Skip when it's a relation + // We'll create the tables later + if (parseType.type === 'Relation') { + relations.push(fieldName); + return; + } + if (['_rperm', '_wperm'].indexOf(fieldName) >= 0) { parseType.contents = { type: 'String' }; } + valuesArray.push(fieldName); valuesArray.push(parseTypeToPostgresType(parseType)); - patternsArray.push(`$${index * 2 + 2}:name $${index * 2 + 3}:raw`); + patternsArray.push(`$${index}:name $${index + 1}:raw`); + if (fieldName === 'objectId') { + patternsArray.push(`PRIMARY KEY ($${index}:name)`); + } + index = index + 2; }); - return this._ensureSchemaCollectionExists() - .then(() => this._client.query(`CREATE TABLE $1:name (${patternsArray.join(',')})`, [className, ...valuesArray])) - .catch(error => { - if (error.code === PostgresDuplicateRelationError) { - // Table already exists, must have been created by a different request. Ignore error. - } else { - throw error; + const qs = `CREATE TABLE IF NOT EXISTS $1:name (${patternsArray.join()})`; + const values = [className, ...valuesArray]; + + return conn.task('create-table', async t => { + try { + await t.none(qs, values); + } catch (error) { + if (error.code !== PostgresDuplicateRelationError) { + throw error; + } + // ELSE: Table already exists, must have been created by a different request. Ignore the error. } - }) - .then(() => this._client.query('INSERT INTO "_SCHEMA" ("className", "schema", "isParseClass") VALUES ($, $, true)', { className, schema })) - .then(() => schema); + await t.tx('create-table-tx', tx => { + return tx.batch( + relations.map(fieldName => { + return tx.none( + 'CREATE TABLE IF NOT EXISTS $ ("relatedId" varChar(120), "owningId" varChar(120), PRIMARY KEY("relatedId", "owningId") )', + { joinTable: `_Join:${fieldName}:${className}` } + ); + }) + ); + }); + }); + } + + async schemaUpgrade(className: string, schema: SchemaType, conn: any) { + debug('schemaUpgrade'); + conn = conn || this._client; + const self = this; + + await conn.task('schema-upgrade', async t => { + const columns = await t.map( + 'SELECT column_name FROM information_schema.columns WHERE table_name = $', + { className }, + a => a.column_name + ); + const newColumns = Object.keys(schema.fields) + .filter(item => columns.indexOf(item) === -1) + .map(fieldName => self.addFieldIfNotExists(className, fieldName, schema.fields[fieldName])); + + await t.batch(newColumns); + }); } - addFieldIfNotExists(className, fieldName, type) { + async addFieldIfNotExists(className: string, fieldName: string, type: any) { // TODO: Must be revised for invalid logic... - return this._client.tx("addFieldIfNotExists", t=> { - return t.query('ALTER TABLE $ ADD COLUMN $ $', { - className, - fieldName, - postgresType: parseTypeToPostgresType(type) - }) - .catch(error => { + debug('addFieldIfNotExists'); + const self = this; + await this._client.tx('add-field-if-not-exists', async t => { + if (type.type !== 'Relation') { + try { + await t.none( + 'ALTER TABLE $ ADD COLUMN IF NOT EXISTS $ $', + { + className, + fieldName, + postgresType: parseTypeToPostgresType(type), + } + ); + } catch (error) { if (error.code === PostgresRelationDoesNotExistError) { - return this.createClass(className, {fields: {[fieldName]: type}}) - } else if (error.code === PostgresDuplicateColumnError) { - // Column already exists, created by other request. Carry on to - // See if it's the right type. - } else { - throw error; + return self.createClass(className, { fields: { [fieldName]: type } }, t); } - }) - .then(() => t.query('SELECT "schema" FROM "_SCHEMA" WHERE "className" = $', {className})) - .then(result => { - if (fieldName in result[0].schema) { - throw "Attempted to add a field that already exists"; - } else { - result[0].schema.fields[fieldName] = type; - return t.query( - 'UPDATE "_SCHEMA" SET "schema"=$ WHERE "className"=$', - {schema: result[0].schema, className} - ); + if (error.code !== PostgresDuplicateColumnError) { + throw error; } - }) + // Column already exists, created by other request. Carry on to see if it's the right type. + } + } else { + await t.none( + 'CREATE TABLE IF NOT EXISTS $ ("relatedId" varChar(120), "owningId" varChar(120), PRIMARY KEY("relatedId", "owningId") )', + { joinTable: `_Join:${fieldName}:${className}` } + ); + } + + const result = await t.any( + 'SELECT "schema" FROM "_SCHEMA" WHERE "className" = $ and ("schema"::json->\'fields\'->$) is not null', + { className, fieldName } + ); + + if (result[0]) { + throw 'Attempted to add a field that already exists'; + } else { + const path = `{fields,${fieldName}}`; + await t.none( + 'UPDATE "_SCHEMA" SET "schema"=jsonb_set("schema", $, $) WHERE "className"=$', + { path, type, className } + ); + } + }); + this._notifySchemaChange(); + } + + async updateFieldOptions(className: string, fieldName: string, type: any) { + await this._client.tx('update-schema-field-options', async t => { + const path = `{fields,${fieldName}}`; + await t.none( + 'UPDATE "_SCHEMA" SET "schema"=jsonb_set("schema", $, $) WHERE "className"=$', + { path, type, className } + ); }); } // Drops a collection. Resolves with true if it was a Parse Schema (eg. _User, Custom, etc.) // and resolves with false if it wasn't (eg. a join table). Rejects if deletion was impossible. - deleteClass(className) { - return notImplemented(); + async deleteClass(className: string) { + const operations = [ + { query: `DROP TABLE IF EXISTS $1:name`, values: [className] }, + { + query: `DELETE FROM "_SCHEMA" WHERE "className" = $1`, + values: [className], + }, + ]; + const response = await this._client + .tx(t => t.none(this._pgp.helpers.concat(operations))) + .then(() => className.indexOf('_Join:') != 0); // resolves with false when _Join table + + this._notifySchemaChange(); + return response; } // Delete all data known to this adapter. Used for testing. - deleteAllClasses() { - return this._client.query('SELECT "className" FROM "_SCHEMA"') - .then(results => { - const classes = ['_SCHEMA', ...results.map(result => result.className)]; - return this._client.tx(t=>t.batch(classes.map(className=>t.none('DROP TABLE $', { className })))); - }, error => { - if (error.code === PostgresRelationDoesNotExistError) { - // No _SCHEMA collection. Don't delete anything. - return; - } else { - throw error; - } - }) + async deleteAllClasses() { + const now = new Date().getTime(); + const helpers = this._pgp.helpers; + debug('deleteAllClasses'); + if (this._client?.$pool.ended) { + return; + } + await this._client + .task('delete-all-classes', async t => { + try { + const results = await t.any('SELECT * FROM "_SCHEMA"'); + const joins = results.reduce((list: Array, schema: any) => { + return list.concat(joinTablesForSchema(schema.schema)); + }, []); + const classes = [ + '_SCHEMA', + '_PushStatus', + '_JobStatus', + '_JobSchedule', + '_Hooks', + '_GlobalConfig', + '_GraphQLConfig', + '_Audience', + '_Idempotency', + ...results.map(result => result.className), + ...joins, + ]; + const queries = classes.map(className => ({ + query: 'DROP TABLE IF EXISTS $', + values: { className }, + })); + await t.tx(tx => tx.none(helpers.concat(queries))); + } catch (error) { + if (error.code !== PostgresRelationDoesNotExistError) { + throw error; + } + // No _SCHEMA collection. Don't delete anything. + } + }) + .then(() => { + debug(`deleteAllClasses done in ${new Date().getTime() - now}`); + }); } // Remove the column and all the data. For Relations, the _Join collection is handled @@ -204,196 +1315,792 @@ export class PostgresStorageAdapter { // may do so. // Returns a Promise. - deleteFields(className, schema, fieldNames) { - return notImplemented(); + async deleteFields(className: string, schema: SchemaType, fieldNames: string[]): Promise { + debug('deleteFields'); + fieldNames = fieldNames.reduce((list: Array, fieldName: string) => { + const field = schema.fields[fieldName]; + if (field.type !== 'Relation') { + list.push(fieldName); + } + delete schema.fields[fieldName]; + return list; + }, []); + + const values = [className, ...fieldNames]; + const columns = fieldNames + .map((name, idx) => { + return `$${idx + 2}:name`; + }) + .join(', DROP COLUMN'); + + await this._client.tx('delete-fields', async t => { + await t.none('UPDATE "_SCHEMA" SET "schema" = $ WHERE "className" = $', { + schema, + className, + }); + if (values.length > 1) { + await t.none(`ALTER TABLE $1:name DROP COLUMN IF EXISTS ${columns}`, values); + } + }); + this._notifySchemaChange(); } // Return a promise for all schemas known to this adapter, in Parse format. In case the // schemas cannot be retrieved, returns a promise that rejects. Requirements for the // rejection reason are TBD. - getAllClasses() { - return this._ensureSchemaCollectionExists() - .then(() => this._client.map('SELECT * FROM "_SCHEMA"', null, row => ({ className: row.className, ...row.schema }))); + async getAllClasses() { + return this._client.task('get-all-classes', async t => { + return await t.map('SELECT * FROM "_SCHEMA"', null, row => + toParseSchema({ className: row.className, ...row.schema }) + ); + }); } // Return a promise for the schema with the given name, in Parse format. If // this adapter doesn't know about the schema, return a promise that rejects with // undefined as the reason. - getClass(className) { - return this._client.query('SELECT * FROM "_SCHEMA" WHERE "className"=$', { className }) - .then(result => { - if (result.length === 1) { + async getClass(className: string) { + debug('getClass'); + return this._client + .any('SELECT * FROM "_SCHEMA" WHERE "className" = $', { + className, + }) + .then(result => { + if (result.length !== 1) { + throw undefined; + } return result[0].schema; - } else { - throw undefined; - } - }); + }) + .then(toParseSchema); } // TODO: remove the mongo format dependency in the return value - createObject(className, schema, object) { + async createObject( + className: string, + schema: SchemaType, + object: any, + transactionalSession: ?any + ) { + debug('createObject'); let columnsArray = []; - let valuesArray = []; + const valuesArray = []; + schema = toPostgresSchema(schema); + const geoPoints = {}; + + object = handleDotFields(object); + + validateKeys(object); + Object.keys(object).forEach(fieldName => { + if (object[fieldName] === null) { + return; + } + var authDataMatch = fieldName.match(/^_auth_data_([a-zA-Z0-9_]+)$/); + const authDataAlreadyExists = !!object.authData; + if (authDataMatch) { + var provider = authDataMatch[1]; + object['authData'] = object['authData'] || {}; + object['authData'][provider] = object[fieldName]; + delete object[fieldName]; + fieldName = 'authData'; + // Avoid adding authData multiple times to the query + if (authDataAlreadyExists) { + return; + } + } + columnsArray.push(fieldName); + if (!schema.fields[fieldName] && className === '_User') { + if ( + fieldName === '_email_verify_token' || + fieldName === '_failed_login_count' || + fieldName === '_perishable_token' || + fieldName === '_password_history' + ) { + valuesArray.push(object[fieldName]); + } + + if (fieldName === '_email_verify_token_expires_at') { + if (object[fieldName]) { + valuesArray.push(object[fieldName].iso); + } else { + valuesArray.push(null); + } + } + + if ( + fieldName === '_account_lockout_expires_at' || + fieldName === '_perishable_token_expires_at' || + fieldName === '_password_changed_at' + ) { + if (object[fieldName]) { + valuesArray.push(object[fieldName].iso); + } else { + valuesArray.push(null); + } + } + return; + } switch (schema.fields[fieldName].type) { case 'Date': - valuesArray.push(object[fieldName].iso); + if (object[fieldName]) { + valuesArray.push(object[fieldName].iso); + } else { + valuesArray.push(null); + } break; case 'Pointer': valuesArray.push(object[fieldName].objectId); break; case 'Array': - if (['_rperm', '_wperm'].includes(fieldName)) { + if (['_rperm', '_wperm'].indexOf(fieldName) >= 0) { valuesArray.push(object[fieldName]); } else { valuesArray.push(JSON.stringify(object[fieldName])); } break; case 'Object': - valuesArray.push(object[fieldName]); - break; + case 'Bytes': case 'String': - valuesArray.push(object[fieldName]); - break; case 'Number': - valuesArray.push(object[fieldName]); - break; case 'Boolean': valuesArray.push(object[fieldName]); break; + case 'File': + valuesArray.push(object[fieldName].name); + break; + case 'Polygon': { + const value = convertPolygonToSQL(object[fieldName].coordinates); + valuesArray.push(value); + break; + } + case 'GeoPoint': + // pop the point and process later + geoPoints[fieldName] = object[fieldName]; + columnsArray.pop(); + break; default: throw `Type ${schema.fields[fieldName].type} not supported yet`; - break; } }); - let columnsPattern = columnsArray.map((col, index) => `$${index + 2}:name`).join(','); - let valuesPattern = valuesArray.map((val, index) => `$${index + 2 + columnsArray.length}${(['_rperm','_wperm'].includes(columnsArray[index])) ? '::text[]' : ''}`).join(','); - let qs = `INSERT INTO $1:name (${columnsPattern}) VALUES (${valuesPattern})` - let values = [className, ...columnsArray, ...valuesArray] - return this._client.query(qs, values) - .then(() => ({ ops: [object] })) - .catch(error => { - if (error.code === PostgresUniqueIndexViolationError) { - throw new Parse.Error(Parse.Error.DUPLICATE_VALUE, 'A duplicate value for a field with unique values was provided'); - } else { - throw error; + + columnsArray = columnsArray.concat(Object.keys(geoPoints)); + const initialValues = valuesArray.map((val, index) => { + let termination = ''; + const fieldName = columnsArray[index]; + if (['_rperm', '_wperm'].indexOf(fieldName) >= 0) { + termination = '::text[]'; + } else if (schema.fields[fieldName] && schema.fields[fieldName].type === 'Array') { + termination = '::jsonb'; } - }) + return `$${index + 2 + columnsArray.length}${termination}`; + }); + const geoPointsInjects = Object.keys(geoPoints).map(key => { + const value = geoPoints[key]; + valuesArray.push(value.longitude, value.latitude); + const l = valuesArray.length + columnsArray.length; + return `POINT($${l}, $${l + 1})`; + }); + + const columnsPattern = columnsArray.map((col, index) => `$${index + 2}:name`).join(); + const valuesPattern = initialValues.concat(geoPointsInjects).join(); + + const qs = `INSERT INTO $1:name (${columnsPattern}) VALUES (${valuesPattern})`; + const values = [className, ...columnsArray, ...valuesArray]; + const promise = (transactionalSession ? transactionalSession.t : this._client) + .none(qs, values) + .then(() => ({ ops: [object] })) + .catch(error => { + if (error.code === PostgresUniqueIndexViolationError) { + const err = new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'A duplicate value for a field with unique values was provided' + ); + err.underlyingError = error; + if (error.constraint) { + // Check for authData unique index violations first + const authDataMatch = error.constraint.match(/_User_unique_authData_([a-zA-Z0-9_]+)_id/); + if (authDataMatch) { + err.userInfo = { duplicated_field: `_auth_data_${authDataMatch[1]}` }; + } else { + const matches = error.constraint.match(/unique_([a-zA-Z]+)/); + if (matches && Array.isArray(matches)) { + err.userInfo = { duplicated_field: matches[1] }; + } + } + } + error = err; + } + throw error; + }); + if (transactionalSession) { + transactionalSession.batch.push(promise); + } + return promise; } // Remove all objects that match the given Parse Query. // If no objects match, reject with OBJECT_NOT_FOUND. If objects are found and deleted, resolve with undefined. // If there is some other error, reject with INTERNAL_SERVER_ERROR. - deleteObjectsByQuery(className, schema, query) { - return this._client.one(`WITH deleted AS (DELETE FROM $ RETURNING *) SELECT count(*) FROM deleted`, { className }, res=>parseInt(res.count)) - .then(count => { - if (count === 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); - } else { - return count; - } + async deleteObjectsByQuery( + className: string, + schema: SchemaType, + query: QueryType, + transactionalSession: ?any + ) { + debug('deleteObjectsByQuery'); + const values = [className]; + const index = 2; + const where = buildWhereClause({ + schema, + index, + query, + caseInsensitive: false, }); + values.push(...where.values); + if (Object.keys(query).length === 0) { + where.pattern = 'TRUE'; + } + const qs = `WITH deleted AS (DELETE FROM $1:name WHERE ${where.pattern} RETURNING *) SELECT count(*) FROM deleted`; + const promise = (transactionalSession ? transactionalSession.t : this._client) + .one(qs, values, a => +a.count) + .then(count => { + if (count === 0) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); + } else { + return count; + } + }) + .catch(error => { + if (error.code !== PostgresRelationDoesNotExistError) { + throw error; + } + // ELSE: Don't delete anything if doesn't exist + }); + if (transactionalSession) { + transactionalSession.batch.push(promise); + } + return promise; } - - // Apply the update to all objects that match the given Parse Query. - updateObjectsByQuery(className, schema, query, update) { - return notImplemented(); + // Return value not currently well specified. + async findOneAndUpdate( + className: string, + schema: SchemaType, + query: QueryType, + update: any, + transactionalSession: ?any + ): Promise { + debug('findOneAndUpdate'); + return this.updateObjectsByQuery(className, schema, query, update, transactionalSession).then( + val => val[0] + ); } - // Return value not currently well specified. - findOneAndUpdate(className, schema, query, update) { - let conditionPatterns = []; - let updatePatterns = []; - let values = [className] + // Apply the update to all objects that match the given Parse Query. + async updateObjectsByQuery( + className: string, + schema: SchemaType, + query: QueryType, + update: any, + transactionalSession: ?any + ): Promise<[any]> { + debug('updateObjectsByQuery'); + const updatePatterns = []; + const values = [className]; let index = 2; + schema = toPostgresSchema(schema); + + const originalUpdate = { ...update }; + + // Set flag for dot notation fields + const dotNotationOptions = {}; + Object.keys(update).forEach(fieldName => { + if (fieldName.indexOf('.') > -1) { + const components = fieldName.split('.'); + const first = components.shift(); + dotNotationOptions[first] = true; + } else { + dotNotationOptions[fieldName] = false; + } + }); + update = handleDotFields(update); + // Resolve authData first, + // So we don't end up with multiple key updates + for (const fieldName in update) { + const authDataMatch = fieldName.match(/^_auth_data_([a-zA-Z0-9_]+)$/); + if (authDataMatch) { + var provider = authDataMatch[1]; + const value = update[fieldName]; + delete update[fieldName]; + update['authData'] = update['authData'] || {}; + update['authData'][provider] = value; + } + } - for (let fieldName in update) { - let fieldValue = update[fieldName]; - if (fieldValue.__op === 'Increment') { + for (const fieldName in update) { + const fieldValue = update[fieldName]; + // Drop any undefined values. + if (typeof fieldValue === 'undefined') { + delete update[fieldName]; + } else if (fieldValue === null) { + updatePatterns.push(`$${index}:name = NULL`); + values.push(fieldName); + index += 1; + } else if (fieldName == 'authData') { + // This recursively sets the json_object + // Only 1 level deep + const generate = (jsonb: string, key: string, value: any) => { + return `json_object_set_key(COALESCE(${jsonb}, '{}'::jsonb), ${key}, ${value})::jsonb`; + }; + const generateRemove = (jsonb: string, key: string) => { + return `(COALESCE(${jsonb}, '{}'::jsonb) - ${key})`; + }; + const lastKey = `$${index}:name`; + const fieldNameIndex = index; + index += 1; + values.push(fieldName); + const update = Object.keys(fieldValue).reduce((lastKey: string, key: string) => { + let value = fieldValue[key]; + if (value && value.__op === 'Delete') { + value = null; + } + if (value === null) { + const str = generateRemove(lastKey, `$${index}::text`); + values.push(key); + index += 1; + return str; + } + const str = generate(lastKey, `$${index}::text`, `$${index + 1}::jsonb`); + index += 2; + if (value) { + value = JSON.stringify(value); + } + values.push(key, value); + return str; + }, lastKey); + updatePatterns.push(`$${fieldNameIndex}:name = ${update}`); + } else if (fieldValue.__op === 'Increment') { updatePatterns.push(`$${index}:name = COALESCE($${index}:name, 0) + $${index + 1}`); values.push(fieldName, fieldValue.amount); index += 2; } else if (fieldValue.__op === 'Add') { - updatePatterns.push(`$${index}:name = COALESCE($${index}:name, '[]'::jsonb) || $${index + 1}`); - values.push(fieldName, fieldValue.objects); + updatePatterns.push( + `$${index}:name = array_add(COALESCE($${index}:name, '[]'::jsonb), $${index + 1}::jsonb)` + ); + values.push(fieldName, JSON.stringify(fieldValue.objects)); + index += 2; + } else if (fieldValue.__op === 'Delete') { + updatePatterns.push(`$${index}:name = $${index + 1}`); + values.push(fieldName, null); index += 2; } else if (fieldValue.__op === 'Remove') { - return Promise.reject(new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Postgres does not support Remove operator.')); + updatePatterns.push( + `$${index}:name = array_remove(COALESCE($${index}:name, '[]'::jsonb), $${index + 1 + }::jsonb)` + ); + values.push(fieldName, JSON.stringify(fieldValue.objects)); + index += 2; } else if (fieldValue.__op === 'AddUnique') { - return Promise.reject(new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Postgres does not support AddUnique operator')); - } else if (fieldName === 'updatedAt') { //TODO: stop special casing this. It should check for __type === 'Date' and use .iso - updatePatterns.push(`$${index}:name = $${index + 1}`) - values.push(fieldName, new Date(fieldValue)); + updatePatterns.push( + `$${index}:name = array_add_unique(COALESCE($${index}:name, '[]'::jsonb), $${index + 1 + }::jsonb)` + ); + values.push(fieldName, JSON.stringify(fieldValue.objects)); + index += 2; + } else if (fieldName === 'updatedAt') { + //TODO: stop special casing this. It should check for __type === 'Date' and use .iso + updatePatterns.push(`$${index}:name = $${index + 1}`); + values.push(fieldName, fieldValue); index += 2; } else if (typeof fieldValue === 'string') { updatePatterns.push(`$${index}:name = $${index + 1}`); values.push(fieldName, fieldValue); index += 2; + } else if (typeof fieldValue === 'boolean') { + updatePatterns.push(`$${index}:name = $${index + 1}`); + values.push(fieldName, fieldValue); + index += 2; } else if (fieldValue.__type === 'Pointer') { updatePatterns.push(`$${index}:name = $${index + 1}`); values.push(fieldName, fieldValue.objectId); index += 2; + } else if (fieldValue.__type === 'Date') { + updatePatterns.push(`$${index}:name = $${index + 1}`); + values.push(fieldName, toPostgresValue(fieldValue)); + index += 2; + } else if (Utils.isDate(fieldValue)) { + updatePatterns.push(`$${index}:name = $${index + 1}`); + values.push(fieldName, fieldValue); + index += 2; + } else if (fieldValue.__type === 'File') { + updatePatterns.push(`$${index}:name = $${index + 1}`); + values.push(fieldName, toPostgresValue(fieldValue)); + index += 2; + } else if (fieldValue.__type === 'GeoPoint') { + updatePatterns.push(`$${index}:name = POINT($${index + 1}, $${index + 2})`); + values.push(fieldName, fieldValue.longitude, fieldValue.latitude); + index += 3; + } else if (fieldValue.__type === 'Polygon') { + const value = convertPolygonToSQL(fieldValue.coordinates); + updatePatterns.push(`$${index}:name = $${index + 1}::polygon`); + values.push(fieldName, value); + index += 2; + } else if (fieldValue.__type === 'Relation') { + // noop + } else if (typeof fieldValue === 'number') { + updatePatterns.push(`$${index}:name = $${index + 1}`); + values.push(fieldName, fieldValue); + index += 2; + } else if ( + typeof fieldValue === 'object' && + schema.fields[fieldName] && + schema.fields[fieldName].type === 'Object' + ) { + // Gather keys to increment + const keysToIncrement = Object.keys(originalUpdate) + .filter(k => { + // choose top level fields that have a delete operation set + // Note that Object.keys is iterating over the **original** update object + // and that some of the keys of the original update could be null or undefined: + // (See the above check `if (fieldValue === null || typeof fieldValue == "undefined")`) + const value = originalUpdate[k]; + return ( + value && + value.__op === 'Increment' && + k.split('.').length === 2 && + k.split('.')[0] === fieldName + ); + }) + .map(k => k.split('.')[1]); + + let incrementPatterns = ''; + const incrementValues = []; + if (keysToIncrement.length > 0) { + incrementPatterns = + ' || ' + + keysToIncrement + .map(c => { + const amount = fieldValue[c].amount; + if (typeof amount !== 'number') { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'incrementing must provide a number'); + } + incrementValues.push(amount); + const amountIndex = index + incrementValues.length; + const jsonSafeName = escapeSqlString(escapeJsonString(c)); + const sqlSafeName = escapeSqlString(c); + return `CONCAT('{"${jsonSafeName}":', COALESCE($${index}:name->>'${sqlSafeName}','0')::int + $${amountIndex}, '}')::jsonb`; + }) + .join(' || '); + // Strip the keys + keysToIncrement.forEach(key => { + delete fieldValue[key]; + }); + } + + const keysToDelete: Array = Object.keys(originalUpdate) + .filter(k => { + // choose top level fields that have a delete operation set. + const value = originalUpdate[k]; + return ( + value && + value.__op === 'Delete' && + k.split('.').length === 2 && + k.split('.')[0] === fieldName + ); + }) + .map(k => k.split('.')[1]); + + const deletePatterns = keysToDelete.reduce((p: string, c: string, i: number) => { + return p + ` - '$${index + 1 + incrementValues.length + i}:value'`; + }, ''); + // Override Object + let updateObject = "'{}'::jsonb"; + + if (dotNotationOptions[fieldName]) { + // Merge Object + updateObject = `COALESCE($${index}:name, '{}'::jsonb)`; + } + updatePatterns.push( + `$${index}:name = (${updateObject} ${deletePatterns} ${incrementPatterns} || $${index + 1 + incrementValues.length + keysToDelete.length + }::jsonb )` + ); + values.push(fieldName, ...incrementValues, ...keysToDelete, JSON.stringify(fieldValue)); + index += 2 + incrementValues.length + keysToDelete.length; + } else if ( + Array.isArray(fieldValue) && + schema.fields[fieldName] && + schema.fields[fieldName].type === 'Array' + ) { + const expectedType = parseTypeToPostgresType(schema.fields[fieldName]); + if (expectedType === 'text[]') { + updatePatterns.push(`$${index}:name = $${index + 1}::text[]`); + values.push(fieldName, fieldValue); + index += 2; + } else { + updatePatterns.push(`$${index}:name = $${index + 1}::jsonb`); + values.push(fieldName, JSON.stringify(fieldValue)); + index += 2; + } } else { - return Promise.reject(new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, `Postgres doesn't support update ${JSON.stringify(fieldValue)} yet`)); + debug('Not supported update', { fieldName, fieldValue }); + return Promise.reject( + new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + `Postgres doesn't support update ${JSON.stringify(fieldValue)} yet` + ) + ); } } - let where = buildWhereClause({ schema, index, query }) + const where = buildWhereClause({ + schema, + index, + query, + caseInsensitive: false, + }); values.push(...where.values); - let qs = `UPDATE $1:name SET ${updatePatterns.join(',')} WHERE ${where.pattern} RETURNING *`; - return this._client.query(qs, values) - .then(val => val[0]); // TODO: This is unsafe, verification is needed, or a different query method; + const whereClause = where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; + const qs = `UPDATE $1:name SET ${updatePatterns.join()} ${whereClause} RETURNING *`; + const promise = (transactionalSession ? transactionalSession.t : this._client) + .any(qs, values) + .catch(error => { + if (error.code === PostgresUniqueIndexViolationError) { + const err = new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'A duplicate value for a field with unique values was provided' + ); + err.underlyingError = error; + if (error.constraint) { + const authDataMatch = error.constraint.match(/_User_unique_authData_([a-zA-Z0-9_]+)_id/); + if (authDataMatch) { + err.userInfo = { duplicated_field: `_auth_data_${authDataMatch[1]}` }; + } else { + const matches = error.constraint.match(/unique_([a-zA-Z]+)/); + if (matches && Array.isArray(matches)) { + err.userInfo = { duplicated_field: matches[1] }; + } + } + } + throw err; + } + throw error; + }); + if (transactionalSession) { + transactionalSession.batch.push(promise); + } + return promise; } // Hopefully, we can get rid of this. It's only used for config and hooks. - upsertOneObject(className, schema, query, update) { - return notImplemented(); + upsertOneObject( + className: string, + schema: SchemaType, + query: QueryType, + update: any, + transactionalSession: ?any + ) { + debug('upsertOneObject'); + const createValue = Object.assign({}, query, update); + return this.createObject(className, schema, createValue, transactionalSession).catch(error => { + // ignore duplicate value errors as it's upsert + if (error.code !== Parse.Error.DUPLICATE_VALUE) { + throw error; + } + return this.findOneAndUpdate(className, schema, query, update, transactionalSession); + }); } - find(className, schema, query, { skip, limit, sort }) { + find( + className: string, + schema: SchemaType, + query: QueryType, + { skip, limit, sort, keys, caseInsensitive, explain }: QueryOptions + ) { + debug('find'); + const hasLimit = limit !== undefined; + const hasSkip = skip !== undefined; let values = [className]; - let where = buildWhereClause({ schema, query, index: 2 }) + const where = buildWhereClause({ + schema, + query, + index: 2, + caseInsensitive, + }); values.push(...where.values); - const wherePattern = where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; - const limitPattern = limit !== undefined ? `LIMIT $${values.length + 1}` : ''; - - const qs = `SELECT * FROM $1:name ${wherePattern} ${limitPattern}`; - if (limit !== undefined) { + const limitPattern = hasLimit ? `LIMIT $${values.length + 1}` : ''; + if (hasLimit) { values.push(limit); } - return this._client.query(qs, values) - .then(results => results.map(object => { - Object.keys(schema.fields).filter(field => schema.fields[field].type === 'Pointer').forEach(fieldName => { - object[fieldName] = { objectId: object[fieldName], __type: 'Pointer', className: schema.fields[fieldName].targetClass }; + const skipPattern = hasSkip ? `OFFSET $${values.length + 1}` : ''; + if (hasSkip) { + values.push(skip); + } + + let sortPattern = ''; + if (sort) { + const sortCopy: any = sort; + const sorting = Object.keys(sort) + .map(key => { + const transformKey = transformDotFieldToComponents(key).join('->'); + // Using $idx pattern gives: non-integer constant in ORDER BY + if (sortCopy[key] === 1) { + return `${transformKey} ASC`; + } + return `${transformKey} DESC`; + }) + .join(); + sortPattern = sort !== undefined && Object.keys(sort).length > 0 ? `ORDER BY ${sorting}` : ''; + } + if (where.sorts && Object.keys((where.sorts: any)).length > 0) { + sortPattern = `ORDER BY ${where.sorts.join()}`; + } + + let columns = '*'; + if (keys) { + // Exclude empty keys + // Replace ACL by it's keys + keys = keys.reduce((memo, key) => { + if (key === 'ACL') { + memo.push('_rperm'); + memo.push('_wperm'); + } else if ( + key.length > 0 && + // Remove selected field not referenced in the schema + // Relation is not a column in postgres + // $score is a Parse special field and is also not a column + ((schema.fields[key] && schema.fields[key].type !== 'Relation') || key === '$score') + ) { + memo.push(key); + } + return memo; + }, []); + columns = keys + .map((key, index) => { + if (key === '$score') { + return `ts_rank_cd(to_tsvector($${2}, $${3}:name), to_tsquery($${4}, $${5}), 32) as score`; + } + return `$${index + values.length + 1}:name`; + }) + .join(); + values = values.concat(keys); + } + + const originalQuery = `SELECT ${columns} FROM $1:name ${wherePattern} ${sortPattern} ${limitPattern} ${skipPattern}`; + const qs = explain ? this.createExplainableQuery(originalQuery) : originalQuery; + return this._client + .any(qs, values) + .catch(error => { + if ( + error.code !== PostgresRelationDoesNotExistError && + error.code !== PostgresMissingColumnError + ) { + throw error; + } + return []; + }) + .then(results => { + if (explain) { + return results; + } + return results.map(object => this.postgresObjectToParseObject(className, object, schema)); }); - //TODO: remove this reliance on the mongo format. DB adapter shouldn't know there is a difference between created at and any other date field. - if (object.createdAt) { - object.createdAt = object.createdAt.toISOString(); + } + + // Converts from a postgres-format object to a REST-format object. + // Does not strip out anything based on a lack of authentication. + postgresObjectToParseObject(className: string, object: any, schema: any) { + Object.keys(schema.fields).forEach(fieldName => { + if (schema.fields[fieldName].type === 'Pointer' && object[fieldName]) { + object[fieldName] = { + objectId: object[fieldName], + __type: 'Pointer', + className: schema.fields[fieldName].targetClass, + }; + } + if (schema.fields[fieldName].type === 'Relation') { + object[fieldName] = { + __type: 'Relation', + className: schema.fields[fieldName].targetClass, + }; } - if (object.updatedAt) { - object.updatedAt = object.updatedAt.toISOString(); + if (object[fieldName] && schema.fields[fieldName].type === 'GeoPoint') { + object[fieldName] = { + __type: 'GeoPoint', + latitude: object[fieldName].y, + longitude: object[fieldName].x, + }; } - if (object.expiresAt) { - object.expiresAt = { __type: 'Date', iso: object.expiresAt.toISOString() }; + if (object[fieldName] && schema.fields[fieldName].type === 'Polygon') { + let coords = new String(object[fieldName]); + coords = coords.substring(2, coords.length - 2).split('),('); + const updatedCoords = coords.map(point => { + return [parseFloat(point.split(',')[1]), parseFloat(point.split(',')[0])]; + }); + object[fieldName] = { + __type: 'Polygon', + coordinates: updatedCoords, + }; } - if (object._email_verify_token_expires_at) { - object._email_verify_token_expires_at = { __type: 'Date', iso: object._email_verify_token_expires_at.toISOString() }; + if (object[fieldName] && schema.fields[fieldName].type === 'File') { + object[fieldName] = { + __type: 'File', + name: object[fieldName], + }; } + }); + //TODO: remove this reliance on the mongo format. DB adapter shouldn't know there is a difference between created at and any other date field. + if (object.createdAt) { + object.createdAt = object.createdAt.toISOString(); + } + if (object.updatedAt) { + object.updatedAt = object.updatedAt.toISOString(); + } + if (object.expiresAt) { + object.expiresAt = { + __type: 'Date', + iso: object.expiresAt.toISOString(), + }; + } + if (object._email_verify_token_expires_at) { + object._email_verify_token_expires_at = { + __type: 'Date', + iso: object._email_verify_token_expires_at.toISOString(), + }; + } + if (object._account_lockout_expires_at) { + object._account_lockout_expires_at = { + __type: 'Date', + iso: object._account_lockout_expires_at.toISOString(), + }; + } + if (object._perishable_token_expires_at) { + object._perishable_token_expires_at = { + __type: 'Date', + iso: object._perishable_token_expires_at.toISOString(), + }; + } + if (object._password_changed_at) { + object._password_changed_at = { + __type: 'Date', + iso: object._password_changed_at.toISOString(), + }; + } - for (let fieldName in object) { - if (object[fieldName] === null) { - delete object[fieldName]; - } - if (object[fieldName] instanceof Date) { - object[fieldName] = { __type: 'Date', iso: object[fieldName].toISOString() }; - } + for (const fieldName in object) { + if (object[fieldName] === null) { + delete object[fieldName]; } + if (Utils.isDate(object[fieldName])) { + object[fieldName] = { + __type: 'Date', + iso: object[fieldName].toISOString(), + }; + } + } - return object; - })) + return object; } // Create a unique index. Unique indexes on nullable fields are not allowed. Since we don't @@ -401,16 +2108,47 @@ export class PostgresStorageAdapter { // As such, we shouldn't expose this function to users of parse until we have an out-of-band // Way of determining if a field is nullable. Undefined doesn't count against uniqueness, // which is why we use sparse indexes. - ensureUniqueness(className, schema, fieldNames) { - // Use the same name for every ensureUniqueness attempt, because postgres - // Will happily create the same index with multiple names. - const constraintName = `unique_${fieldNames.sort().join('_')}`; + async ensureUniqueness(className: string, schema: SchemaType, fieldNames: string[]) { + const constraintName = `${className}_unique_${fieldNames.sort().join('_')}`; const constraintPatterns = fieldNames.map((fieldName, index) => `$${index + 3}:name`); - const qs = `ALTER TABLE $1:name ADD CONSTRAINT $2:name UNIQUE (${constraintPatterns.join(',')})`; - return this._client.query(qs,[className, constraintName, ...fieldNames]) - .catch(error => { + const qs = `CREATE UNIQUE INDEX IF NOT EXISTS $2:name ON $1:name(${constraintPatterns.join()})`; + return this._client.none(qs, [className, constraintName, ...fieldNames]).catch(error => { if (error.code === PostgresDuplicateRelationError && error.message.includes(constraintName)) { // Index already exists. Ignore error. + } else if ( + error.code === PostgresUniqueIndexViolationError && + error.message.includes(constraintName) + ) { + // Cast the error into the proper parse error + throw new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'A duplicate value for a field with unique values was provided' + ); + } else { + throw error; + } + }); + } + + // Creates a unique index on authData->->>'id' to prevent + // race conditions during concurrent signups with the same authData. + async ensureAuthDataUniqueness(provider: string) { + const indexName = `_User_unique_authData_${provider}_id`; + const qs = `CREATE UNIQUE INDEX IF NOT EXISTS $1:name ON "_User" (("authData"->$2::text->>'id')) WHERE "authData"->$2::text->>'id' IS NOT NULL`; + await this._client.none(qs, [indexName, provider]).catch(error => { + if ( + error.code === PostgresDuplicateRelationError && + error.message.includes(indexName) + ) { + // Index already exists. Ignore error. + } else if ( + error.code === PostgresUniqueIndexViolationError && + error.message.includes(indexName) + ) { + throw new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'Tried to ensure field uniqueness for a class that already has duplicates.' + ); } else { throw error; } @@ -418,21 +2156,663 @@ export class PostgresStorageAdapter { } // Executes a count. - count(className, schema, query) { - let values = [className]; - let where = buildWhereClause({ schema, query, index: 2 }); + async count( + className: string, + schema: SchemaType, + query: QueryType, + readPreference?: string, + estimate?: boolean = true + ) { + debug('count'); + const values = [className]; + const where = buildWhereClause({ + schema, + query, + index: 2, + caseInsensitive: false, + }); + values.push(...where.values); + + const wherePattern = where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; + let qs = ''; + + if (where.pattern.length > 0 || !estimate) { + qs = `SELECT count(*) FROM $1:name ${wherePattern}`; + } else { + qs = 'SELECT reltuples AS approximate_row_count FROM pg_class WHERE relname = $1'; + } + + return this._client + .one(qs, values, a => { + if (a.approximate_row_count == null || a.approximate_row_count == -1) { + return !isNaN(+a.count) ? +a.count : 0; + } else { + return +a.approximate_row_count; + } + }) + .catch(error => { + if ( + error.code !== PostgresRelationDoesNotExistError && + error.code !== PostgresMissingColumnError + ) { + throw error; + } + return 0; + }); + } + + async distinct(className: string, schema: SchemaType, query: QueryType, fieldName: string) { + debug('distinct'); + const fieldSegments = fieldName.split('.'); + for (const segment of fieldSegments) { + if (!segment.match(/^[a-zA-Z][a-zA-Z0-9_]*$/)) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}`); + } + } + let field = fieldName; + let column = fieldName; + const isNested = fieldName.indexOf('.') >= 0; + if (isNested) { + field = transformDotFieldToComponents(fieldName).join('->'); + column = fieldSegments[0]; + } + const isArrayField = + schema.fields && schema.fields[fieldName] && schema.fields[fieldName].type === 'Array'; + const isPointerField = + schema.fields && schema.fields[fieldName] && schema.fields[fieldName].type === 'Pointer'; + const values = [field, column, className]; + const where = buildWhereClause({ + schema, + query, + index: 4, + caseInsensitive: false, + }); values.push(...where.values); const wherePattern = where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; - const qs = `SELECT COUNT(*) FROM $1:name ${wherePattern}`; - return this._client.query(qs, values) - .then(result => parseInt(result[0].count)) + const transformer = isArrayField ? 'jsonb_array_elements' : 'ON'; + let qs = `SELECT DISTINCT ${transformer}($1:name) $2:name FROM $3:name ${wherePattern}`; + if (isNested) { + qs = `SELECT DISTINCT ${transformer}($1:raw) $2:raw FROM $3:name ${wherePattern}`; + } + return this._client + .any(qs, values) + .catch(error => { + if (error.code === PostgresMissingColumnError) { + return []; + } + throw error; + }) + .then(results => { + if (!isNested) { + results = results.filter(object => object[field] !== null); + return results.map(object => { + if (!isPointerField) { + return object[field]; + } + return { + __type: 'Pointer', + className: schema.fields[fieldName].targetClass, + objectId: object[field], + }; + }); + } + const child = fieldName.split('.')[1]; + return results.map(object => object[column][child]); + }) + .then(results => + results.map(object => this.postgresObjectToParseObject(className, object, schema)) + ); + } + + async aggregate( + className: string, + schema: any, + pipeline: any, + readPreference: ?string, + hint: ?mixed, + explain?: boolean + ) { + debug('aggregate'); + const values = [className]; + let index: number = 2; + let columns: string[] = []; + let countField = null; + let groupValues = null; + let wherePattern = ''; + let limitPattern = ''; + let skipPattern = ''; + let sortPattern = ''; + let groupPattern = ''; + for (let i = 0; i < pipeline.length; i += 1) { + const stage = pipeline[i]; + if (stage.$group) { + for (const field in stage.$group) { + const value = stage.$group[field]; + if (value === null || value === undefined) { + continue; + } + if (field === '_id' && typeof value === 'string' && value !== '') { + columns.push(`$${index}:name AS "objectId"`); + groupPattern = `GROUP BY $${index}:name`; + values.push(transformAggregateField(value)); + index += 1; + continue; + } + if (field === '_id' && typeof value === 'object' && Object.keys(value).length !== 0) { + groupValues = value; + const groupByFields = []; + for (const alias in value) { + if (typeof value[alias] === 'string' && value[alias]) { + const source = transformAggregateField(value[alias]); + if (!groupByFields.includes(`"${source}"`)) { + groupByFields.push(`"${source}"`); + } + values.push(source, alias); + columns.push(`$${index}:name AS $${index + 1}:name`); + index += 2; + } else { + const operation = Object.keys(value[alias])[0]; + const source = transformAggregateField(value[alias][operation]); + if (mongoAggregateToPostgres[operation]) { + if (!groupByFields.includes(`"${source}"`)) { + groupByFields.push(`"${source}"`); + } + columns.push( + `EXTRACT(${mongoAggregateToPostgres[operation] + } FROM $${index}:name AT TIME ZONE 'UTC')::integer AS $${index + 1}:name` + ); + values.push(source, alias); + index += 2; + } + } + } + groupPattern = `GROUP BY $${index}:raw`; + values.push(groupByFields.join()); + index += 1; + continue; + } + if (typeof value === 'object') { + if (value.$sum) { + if (typeof value.$sum === 'string') { + columns.push(`SUM($${index}:name) AS $${index + 1}:name`); + values.push(transformAggregateField(value.$sum), field); + index += 2; + } else { + countField = field; + columns.push(`COUNT(*) AS $${index}:name`); + values.push(field); + index += 1; + } + } + if (value.$max) { + columns.push(`MAX($${index}:name) AS $${index + 1}:name`); + values.push(transformAggregateField(value.$max), field); + index += 2; + } + if (value.$min) { + columns.push(`MIN($${index}:name) AS $${index + 1}:name`); + values.push(transformAggregateField(value.$min), field); + index += 2; + } + if (value.$avg) { + columns.push(`AVG($${index}:name) AS $${index + 1}:name`); + values.push(transformAggregateField(value.$avg), field); + index += 2; + } + } + } + } else { + columns.push('*'); + } + if (stage.$project) { + if (columns.includes('*')) { + columns = []; + } + for (const field in stage.$project) { + const value = stage.$project[field]; + if (value === 1 || value === true) { + columns.push(`$${index}:name`); + values.push(field); + index += 1; + } + } + } + if (stage.$match) { + const patterns = []; + const orOrAnd = Object.prototype.hasOwnProperty.call(stage.$match, '$or') + ? ' OR ' + : ' AND '; + + if (stage.$match.$or) { + const collapse = {}; + stage.$match.$or.forEach(element => { + for (const key in element) { + collapse[key] = element[key]; + } + }); + stage.$match = collapse; + } + for (let field in stage.$match) { + const value = stage.$match[field]; + if (field === '_id') { + field = 'objectId'; + } + const matchPatterns = []; + Object.keys(ParseToPosgresComparator).forEach(cmp => { + if (value[cmp]) { + const pgComparator = ParseToPosgresComparator[cmp]; + matchPatterns.push(`$${index}:name ${pgComparator} $${index + 1}`); + values.push(field, toPostgresValue(value[cmp])); + index += 2; + } + }); + if (matchPatterns.length > 0) { + patterns.push(`(${matchPatterns.join(' AND ')})`); + } + if (schema.fields[field] && schema.fields[field].type && matchPatterns.length === 0) { + patterns.push(`$${index}:name = $${index + 1}`); + values.push(field, value); + index += 2; + } + } + wherePattern = patterns.length > 0 ? `WHERE ${patterns.join(` ${orOrAnd} `)}` : ''; + } + if (stage.$limit) { + limitPattern = `LIMIT $${index}`; + values.push(stage.$limit); + index += 1; + } + if (stage.$skip) { + skipPattern = `OFFSET $${index}`; + values.push(stage.$skip); + index += 1; + } + if (stage.$sort) { + const sort = stage.$sort; + const keys = Object.keys(sort); + const sorting = keys + .map(key => { + const transformer = sort[key] === 1 ? 'ASC' : 'DESC'; + const order = `$${index}:name ${transformer}`; + index += 1; + return order; + }) + .join(); + values.push(...keys); + sortPattern = sort !== undefined && sorting.length > 0 ? `ORDER BY ${sorting}` : ''; + } + } + + if (groupPattern) { + columns.forEach((e, i, a) => { + if (e && e.trim() === '*') { + a[i] = ''; + } + }); + } + + const originalQuery = `SELECT ${columns + .filter(Boolean) + .join()} FROM $1:name ${wherePattern} ${skipPattern} ${groupPattern} ${sortPattern} ${limitPattern}`; + const qs = explain ? this.createExplainableQuery(originalQuery) : originalQuery; + return this._client.any(qs, values).then(a => { + if (explain) { + return a; + } + const results = a.map(object => this.postgresObjectToParseObject(className, object, schema)); + results.forEach(result => { + if (!Object.prototype.hasOwnProperty.call(result, 'objectId')) { + result.objectId = null; + } + if (groupValues) { + result.objectId = {}; + for (const key in groupValues) { + result.objectId[key] = result[key]; + delete result[key]; + } + } + if (countField) { + result[countField] = parseInt(result[countField], 10); + } + }); + return results; + }); + } + + async performInitialization({ VolatileClassesSchemas }: any) { + // TODO: This method needs to be rewritten to make proper use of connections (@vitaly-t) + debug('performInitialization'); + await this._ensureSchemaCollectionExists(); + const promises = VolatileClassesSchemas.map(schema => { + return this.createTable(schema.className, schema) + .catch(err => { + if ( + err.code === PostgresDuplicateRelationError || + err.code === Parse.Error.INVALID_CLASS_NAME + ) { + return Promise.resolve(); + } + throw err; + }) + .then(() => this.schemaUpgrade(schema.className, schema)); + }); + promises.push(this._listenToSchema()); + return Promise.all(promises) + .then(() => { + return this._client.tx('perform-initialization', async t => { + await t.none(sql.misc.jsonObjectSetKeys); + await t.none(sql.array.add); + await t.none(sql.array.addUnique); + await t.none(sql.array.remove); + await t.none(sql.array.containsAll); + await t.none(sql.array.containsAllRegex); + await t.none(sql.array.contains); + return t.ctx; + }); + }) + .then(ctx => { + debug(`initializationDone in ${ctx.duration}`); + }) + .catch(error => { + // eslint-disable-next-line no-console + console.error(error); + }); + } + + async createIndexes(className: string, indexes: any, conn: ?any): Promise { + return (conn || this._client).tx(t => + t.batch( + indexes.map(i => { + return t.none('CREATE INDEX IF NOT EXISTS $1:name ON $2:name ($3:name)', [ + i.name, + className, + i.key, + ]); + }) + ) + ); + } + + async createIndexesIfNeeded( + className: string, + fieldName: string, + type: any, + conn: ?any + ): Promise { + await (conn || this._client).none('CREATE INDEX IF NOT EXISTS $1:name ON $2:name ($3:name)', [ + fieldName, + className, + type, + ]); + } + + async dropIndexes(className: string, indexes: any, conn: any): Promise { + const queries = indexes.map(i => ({ + query: 'DROP INDEX $1:name', + values: i, + })); + await (conn || this._client).tx(t => t.none(this._pgp.helpers.concat(queries))); + } + + async getIndexes(className: string) { + const qs = 'SELECT * FROM pg_indexes WHERE tablename = ${className}'; + return this._client.any(qs, { className }); + } + + async updateSchemaWithIndexes(): Promise { + return Promise.resolve(); + } + + // Used for testing purposes + async updateEstimatedCount(className: string) { + return this._client.none('ANALYZE $1:name', [className]); + } + + async createTransactionalSession(): Promise { + return new Promise(resolve => { + const transactionalSession = {}; + transactionalSession.result = this._client.tx(t => { + transactionalSession.t = t; + transactionalSession.promise = new Promise(resolve => { + transactionalSession.resolve = resolve; + }); + transactionalSession.batch = []; + resolve(transactionalSession); + return transactionalSession.promise; + }); + }); + } + + commitTransactionalSession(transactionalSession: any): Promise { + transactionalSession.resolve(transactionalSession.t.batch(transactionalSession.batch)); + return transactionalSession.result; + } + + abortTransactionalSession(transactionalSession: any): Promise { + const result = transactionalSession.result.catch(); + transactionalSession.batch.push(Promise.reject()); + transactionalSession.resolve(transactionalSession.t.batch(transactionalSession.batch)); + return result; + } + + async ensureIndex( + className: string, + schema: SchemaType, + fieldNames: string[], + indexName: ?string, + caseInsensitive: boolean = false, + options?: Object = {} + ): Promise { + 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 }; + const constraintPatterns = caseInsensitive + ? fieldNames.map((fieldName, index) => `lower($${index + 3}:name) varchar_pattern_ops`) + : fieldNames.map((fieldName, index) => `$${index + 3}:name`); + const qs = `CREATE INDEX IF NOT EXISTS $1:name ON $2:name (${constraintPatterns.join()})`; + const setIdempotencyFunction = + options.setIdempotencyFunction !== undefined ? options.setIdempotencyFunction : false; + if (setIdempotencyFunction) { + await this.ensureIdempotencyFunctionExists(options); + } + await conn.none(qs, [indexNameOptions.name, className, ...fieldNames]).catch(error => { + if ( + error.code === PostgresDuplicateRelationError && + error.message.includes(indexNameOptions.name) + ) { + // Index already exists. Ignore error. + } else if ( + error.code === PostgresUniqueIndexViolationError && + error.message.includes(indexNameOptions.name) + ) { + // Cast the error into the proper parse error + throw new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'A duplicate value for a field with unique values was provided' + ); + } else { + throw error; + } + }); + } + + async deleteIdempotencyFunction(options?: Object = {}): Promise { + const conn = options.conn !== undefined ? options.conn : this._client; + const qs = 'DROP FUNCTION IF EXISTS idempotency_delete_expired_records()'; + return conn.none(qs).catch(error => { + throw error; + }); + } + + async ensureIdempotencyFunctionExists(options?: Object = {}): Promise { + const conn = options.conn !== undefined ? options.conn : this._client; + const ttlOptions = options.ttl !== undefined ? `${options.ttl} seconds` : '60 seconds'; + const qs = + 'CREATE OR REPLACE FUNCTION idempotency_delete_expired_records() RETURNS void LANGUAGE plpgsql AS $$ BEGIN DELETE FROM "_Idempotency" WHERE expire < NOW() - INTERVAL $1; END; $$;'; + return conn.none(qs, [ttlOptions]).catch(error => { + throw error; + }); + } +} + +function convertPolygonToSQL(polygon) { + if (polygon.length < 3) { + throw new Parse.Error(Parse.Error.INVALID_JSON, `Polygon must have at least 3 values`); + } + if ( + polygon[0][0] !== polygon[polygon.length - 1][0] || + polygon[0][1] !== polygon[polygon.length - 1][1] + ) { + polygon.push(polygon[0]); + } + const unique = polygon.filter((item, index, ar) => { + let foundIndex = -1; + for (let i = 0; i < ar.length; i += 1) { + const pt = ar[i]; + if (pt[0] === item[0] && pt[1] === item[1]) { + foundIndex = i; + break; + } + } + return foundIndex === index; + }); + if (unique.length < 3) { + throw new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + 'GeoJSON: Loop must have at least 3 different vertices' + ); + } + const points = polygon + .map(point => { + Parse.GeoPoint._validate(parseFloat(point[1]), parseFloat(point[0])); + return `(${point[1]}, ${point[0]})`; + }) + .join(', '); + return `(${points})`; +} + +function removeWhiteSpace(regex) { + if (!regex.endsWith('\n')) { + regex += '\n'; + } + + // remove non escaped comments + return ( + regex + .replace(/([^\\])#.*\n/gim, '$1') + // remove lines starting with a comment + .replace(/^#.*\n/gim, '') + // remove non escaped whitespace + .replace(/([^\\])\s+/gim, '$1') + // remove whitespace at the beginning of a line + .replace(/^\s+/, '') + .trim() + ); +} + +function processRegexPattern(s) { + if (s && s.startsWith('^')) { + // regex for startsWith + return '^' + literalizeRegexPart(s.slice(1)); + } else if (s && s.endsWith('$')) { + // regex for endsWith + return literalizeRegexPart(s.slice(0, s.length - 1)) + '$'; + } + + // regex for contains + return literalizeRegexPart(s); +} + +function isStartsWithRegex(value) { + if (!value || typeof value !== 'string' || !value.startsWith('^')) { + return false; + } + + const matches = value.match(/\^\\Q.*\\E/); + return !!matches; +} + +function isAllValuesRegexOrNone(values) { + if (!values || !Array.isArray(values) || values.length === 0) { + return true; + } + + const firstValuesIsRegex = isStartsWithRegex(values[0].$regex); + if (values.length === 1) { + return firstValuesIsRegex; + } + + for (let i = 1, length = values.length; i < length; ++i) { + if (firstValuesIsRegex !== isStartsWithRegex(values[i].$regex)) { + return false; + } } + + return true; +} + +function isAnyValueRegexStartsWith(values) { + return values.some(function (value) { + return isStartsWithRegex(value.$regex); + }); +} + +function createLiteralRegex(remaining: string) { + return remaining + .split('') + .map(c => { + const regex = RegExp('[0-9 ]|\\p{L}', 'u'); // Support all Unicode letter chars + if (c.match(regex) !== null) { + // Don't escape alphanumeric characters + return c; + } + // Escape everything else (single quotes with single quotes, everything else with a backslash) + return c === `'` ? `''` : `\\${c}`; + }) + .join(''); } -function notImplemented() { - return Promise.reject(new Error('Not implemented yet.')); +function literalizeRegexPart(s: string) { + const matcher1 = /\\Q((?!\\E).*)\\E$/; + const result1: any = s.match(matcher1); + if (result1 && result1.length > 1 && result1.index > -1) { + // Process Regex that has a beginning and an end specified for the literal text + const prefix = s.substring(0, result1.index); + const remaining = result1[1]; + + return literalizeRegexPart(prefix) + createLiteralRegex(remaining); + } + + // Process Regex that has a beginning specified for the literal text + const matcher2 = /\\Q((?!\\E).*)$/; + const result2: any = s.match(matcher2); + if (result2 && result2.length > 1 && result2.index > -1) { + const prefix = s.substring(0, result2.index); + const remaining = result2[1]; + + return literalizeRegexPart(prefix) + createLiteralRegex(remaining); + } + + // Remove problematic chars from remaining text + return s + // Remove all instances of \Q and \E + .replace(/([^\\])(\\E)/, '$1') + .replace(/([^\\])(\\Q)/, '$1') + .replace(/^\\E/, '') + .replace(/^\\Q/, '') + // Ensure even number of single quote sequences by adding an extra single quote if needed; + // this ensures that every single quote is escaped + .replace(/'+/g, match => { + return match.length % 2 === 0 ? match : match + "'"; + }); } +var GeoPointCoder = { + isValidJSON(value) { + return typeof value === 'object' && value !== null && value.__type === 'GeoPoint'; + }, +}; + export default PostgresStorageAdapter; -module.exports = PostgresStorageAdapter; // Required for tests diff --git a/src/Adapters/Storage/Postgres/sql/array/add-unique.sql b/src/Adapters/Storage/Postgres/sql/array/add-unique.sql new file mode 100644 index 0000000000..aad90d45f5 --- /dev/null +++ b/src/Adapters/Storage/Postgres/sql/array/add-unique.sql @@ -0,0 +1,11 @@ +CREATE OR REPLACE FUNCTION array_add_unique( + "array" jsonb, + "values" jsonb +) + RETURNS jsonb + LANGUAGE sql + IMMUTABLE + STRICT +AS $function$ + SELECT array_to_json(ARRAY(SELECT DISTINCT unnest(ARRAY(SELECT DISTINCT jsonb_array_elements("array")) || ARRAY(SELECT DISTINCT jsonb_array_elements("values")))))::jsonb; +$function$; diff --git a/src/Adapters/Storage/Postgres/sql/array/add.sql b/src/Adapters/Storage/Postgres/sql/array/add.sql new file mode 100644 index 0000000000..a0b5859908 --- /dev/null +++ b/src/Adapters/Storage/Postgres/sql/array/add.sql @@ -0,0 +1,11 @@ +CREATE OR REPLACE FUNCTION array_add( + "array" jsonb, + "values" jsonb +) + RETURNS jsonb + LANGUAGE sql + IMMUTABLE + STRICT +AS $function$ + SELECT array_to_json(ARRAY(SELECT unnest(ARRAY(SELECT DISTINCT jsonb_array_elements("array")) || ARRAY(SELECT jsonb_array_elements("values")))))::jsonb; +$function$; diff --git a/src/Adapters/Storage/Postgres/sql/array/contains-all-regex.sql b/src/Adapters/Storage/Postgres/sql/array/contains-all-regex.sql new file mode 100644 index 0000000000..7ca5853a9f --- /dev/null +++ b/src/Adapters/Storage/Postgres/sql/array/contains-all-regex.sql @@ -0,0 +1,14 @@ +CREATE OR REPLACE FUNCTION array_contains_all_regex( + "array" jsonb, + "values" jsonb +) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + STRICT +AS $function$ + SELECT CASE + WHEN 0 = jsonb_array_length("values") THEN true = false + ELSE (SELECT RES.CNT = jsonb_array_length("values") FROM (SELECT COUNT(*) as CNT FROM jsonb_array_elements_text("array") as elt WHERE elt LIKE ANY (SELECT jsonb_array_elements_text("values"))) as RES) + END; +$function$; \ No newline at end of file diff --git a/src/Adapters/Storage/Postgres/sql/array/contains-all.sql b/src/Adapters/Storage/Postgres/sql/array/contains-all.sql new file mode 100644 index 0000000000..8db1ca0e7b --- /dev/null +++ b/src/Adapters/Storage/Postgres/sql/array/contains-all.sql @@ -0,0 +1,14 @@ +CREATE OR REPLACE FUNCTION array_contains_all( + "array" jsonb, + "values" jsonb +) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + STRICT +AS $function$ + SELECT CASE + WHEN 0 = jsonb_array_length("values") THEN true = false + ELSE (SELECT RES.CNT = jsonb_array_length("values") FROM (SELECT COUNT(*) as CNT FROM jsonb_array_elements_text("array") as elt WHERE elt IN (SELECT jsonb_array_elements_text("values"))) as RES) + END; +$function$; diff --git a/src/Adapters/Storage/Postgres/sql/array/contains.sql b/src/Adapters/Storage/Postgres/sql/array/contains.sql new file mode 100644 index 0000000000..f7c458782e --- /dev/null +++ b/src/Adapters/Storage/Postgres/sql/array/contains.sql @@ -0,0 +1,11 @@ +CREATE OR REPLACE FUNCTION array_contains( + "array" jsonb, + "values" jsonb +) + RETURNS boolean + LANGUAGE sql + IMMUTABLE + STRICT +AS $function$ + SELECT RES.CNT >= 1 FROM (SELECT COUNT(*) as CNT FROM jsonb_array_elements("array") as elt WHERE elt IN (SELECT jsonb_array_elements("values"))) as RES; +$function$; diff --git a/src/Adapters/Storage/Postgres/sql/array/remove.sql b/src/Adapters/Storage/Postgres/sql/array/remove.sql new file mode 100644 index 0000000000..52895d2f46 --- /dev/null +++ b/src/Adapters/Storage/Postgres/sql/array/remove.sql @@ -0,0 +1,11 @@ +CREATE OR REPLACE FUNCTION array_remove( + "array" jsonb, + "values" jsonb +) + RETURNS jsonb + LANGUAGE sql + IMMUTABLE + STRICT +AS $function$ + SELECT array_to_json(ARRAY(SELECT * FROM jsonb_array_elements("array") as elt WHERE elt NOT IN (SELECT * FROM (SELECT jsonb_array_elements("values")) AS sub)))::jsonb; +$function$; diff --git a/src/Adapters/Storage/Postgres/sql/index.js b/src/Adapters/Storage/Postgres/sql/index.js new file mode 100644 index 0000000000..ad151f2170 --- /dev/null +++ b/src/Adapters/Storage/Postgres/sql/index.js @@ -0,0 +1,32 @@ +'use strict'; + +var QueryFile = require('pg-promise').QueryFile; +var path = require('path'); + +module.exports = { + array: { + add: sql('array/add.sql'), + addUnique: sql('array/add-unique.sql'), + contains: sql('array/contains.sql'), + containsAll: sql('array/contains-all.sql'), + containsAllRegex: sql('array/contains-all-regex.sql'), + remove: sql('array/remove.sql'), + }, + misc: { + jsonObjectSetKeys: sql('misc/json-object-set-keys.sql'), + }, +}; + +/////////////////////////////////////////////// +// Helper for linking to external query files; +function sql(file) { + var fullPath = path.join(__dirname, file); // generating full path; + + var qf = new QueryFile(fullPath, { minify: true }); + + if (qf.error) { + throw qf.error; + } + + return qf; +} diff --git a/src/Adapters/Storage/Postgres/sql/misc/json-object-set-keys.sql b/src/Adapters/Storage/Postgres/sql/misc/json-object-set-keys.sql new file mode 100644 index 0000000000..eb28b36928 --- /dev/null +++ b/src/Adapters/Storage/Postgres/sql/misc/json-object-set-keys.sql @@ -0,0 +1,19 @@ +-- Function to set a key on a nested JSON document + +CREATE OR REPLACE FUNCTION json_object_set_key( + "json" jsonb, + key_to_set TEXT, + value_to_set anyelement +) + RETURNS jsonb + LANGUAGE sql + IMMUTABLE + STRICT +AS $function$ +SELECT concat('{', string_agg(to_json("key") || ':' || "value", ','), '}')::jsonb + FROM (SELECT * + FROM jsonb_each("json") + WHERE key <> key_to_set + UNION ALL + SELECT key_to_set, to_json("value_to_set")::jsonb) AS fields +$function$; diff --git a/src/Adapters/Storage/StorageAdapter.js b/src/Adapters/Storage/StorageAdapter.js new file mode 100644 index 0000000000..19c945265b --- /dev/null +++ b/src/Adapters/Storage/StorageAdapter.js @@ -0,0 +1,149 @@ +// @flow +export type SchemaType = any; +export type StorageClass = any; +export type QueryType = any; + +export type QueryOptions = { + skip?: number, + limit?: number, + acl?: string[], + sort?: { [string]: number }, + count?: boolean | number, + keys?: string[], + op?: string, + distinct?: boolean, + pipeline?: any, + readPreference?: ?string, + hint?: ?mixed, + explain?: Boolean, + caseInsensitive?: boolean, + action?: string, + addsField?: boolean, + comment?: string, +}; + +export type UpdateQueryOptions = { + many?: boolean, + upsert?: boolean, +}; + +export type FullQueryOptions = QueryOptions & UpdateQueryOptions; + +export type UpdateManyResult = { + matchedCount?: number, + modifiedCount?: number, +}; + +export interface StorageAdapter { + canSortOnJoinTables: boolean; + schemaCacheTtl: ?number; + enableSchemaHooks: boolean; + + classExists(className: string): Promise; + setClassLevelPermissions(className: string, clps: any): Promise; + createClass(className: string, schema: SchemaType): Promise; + addFieldIfNotExists(className: string, fieldName: string, type: any): Promise; + updateFieldOptions(className: string, fieldName: string, type: any): Promise; + deleteClass(className: string): Promise; + deleteAllClasses(fast: boolean): Promise; + deleteFields(className: string, schema: SchemaType, fieldNames: Array): Promise; + getAllClasses(): Promise; + getClass(className: string): Promise; + createObject( + className: string, + schema: SchemaType, + object: any, + transactionalSession: ?any + ): Promise; + deleteObjectsByQuery( + className: string, + schema: SchemaType, + query: QueryType, + transactionalSession: ?any + ): Promise; + /** + * Updates all objects that match the given query. + * Adapters may return an `UpdateManyResult` with optional `matchedCount` and `modifiedCount` + * to indicate how many documents were matched and modified. If not provided, the caller + * receives `undefined` for these fields. + */ + updateObjectsByQuery( + className: string, + schema: SchemaType, + query: QueryType, + update: any, + transactionalSession: ?any + ): Promise<[any] | UpdateManyResult>; + findOneAndUpdate( + className: string, + schema: SchemaType, + query: QueryType, + update: any, + transactionalSession: ?any + ): Promise; + upsertOneObject( + className: string, + schema: SchemaType, + query: QueryType, + update: any, + transactionalSession: ?any + ): Promise; + find( + className: string, + schema: SchemaType, + query: QueryType, + options: QueryOptions + ): Promise<[any]>; + ensureIndex( + className: string, + schema: SchemaType, + fieldNames: string[], + indexName?: string, + caseSensitive?: boolean, + options?: Object + ): Promise; + ensureUniqueness(className: string, schema: SchemaType, fieldNames: Array): Promise; + count( + className: string, + schema: SchemaType, + query: QueryType, + readPreference?: string, + estimate?: boolean, + hint?: mixed, + comment?: string + ): Promise; + distinct( + className: string, + schema: SchemaType, + query: QueryType, + fieldName: string + ): Promise; + aggregate( + className: string, + schema: any, + pipeline: any, + readPreference: ?string, + hint: ?mixed, + explain?: boolean, + comment?: string, + rawValues?: boolean, + rawFieldNames?: boolean + ): Promise; + performInitialization(options: ?any): Promise; + watch(callback: () => void): void; + + // Indexing + createIndexes(className: string, indexes: any, conn: ?any): Promise; + getIndexes(className: string, connection: ?any): Promise; + updateSchemaWithIndexes(): Promise; + setIndexesWithSchemaFormat( + className: string, + submittedIndexes: any, + existingIndexes: any, + fields: any, + conn: ?any + ): Promise; + createTransactionalSession(): Promise; + commitTransactionalSession(transactionalSession: any): Promise; + abortTransactionalSession(transactionalSession: any): Promise; +} diff --git a/src/Adapters/WebSocketServer/WSAdapter.js b/src/Adapters/WebSocketServer/WSAdapter.js new file mode 100644 index 0000000000..db35d928bf --- /dev/null +++ b/src/Adapters/WebSocketServer/WSAdapter.js @@ -0,0 +1,26 @@ +/* eslint-disable unused-imports/no-unused-vars */ +import { WSSAdapter } from './WSSAdapter'; +const WebSocketServer = require('ws').Server; + +/** + * Wrapper for ws node module + */ +export class WSAdapter extends WSSAdapter { + constructor(options: any) { + super(options); + this.options = options; + } + + onListen() {} + onConnection(ws) {} + onError(error) {} + start() { + const wss = new WebSocketServer({ server: this.options.server }); + wss.on('listening', this.onListen); + wss.on('connection', this.onConnection); + wss.on('error', this.onError); + } + close() {} +} + +export default WSAdapter; diff --git a/src/Adapters/WebSocketServer/WSSAdapter.js b/src/Adapters/WebSocketServer/WSSAdapter.js new file mode 100644 index 0000000000..0831828f3c --- /dev/null +++ b/src/Adapters/WebSocketServer/WSSAdapter.js @@ -0,0 +1,59 @@ +/* eslint-disable unused-imports/no-unused-vars */ +// WebSocketServer Adapter +// +// Adapter classes must implement the following functions: +// * onListen() +// * onConnection(ws) +// * onError(error) +// * start() +// * close() +// +// Default is WSAdapter. The above functions will be binded. + +/** + * @interface + * @memberof module:Adapters + */ +export class WSSAdapter { + /** + * @param {Object} options - {http.Server|https.Server} server + */ + constructor(options) { + this.onListen = () => {}; + this.onConnection = () => {}; + this.onError = () => {}; + } + + // /** + // * Emitted when the underlying server has been bound. + // */ + // onListen() {} + + // /** + // * Emitted when the handshake is complete. + // * + // * @param {WebSocket} ws - RFC 6455 WebSocket. + // */ + // onConnection(ws) {} + + // /** + // * Emitted when error event is called. + // * + // * @param {Error} error - WebSocketServer error + // */ + // onError(error) {} + + /** + * Initialize Connection. + * + * @param {Object} options + */ + start(options) {} + + /** + * Closes server. + */ + close() {} +} + +export default WSSAdapter; diff --git a/src/Auth.js b/src/Auth.js index efceb62699..dd75aaace2 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -1,15 +1,30 @@ -var deepcopy = require('deepcopy'); -var Parse = require('parse/node').Parse; -var RestQuery = require('./RestQuery'); +const Parse = require('parse/node'); +import { isDeepStrictEqual } from 'util'; +import { getRequestObject, resolveError } from './triggers'; +import { logger } from './logger'; +import { LRUCache as LRU } from 'lru-cache'; +import RestQuery from './RestQuery'; +import RestWrite from './RestWrite'; // An Auth object tells you who is requesting something and whether // the master key was used. // userObject is a Parse.User and can be null if there's no user. -function Auth({ config, isMaster = false, user, installationId } = {}) { +function Auth({ + config, + cacheController = undefined, + isMaster = false, + isMaintenance = false, + isReadOnly = false, + user, + installationId, +}) { this.config = config; + this.cacheController = cacheController || (config && config.cacheController); this.installationId = installationId; this.isMaster = isMaster; + this.isMaintenance = isMaintenance; this.user = user; + this.isReadOnly = isReadOnly; // Assuming a users roles won't change during a single request, we'll // only load them once. @@ -20,14 +35,17 @@ function Auth({ config, isMaster = false, user, installationId } = {}) { // Whether this auth could possibly modify the given user id. // It still could be forbidden via ACLs even if this returns true. -Auth.prototype.couldUpdateUserId = function(userId) { +Auth.prototype.isUnauthenticated = function () { if (this.isMaster) { - return true; + return false; } - if (this.user && this.user.id === userId) { - return true; + if (this.isMaintenance) { + return false; } - return false; + if (this.user) { + return false; + } + return true; }; // A helper to get a master-level Auth object @@ -35,52 +53,205 @@ function master(config) { return new Auth({ config, isMaster: true }); } +// A helper to get a maintenance-level Auth object +function maintenance(config) { + return new Auth({ config, isMaintenance: true }); +} + +// A helper to get a master-level Auth object +function readOnly(config) { + return new Auth({ config, isMaster: true, isReadOnly: true }); +} + // A helper to get a nobody-level Auth object function nobody(config) { return new Auth({ config, isMaster: false }); } +const throttle = new LRU({ + max: 10000, + ttl: 500, +}); +/** + * Checks whether session should be updated based on last update time & session length. + */ +function shouldUpdateSessionExpiry(config, session) { + const resetAfter = config.sessionLength / 2; + const lastUpdated = new Date(session?.updatedAt); + const skipRange = new Date(); + skipRange.setTime(skipRange.getTime() - resetAfter * 1000); + return lastUpdated <= skipRange; +} + +const renewSessionIfNeeded = async ({ config, session, sessionToken }) => { + if (!config?.extendSessionOnUse) { + return; + } + if (throttle.get(sessionToken)) { + return; + } + throttle.set(sessionToken, true); + try { + if (!session) { + const query = await RestQuery({ + method: RestQuery.Method.get, + config, + auth: master(config), + runBeforeFind: false, + className: '_Session', + restWhere: { sessionToken }, + restOptions: { limit: 1 }, + }); + const { results } = await query.execute(); + session = results[0]; + } + + if (!shouldUpdateSessionExpiry(config, session) || !session) { + return; + } + const expiresAt = config.generateSessionExpiresAt(); + await new RestWrite( + config, + master(config), + '_Session', + { objectId: session.objectId }, + { expiresAt: Parse._encode(expiresAt) } + ).execute(); + } catch (e) { + if (e?.code !== Parse.Error.OBJECT_NOT_FOUND) { + logger.error('Could not update session expiry: ', e); + } + } +}; // Returns a promise that resolves to an Auth object -var getAuthForSessionToken = function({ config, sessionToken, installationId } = {}) { - return config.cacheController.user.get(sessionToken).then((userJSON) => { - if (userJSON) { - let cachedUser = Parse.Object.fromJSON(userJSON); - return Promise.resolve(new Auth({config, isMaster: false, installationId, user: cachedUser})); +const getAuthForSessionToken = async function ({ + config, + cacheController, + sessionToken, + installationId, +}) { + cacheController = cacheController || (config && config.cacheController); + if (cacheController) { + const cached = await cacheController.user.get(sessionToken); + if (cached) { + const { expiresAt: cachedExpiresAt, ...userJSON } = cached; + if (cachedExpiresAt && new Date(cachedExpiresAt) < new Date()) { + cacheController.user.del(sessionToken); + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Session token is expired.'); + } + const cachedUser = Parse.Object.fromJSON(userJSON); + renewSessionIfNeeded({ config, sessionToken }); + return Promise.resolve( + new Auth({ + config, + cacheController, + isMaster: false, + installationId, + user: cachedUser, + }) + ); } + } - var restOptions = { + let results; + if (config) { + const restOptions = { limit: 1, - include: 'user' + include: 'user', }; + const RestQuery = require('./RestQuery'); + const query = await RestQuery({ + method: RestQuery.Method.get, + config, + runBeforeFind: false, + auth: master(config), + className: '_Session', + restWhere: { sessionToken }, + restOptions, + }); + results = (await query.execute()).results; + } else { + results = ( + await new Parse.Query(Parse.Session) + .limit(1) + .include('user') + .equalTo('sessionToken', sessionToken) + .find({ useMasterKey: true }) + ).map(obj => obj.toJSON()); + } + + if (results.length !== 1 || !results[0]['user']) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token'); + } + const session = results[0]; + const now = new Date(), + expiresAt = session.expiresAt ? new Date(session.expiresAt.iso) : undefined; + if (expiresAt < now) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Session token is expired.'); + } + const obj = session.user; + + if (typeof obj['objectId'] === 'string' && obj['objectId'].startsWith('role:')) { + throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Invalid object ID.'); + } + + delete obj.password; + obj['className'] = '_User'; + obj['sessionToken'] = sessionToken; + if (cacheController) { + cacheController.user.put(sessionToken, { ...obj, expiresAt: expiresAt?.toISOString() }); + } + renewSessionIfNeeded({ config, session, sessionToken }); + const userObject = Parse.Object.fromJSON(obj); + return new Auth({ + config, + cacheController, + isMaster: false, + installationId, + user: userObject, + }); +}; + +var getAuthForLegacySessionToken = async function ({ config, sessionToken, installationId }) { + var restOptions = { + limit: 1, + }; + const RestQuery = require('./RestQuery'); + var query = await RestQuery({ + method: RestQuery.Method.get, + config, + runBeforeFind: false, + auth: master(config), + className: '_User', + restWhere: { _session_token: sessionToken }, + restOptions, + }); + return query.execute().then(response => { + var results = response.results; + if (results.length !== 1) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'invalid legacy session token'); + } + const obj = results[0]; - var query = new RestQuery(config, master(config), '_Session', {sessionToken}, restOptions); - return query.execute().then((response) => { - var results = response.results; - if (results.length !== 1 || !results[0]['user']) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'invalid session token'); - } - - var now = new Date(), - expiresAt = results[0].expiresAt ? new Date(results[0].expiresAt.iso) : undefined; - if (expiresAt < now) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, - 'Session token is expired.'); - } - var obj = results[0]['user']; - delete obj.password; - obj['className'] = '_User'; - obj['sessionToken'] = sessionToken; - config.cacheController.user.put(sessionToken, obj); - let userObject = Parse.Object.fromJSON(obj); - return new Auth({config, isMaster: false, installationId, user: userObject}); + if (typeof obj['objectId'] === 'string' && obj['objectId'].startsWith('role:')) { + throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Invalid object ID.'); + } + + obj.className = '_User'; + const userObject = Parse.Object.fromJSON(obj); + return new Auth({ + config, + isMaster: false, + installationId, + user: userObject, }); }); }; // Returns a promise that resolves to an array of role names -Auth.prototype.getUserRoles = function() { - if (this.isMaster || !this.user) { +Auth.prototype.getUserRoles = function () { + if (this.isMaster || this.isMaintenance || !this.user) { return Promise.resolve([]); } if (this.fetchedRoles) { @@ -93,107 +264,418 @@ Auth.prototype.getUserRoles = function() { return this.rolePromise; }; -// Iterates through the role tree and compiles a users roles -Auth.prototype._loadRoles = function() { - var cacheAdapter = this.config.cacheController; - return cacheAdapter.role.get(this.user.id).then((cachedRoles) => { +Auth.prototype.getRolesForUser = async function () { + //Stack all Parse.Role + const results = []; + if (this.config) { + const restWhere = { + users: { + __type: 'Pointer', + className: '_User', + objectId: this.user.id, + }, + }; + const RestQuery = require('./RestQuery'); + const query = await RestQuery({ + method: RestQuery.Method.find, + runBeforeFind: false, + config: this.config, + auth: master(this.config), + className: '_Role', + restWhere, + }); + await query.each(result => results.push(result)); + } else { + await new Parse.Query(Parse.Role) + .equalTo('users', this.user) + .each(result => results.push(result.toJSON()), { useMasterKey: true }); + } + return results; +}; + +// Iterates through the role tree and compiles a user's roles +Auth.prototype._loadRoles = async function () { + if (this.cacheController) { + const cachedRoles = await this.cacheController.role.get(this.user.id); if (cachedRoles != null) { this.fetchedRoles = true; this.userRoles = cachedRoles; - return Promise.resolve(cachedRoles); + return cachedRoles; } + } + + // First get the role ids this user is directly a member of + const results = await this.getRolesForUser(); + if (!results.length) { + this.userRoles = []; + this.fetchedRoles = true; + this.rolePromise = null; + + this.cacheRoles(); + return this.userRoles; + } + + const rolesMap = results.reduce( + (m, r) => { + m.names.push(r.name); + m.ids.push(r.objectId); + return m; + }, + { ids: [], names: [] } + ); + + // run the recursive finding + const roleNames = await this._getAllRolesNamesForRoleIds(rolesMap.ids, rolesMap.names); + this.userRoles = roleNames.map(r => { + return 'role:' + r; + }); + this.fetchedRoles = true; + this.rolePromise = null; + this.cacheRoles(); + return this.userRoles; +}; + +Auth.prototype.cacheRoles = function () { + if (!this.cacheController) { + return false; + } + this.cacheController.role.put(this.user.id, Array(...this.userRoles)); + return true; +}; + +Auth.prototype.clearRoleCache = function (sessionToken) { + if (!this.cacheController) { + return false; + } + this.cacheController.role.del(this.user.id); + this.cacheController.user.del(sessionToken); + return true; +}; - var restWhere = { - 'users': { +Auth.prototype.getRolesByIds = async function (ins) { + const results = []; + // Build an OR query across all parentRoles + if (!this.config) { + await new Parse.Query(Parse.Role) + .containedIn( + 'roles', + ins.map(id => { + const role = new Parse.Object(Parse.Role); + role.id = id; + return role; + }) + ) + .each(result => results.push(result.toJSON()), { useMasterKey: true }); + } else { + const roles = ins.map(id => { + return { __type: 'Pointer', - className: '_User', - objectId: this.user.id - } - }; - // First get the role ids this user is directly a member of - var query = new RestQuery(this.config, master(this.config), '_Role', restWhere, {}); - return query.execute().then((response) => { - var results = response.results; - if (!results.length) { - this.userRoles = []; - this.fetchedRoles = true; - this.rolePromise = null; - - cacheAdapter.role.put(this.user.id, this.userRoles); - return Promise.resolve(this.userRoles); - } - var rolesMap = results.reduce((m, r) => { - m.names.push(r.name); - m.ids.push(r.objectId); - return m; - }, {ids: [], names: []}); - - // run the recursive finding - return this._getAllRolesNamesForRoleIds(rolesMap.ids, rolesMap.names) - .then((roleNames) => { - this.userRoles = roleNames.map((r) => { - return 'role:' + r; - }); - this.fetchedRoles = true; - this.rolePromise = null; - - cacheAdapter.role.put(this.user.id, this.userRoles); - return Promise.resolve(this.userRoles); - }); + className: '_Role', + objectId: id, + }; }); - }); + const restWhere = { roles: { $in: roles } }; + const RestQuery = require('./RestQuery'); + const query = await RestQuery({ + method: RestQuery.Method.find, + config: this.config, + runBeforeFind: false, + auth: master(this.config), + className: '_Role', + restWhere, + }); + await query.each(result => results.push(result)); + } + return results; }; // Given a list of roleIds, find all the parent roles, returns a promise with all names -Auth.prototype._getAllRolesNamesForRoleIds = function(roleIDs, names = [], queriedRoles = {}) { - let ins = roleIDs.filter((roleID) => { - return queriedRoles[roleID] !== true; - }).map((roleID) => { - // mark as queried +Auth.prototype._getAllRolesNamesForRoleIds = function (roleIDs, names = [], queriedRoles = {}) { + const ins = roleIDs.filter(roleID => { + const wasQueried = queriedRoles[roleID] !== true; queriedRoles[roleID] = true; - return { - __type: 'Pointer', - className: '_Role', - objectId: roleID - } + return wasQueried; }); // all roles are accounted for, return the names if (ins.length == 0) { return Promise.resolve([...new Set(names)]); } - // Build an OR query across all parentRoles - let restWhere; - if (ins.length == 1) { - restWhere = { 'roles': ins[0] }; - } else { - restWhere = { 'roles': { '$in': ins }} + + return this.getRolesByIds(ins) + .then(results => { + // Nothing found + if (!results.length) { + return Promise.resolve(names); + } + // Map the results with all Ids and names + const resultMap = results.reduce( + (memo, role) => { + memo.names.push(role.name); + memo.ids.push(role.objectId); + return memo; + }, + { ids: [], names: [] } + ); + // store the new found names + names = names.concat(resultMap.names); + // find the next ones, circular roles will be cut + return this._getAllRolesNamesForRoleIds(resultMap.ids, names, queriedRoles); + }) + .then(names => { + return Promise.resolve([...new Set(names)]); + }); +}; + +const findUsersWithAuthData = async (config, authData, beforeFind, currentUserAuthData) => { + const providers = Object.keys(authData); + + const queries = await Promise.all( + providers.map(async provider => { + const providerAuthData = authData[provider]; + + // Skip providers being unlinked (null value) + if (providerAuthData === null) { + return null; + } + + // Skip beforeFind only when incoming data is confirmed unchanged from stored data. + // This handles echoed-back authData from afterFind (e.g. client sends back { id: 'x' } + // alongside a provider unlink). On login/signup, currentUserAuthData is undefined so + // beforeFind always runs, preserving it as the security gate for missing credentials. + const storedProviderData = currentUserAuthData?.[provider]; + const incomingKeys = Object.keys(providerAuthData || {}); + const isUnchanged = storedProviderData && incomingKeys.length > 0 && + !incomingKeys.some(key => !isDeepStrictEqual(providerAuthData[key], storedProviderData[key])); + + const validatorConfig = config.authDataManager.getValidatorForProvider(provider); + // Skip database query for unconfigured providers to avoid unindexed collection scans; + // the provider will be rejected later in handleAuthDataValidation with UNSUPPORTED_SERVICE + if (!validatorConfig?.validator) { + return null; + } + const adapter = validatorConfig.adapter; + if (beforeFind && typeof adapter?.beforeFind === 'function' && !isUnchanged) { + await adapter.beforeFind(providerAuthData); + } + + if (!providerAuthData?.id) { + return null; + } + + if (typeof providerAuthData.id !== 'string') { + throw new Parse.Error(Parse.Error.INVALID_VALUE, `Invalid authData id for provider '${provider}'.`); + } + + return { [`authData.${provider}.id`]: providerAuthData.id }; + }) + ); + + // Filter out null queries + const validQueries = queries.filter(query => query !== null); + + if (!validQueries.length) { + return []; } - let query = new RestQuery(this.config, master(this.config), '_Role', restWhere, {}); - return query.execute().then((response) => { - var results = response.results; - // Nothing found - if (!results.length) { - return Promise.resolve(names); + + // Perform database query + return config.database.find('_User', { $or: validQueries }, { limit: 2 }); +}; + +const hasMutatedAuthData = (authData, userAuthData) => { + if (!userAuthData) { return { hasMutatedAuthData: true, mutatedAuthData: authData }; } + const mutatedAuthData = {}; + Object.keys(authData).forEach(provider => { + // Anonymous provider is not handled this way + if (provider === 'anonymous') { return; } + const providerData = authData[provider]; + const userProviderAuthData = userAuthData[provider]; + + // If unlinking (setting to null), consider it mutated + if (providerData === null) { + mutatedAuthData[provider] = providerData; + return; } - // Map the results with all Ids and names - let resultMap = results.reduce((memo, role) => { - memo.names.push(role.name); - memo.ids.push(role.objectId); - return memo; - }, {ids: [], names: []}); - // store the new found names - names = names.concat(resultMap.names); - // find the next ones, circular roles will be cut - return this._getAllRolesNamesForRoleIds(resultMap.ids, names, queriedRoles) - }).then((names) => { - return Promise.resolve([...new Set(names)]) - }) -} + + // If provider doesn't exist in stored data, it's new + if (!userProviderAuthData) { + mutatedAuthData[provider] = providerData; + return; + } + + // Check if incoming data represents actual changes vs just echoing back + // what afterFind returned. If incoming data is a subset of stored data + // (all incoming fields match stored values), it's not mutated. + // If incoming data has different values or fields not in stored data, it's mutated. + // This handles the case where afterFind strips sensitive fields like 'code': + // - Incoming: { id: 'x' }, Stored: { id: 'x', code: 'secret' } -> NOT mutated (subset) + // - Incoming: { id: 'x', token: 'new' }, Stored: { id: 'x', token: 'old' } -> MUTATED + const incomingKeys = Object.keys(providerData || {}); + const hasChanges = incomingKeys.some(key => { + return !isDeepStrictEqual(providerData[key], userProviderAuthData[key]); + }); + + if (hasChanges) { + mutatedAuthData[provider] = providerData; + } + }); + const hasMutatedAuthData = Object.keys(mutatedAuthData).length !== 0; + return { hasMutatedAuthData, mutatedAuthData }; +}; + +const checkIfUserHasProvidedConfiguredProvidersForLogin = ( + req = {}, + authData = {}, + userAuthData = {}, + config +) => { + const savedUserProviders = Object.keys(userAuthData) + .map(provider => { + const validator = config.authDataManager.getValidatorForProvider(provider); + if (!validator || !validator.adapter) { + return null; + } + return { name: provider, adapter: validator.adapter }; + }) + .filter(Boolean); + + const hasProvidedASoloProvider = savedUserProviders.some( + provider => + provider && provider.adapter && provider.adapter.policy === 'solo' && authData[provider.name] + ); + + // Solo providers can be considered as safe, so we do not have to check if the user needs + // to provide an additional provider to login. An auth adapter with "solo" (like webauthn) means + // no "additional" auth needs to be provided to login (like OTP, MFA) + if (hasProvidedASoloProvider) { + return; + } + + const additionProvidersNotFound = []; + const hasProvidedAtLeastOneAdditionalProvider = savedUserProviders.some(provider => { + let policy = provider.adapter.policy; + if (typeof policy === 'function') { + const requestObject = { + ip: req.config.ip, + user: req.auth.user, + master: req.auth.isMaster, + }; + policy = policy.call(provider.adapter, requestObject, userAuthData[provider.name]); + } + if (policy === 'additional') { + if (authData[provider.name]) { + return true; + } else { + // Push missing provider for error message + additionProvidersNotFound.push(provider.name); + } + } + }); + if (hasProvidedAtLeastOneAdditionalProvider || !additionProvidersNotFound.length) { + return; + } + + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + `Missing additional authData ${additionProvidersNotFound.join(',')}` + ); +}; + +// Validate each authData step-by-step and return the provider responses +const handleAuthDataValidation = async (authData, req, foundUser) => { + let user; + if (foundUser) { + user = Parse.User.fromJSON({ className: '_User', ...foundUser }); + // Find user by session and current objectId; only pass user if it's the current user or master key is provided + } else if ( + (req.auth && + req.auth.user && + typeof req.getUserId === 'function' && + req.getUserId() === req.auth.user.id) || + (req.auth && req.auth.isMaster && typeof req.getUserId === 'function' && req.getUserId()) + ) { + user = new Parse.User(); + user.id = req.auth.isMaster ? req.getUserId() : req.auth.user.id; + await user.fetch({ useMasterKey: true }); + } + + const { updatedObject } = req.buildParseObjects(); + const requestObject = getRequestObject(undefined, req.auth, updatedObject, user, req.config); + // Perform validation as step-by-step pipeline for better error consistency + // and also to avoid to trigger a provider (like OTP SMS) if another one fails + const acc = { authData: {}, authDataResponse: {} }; + const authKeys = Object.keys(authData).sort(); + for (const provider of authKeys) { + let method = ''; + try { + if (authData[provider] === null) { + acc.authData[provider] = null; + continue; + } + const { validator } = req.config.authDataManager.getValidatorForProvider(provider) || {}; + const authProvider = (req.config.auth || {})[provider] || {}; + if (!validator || authProvider.enabled === false) { + throw new Parse.Error( + Parse.Error.UNSUPPORTED_SERVICE, + 'This authentication method is unsupported.' + ); + } + let validationResult = await validator(authData[provider], req, user, requestObject); + method = validationResult && validationResult.method; + requestObject.triggerName = method; + if (validationResult && validationResult.validator) { + validationResult = await validationResult.validator(); + } + if (!validationResult) { + acc.authData[provider] = authData[provider]; + continue; + } + if (!Object.keys(validationResult).length) { + acc.authData[provider] = authData[provider]; + continue; + } + + if (validationResult.response) { + acc.authDataResponse[provider] = validationResult.response; + } + // Some auth providers after initialization will avoid to replace authData already stored + if (!validationResult.doNotSave) { + acc.authData[provider] = validationResult.save || authData[provider]; + } + } catch (err) { + const e = resolveError(err, { + code: Parse.Error.SCRIPT_FAILED, + message: 'Auth failed. Unknown error.', + }); + const userString = + req.auth && req.auth.user ? req.auth.user.id : req.data.objectId || undefined; + logger.error( + `Failed running auth step ${method} for ${provider} for user ${userString} with Error: ` + + JSON.stringify(e), + { + authenticationStep: method, + error: e, + user: userString, + provider, + } + ); + throw e; + } + } + return acc; +}; module.exports = { - Auth: Auth, - master: master, - nobody: nobody, - getAuthForSessionToken: getAuthForSessionToken + Auth, + master, + maintenance, + nobody, + readOnly, + shouldUpdateSessionExpiry, + getAuthForSessionToken, + getAuthForLegacySessionToken, + findUsersWithAuthData, + hasMutatedAuthData, + checkIfUserHasProvidedConfiguredProvidersForLogin, + handleAuthDataValidation, }; diff --git a/src/AuthDataLock.js b/src/AuthDataLock.js new file mode 100644 index 0000000000..c809a8b3d8 --- /dev/null +++ b/src/AuthDataLock.js @@ -0,0 +1,47 @@ +// Apply optimistic locking for authData provider field changes. For each lockable +// top-level field in the original authData whose value differs from the incoming +// value, add an equality constraint for the original value to the update WHERE +// clause. Concurrent requests racing the same single-use token will only allow the +// first update to match; subsequent updates miss and surface as OBJECT_NOT_FOUND. +// +// Only fields whose values round-trip cleanly through both storage adapters are +// locked: primitives (string, number, boolean) and arrays. Date values and nested +// objects are skipped because their JSON representation differs between the +// MongoDB and Postgres adapters, and because Parse Server's query-key validator +// rejects deeper paths containing characters like `+` (e.g. phone-number keys). +// Locking the consumed single-use credential (the MFA token string or the +// recovery-code array) is sufficient — its removal invalidates the WHERE clause +// for concurrent writers. +export function applyAuthDataOptimisticLock(query, originalAuthData, newAuthData) { + if (!originalAuthData) { + return; + } + for (const provider of Object.keys(newAuthData)) { + const original = originalAuthData[provider]; + if (!original || typeof original !== 'object') { + continue; + } + for (const [field, value] of Object.entries(original)) { + if (!isLockableAuthDataValue(value)) { + continue; + } + if (JSON.stringify(value) !== JSON.stringify(newAuthData[provider]?.[field])) { + query[`authData.${provider}.${field}`] = value; + } + } + } +} + +function isLockableAuthDataValue(value) { + if (value === null || value === undefined) { + return false; + } + const t = typeof value; + if (t === 'string' || t === 'number' || t === 'boolean') { + return true; + } + if (Array.isArray(value)) { + return true; + } + return false; +} diff --git a/src/ClientSDK.js b/src/ClientSDK.js index 4eebf203e5..698729fc4f 100644 --- a/src/ClientSDK.js +++ b/src/ClientSDK.js @@ -1,7 +1,7 @@ var semver = require('semver'); function compatible(compatibleSDK) { - return function(clientSDK) { + return function (clientSDK) { if (typeof clientSDK === 'string') { clientSDK = fromString(clientSDK); } @@ -9,26 +9,26 @@ function compatible(compatibleSDK) { if (!clientSDK) { return true; } - let clientVersion = clientSDK.version; - let compatiblityVersion = compatibleSDK[clientSDK.sdk]; + const clientVersion = clientSDK.version; + const compatiblityVersion = compatibleSDK[clientSDK.sdk]; return semver.satisfies(clientVersion, compatiblityVersion); - } + }; } function supportsForwardDelete(clientSDK) { return compatible({ - js: '>=1.9.0' + js: '>=1.9.0', })(clientSDK); } function fromString(version) { - let versionRE = /([-a-zA-Z]+)([0-9\.]+)/; - let match = version.toLowerCase().match(versionRE); + const versionRE = /([-a-zA-Z]+)([0-9\.]+)/; + const match = version.toLowerCase().match(versionRE); if (match && match.length === 3) { return { sdk: match[1], - version: match[2] - } + version: match[2], + }; } return undefined; } @@ -36,5 +36,5 @@ function fromString(version) { module.exports = { compatible, supportsForwardDelete, - fromString -} + fromString, +}; diff --git a/src/Config.js b/src/Config.js index c6cd30aab6..95543c6c6b 100644 --- a/src/Config.js +++ b/src/Config.js @@ -2,117 +2,646 @@ // configured. // mount is the URL for the root of the API; includes http, domain, etc. +import { isBoolean, isString } from 'lodash'; +import { pathToRegexp } from 'path-to-regexp'; +import net from 'net'; import AppCache from './cache'; -import SchemaCache from './Controllers/SchemaCache'; import DatabaseController from './Controllers/DatabaseController'; +import { logLevels as validLogLevels } from './Controllers/LoggerController'; +import { version } from '../package.json'; +import { + AccountLockoutOptions, + DatabaseOptions, + FileDownloadOptions, + FileUploadOptions, + IdempotencyOptions, + InstallationOptions, + LiveQueryOptions, + LogLevels, + PagesOptions, + ParseServerOptions, + SchemaOptions, + RequestComplexityOptions, + SecurityOptions, +} from './Options/Definitions'; +import ParseServer from './cloud-code/Parse.Server'; +import Deprecator from './Deprecator/Deprecator'; +import Utils from './Utils'; function removeTrailingSlash(str) { if (!str) { return str; } - if (str.endsWith("/")) { - str = str.substr(0, str.length-1); + if (str.endsWith('/')) { + str = str.substring(0, str.length - 1); } return str; } +/** + * Config keys that need to be loaded asynchronously. + */ +const asyncKeys = ['publicServerURL']; + export class Config { - constructor(applicationId: string, mount: string) { - let cacheInfo = AppCache.get(applicationId); + static get(applicationId: string, mount: string) { + const cacheInfo = AppCache.get(applicationId); if (!cacheInfo) { return; } + const config = new Config(); + config.applicationId = applicationId; + Object.keys(cacheInfo).forEach(key => { + if (key == 'databaseController') { + config.database = new DatabaseController(cacheInfo.databaseController.adapter, config); + } else { + config[key] = cacheInfo[key]; + } + }); + config.mount = removeTrailingSlash(mount); + config.generateSessionExpiresAt = config.generateSessionExpiresAt.bind(config); + config.generateEmailVerifyTokenExpiresAt = config.generateEmailVerifyTokenExpiresAt.bind( + config + ); + config.version = version; + return config; + } - this.applicationId = applicationId; - this.jsonLogs = cacheInfo.jsonLogs; - this.masterKey = cacheInfo.masterKey; - this.clientKey = cacheInfo.clientKey; - this.javascriptKey = cacheInfo.javascriptKey; - this.dotNetKey = cacheInfo.dotNetKey; - this.restAPIKey = cacheInfo.restAPIKey; - this.webhookKey = cacheInfo.webhookKey; - this.fileKey = cacheInfo.fileKey; - this.facebookAppIds = cacheInfo.facebookAppIds; - this.allowClientClassCreation = cacheInfo.allowClientClassCreation; - - // Create a new DatabaseController per request - if (cacheInfo.databaseController) { - const schemaCache = new SchemaCache(cacheInfo.cacheController, cacheInfo.schemaCacheTTL); - this.database = new DatabaseController(cacheInfo.databaseController.adapter, schemaCache); - } - - this.schemaCacheTTL = cacheInfo.schemaCacheTTL; - - this.serverURL = cacheInfo.serverURL; - this.publicServerURL = removeTrailingSlash(cacheInfo.publicServerURL); - this.verifyUserEmails = cacheInfo.verifyUserEmails; - this.preventLoginWithUnverifiedEmail = cacheInfo.preventLoginWithUnverifiedEmail; - this.emailVerifyTokenValidityDuration = cacheInfo.emailVerifyTokenValidityDuration; - this.appName = cacheInfo.appName; - - this.analyticsController = cacheInfo.analyticsController; - this.cacheController = cacheInfo.cacheController; - this.hooksController = cacheInfo.hooksController; - this.filesController = cacheInfo.filesController; - this.pushController = cacheInfo.pushController; - this.loggerController = cacheInfo.loggerController; - this.userController = cacheInfo.userController; - this.authDataManager = cacheInfo.authDataManager; - this.customPages = cacheInfo.customPages || {}; - this.mount = removeTrailingSlash(mount); - this.liveQueryController = cacheInfo.liveQueryController; - this.sessionLength = cacheInfo.sessionLength; - this.expireInactiveSessions = cacheInfo.expireInactiveSessions; - this.generateSessionExpiresAt = this.generateSessionExpiresAt.bind(this); - this.generateEmailVerifyTokenExpiresAt = this.generateEmailVerifyTokenExpiresAt.bind(this); - this.revokeSessionOnPasswordReset = cacheInfo.revokeSessionOnPasswordReset; - } - - static validate({ - verifyUserEmails, - userController, - appName, + async loadKeys() { + await Promise.all( + asyncKeys.map(async key => { + if (typeof this[`_${key}`] === 'function') { + try { + this[key] = await this[`_${key}`](); + } catch (error) { + throw new Error(`Failed to resolve async config key '${key}': ${error.message}`); + } + } + }) + ); + + const cachedConfig = AppCache.get(this.appId); + if (cachedConfig) { + const updatedConfig = { ...cachedConfig }; + asyncKeys.forEach(key => { + updatedConfig[key] = this[key]; + }); + AppCache.put(this.appId, updatedConfig); + } + } + + static transformConfiguration(serverConfiguration) { + for (const key of Object.keys(serverConfiguration)) { + if (asyncKeys.includes(key) && typeof serverConfiguration[key] === 'function') { + serverConfiguration[`_${key}`] = serverConfiguration[key]; + delete serverConfiguration[key]; + } + } + } + + static put(serverConfiguration) { + Config.validateOptions(serverConfiguration); + Config.validateControllers(serverConfiguration); + if (serverConfiguration.routeAllowList) { + serverConfiguration._routeAllowListRegex = serverConfiguration.routeAllowList.map( + pattern => new RegExp('^' + pattern + '$') + ); + } + Config.transformConfiguration(serverConfiguration); + AppCache.put(serverConfiguration.appId, serverConfiguration); + Config.setupPasswordValidator(serverConfiguration.passwordPolicy); + return serverConfiguration; + } + + static validateOptions({ + customPages, publicServerURL, revokeSessionOnPasswordReset, expireInactiveSessions, sessionLength, - emailVerifyTokenValidityDuration + defaultLimit, + maxLimit, + accountLockout, + passwordPolicy, + masterKeyIps, + masterKey, + maintenanceKey, + maintenanceKeyIps, + readOnlyMasterKey, + readOnlyMasterKeyIps, + allowHeaders, + idempotencyOptions, + fileUpload, + fileDownload, + pages, + security, + enforcePrivateUsers, + enableInsecureAuthAdapters, + schema, + requestKeywordDenylist, + allowExpiredAuthDataToken, + logLevels, + rateLimit, + databaseOptions, + extendSessionOnUse, + allowClientClassCreation, + requestComplexity, + liveQuery, + routeAllowList, + installation, }) { - const emailAdapter = userController.adapter; - if (verifyUserEmails) { - this.validateEmailConfiguration({emailAdapter, appName, publicServerURL, emailVerifyTokenValidityDuration}); + if (masterKey === readOnlyMasterKey) { + throw new Error('masterKey and readOnlyMasterKey should be different'); } + if (masterKey === maintenanceKey) { + throw new Error('masterKey and maintenanceKey should be different'); + } + + this.validateAccountLockoutPolicy(accountLockout); + this.validatePasswordPolicy(passwordPolicy); + this.validateFileUploadOptions(fileUpload); + if (fileDownload == null) { + fileDownload = {}; + arguments[0].fileDownload = fileDownload; + } + this.validateFileDownloadOptions(fileDownload); + if (typeof revokeSessionOnPasswordReset !== 'boolean') { throw 'revokeSessionOnPasswordReset must be a boolean value'; } - if (publicServerURL) { - if (!publicServerURL.startsWith("http://") && !publicServerURL.startsWith("https://")) { - throw "publicServerURL should be a valid HTTPS URL starting with https://" - } + if (typeof extendSessionOnUse !== 'boolean') { + throw 'extendSessionOnUse must be a boolean value'; } + this.validatePublicServerURL({ publicServerURL }); this.validateSessionConfiguration(sessionLength, expireInactiveSessions); + this.validateIps('masterKeyIps', masterKeyIps); + this.validateIps('maintenanceKeyIps', maintenanceKeyIps); + this.validateIps('readOnlyMasterKeyIps', readOnlyMasterKeyIps); + this.validateDefaultLimit(defaultLimit); + this.validateMaxLimit(maxLimit); + this.validateAllowHeaders(allowHeaders); + this.validateIdempotencyOptions(idempotencyOptions); + this.validatePagesOptions(pages); + this.validateSecurityOptions(security); + this.validateSchemaOptions(schema); + this.validateEnforcePrivateUsers(enforcePrivateUsers); + this.validateEnableInsecureAuthAdapters(enableInsecureAuthAdapters); + this.validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken); + this.validateRequestKeywordDenylist(requestKeywordDenylist); + this.validateRateLimit(rateLimit); + this.validateLogLevels(logLevels); + this.validateDatabaseOptions(databaseOptions); + this.validateCustomPages(customPages); + this.validateAllowClientClassCreation(allowClientClassCreation); + this.validateRequestComplexity(requestComplexity); + this.validateLiveQueryOptions(liveQuery); + this.validateRouteAllowList(routeAllowList); + this.validateInstallation(installation); } -static validateEmailConfiguration({emailAdapter, appName, publicServerURL, emailVerifyTokenValidityDuration}) { + static validateCustomPages(customPages) { + if (!customPages) { return; } + + if (Object.prototype.toString.call(customPages) !== '[object Object]') { + throw Error('Parse Server option customPages must be an object.'); + } + } + + static validateControllers({ + verifyUserEmails, + userController, + appName, + publicServerURL, + _publicServerURL, + emailVerifyTokenValidityDuration, + emailVerifyTokenReuseIfValid, + emailVerifySuccessOnInvalidEmail, + }) { + const emailAdapter = userController.adapter; + if (verifyUserEmails) { + this.validateEmailConfiguration({ + emailAdapter, + appName, + publicServerURL: publicServerURL || _publicServerURL, + emailVerifyTokenValidityDuration, + emailVerifyTokenReuseIfValid, + emailVerifySuccessOnInvalidEmail, + }); + } + } + + static validateRequestKeywordDenylist(requestKeywordDenylist) { + if (requestKeywordDenylist === undefined) { + requestKeywordDenylist = requestKeywordDenylist.default; + } else if (!Array.isArray(requestKeywordDenylist)) { + throw 'Parse Server option requestKeywordDenylist must be an array.'; + } + } + + static validateEnforcePrivateUsers(enforcePrivateUsers) { + if (typeof enforcePrivateUsers !== 'boolean') { + throw 'Parse Server option enforcePrivateUsers must be a boolean.'; + } + } + + static validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken) { + if (typeof allowExpiredAuthDataToken !== 'boolean') { + throw 'Parse Server option allowExpiredAuthDataToken must be a boolean.'; + } + } + + static validateAllowClientClassCreation(allowClientClassCreation) { + if (typeof allowClientClassCreation !== 'boolean') { + throw 'Parse Server option allowClientClassCreation must be a boolean.'; + } + } + + static validateSecurityOptions(security) { + if (Object.prototype.toString.call(security) !== '[object Object]') { + throw 'Parse Server option security must be an object.'; + } + if (security.enableCheck === undefined) { + security.enableCheck = SecurityOptions.enableCheck.default; + } else if (!isBoolean(security.enableCheck)) { + throw 'Parse Server option security.enableCheck must be a boolean.'; + } + if (security.enableCheckLog === undefined) { + security.enableCheckLog = SecurityOptions.enableCheckLog.default; + } else if (!isBoolean(security.enableCheckLog)) { + throw 'Parse Server option security.enableCheckLog must be a boolean.'; + } + } + + static validateSchemaOptions(schema: SchemaOptions) { + if (!schema) { return; } + if (Object.prototype.toString.call(schema) !== '[object Object]') { + throw 'Parse Server option schema must be an object.'; + } + if (schema.definitions === undefined) { + schema.definitions = SchemaOptions.definitions.default; + } else if (!Array.isArray(schema.definitions)) { + throw 'Parse Server option schema.definitions must be an array.'; + } + if (schema.strict === undefined) { + schema.strict = SchemaOptions.strict.default; + } else if (!isBoolean(schema.strict)) { + throw 'Parse Server option schema.strict must be a boolean.'; + } + if (schema.deleteExtraFields === undefined) { + schema.deleteExtraFields = SchemaOptions.deleteExtraFields.default; + } else if (!isBoolean(schema.deleteExtraFields)) { + throw 'Parse Server option schema.deleteExtraFields must be a boolean.'; + } + if (schema.recreateModifiedFields === undefined) { + schema.recreateModifiedFields = SchemaOptions.recreateModifiedFields.default; + } else if (!isBoolean(schema.recreateModifiedFields)) { + throw 'Parse Server option schema.recreateModifiedFields must be a boolean.'; + } + if (schema.lockSchemas === undefined) { + schema.lockSchemas = SchemaOptions.lockSchemas.default; + } else if (!isBoolean(schema.lockSchemas)) { + throw 'Parse Server option schema.lockSchemas must be a boolean.'; + } + if (schema.beforeMigration === undefined) { + schema.beforeMigration = null; + } else if (schema.beforeMigration !== null && typeof schema.beforeMigration !== 'function') { + throw 'Parse Server option schema.beforeMigration must be a function.'; + } + if (schema.afterMigration === undefined) { + schema.afterMigration = null; + } else if (schema.afterMigration !== null && typeof schema.afterMigration !== 'function') { + throw 'Parse Server option schema.afterMigration must be a function.'; + } + } + + static validatePagesOptions(pages) { + if (Object.prototype.toString.call(pages) !== '[object Object]') { + throw 'Parse Server option pages must be an object.'; + } + if (pages.enableLocalization === undefined) { + pages.enableLocalization = PagesOptions.enableLocalization.default; + } else if (!isBoolean(pages.enableLocalization)) { + throw 'Parse Server option pages.enableLocalization must be a boolean.'; + } + if (pages.localizationJsonPath === undefined) { + pages.localizationJsonPath = PagesOptions.localizationJsonPath.default; + } else if (!isString(pages.localizationJsonPath)) { + throw 'Parse Server option pages.localizationJsonPath must be a string.'; + } + if (pages.localizationFallbackLocale === undefined) { + pages.localizationFallbackLocale = PagesOptions.localizationFallbackLocale.default; + } else if (!isString(pages.localizationFallbackLocale)) { + throw 'Parse Server option pages.localizationFallbackLocale must be a string.'; + } + if (pages.placeholders === undefined) { + pages.placeholders = PagesOptions.placeholders.default; + } else if ( + Object.prototype.toString.call(pages.placeholders) !== '[object Object]' && + typeof pages.placeholders !== 'function' + ) { + throw 'Parse Server option pages.placeholders must be an object or a function.'; + } + if (pages.forceRedirect === undefined) { + pages.forceRedirect = PagesOptions.forceRedirect.default; + } else if (!isBoolean(pages.forceRedirect)) { + throw 'Parse Server option pages.forceRedirect must be a boolean.'; + } + if (pages.pagesPath !== undefined && !isString(pages.pagesPath)) { + throw 'Parse Server option pages.pagesPath must be a string.'; + } + if (pages.pagesEndpoint === undefined) { + pages.pagesEndpoint = PagesOptions.pagesEndpoint.default; + } else if (!isString(pages.pagesEndpoint)) { + throw 'Parse Server option pages.pagesEndpoint must be a string.'; + } + if (pages.customUrls === undefined) { + pages.customUrls = PagesOptions.customUrls.default; + } else if (Object.prototype.toString.call(pages.customUrls) !== '[object Object]') { + throw 'Parse Server option pages.customUrls must be an object.'; + } + if (pages.customRoutes === undefined) { + pages.customRoutes = PagesOptions.customRoutes.default; + } else if (!Array.isArray(pages.customRoutes)) { + throw 'Parse Server option pages.customRoutes must be an array.'; + } + if (pages.encodePageParamHeaders === undefined) { + pages.encodePageParamHeaders = PagesOptions.encodePageParamHeaders.default; + } else if (!isBoolean(pages.encodePageParamHeaders)) { + throw 'Parse Server option pages.encodePageParamHeaders must be a boolean.'; + } + } + + 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 (!Array.isArray(idempotencyOptions.paths)) { + throw 'idempotency paths must be of an array of strings'; + } + } + + static validateAccountLockoutPolicy(accountLockout) { + if (accountLockout) { + if ( + typeof accountLockout.duration !== 'number' || + accountLockout.duration <= 0 || + accountLockout.duration > 99999 + ) { + throw 'Account lockout duration should be greater than 0 and less than 100000'; + } + + if ( + !Number.isInteger(accountLockout.threshold) || + accountLockout.threshold < 1 || + accountLockout.threshold > 999 + ) { + throw 'Account lockout threshold should be an integer greater than 0 and less than 1000'; + } + + if (accountLockout.unlockOnPasswordReset === undefined) { + accountLockout.unlockOnPasswordReset = AccountLockoutOptions.unlockOnPasswordReset.default; + } else if (!isBoolean(accountLockout.unlockOnPasswordReset)) { + throw 'Parse Server option accountLockout.unlockOnPasswordReset must be a boolean.'; + } + } + } + + static validatePasswordPolicy(passwordPolicy) { + if (passwordPolicy) { + if ( + passwordPolicy.maxPasswordAge !== undefined && + (typeof passwordPolicy.maxPasswordAge !== 'number' || passwordPolicy.maxPasswordAge < 0) + ) { + throw 'passwordPolicy.maxPasswordAge must be a positive number'; + } + + if ( + passwordPolicy.resetTokenValidityDuration !== undefined && + (typeof passwordPolicy.resetTokenValidityDuration !== 'number' || + passwordPolicy.resetTokenValidityDuration <= 0) + ) { + throw 'passwordPolicy.resetTokenValidityDuration must be a positive number'; + } + + if (passwordPolicy.validatorPattern) { + if (typeof passwordPolicy.validatorPattern === 'string') { + passwordPolicy.validatorPattern = new RegExp(passwordPolicy.validatorPattern); + } else if (!Utils.isRegExp(passwordPolicy.validatorPattern)) { + throw 'passwordPolicy.validatorPattern must be a regex string or RegExp object.'; + } + } + + if ( + passwordPolicy.validatorCallback && + typeof passwordPolicy.validatorCallback !== 'function' + ) { + throw 'passwordPolicy.validatorCallback must be a function.'; + } + + if ( + passwordPolicy.doNotAllowUsername && + typeof passwordPolicy.doNotAllowUsername !== 'boolean' + ) { + throw 'passwordPolicy.doNotAllowUsername must be a boolean value.'; + } + + if ( + passwordPolicy.maxPasswordHistory && + (!Number.isInteger(passwordPolicy.maxPasswordHistory) || + passwordPolicy.maxPasswordHistory <= 0 || + passwordPolicy.maxPasswordHistory > 20) + ) { + throw 'passwordPolicy.maxPasswordHistory must be an integer ranging 0 - 20'; + } + + if ( + passwordPolicy.resetTokenReuseIfValid && + typeof passwordPolicy.resetTokenReuseIfValid !== 'boolean' + ) { + throw 'resetTokenReuseIfValid must be a boolean value'; + } + if (passwordPolicy.resetTokenReuseIfValid && !passwordPolicy.resetTokenValidityDuration) { + throw 'You cannot use resetTokenReuseIfValid without resetTokenValidityDuration'; + } + + if ( + passwordPolicy.resetPasswordSuccessOnInvalidEmail !== undefined && + typeof passwordPolicy.resetPasswordSuccessOnInvalidEmail !== 'boolean' + ) { + throw 'resetPasswordSuccessOnInvalidEmail must be a boolean value'; + } + + } + } + + // if the passwordPolicy.validatorPattern is configured then setup a callback to process the pattern + static setupPasswordValidator(passwordPolicy) { + if (passwordPolicy && passwordPolicy.validatorPattern) { + passwordPolicy.patternValidator = value => { + return passwordPolicy.validatorPattern.test(value); + }; + } + } + + static validatePublicServerURL({ publicServerURL, required = false }) { + if (!publicServerURL) { + if (!required) { + return; + } + throw 'The option publicServerURL is required.'; + } + + const type = typeof publicServerURL; + + if (type === 'string') { + if (!publicServerURL.startsWith('http://') && !publicServerURL.startsWith('https://')) { + throw 'The option publicServerURL must be a valid URL starting with http:// or https://.'; + } + return; + } + + if (type === 'function') { + return; + } + + throw `The option publicServerURL must be a string or function, but got ${type}.`; + } + + static validateEmailConfiguration({ + emailAdapter, + appName, + publicServerURL, + emailVerifyTokenValidityDuration, + emailVerifyTokenReuseIfValid, + emailVerifySuccessOnInvalidEmail, + }) { if (!emailAdapter) { throw 'An emailAdapter is required for e-mail verification and password resets.'; } if (typeof appName !== 'string') { throw 'An app name is required for e-mail verification and password resets.'; } - if (typeof publicServerURL !== 'string') { - throw 'A public server url is required for e-mail verification and password resets.'; - } + this.validatePublicServerURL({ publicServerURL, required: true }); if (emailVerifyTokenValidityDuration) { if (isNaN(emailVerifyTokenValidityDuration)) { throw 'Email verify token validity duration must be a valid number.'; } else if (emailVerifyTokenValidityDuration <= 0) { - throw 'Email verify token validity duration must be a value greater than 0.' + throw 'Email verify token validity duration must be a value greater than 0.'; } } + if (emailVerifyTokenReuseIfValid && typeof emailVerifyTokenReuseIfValid !== 'boolean') { + throw 'emailVerifyTokenReuseIfValid must be a boolean value'; + } + if (emailVerifyTokenReuseIfValid && !emailVerifyTokenValidityDuration) { + throw 'You cannot use emailVerifyTokenReuseIfValid without emailVerifyTokenValidityDuration'; + } + if (emailVerifySuccessOnInvalidEmail !== undefined && typeof emailVerifySuccessOnInvalidEmail !== 'boolean') { + throw 'emailVerifySuccessOnInvalidEmail must be a boolean value'; + } + } + + static validateFileUploadOptions(fileUpload) { + try { + if (fileUpload == null || typeof fileUpload !== 'object' || Array.isArray(fileUpload)) { + throw 'fileUpload must be an object value.'; + } + } catch (e) { + if (e instanceof ReferenceError) { + return; + } + throw e; + } + if (fileUpload.enableForAnonymousUser === undefined) { + fileUpload.enableForAnonymousUser = FileUploadOptions.enableForAnonymousUser.default; + } else if (typeof fileUpload.enableForAnonymousUser !== 'boolean') { + throw 'fileUpload.enableForAnonymousUser must be a boolean value.'; + } + if (fileUpload.enableForPublic === undefined) { + fileUpload.enableForPublic = FileUploadOptions.enableForPublic.default; + } else if (typeof fileUpload.enableForPublic !== 'boolean') { + throw 'fileUpload.enableForPublic must be a boolean value.'; + } + if (fileUpload.enableForAuthenticatedUser === undefined) { + fileUpload.enableForAuthenticatedUser = FileUploadOptions.enableForAuthenticatedUser.default; + } else if (typeof fileUpload.enableForAuthenticatedUser !== 'boolean') { + throw 'fileUpload.enableForAuthenticatedUser must be a boolean value.'; + } + if (fileUpload.fileExtensions === undefined) { + fileUpload.fileExtensions = FileUploadOptions.fileExtensions.default; + } else if (!Array.isArray(fileUpload.fileExtensions)) { + throw 'fileUpload.fileExtensions must be an array.'; + } + if (fileUpload.allowedFileUrlDomains === undefined) { + fileUpload.allowedFileUrlDomains = FileUploadOptions.allowedFileUrlDomains.default; + } else if (!Array.isArray(fileUpload.allowedFileUrlDomains)) { + throw 'fileUpload.allowedFileUrlDomains must be an array.'; + } else { + for (const domain of fileUpload.allowedFileUrlDomains) { + if (typeof domain !== 'string' || domain === '') { + throw 'fileUpload.allowedFileUrlDomains must contain only non-empty strings.'; + } + } + } + } + + static validateFileDownloadOptions(fileDownload) { + try { + if (fileDownload == null || typeof fileDownload !== 'object' || Array.isArray(fileDownload)) { + throw 'fileDownload must be an object value.'; + } + } catch (e) { + if (e instanceof ReferenceError) { + return; + } + throw e; + } + if (fileDownload.enableForAnonymousUser === undefined) { + fileDownload.enableForAnonymousUser = FileDownloadOptions.enableForAnonymousUser.default; + } else if (typeof fileDownload.enableForAnonymousUser !== 'boolean') { + throw 'fileDownload.enableForAnonymousUser must be a boolean value.'; + } + if (fileDownload.enableForPublic === undefined) { + fileDownload.enableForPublic = FileDownloadOptions.enableForPublic.default; + } else if (typeof fileDownload.enableForPublic !== 'boolean') { + throw 'fileDownload.enableForPublic must be a boolean value.'; + } + if (fileDownload.enableForAuthenticatedUser === undefined) { + fileDownload.enableForAuthenticatedUser = FileDownloadOptions.enableForAuthenticatedUser.default; + } else if (typeof fileDownload.enableForAuthenticatedUser !== 'boolean') { + throw 'fileDownload.enableForAuthenticatedUser must be a boolean value.'; + } + } + + static validateIps(field, masterKeyIps) { + for (let ip of masterKeyIps) { + if (ip.includes('/')) { + ip = ip.split('/')[0]; + } + if (!net.isIP(ip)) { + throw `The Parse Server option "${field}" contains an invalid IP address "${ip}".`; + } + } + } + + static validateEnableInsecureAuthAdapters(enableInsecureAuthAdapters) { + if (enableInsecureAuthAdapters && typeof enableInsecureAuthAdapters !== 'boolean') { + throw 'Parse Server option enableInsecureAuthAdapters must be a boolean.'; + } + if (enableInsecureAuthAdapters) { + Deprecator.logRuntimeDeprecation({ usage: 'insecure adapter' }); + } } get mount() { @@ -131,9 +660,233 @@ static validateEmailConfiguration({emailAdapter, appName, publicServerURL, email if (expireInactiveSessions) { if (isNaN(sessionLength)) { throw 'Session length must be a valid number.'; + } else if (sessionLength <= 0) { + throw 'Session length must be a value greater than 0.'; + } + } + } + + static validateDefaultLimit(defaultLimit) { + if (defaultLimit == null) { + defaultLimit = ParseServerOptions.defaultLimit.default; + } + if (typeof defaultLimit !== 'number') { + throw 'Default limit must be a number.'; + } + if (defaultLimit <= 0) { + throw 'Default limit must be a value greater than 0.'; + } + } + + static validateMaxLimit(maxLimit) { + if (maxLimit <= 0) { + throw 'Max limit must be a value greater than 0.'; + } + } + + static validateRequestComplexity(requestComplexity) { + if (requestComplexity == null) { + return; + } + if (typeof requestComplexity !== 'object' || Array.isArray(requestComplexity)) { + throw new Error('requestComplexity must be an object.'); + } + const validKeys = Object.keys(RequestComplexityOptions); + for (const key of Object.keys(requestComplexity)) { + if (!validKeys.includes(key)) { + throw new Error(`requestComplexity contains unknown property '${key}'.`); + } + } + for (const key of validKeys) { + if (requestComplexity[key] !== undefined) { + const value = requestComplexity[key]; + const def = RequestComplexityOptions[key]; + if (typeof def.default === 'boolean') { + if (typeof value !== 'boolean') { + throw new Error(`requestComplexity.${key} must be a boolean.`); + } + } else if (!Number.isInteger(value) || (value < 1 && value !== -1)) { + throw new Error(`requestComplexity.${key} must be a positive integer or -1 to disable.`); + } + } else { + requestComplexity[key] = RequestComplexityOptions[key].default; + } + } + } + + static validateInstallation(installation) { + if (installation === undefined) { + return; + } + if (typeof installation !== 'object' || Array.isArray(installation) || installation === null) { + throw 'installation must be an object.'; + } + const validKeys = [ + 'duplicateDeviceTokenActionEnforceAuth', + 'duplicateDeviceTokenAction', + 'duplicateDeviceTokenMergePriority', + ]; + for (const key of Object.keys(installation)) { + if (!validKeys.includes(key)) { + throw `installation contains unknown property '${key}'.`; + } + } + if (installation.duplicateDeviceTokenActionEnforceAuth === undefined) { + installation.duplicateDeviceTokenActionEnforceAuth = + InstallationOptions.duplicateDeviceTokenActionEnforceAuth.default; + } else if (typeof installation.duplicateDeviceTokenActionEnforceAuth !== 'boolean') { + throw 'installation.duplicateDeviceTokenActionEnforceAuth must be a boolean.'; + } + const validActions = ['delete', 'update']; + if (installation.duplicateDeviceTokenAction === undefined) { + installation.duplicateDeviceTokenAction = + InstallationOptions.duplicateDeviceTokenAction.default; + } else if (!validActions.includes(installation.duplicateDeviceTokenAction)) { + throw "installation.duplicateDeviceTokenAction must be one of: 'delete', 'update'."; + } + const validPriorities = ['deviceToken', 'installationId']; + if (installation.duplicateDeviceTokenMergePriority === undefined) { + installation.duplicateDeviceTokenMergePriority = + InstallationOptions.duplicateDeviceTokenMergePriority.default; + } else if (!validPriorities.includes(installation.duplicateDeviceTokenMergePriority)) { + throw "installation.duplicateDeviceTokenMergePriority must be one of: 'deviceToken', 'installationId'."; + } + } + + static validateAllowHeaders(allowHeaders) { + if (![null, undefined].includes(allowHeaders)) { + if (Array.isArray(allowHeaders)) { + allowHeaders.forEach(header => { + if (typeof header !== 'string') { + throw 'Allow headers must only contain strings'; + } else if (!header.trim().length) { + throw 'Allow headers must not contain empty strings'; + } + }); + } else { + throw 'Allow headers must be an array'; + } + } + } + + static validateLogLevels(logLevels) { + for (const key of Object.keys(LogLevels)) { + if (logLevels[key]) { + if (validLogLevels.indexOf(logLevels[key]) === -1) { + throw `'${key}' must be one of ${JSON.stringify(validLogLevels)}`; + } + } else { + logLevels[key] = LogLevels[key].default; + } + } + } + + static validateDatabaseOptions(databaseOptions) { + if (databaseOptions == undefined) { + return; + } + if (Object.prototype.toString.call(databaseOptions) !== '[object Object]') { + throw `databaseOptions must be an object`; + } + + if (databaseOptions.enableSchemaHooks === undefined) { + databaseOptions.enableSchemaHooks = DatabaseOptions.enableSchemaHooks.default; + } else if (typeof databaseOptions.enableSchemaHooks !== 'boolean') { + throw `databaseOptions.enableSchemaHooks must be a boolean`; + } + if (databaseOptions.schemaCacheTtl === undefined) { + databaseOptions.schemaCacheTtl = DatabaseOptions.schemaCacheTtl.default; + } else if (typeof databaseOptions.schemaCacheTtl !== 'number') { + throw `databaseOptions.schemaCacheTtl must be a number`; + } + if (databaseOptions.allowPublicExplain === undefined) { + databaseOptions.allowPublicExplain = DatabaseOptions.allowPublicExplain.default; + } else if (typeof databaseOptions.allowPublicExplain !== 'boolean') { + throw `Parse Server option 'databaseOptions.allowPublicExplain' must be a boolean.`; + } + } + + static validateLiveQueryOptions(liveQuery) { + if (liveQuery == undefined) { + return; + } + if (liveQuery.regexTimeout === undefined) { + liveQuery.regexTimeout = LiveQueryOptions.regexTimeout.default; + } else if (typeof liveQuery.regexTimeout !== 'number') { + throw `liveQuery.regexTimeout must be a number`; + } + } + + static validateRouteAllowList(routeAllowList) { + if (routeAllowList === undefined || routeAllowList === null) { + return; + } + if (!Array.isArray(routeAllowList)) { + throw 'Parse Server option routeAllowList must be an array of strings.'; + } + for (const pattern of routeAllowList) { + if (typeof pattern !== 'string') { + throw `Parse Server option routeAllowList contains a non-string value.`; + } + try { + new RegExp('^' + pattern + '$'); + } catch { + throw `Parse Server option routeAllowList contains an invalid regex pattern: "${pattern}".`; + } + } + } + + static validateRateLimit(rateLimit) { + if (!rateLimit) { + return; + } + if ( + Object.prototype.toString.call(rateLimit) !== '[object Object]' && + !Array.isArray(rateLimit) + ) { + throw `rateLimit must be an array or object`; + } + const options = Array.isArray(rateLimit) ? rateLimit : [rateLimit]; + for (const option of options) { + if (Object.prototype.toString.call(option) !== '[object Object]') { + throw `rateLimit must be an array of objects`; + } + if (option.requestPath == null) { + throw `rateLimit.requestPath must be defined`; + } + if (typeof option.requestPath !== 'string') { + throw `rateLimit.requestPath must be a string`; + } + + // Validate that the path is valid path-to-regexp syntax + try { + pathToRegexp(option.requestPath); + } catch (error) { + throw `rateLimit.requestPath "${option.requestPath}" is not valid: ${error.message}`; + } + + if (option.requestTimeWindow == null) { + throw `rateLimit.requestTimeWindow must be defined`; + } + if (typeof option.requestTimeWindow !== 'number') { + throw `rateLimit.requestTimeWindow must be a number`; + } + if (option.includeInternalRequests && typeof option.includeInternalRequests !== 'boolean') { + throw `rateLimit.includeInternalRequests must be a boolean`; + } + if (option.requestCount == null) { + throw `rateLimit.requestCount must be defined`; + } + if (typeof option.requestCount !== 'number') { + throw `rateLimit.requestCount must be a number`; } - else if (sessionLength <= 0) { - throw 'Session length must be a value greater than 0.' + if (option.errorResponseMessage && typeof option.errorResponseMessage !== 'string') { + throw `rateLimit.errorResponseMessage must be a string`; + } + const options = Object.keys(ParseServer.RateLimitZone); + if (option.zone && !options.includes(option.zone)) { + const formatter = new Intl.ListFormat('en', { style: 'short', type: 'disjunction' }); + throw `rateLimit.zone must be one of ${formatter.format(options)}`; } } } @@ -143,7 +896,15 @@ static validateEmailConfiguration({emailAdapter, appName, publicServerURL, email return undefined; } var now = new Date(); - return new Date(now.getTime() + (this.emailVerifyTokenValidityDuration*1000)); + return new Date(now.getTime() + this.emailVerifyTokenValidityDuration * 1000); + } + + generatePasswordResetTokenExpiresAt() { + if (!this.passwordPolicy || !this.passwordPolicy.resetTokenValidityDuration) { + return undefined; + } + const now = new Date(); + return new Date(now.getTime() + this.passwordPolicy.resetTokenValidityDuration * 1000); } generateSessionExpiresAt() { @@ -151,15 +912,45 @@ static validateEmailConfiguration({emailAdapter, appName, publicServerURL, email return undefined; } var now = new Date(); - return new Date(now.getTime() + (this.sessionLength*1000)); + return new Date(now.getTime() + this.sessionLength * 1000); + } + + unregisterRateLimiters() { + let i = this.rateLimits?.length; + while (i--) { + const limit = this.rateLimits[i]; + if (limit.cloud) { + this.rateLimits.splice(i, 1); + } + } } get invalidLinkURL() { return this.customPages.invalidLink || `${this.publicServerURL}/apps/invalid_link.html`; } + get invalidVerificationLinkURL() { + return ( + this.customPages.invalidVerificationLink || + `${this.publicServerURL}/apps/invalid_verification_link.html` + ); + } + + get linkSendSuccessURL() { + return ( + this.customPages.linkSendSuccess || `${this.publicServerURL}/apps/link_send_success.html` + ); + } + + get linkSendFailURL() { + return this.customPages.linkSendFail || `${this.publicServerURL}/apps/link_send_fail.html`; + } + get verifyEmailSuccessURL() { - return this.customPages.verifyEmailSuccess || `${this.publicServerURL}/apps/verify_email_success.html`; + return ( + this.customPages.verifyEmailSuccess || + `${this.publicServerURL}/apps/verify_email_success.html` + ); } get choosePasswordURL() { @@ -167,15 +958,49 @@ static validateEmailConfiguration({emailAdapter, appName, publicServerURL, email } get requestResetPasswordURL() { - return `${this.publicServerURL}/apps/${this.applicationId}/request_password_reset`; + return `${this.publicServerURL}/${this.pagesEndpoint}/${this.applicationId}/request_password_reset`; } get passwordResetSuccessURL() { - return this.customPages.passwordResetSuccess || `${this.publicServerURL}/apps/password_reset_success.html`; + return ( + this.customPages.passwordResetSuccess || + `${this.publicServerURL}/apps/password_reset_success.html` + ); + } + + get parseFrameURL() { + return this.customPages.parseFrameURL; } get verifyEmailURL() { - return `${this.publicServerURL}/apps/${this.applicationId}/verify_email`; + return `${this.publicServerURL}/${this.pagesEndpoint}/${this.applicationId}/verify_email`; + } + + async loadMasterKey() { + if (typeof this.masterKey === 'function') { + const ttlIsEmpty = !this.masterKeyTtl; + const isExpired = this.masterKeyCache?.expiresAt && this.masterKeyCache.expiresAt < new Date(); + + if ((!isExpired || ttlIsEmpty) && this.masterKeyCache?.masterKey) { + return this.masterKeyCache.masterKey; + } + + const masterKey = await this.masterKey(); + + const expiresAt = this.masterKeyTtl ? new Date(Date.now() + 1000 * this.masterKeyTtl) : null + this.masterKeyCache = { masterKey, expiresAt }; + Config.put(this); + + return this.masterKeyCache.masterKey; + } + + return this.masterKey; + } + + get pagesEndpoint() { + return this.pages && this.pages.pagesEndpoint + ? this.pages.pagesEndpoint + : 'apps'; } } diff --git a/src/Controllers/AdaptableController.js b/src/Controllers/AdaptableController.js index a7cf4e9ba9..610b48b3fd 100644 --- a/src/Controllers/AdaptableController.js +++ b/src/Controllers/AdaptableController.js @@ -10,10 +10,8 @@ based on the parameters passed // _adapter is private, use Symbol var _adapter = Symbol(); -import Config from '../Config'; export class AdaptableController { - constructor(adapter, appId, options) { this.options = options; this.appId = appId; @@ -29,36 +27,41 @@ export class AdaptableController { return this[_adapter]; } - get config() { - return new Config(this.appId); - } - expectedAdapterType() { - throw new Error("Subclasses should implement expectedAdapterType()"); + throw new Error('Subclasses should implement expectedAdapterType()'); } validateAdapter(adapter) { + AdaptableController.validateAdapter(adapter, this); + } + + static validateAdapter(adapter, self, ExpectedType) { if (!adapter) { - throw new Error(this.constructor.name+" requires an adapter"); + throw new Error(this.constructor.name + ' requires an adapter'); } - let Type = this.expectedAdapterType(); + const Type = ExpectedType || self.expectedAdapterType(); // Allow skipping for testing if (!Type) { return; } // Makes sure the prototype matches - let mismatches = Object.getOwnPropertyNames(Type.prototype).reduce( (obj, key) => { - const adapterType = typeof adapter[key]; - const expectedType = typeof Type.prototype[key]; - if (adapterType !== expectedType) { - obj[key] = { - expected: expectedType, - actual: adapterType - } - } - return obj; + const mismatches = Object.getOwnPropertyNames(Type.prototype).reduce((obj, key) => { + // Skip getters — they provide optional defaults that adapters don't need to implement + const descriptor = Object.getOwnPropertyDescriptor(Type.prototype, key); + if (descriptor && typeof descriptor.get === 'function') { + return obj; + } + const adapterType = typeof adapter[key]; + const expectedType = typeof Type.prototype[key]; + if (adapterType !== expectedType) { + obj[key] = { + expected: expectedType, + actual: adapterType, + }; + } + return obj; }, {}); if (Object.keys(mismatches).length > 0) { diff --git a/src/Controllers/AnalyticsController.js b/src/Controllers/AnalyticsController.js index 8bcda298a6..af74681d86 100644 --- a/src/Controllers/AnalyticsController.js +++ b/src/Controllers/AnalyticsController.js @@ -3,19 +3,27 @@ import { AnalyticsAdapter } from '../Adapters/Analytics/AnalyticsAdapter'; export class AnalyticsController extends AdaptableController { appOpened(req) { - return this.adapter.appOpened(req.body, req).then( - function(response) { - return { response: response }; - }).catch((err) => { + return Promise.resolve() + .then(() => { + return this.adapter.appOpened(req.body || {}, req); + }) + .then(response => { + return { response: response || {} }; + }) + .catch(() => { return { response: {} }; }); } trackEvent(req) { - return this.adapter.trackEvent(req.params.eventName, req.body, req).then( - function(response) { - return { response: response }; - }).catch((err) => { + return Promise.resolve() + .then(() => { + return this.adapter.trackEvent(req.params.eventName, req.body || {}, req); + }) + .then(response => { + return { response: response || {} }; + }) + .catch(() => { return { response: {} }; }); } diff --git a/src/Controllers/CacheController.js b/src/Controllers/CacheController.js index a6c88e9362..0c645c5236 100644 --- a/src/Controllers/CacheController.js +++ b/src/Controllers/CacheController.js @@ -1,5 +1,5 @@ import AdaptableController from './AdaptableController'; -import CacheAdapter from '../Adapters/Cache/CacheAdapter'; +import CacheAdapter from '../Adapters/Cache/CacheAdapter'; const KEY_SEPARATOR_CHAR = ':'; @@ -20,17 +20,17 @@ export class SubCache { } get(key) { - let cacheKey = joinKeys(this.prefix, key); + const cacheKey = joinKeys(this.prefix, key); return this.cache.get(cacheKey); } put(key, value, ttl) { - let cacheKey = joinKeys(this.prefix, key); + const cacheKey = joinKeys(this.prefix, key); return this.cache.put(cacheKey, value, ttl); } del(key) { - let cacheKey = joinKeys(this.prefix, key); + const cacheKey = joinKeys(this.prefix, key); return this.cache.del(cacheKey); } @@ -39,28 +39,27 @@ export class SubCache { } } - export class CacheController extends AdaptableController { - constructor(adapter, appId, options = {}) { super(adapter, appId, options); this.role = new SubCache('role', this); this.user = new SubCache('user', this); + this.graphQL = new SubCache('graphQL', this); } get(key) { - let cacheKey = joinKeys(this.appId, key); + const cacheKey = joinKeys(this.appId, key); return this.adapter.get(cacheKey).then(null, () => Promise.resolve(null)); } put(key, value, ttl) { - let cacheKey = joinKeys(this.appId, key); + const cacheKey = joinKeys(this.appId, key); return this.adapter.put(cacheKey, value, ttl); } del(key) { - let cacheKey = joinKeys(this.appId, key); + const cacheKey = joinKeys(this.appId, key); return this.adapter.del(cacheKey); } diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index f286907f13..6d13bf6553 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -1,27 +1,91 @@ -īģŋ// A database adapter that works with data exported from the hosted +īģŋ// @flow +// A database adapter that works with data exported from the hosted // Parse database. +// @flow-disable-next +import { Parse } from 'parse/node'; +// @flow-disable-next +import _ from 'lodash'; +// @flow-disable-next import intersect from 'intersect'; -import _ from 'lodash'; - -var mongodb = require('mongodb'); -var Parse = require('parse/node').Parse; - -var SchemaController = require('./SchemaController'); +import logger from '../logger'; +import Utils from '../Utils'; +import * as SchemaController from './SchemaController'; +import { StorageAdapter } from '../Adapters/Storage/StorageAdapter'; +import MongoStorageAdapter from '../Adapters/Storage/Mongo/MongoStorageAdapter'; +import PostgresStorageAdapter from '../Adapters/Storage/Postgres/PostgresStorageAdapter'; +import SchemaCache from '../Adapters/Cache/SchemaCache'; +import type { LoadSchemaOptions } from './types'; +import type { ParseServerOptions } from '../Options'; +import type { QueryOptions, FullQueryOptions } from '../Adapters/Storage/StorageAdapter'; +import { createSanitizedError } from '../Error'; + +// Query operators that always pass validation regardless of auth level. +const queryOperators = ['$and', '$or', '$nor']; + +// Registry of internal fields with access permissions. +// Internal fields are never directly writable by clients, so clientWrite is omitted. +// - clientRead: any client can use this field in queries +// - masterRead: master key can use this field in queries +// - masterWrite: master key can use this field in updates +const internalFields = { + _rperm: { clientRead: true, masterRead: true, masterWrite: true }, + _wperm: { clientRead: true, masterRead: true, masterWrite: true }, + _hashed_password: { clientRead: false, masterRead: false, masterWrite: true }, + _email_verify_token: { clientRead: false, masterRead: true, masterWrite: true }, + _perishable_token: { clientRead: false, masterRead: true, masterWrite: true }, + _perishable_token_expires_at: { clientRead: false, masterRead: true, masterWrite: true }, + _email_verify_token_expires_at: { clientRead: false, masterRead: true, masterWrite: true }, + _failed_login_count: { clientRead: false, masterRead: true, masterWrite: true }, + _account_lockout_expires_at: { clientRead: false, masterRead: true, masterWrite: true }, + _password_changed_at: { clientRead: false, masterRead: true, masterWrite: true }, + _password_history: { clientRead: false, masterRead: true, masterWrite: true }, + _tombstone: { clientRead: false, masterRead: true, masterWrite: false }, + _session_token: { clientRead: false, masterRead: true, masterWrite: false }, + ///////////////////////////////////////////////////////////////////////////////////////////// + // The following fields are not accessed by their _-prefixed name through the API; + // they are mapped to REST-level names in the adapter layer or handled through + // separate code paths. + ///////////////////////////////////////////////////////////////////////////////////////////// + // System fields (mapped to REST-level names): + // _id (objectId) + // _created_at (createdAt) + // _updated_at (updatedAt) + // _last_used (lastUsed) + // _expiresAt (expiresAt) + ///////////////////////////////////////////////////////////////////////////////////////////// + // Legacy ACL format: mapped to/from _rperm/_wperm + // _acl + ///////////////////////////////////////////////////////////////////////////////////////////// + // Schema metadata: not data fields, used only for schema configuration + // _metadata + // _client_permissions + ///////////////////////////////////////////////////////////////////////////////////////////// + // Dynamic auth data fields: used only in projections and updates, not in queries + // _auth_data_ +}; -const deepcopy = require('deepcopy'); +// Derived access lists +const specialQueryKeys = [ + ...queryOperators, + ...Object.keys(internalFields).filter(k => internalFields[k].clientRead), +]; +const specialMasterQueryKeys = [ + ...queryOperators, + ...Object.keys(internalFields).filter(k => internalFields[k].masterRead), +]; function addWriteACL(query, acl) { - let newQuery = _.cloneDeep(query); + const newQuery = _.cloneDeep(query); //Can't be any existing '_wperm' query, we don't allow client queries on that, no need to $and - newQuery._wperm = { "$in" : [null, ...acl]}; + newQuery._wperm = { $in: [null, ...acl] }; return newQuery; } function addReadACL(query, acl) { - let newQuery = _.cloneDeep(query); + const newQuery = _.cloneDeep(query); //Can't be any existing '_rperm' query, we don't allow client queries on that, no need to $and - newQuery._rperm = { "$in" : [null, "*", ...acl]}; + newQuery._rperm = { $in: [null, '*', ...acl] }; return newQuery; } @@ -34,7 +98,7 @@ const transformObjectACL = ({ ACL, ...result }) => { result._wperm = []; result._rperm = []; - for (let entry in ACL) { + for (const entry in ACL) { if (ACL[entry].read) { result._rperm.push(entry); } @@ -43,130 +107,198 @@ const transformObjectACL = ({ ACL, ...result }) => { } } return result; -} +}; -const specialQuerykeys = ['$and', '$or', '_rperm', '_wperm', '_perishable_token', '_email_verify_token', '_email_verify_token_expires_at']; -const validateQuery = query => { +const validateQuery = ( + query: any, + isMaster: boolean, + isMaintenance: boolean, + update: boolean, + options: ?ParseServerOptions, + _depth: number = 0 +): void => { + if (isMaintenance) { + isMaster = true; + } + const rc = options?.requestComplexity; + if (!isMaster && rc && rc.queryDepth !== -1 && _depth > rc.queryDepth) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Query condition nesting depth exceeds maximum allowed depth of ${rc.queryDepth}` + ); + } if (query.ACL) { throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Cannot query on ACL.'); } if (query.$or) { - if (query.$or instanceof Array) { - query.$or.forEach(validateQuery); + if (Array.isArray(query.$or)) { + query.$or.forEach(value => validateQuery(value, isMaster, isMaintenance, update, options, _depth + 1)); } else { throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Bad $or format - use an array value.'); } } if (query.$and) { - if (query.$and instanceof Array) { - query.$and.forEach(validateQuery); + if (Array.isArray(query.$and)) { + query.$and.forEach(value => validateQuery(value, isMaster, isMaintenance, update, options, _depth + 1)); } else { throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Bad $and format - use an array value.'); } } + if (query.$nor) { + if (Array.isArray(query.$nor) && query.$nor.length > 0) { + query.$nor.forEach(value => validateQuery(value, isMaster, isMaintenance, update, options, _depth + 1)); + } else { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + 'Bad $nor format - use an array of at least 1 value.' + ); + } + } + Object.keys(query).forEach(key => { - if (query && query[key] && query[key].$regex) { + if (query && query[key] && query[key].$regex !== undefined) { + if (!isMaster && rc && rc.allowRegex === false) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, '$regex operator is not allowed'); + } + if (typeof query[key].$regex !== 'string') { + throw new Parse.Error(Parse.Error.INVALID_QUERY, '$regex value must be a string'); + } + if (query[key].$options !== undefined && typeof query[key].$options !== 'string') { + throw new Parse.Error(Parse.Error.INVALID_QUERY, '$options value must be a string'); + } if (typeof query[key].$options === 'string') { - if (!query[key].$options.match(/^[imxs]+$/)) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, `Bad $options value for query: ${query[key].$options}`); + if (!query[key].$options.match(/^[imxsu]+$/)) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Bad $options value for query: ${query[key].$options}` + ); } } } - if (!specialQuerykeys.includes(key) && !key.match(/^[a-zA-Z][a-zA-Z0-9_\.]*$/)) { + if ( + !key.match(/^[a-zA-Z][a-zA-Z0-9_\.]*$/) && + !specialQueryKeys.includes(key) && + !(isMaster && specialMasterQueryKeys.includes(key)) + ) { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid key name: ${key}`); } }); -} - -function DatabaseController(adapter, schemaCache) { - this.adapter = adapter; - this.schemaCache = schemaCache; - // We don't want a mutable this.schema, because then you could have - // one request that uses different schemas for different parts of - // it. Instead, use loadSchema to get a schema. - this.schemaPromise = null; -} - -DatabaseController.prototype.collectionExists = function(className) { - return this.adapter.classExists(className); }; -DatabaseController.prototype.purgeCollection = function(className) { - return this.loadSchema() - .then(schemaController => schemaController.getOneSchema(className)) - .then(schema => this.adapter.deleteObjectsByQuery(className, schema, {})); -}; +// Filters out any data that shouldn't be on this REST-formatted object. +const filterSensitiveData = ( + isMaster: boolean, + isMaintenance: boolean, + aclGroup: any[], + auth: any, + operation: any, + schema: SchemaController.SchemaController | any, + className: string, + protectedFields: null | Array, + object: any, + protectedFieldsOwnerExempt: ?boolean +) => { + let userId = null; + if (auth && auth.user) { userId = auth.user.id; } + + // replace protectedFields when using pointer-permissions + const perms = + schema && schema.getClassLevelPermissions ? schema.getClassLevelPermissions(className) : {}; + if (perms) { + const isReadOperation = ['get', 'find'].indexOf(operation) > -1; + + if (isReadOperation && perms.protectedFields) { + // extract protectedFields added with the pointer-permission prefix + const protectedFieldsPointerPerm = Object.keys(perms.protectedFields) + .filter(key => key.startsWith('userField:')) + .map(key => { + return { key: key.substring(10), value: perms.protectedFields[key] }; + }); -DatabaseController.prototype.validateClassName = function(className) { - if (!SchemaController.classNameIsValid(className)) { - return Promise.reject(new Parse.Error(Parse.Error.INVALID_CLASS_NAME, 'invalid className: ' + className)); - } - return Promise.resolve(); -}; + const newProtectedFields: Array[] = []; + let overrideProtectedFields = false; + + // check if the object grants the current user access based on the extracted fields + protectedFieldsPointerPerm.forEach(pointerPerm => { + let pointerPermIncludesUser = false; + const readUserFieldValue = object[pointerPerm.key]; + if (readUserFieldValue) { + if (Array.isArray(readUserFieldValue)) { + pointerPermIncludesUser = readUserFieldValue.some( + user => user.objectId && user.objectId === userId + ); + } else { + pointerPermIncludesUser = + readUserFieldValue.objectId && readUserFieldValue.objectId === userId; + } + } -// Returns a promise for a schemaController. -DatabaseController.prototype.loadSchema = function(options = {clearCache: false}) { - if (!this.schemaPromise) { - this.schemaPromise = SchemaController.load(this.adapter, this.schemaCache, options); - this.schemaPromise.then(() => delete this.schemaPromise, - () => delete this.schemaPromise); - } - return this.schemaPromise; -}; + if (pointerPermIncludesUser) { + overrideProtectedFields = true; + newProtectedFields.push(pointerPerm.value); + } + }); -// Returns a promise for the classname that is related to the given -// classname through the key. -// TODO: make this not in the DatabaseController interface -DatabaseController.prototype.redirectClassNameForKey = function(className, key) { - return this.loadSchema().then((schema) => { - var t = schema.getExpectedType(className, key); - if (t && t.type == 'Relation') { - return t.targetClass; - } else { - return className; + // if at least one pointer-permission affected the current user + // intersect vs protectedFields from previous stage (@see addProtectedFields) + // Sets theory (intersections): A x (B x C) == (A x B) x C + if (overrideProtectedFields && protectedFields) { + newProtectedFields.push(protectedFields); + } + // intersect all sets of protectedFields + newProtectedFields.forEach(fields => { + if (fields) { + // if there're no protctedFields by other criteria ( id / role / auth) + // then we must intersect each set (per userField) + if (!protectedFields) { + protectedFields = fields; + } else { + protectedFields = protectedFields.filter(v => fields.includes(v)); + } + } + }); } - }); -}; + } -// Uses the schema to validate the object (REST API format). -// Returns a promise that resolves to the new schema. -// This does not update this.schema, because in a situation like a -// batch request, that could confuse other users of the schema. -DatabaseController.prototype.validateObject = function(className, object, query, { acl }) { - let schema; - let isMaster = acl === undefined; - var aclGroup = acl || []; - return this.loadSchema().then(s => { - schema = s; - if (isMaster) { - return Promise.resolve(); - } - return this.canAddField(schema, className, object, aclGroup); - }).then(() => { - return schema.validateObject(className, object, query); - }); -}; + const isUserClass = className === '_User'; + if (isUserClass) { + object.password = object._hashed_password; + delete object._hashed_password; + delete object.sessionToken; + } -// Filters out any data that shouldn't be on this REST-formatted object. -const filterSensitiveData = (isMaster, aclGroup, className, object) => { - if (className !== '_User') { + if (isMaintenance) { return object; } - object.password = object._hashed_password; - delete object._hashed_password; + /* special treat for the user class: don't filter protectedFields if currently loggedin user is + the retrieved user, unless protectedFieldsOwnerExempt is false */ + const isOwnerExempt = protectedFieldsOwnerExempt !== false && isUserClass && userId && object.objectId === userId; + if (!isOwnerExempt) { + protectedFields && protectedFields.forEach(k => delete object[k]); - delete object.sessionToken; + // fields not requested by client (excluded), + // but were needed to apply protectedFields + perms?.protectedFields?.temporaryKeys?.forEach(k => delete object[k]); + } - if (isMaster || (aclGroup.indexOf(object.objectId) > -1)) { + for (const key in object) { + if (key.charAt(0) === '_') { + delete object[key]; + } + } + + if (!isUserClass || isMaster) { return object; } + if (aclGroup.indexOf(object.objectId) > -1) { + return object; + } delete object.authData; - return object; }; @@ -178,223 +310,18 @@ const filterSensitiveData = (isMaster, aclGroup, className, object) => { // acl: a list of strings. If the object to be updated has an ACL, // one of the provided strings must provide the caller with // write permissions. -const specialKeysForUpdate = ['_hashed_password', '_perishable_token', '_email_verify_token', '_email_verify_token_expires_at']; -DatabaseController.prototype.update = function(className, query, update, { - acl, - many, - upsert, -} = {}, skipSanitization = false) { - const originalUpdate = update; - // Make a copy of the object, so we don't mutate the incoming data. - update = deepcopy(update); - - var isMaster = acl === undefined; - var aclGroup = acl || []; - var mongoUpdate; - return this.loadSchema() - .then(schemaController => { - return (isMaster ? Promise.resolve() : schemaController.validatePermission(className, aclGroup, 'update')) - .then(() => this.handleRelationUpdates(className, query.objectId, update)) - .then(() => { - if (!isMaster) { - query = this.addPointerPermissions(schemaController, className, 'update', query, aclGroup); - } - if (!query) { - return Promise.resolve(); - } - if (acl) { - query = addWriteACL(query, acl); - } - validateQuery(query); - return schemaController.getOneSchema(className) - .catch(error => { - // If the schema doesn't exist, pretend it exists with no fields. This behaviour - // will likely need revisiting. - if (error === undefined) { - return { fields: {} }; - } - throw error; - }) - .then(schema => { - Object.keys(update).forEach(fieldName => { - if (fieldName.match(/^authData\.([a-zA-Z0-9_]+)\.id$/)) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name for update: ${fieldName}`); - } - fieldName = fieldName.split('.')[0]; - if (!SchemaController.fieldNameIsValid(fieldName) && !specialKeysForUpdate.includes(fieldName)) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name for update: ${fieldName}`); - } - }); - for (let updateOperation in update) { - if (Object.keys(updateOperation).some(innerKey => innerKey.includes('$') || innerKey.includes('.'))) { - throw new Parse.Error(Parse.Error.INVALID_NESTED_KEY, "Nested keys should not contain the '$' or '.' characters"); - } - } - update = transformObjectACL(update); - transformAuthData(className, update, schema); - if (many) { - return this.adapter.updateObjectsByQuery(className, schema, query, update); - } else if (upsert) { - return this.adapter.upsertOneObject(className, schema, query, update); - } else { - return this.adapter.findOneAndUpdate(className, schema, query, update) - } - }); - }) - .then(result => { - if (!result) { - return Promise.reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.')); - } - if (skipSanitization) { - return Promise.resolve(result); - } - return sanitizeDatabaseResult(originalUpdate, result); - }); - }); -}; - -function sanitizeDatabaseResult(originalObject, result) { - let response = {}; - if (!result) { - return Promise.resolve(response); - } - Object.keys(originalObject).forEach(key => { - let keyUpdate = originalObject[key]; - // determine if that was an op - if (keyUpdate && typeof keyUpdate === 'object' && keyUpdate.__op - && ['Add', 'AddUnique', 'Remove', 'Increment'].indexOf(keyUpdate.__op) > -1) { - // only valid ops that produce an actionable result - response[key] = result[key]; - } - }); - return Promise.resolve(response); -} - -// Processes relation-updating operations from a REST-format update. -// Returns a promise that resolves successfully when these are -// processed. -// This mutates update. -DatabaseController.prototype.handleRelationUpdates = function(className, objectId, update) { - var pending = []; - var deleteMe = []; - objectId = update.objectId || objectId; +const specialKeysForUpdate = Object.keys(internalFields).filter(k => internalFields[k].masterWrite); - var process = (op, key) => { - if (!op) { - return; - } - if (op.__op == 'AddRelation') { - for (var object of op.objects) { - pending.push(this.addRelation(key, className, - objectId, - object.objectId)); - } - deleteMe.push(key); - } - - if (op.__op == 'RemoveRelation') { - for (var object of op.objects) { - pending.push(this.removeRelation(key, className, - objectId, - object.objectId)); - } - deleteMe.push(key); - } - - if (op.__op == 'Batch') { - for (var x of op.ops) { - process(x, key); - } - } - }; - - for (var key in update) { - process(update[key], key); - } - for (var key of deleteMe) { - delete update[key]; - } - return Promise.all(pending); +const isSpecialUpdateKey = key => { + return specialKeysForUpdate.indexOf(key) >= 0; }; -// Adds a relation. -// Returns a promise that resolves successfully iff the add was successful. -const relationSchema = { fields: { relatedId: { type: 'String' }, owningId: { type: 'String' } } }; -DatabaseController.prototype.addRelation = function(key, fromClassName, fromId, toId) { - let doc = { - relatedId: toId, - owningId : fromId - }; - return this.adapter.upsertOneObject(`_Join:${key}:${fromClassName}`, relationSchema, doc, doc); -}; - -// Removes a relation. -// Returns a promise that resolves successfully iff the remove was -// successful. -DatabaseController.prototype.removeRelation = function(key, fromClassName, fromId, toId) { - var doc = { - relatedId: toId, - owningId: fromId - }; - return this.adapter.deleteObjectsByQuery(`_Join:${key}:${fromClassName}`, relationSchema, doc) - .catch(error => { - // We don't care if they try to delete a non-existent relation. - if (error.code == Parse.Error.OBJECT_NOT_FOUND) { - return; - } - throw error; - }); -}; - -// Removes objects matches this query from the database. -// Returns a promise that resolves successfully iff the object was -// deleted. -// Options: -// acl: a list of strings. If the object to be updated has an ACL, -// one of the provided strings must provide the caller with -// write permissions. -DatabaseController.prototype.destroy = function(className, query, { acl } = {}) { - const isMaster = acl === undefined; - const aclGroup = acl || []; - - return this.loadSchema() - .then(schemaController => { - return (isMaster ? Promise.resolve() : schemaController.validatePermission(className, aclGroup, 'delete')) - .then(() => { - if (!isMaster) { - query = this.addPointerPermissions(schemaController, className, 'delete', query, aclGroup); - if (!query) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); - } - } - // delete by query - if (acl) { - query = addWriteACL(query, acl); - } - validateQuery(query); - return schemaController.getOneSchema(className) - .catch(error => { - // If the schema doesn't exist, pretend it exists with no fields. This behaviour - // will likely need revisiting. - if (error === undefined) { - return { fields: {} }; - } - throw error; - }) - .then(parseFormatSchema => this.adapter.deleteObjectsByQuery(className, parseFormatSchema, query)) - .catch(error => { - // When deleting sessions while changing passwords, don't throw an error if they don't have any sessions. - if (className === "_Session" && error.code === Parse.Error.OBJECT_NOT_FOUND) { - return Promise.resolve({}); - } - throw error; - }); - }); - }); -}; +function joinTableName(className, key) { + return `_Join:${key}:${className}`; +} const flattenUpdateOperatorsForCreate = object => { - for (let key in object) { + for (const key in object) { if (object[key] && object[key].__op) { switch (object[key].__op) { case 'Increment': @@ -403,190 +330,799 @@ const flattenUpdateOperatorsForCreate = object => { } object[key] = object[key].amount; break; + case 'SetOnInsert': + object[key] = object[key].amount; + break; case 'Add': - if (!(object[key].objects instanceof Array)) { + if (!Array.isArray(object[key].objects)) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to add must be an array'); } object[key] = object[key].objects; break; case 'AddUnique': - if (!(object[key].objects instanceof Array)) { + if (!Array.isArray(object[key].objects)) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to add must be an array'); } object[key] = object[key].objects; break; case 'Remove': - if (!(object[key].objects instanceof Array)) { + if (!Array.isArray(object[key].objects)) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to add must be an array'); } - object[key] = [] + object[key] = []; break; case 'Delete': delete object[key]; break; default: - throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, `The ${object[key].__op} operator is not supported yet.`); + throw new Parse.Error( + Parse.Error.COMMAND_UNAVAILABLE, + `The ${object[key].__op} operator is not supported yet.` + ); } } } -} +}; const transformAuthData = (className, object, schema) => { if (object.authData && className === '_User') { - Object.keys(object.authData).forEach(provider => { + Object.keys(object.authData).forEach(provider => { const providerData = object.authData[provider]; const fieldName = `_auth_data_${provider}`; if (providerData == null) { object[fieldName] = { - __op: 'Delete' - } + __op: 'Delete', + }; } else { object[fieldName] = providerData; - schema.fields[fieldName] = { type: 'Object' } + schema.fields[fieldName] = { type: 'Object' }; } }); delete object.authData; } -} - -// Inserts an object into the database. -// Returns a promise that resolves successfully iff the object saved. -DatabaseController.prototype.create = function(className, object, { acl } = {}) { - // Make a copy of the object, so we don't mutate the incoming data. - let originalObject = object; - object = transformObjectACL(object); - - object.createdAt = { iso: object.createdAt, __type: 'Date' }; - object.updatedAt = { iso: object.updatedAt, __type: 'Date' }; - - var isMaster = acl === undefined; - var aclGroup = acl || []; - - return this.validateClassName(className) - .then(() => this.loadSchema()) - .then(schemaController => { - return (isMaster ? Promise.resolve() : schemaController.validatePermission(className, aclGroup, 'create')) - .then(() => this.handleRelationUpdates(className, null, object)) - .then(() => schemaController.enforceClassExists(className)) - .then(() => schemaController.reloadData()) - .then(() => schemaController.getOneSchema(className, true)) - .then(schema => { - transformAuthData(className, object, schema); - flattenUpdateOperatorsForCreate(object); - return this.adapter.createObject(className, SchemaController.convertSchemaToAdapterSchema(schema), object); - }) - .then(result => sanitizeDatabaseResult(originalObject, result.ops[0])); - }) }; +// Transforms a Database format ACL to a REST API format ACL +const untransformObjectACL = ({ _rperm, _wperm, ...output }) => { + if (_rperm || _wperm) { + output.ACL = {}; -DatabaseController.prototype.canAddField = function(schema, className, object, aclGroup) { - let classSchema = schema.data[className]; - if (!classSchema) { - return Promise.resolve(); + (_rperm || []).forEach(entry => { + if (!output.ACL[entry]) { + output.ACL[entry] = { read: true }; + } else { + output.ACL[entry]['read'] = true; + } + }); + + (_wperm || []).forEach(entry => { + if (!output.ACL[entry]) { + output.ACL[entry] = { write: true }; + } else { + output.ACL[entry]['write'] = true; + } + }); } - let fields = Object.keys(object); - let schemaFields = Object.keys(classSchema); - let newKeys = fields.filter((field) => { - return schemaFields.indexOf(field) < 0; - }) - if (newKeys.length > 0) { - return schema.validatePermission(className, aclGroup, 'addField'); + return output; +}; + +/** + * When querying, the fieldName may be compound, extract the root fieldName + * `temperature.celsius` becomes `temperature` + * @param {string} fieldName that may be a compound field name + * @returns {string} the root name of the field + */ +const getRootFieldName = (fieldName: string): string => { + return fieldName.split('.')[0]; +}; + +const relationSchema = { + fields: { relatedId: { type: 'String' }, owningId: { type: 'String' } }, +}; + +const convertEmailToLowercase = (object, className, options) => { + if (className === '_User' && options.convertEmailToLowercase) { + if (typeof object['email'] === 'string') { + object['email'] = object['email'].toLowerCase(); + } } - return Promise.resolve(); -} +}; -// Won't delete collections in the system namespace -// Returns a promise. -DatabaseController.prototype.deleteEverything = function() { - this.schemaPromise = null; - return this.adapter.deleteAllClasses(); +const convertUsernameToLowercase = (object, className, options) => { + if (className === '_User' && options.convertUsernameToLowercase) { + if (typeof object['username'] === 'string') { + object['username'] = object['username'].toLowerCase(); + } + } }; -// Finds the keys in a query. Returns a Set. REST format only -function keysForQuery(query) { - var sublist = query['$and'] || query['$or']; - if (sublist) { - let answer = sublist.reduce((memo, subquery) => { - return memo.concat(keysForQuery(subquery)); - }, []); +class DatabaseController { + adapter: StorageAdapter; + schemaCache: any; + schemaPromise: ?Promise; + _transactionalSession: ?any; + options: ParseServerOptions; + idempotencyOptions: any; + + constructor(adapter: StorageAdapter, options: ParseServerOptions) { + this.adapter = adapter; + this.options = options || {}; + this.idempotencyOptions = this.options.idempotencyOptions || {}; + // Prevent mutable this.schema, otherwise one request could use + // multiple schemas, so instead use loadSchema to get a schema. + this.schemaPromise = null; + this._transactionalSession = null; + this.options = options; + } - return new Set(answer); + collectionExists(className: string): Promise { + return this.adapter.classExists(className); } - return new Set(Object.keys(query)); -} + purgeCollection(className: string): Promise { + return this.loadSchema() + .then(schemaController => schemaController.getOneSchema(className)) + .then(schema => this.adapter.deleteObjectsByQuery(className, schema, {})); + } -// Returns a promise for a list of related ids given an owning id. -// className here is the owning className. -DatabaseController.prototype.relatedIds = function(className, key, owningId) { - return this.adapter.find(joinTableName(className, key), relationSchema, { owningId }, {}) - .then(results => results.map(result => result.relatedId)); -}; + validateClassName(className: string): Promise { + if (!SchemaController.classNameIsValid(className)) { + return Promise.reject( + new Parse.Error(Parse.Error.INVALID_CLASS_NAME, 'invalid className: ' + className) + ); + } + return Promise.resolve(); + } -// Returns a promise for a list of owning ids given some related ids. -// className here is the owning className. -DatabaseController.prototype.owningIds = function(className, key, relatedIds) { - return this.adapter.find(joinTableName(className, key), relationSchema, { relatedId: { '$in': relatedIds } }, {}) - .then(results => results.map(result => result.owningId)); -}; + // Returns a promise for a schemaController. + loadSchema( + options: LoadSchemaOptions = { clearCache: false } + ): Promise { + if (this.schemaPromise != null) { + return this.schemaPromise; + } + this.schemaPromise = SchemaController.load(this.adapter, options); + this.schemaPromise.then( + () => delete this.schemaPromise, + () => delete this.schemaPromise + ); + return this.loadSchema(options); + } -// Modifies query so that it no longer has $in on relation fields, or -// equal-to-pointer constraints on relation fields. -// Returns a promise that resolves when query is mutated -DatabaseController.prototype.reduceInRelation = function(className, query, schema) { - - // Search for an in-relation or equal-to-relation - // Make it sequential for now, not sure of paralleization side effects - if (query['$or']) { - let ors = query['$or']; - return Promise.all(ors.map((aQuery, index) => { - return this.reduceInRelation(className, aQuery, schema).then((aQuery) => { - query['$or'][index] = aQuery; + loadSchemaIfNeeded( + schemaController: SchemaController.SchemaController, + options: LoadSchemaOptions = { clearCache: false } + ): Promise { + return schemaController ? Promise.resolve(schemaController) : this.loadSchema(options); + } + + // Returns a promise for the classname that is related to the given + // classname through the key. + // TODO: make this not in the DatabaseController interface + redirectClassNameForKey(className: string, key: string): Promise { + return this.loadSchema().then(schema => { + var t = schema.getExpectedType(className, key); + if (t != null && typeof t !== 'string' && t.type === 'Relation') { + return t.targetClass; + } + return className; + }); + } + + // Uses the schema to validate the object (REST API format). + // Returns a promise that resolves to the new schema. + // This does not update this.schema, because in a situation like a + // batch request, that could confuse other users of the schema. + validateObject( + className: string, + object: any, + query: any, + runOptions: QueryOptions, + maintenance: boolean + ): Promise { + let schema; + const acl = runOptions.acl; + const isMaster = acl === undefined; + var aclGroup: string[] = acl || []; + return this.loadSchema() + .then(s => { + schema = s; + if (isMaster) { + return Promise.resolve(); + } + return this.canAddField(schema, className, object, aclGroup, runOptions); + }) + .then(() => { + return schema.validateObject(className, object, query, maintenance); }); - })).then(() => { - return Promise.resolve(query); + } + + /** + * Updates objects in the database that match the given query. + * @param {Object} options + * @param {boolean} [options.many=false] When true, updates all matching documents + * and returns `{ matchedCount, modifiedCount }` where values are numbers if the + * storage adapter supports `UpdateManyResult`, or `undefined` otherwise. + */ + update( + className: string, + query: any, + update: any, + { acl, many, upsert, addsField }: FullQueryOptions = {}, + skipSanitization: boolean = false, + validateOnly: boolean = false, + validSchemaController: SchemaController.SchemaController + ): Promise { + try { + Utils.checkProhibitedKeywords(this.options, update); + } catch (error) { + return Promise.reject(new Parse.Error(Parse.Error.INVALID_KEY_NAME, `${error}`)); + } + try { + const { validateFileUrlsInObject } = require('../FileUrlValidator'); + validateFileUrlsInObject(update, this.options); + } catch (error) { + return Promise.reject(error instanceof Parse.Error ? error : new Parse.Error(Parse.Error.FILE_SAVE_ERROR, error.message || error)); + } + const originalQuery = query; + const originalUpdate = update; + // Make a copy of the object, so we don't mutate the incoming data. + update = structuredClone(update); + var relationUpdates = []; + var isMaster = acl === undefined; + var aclGroup = acl || []; + + return this.loadSchemaIfNeeded(validSchemaController).then(schemaController => { + return (isMaster + ? Promise.resolve() + : schemaController.validatePermission(className, aclGroup, 'update') + ) + .then(() => { + relationUpdates = this.collectRelationUpdates(className, originalQuery.objectId, update); + if (!isMaster) { + query = this.addPointerPermissions( + schemaController, + className, + 'update', + query, + aclGroup + ); + + if (addsField) { + query = { + $and: [ + query, + this.addPointerPermissions( + schemaController, + className, + 'addField', + query, + aclGroup + ), + ], + }; + } + } + if (!query) { + return Promise.resolve(); + } + if (acl) { + query = addWriteACL(query, acl); + } + validateQuery(query, isMaster, false, true, this.options); + return schemaController + .getOneSchema(className, true) + .catch(error => { + // If the schema doesn't exist, pretend it exists with no fields. This behavior + // will likely need revisiting. + if (error === undefined) { + return { fields: {} }; + } + throw error; + }) + .then(schema => { + Object.keys(update).forEach(fieldName => { + if (fieldName.match(/^authData\./)) { + throw new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + `Invalid field name for update: ${fieldName}` + ); + } + const rootFieldName = getRootFieldName(fieldName); + if ( + !SchemaController.fieldNameIsValid(rootFieldName, className) && + !isSpecialUpdateKey(rootFieldName) + ) { + throw new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + `Invalid field name for update: ${fieldName}` + ); + } + }); + for (const updateOperation in update) { + if ( + update[updateOperation] && + typeof update[updateOperation] === 'object' && + Object.keys(update[updateOperation]).some( + innerKey => innerKey.includes('$') || innerKey.includes('.') + ) + ) { + throw new Parse.Error( + Parse.Error.INVALID_NESTED_KEY, + "Nested keys should not contain the '$' or '.' characters" + ); + } + } + update = transformObjectACL(update); + convertEmailToLowercase(update, className, this.options); + convertUsernameToLowercase(update, className, this.options); + transformAuthData(className, update, schema); + if (validateOnly) { + return this.adapter.find(className, schema, query, { readPreference: 'primary' }).then(result => { + if (!result || !result.length) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); + } + return {}; + }); + } + if (many) { + return this.adapter.updateObjectsByQuery( + className, + schema, + query, + update, + this._transactionalSession + ); + } else if (upsert) { + return this.adapter.upsertOneObject( + className, + schema, + query, + update, + this._transactionalSession + ); + } else { + return this.adapter.findOneAndUpdate( + className, + schema, + query, + update, + this._transactionalSession + ); + } + }); + }) + .then((result: any) => { + if (!result) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); + } + if (validateOnly) { + return result; + } + return this.handleRelationUpdates( + className, + originalQuery.objectId, + update, + relationUpdates + ).then(() => { + return result; + }); + }) + .then(result => { + if (skipSanitization) { + return Promise.resolve(result); + } + if (many) { + return { + matchedCount: typeof result?.matchedCount === 'number' + ? result.matchedCount + : undefined, + modifiedCount: typeof result?.modifiedCount === 'number' + ? result.modifiedCount + : undefined, + }; + } + return this._sanitizeDatabaseResult(originalUpdate, result); + }); }); } - let promises = Object.keys(query).map((key) => { - if (query[key] && (query[key]['$in'] || query[key]['$ne'] || query[key]['$nin'] || query[key].__type == 'Pointer')) { - let t = schema.getExpectedType(className, key); - if (!t || t.type !== 'Relation') { - return Promise.resolve(query); + // Collect all relation-updating operations from a REST-format update. + // Returns a list of all relation updates to perform + // This mutates update. + collectRelationUpdates(className: string, objectId: ?string, update: any) { + var ops = []; + var deleteMe = []; + objectId = update.objectId || objectId; + + var process = (op, key) => { + if (!op) { + return; } - let relatedClassName = t.targetClass; - // Build the list of queries - let queries = Object.keys(query[key]).map((constraintKey) => { - let relatedIds; - let isNegation = false; - if (constraintKey === 'objectId') { - relatedIds = [query[key].objectId]; - } else if (constraintKey == '$in') { - relatedIds = query[key]['$in'].map(r => r.objectId); - } else if (constraintKey == '$nin') { - isNegation = true; - relatedIds = query[key]['$nin'].map(r => r.objectId); - } else if (constraintKey == '$ne') { - isNegation = true; - relatedIds = [query[key]['$ne'].objectId]; - } else { + if (op.__op == 'AddRelation') { + ops.push({ key, op }); + deleteMe.push(key); + } + + if (op.__op == 'RemoveRelation') { + ops.push({ key, op }); + deleteMe.push(key); + } + + if (op.__op == 'Batch') { + for (var x of op.ops) { + process(x, key); + } + } + }; + + for (const key in update) { + process(update[key], key); + } + for (const key of deleteMe) { + delete update[key]; + } + return ops; + } + + // Processes relation-updating operations from a REST-format update. + // Returns a promise that resolves when all updates have been performed + handleRelationUpdates(className: string, objectId: string, update: any, ops: any) { + var pending = []; + objectId = update.objectId || objectId; + ops.forEach(({ key, op }) => { + if (!op) { + return; + } + if (op.__op == 'AddRelation') { + for (const object of op.objects) { + pending.push(this.addRelation(key, className, objectId, object.objectId)); + } + } + + if (op.__op == 'RemoveRelation') { + for (const object of op.objects) { + pending.push(this.removeRelation(key, className, objectId, object.objectId)); + } + } + }); + + return Promise.all(pending); + } + + // Adds a relation. + // Returns a promise that resolves successfully iff the add was successful. + addRelation(key: string, fromClassName: string, fromId: string, toId: string) { + const doc = { + relatedId: toId, + owningId: fromId, + }; + return this.adapter.upsertOneObject( + `_Join:${key}:${fromClassName}`, + relationSchema, + doc, + doc, + this._transactionalSession + ); + } + + // Removes a relation. + // Returns a promise that resolves successfully iff the remove was + // successful. + removeRelation(key: string, fromClassName: string, fromId: string, toId: string) { + var doc = { + relatedId: toId, + owningId: fromId, + }; + return this.adapter + .deleteObjectsByQuery( + `_Join:${key}:${fromClassName}`, + relationSchema, + doc, + this._transactionalSession + ) + .catch(error => { + // We don't care if they try to delete a non-existent relation. + if (error.code == Parse.Error.OBJECT_NOT_FOUND) { return; } - return { - isNegation, - relatedIds + throw error; + }); + } + + // Removes objects matches this query from the database. + // Returns a promise that resolves successfully iff the object was + // deleted. + // Options: + // acl: a list of strings. If the object to be updated has an ACL, + // one of the provided strings must provide the caller with + // write permissions. + destroy( + className: string, + query: any, + { acl }: QueryOptions = {}, + validSchemaController: SchemaController.SchemaController + ): Promise { + const isMaster = acl === undefined; + const aclGroup = acl || []; + + return this.loadSchemaIfNeeded(validSchemaController).then(schemaController => { + return (isMaster + ? Promise.resolve() + : schemaController.validatePermission(className, aclGroup, 'delete') + ).then(() => { + if (!isMaster) { + query = this.addPointerPermissions( + schemaController, + className, + 'delete', + query, + aclGroup + ); + if (!query) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); + } } + // delete by query + if (acl) { + query = addWriteACL(query, acl); + } + validateQuery(query, isMaster, false, false, this.options); + return schemaController + .getOneSchema(className) + .catch(error => { + // If the schema doesn't exist, pretend it exists with no fields. This behavior + // will likely need revisiting. + if (error === undefined) { + return { fields: {} }; + } + throw error; + }) + .then(parseFormatSchema => + this.adapter.deleteObjectsByQuery( + className, + parseFormatSchema, + query, + this._transactionalSession + ) + ) + .catch(error => { + // When deleting sessions while changing passwords, don't throw an error if they don't have any sessions. + if (className === '_Session' && error.code === Parse.Error.OBJECT_NOT_FOUND) { + return Promise.resolve({}); + } + throw error; + }); }); + }); + } + + // Inserts an object into the database. + // Returns a promise that resolves successfully iff the object saved. + create( + className: string, + object: any, + { acl }: QueryOptions = {}, + validateOnly: boolean = false, + validSchemaController: SchemaController.SchemaController + ): Promise { + try { + Utils.checkProhibitedKeywords(this.options, object); + } catch (error) { + return Promise.reject(new Parse.Error(Parse.Error.INVALID_KEY_NAME, `${error}`)); + } + try { + const { validateFileUrlsInObject } = require('../FileUrlValidator'); + validateFileUrlsInObject(object, this.options); + } catch (error) { + return Promise.reject(error instanceof Parse.Error ? error : new Parse.Error(Parse.Error.FILE_SAVE_ERROR, error.message || error)); + } + // Make a copy of the object, so we don't mutate the incoming data. + const originalObject = object; + object = transformObjectACL(object); + + convertEmailToLowercase(object, className, this.options); + convertUsernameToLowercase(object, className, this.options); + object.createdAt = { iso: object.createdAt, __type: 'Date' }; + object.updatedAt = { iso: object.updatedAt, __type: 'Date' }; + + var isMaster = acl === undefined; + var aclGroup = acl || []; + const relationUpdates = this.collectRelationUpdates(className, null, object); + + return this.validateClassName(className) + .then(() => this.loadSchemaIfNeeded(validSchemaController)) + .then(schemaController => { + return (isMaster + ? Promise.resolve() + : schemaController.validatePermission(className, aclGroup, 'create') + ) + .then(() => schemaController.enforceClassExists(className)) + .then(() => schemaController.getOneSchema(className, true)) + .then(schema => { + transformAuthData(className, object, schema); + flattenUpdateOperatorsForCreate(object); + if (validateOnly) { + return {}; + } + return this.adapter.createObject( + className, + SchemaController.convertSchemaToAdapterSchema(schema), + object, + this._transactionalSession + ); + }) + .then(result => { + if (validateOnly) { + return originalObject; + } + return this.handleRelationUpdates( + className, + object.objectId, + object, + relationUpdates + ).then(() => { + return this._sanitizeDatabaseResult(originalObject, result.ops[0]); + }); + }); + }); + } + + canAddField( + schema: SchemaController.SchemaController, + className: string, + object: any, + aclGroup: string[], + runOptions: QueryOptions + ): Promise { + const classSchema = schema.schemaData[className]; + if (!classSchema) { + return Promise.resolve(); + } + const fields = Object.keys(object); + const schemaFields = Object.keys(classSchema.fields); + const newKeys = fields.filter(field => { + // Skip fields that are unset + if (object[field] && object[field].__op && object[field].__op === 'Delete') { + return false; + } + return schemaFields.indexOf(getRootFieldName(field)) < 0; + }); + if (newKeys.length > 0) { + // adds a marker that new field is being adding during update + runOptions.addsField = true; + + const action = runOptions.action; + return schema.validatePermission(className, aclGroup, 'addField', action); + } + return Promise.resolve(); + } + + // Won't delete collections in the system namespace + /** + * Delete all classes and clears the schema cache + * + * @param {boolean} fast set to true if it's ok to just delete rows and not indexes + * @returns {Promise} when the deletions completes + */ + deleteEverything(fast: boolean = false): Promise { + this.schemaPromise = null; + SchemaCache.clear(); + return this.adapter.deleteAllClasses(fast); + } + + // Returns a promise for a list of related ids given an owning id. + // className here is the owning className. + relatedIds( + className: string, + key: string, + owningId: string, + queryOptions: QueryOptions + ): Promise> { + const { skip, limit, sort } = queryOptions; + const findOptions = {}; + if (sort && sort.createdAt && this.adapter.canSortOnJoinTables) { + findOptions.sort = { _id: sort.createdAt }; + findOptions.limit = limit; + findOptions.skip = skip; + queryOptions.skip = 0; + } + return this.adapter + .find(joinTableName(className, key), relationSchema, { owningId }, findOptions) + .then(results => results.map(result => result.relatedId)); + } + + // Returns a promise for a list of owning ids given some related ids. + // className here is the owning className. + owningIds(className: string, key: string, relatedIds: string[]): Promise { + return this.adapter + .find( + joinTableName(className, key), + relationSchema, + { relatedId: { $in: relatedIds } }, + { keys: ['owningId'] } + ) + .then(results => results.map(result => result.owningId)); + } + + // Modifies query so that it no longer has $in on relation fields, or + // equal-to-pointer constraints on relation fields. + // Returns a promise that resolves when query is mutated + reduceInRelation(className: string, query: any, schema: any): Promise { + // Search for an in-relation or equal-to-relation + // Make it sequential for now, not sure of paralleization side effects + const promises = []; + if (query['$or']) { + const ors = query['$or']; + promises.push( + ...ors.map((aQuery, index) => { + return this.reduceInRelation(className, aQuery, schema).then(aQuery => { + query['$or'][index] = aQuery; + }); + }) + ); + } + if (query['$and']) { + const ands = query['$and']; + promises.push( + ...ands.map((aQuery, index) => { + return this.reduceInRelation(className, aQuery, schema).then(aQuery => { + query['$and'][index] = aQuery; + }); + }) + ); + } + + const otherKeys = Object.keys(query).map(key => { + if (key === '$and' || key === '$or') { + return; + } + const t = schema.getExpectedType(className, key); + if (!t || t.type !== 'Relation') { + return Promise.resolve(query); + } + let queries: ?(any[]) = null; + if ( + query[key] && + (query[key]['$in'] || + query[key]['$ne'] || + query[key]['$nin'] || + query[key].__type == 'Pointer') + ) { + // Build the list of queries + queries = Object.keys(query[key]).map(constraintKey => { + let relatedIds; + let isNegation = false; + if (constraintKey === 'objectId') { + relatedIds = [query[key].objectId]; + } else if (constraintKey == '$in') { + relatedIds = query[key]['$in'].map(r => r.objectId); + } else if (constraintKey == '$nin') { + isNegation = true; + relatedIds = query[key]['$nin'].map(r => r.objectId); + } else if (constraintKey == '$ne') { + isNegation = true; + relatedIds = [query[key]['$ne'].objectId]; + } else { + return; + } + return { + isNegation, + relatedIds, + }; + }); + } else { + queries = [{ isNegation: false, relatedIds: [] }]; + } // remove the current queryKey as we don,t need it anymore delete query[key]; - // execute each query independnently to build the list of + // execute each query independently to build the list of // $in / $nin - let promises = queries.map((q) => { + const promises = queries.map(q => { if (!q) { return Promise.resolve(); } - return this.owningIds(className, key, q.relatedIds).then((ids) => { + return this.owningIds(className, key, q.relatedIds).then(ids => { if (q.isNegation) { this.addNotInObjectIdsIds(ids, query); } else { @@ -598,286 +1134,892 @@ DatabaseController.prototype.reduceInRelation = function(className, query, schem return Promise.all(promises).then(() => { return Promise.resolve(); - }) + }); + }); + + return Promise.all([...promises, ...otherKeys]).then(() => { + return Promise.resolve(query); + }); + } + // Modifies query so that it no longer has $relatedTo + // Returns a promise that resolves when query is mutated + reduceRelationKeys(className: string, query: any, queryOptions: any): ?Promise { + if (query['$or']) { + return Promise.all( + query['$or'].map(aQuery => { + return this.reduceRelationKeys(className, aQuery, queryOptions); + }) + ); } - return Promise.resolve(); - }) + if (query['$and']) { + return Promise.all( + query['$and'].map(aQuery => { + return this.reduceRelationKeys(className, aQuery, queryOptions); + }) + ); + } + var relatedTo = query['$relatedTo']; + if (relatedTo) { + return this.relatedIds( + relatedTo.object.className, + relatedTo.key, + relatedTo.object.objectId, + queryOptions + ) + .then(ids => { + delete query['$relatedTo']; + this.addInObjectIdsIds(ids, query); + return this.reduceRelationKeys(className, query, queryOptions); + }) + .then(() => {}); + } + } - return Promise.all(promises).then(() => { - return Promise.resolve(query); - }) -}; + addInObjectIdsIds(ids: ?Array = null, query: any) { + const idsFromString: ?Array = + typeof query.objectId === 'string' ? [query.objectId] : null; + const idsFromEq: ?Array = + query.objectId && query.objectId['$eq'] ? [query.objectId['$eq']] : null; + const idsFromIn: ?Array = + query.objectId && query.objectId['$in'] ? query.objectId['$in'] : null; + + // @flow-disable-next + const allIds: Array> = [idsFromString, idsFromEq, idsFromIn, ids].filter( + list => list !== null + ); + const totalLength = allIds.reduce((memo, list) => memo + list.length, 0); + + let idsIntersection = []; + if (totalLength > 125) { + idsIntersection = intersect.big(allIds); + } else { + idsIntersection = intersect(allIds); + } -// Modifies query so that it no longer has $relatedTo -// Returns a promise that resolves when query is mutated -DatabaseController.prototype.reduceRelationKeys = function(className, query) { - - if (query['$or']) { - return Promise.all(query['$or'].map((aQuery) => { - return this.reduceRelationKeys(className, aQuery); - })); - } - - var relatedTo = query['$relatedTo']; - if (relatedTo) { - return this.relatedIds( - relatedTo.object.className, - relatedTo.key, - relatedTo.object.objectId).then((ids) => { - delete query['$relatedTo']; - this.addInObjectIdsIds(ids, query); - return this.reduceRelationKeys(className, query); - }); + // Need to make sure we don't clobber existing shorthand $eq constraints on objectId. + if (!('objectId' in query)) { + query.objectId = { + $in: undefined, + }; + } else if (typeof query.objectId === 'string') { + query.objectId = { + $in: undefined, + $eq: query.objectId, + }; + } + query.objectId['$in'] = idsIntersection; + + return query; } -}; -DatabaseController.prototype.addInObjectIdsIds = function(ids = null, query) { - let idsFromString = typeof query.objectId === 'string' ? [query.objectId] : null; - let idsFromEq = query.objectId && query.objectId['$eq'] ? [query.objectId['$eq']] : null; - let idsFromIn = query.objectId && query.objectId['$in'] ? query.objectId['$in'] : null; + addNotInObjectIdsIds(ids: string[] = [], query: any) { + const idsFromNin = query.objectId && query.objectId['$nin'] ? query.objectId['$nin'] : []; + let allIds = [...idsFromNin, ...ids].filter(list => list !== null); + + // make a set and spread to remove duplicates + allIds = [...new Set(allIds)]; - let allIds = [idsFromString, idsFromEq, idsFromIn, ids].filter(list => list !== null); - let totalLength = allIds.reduce((memo, list) => memo + list.length, 0); + // Need to make sure we don't clobber existing shorthand $eq constraints on objectId. + if (!('objectId' in query)) { + query.objectId = { + $nin: undefined, + }; + } else if (typeof query.objectId === 'string') { + query.objectId = { + $nin: undefined, + $eq: query.objectId, + }; + } - let idsIntersection = []; - if (totalLength > 125) { - idsIntersection = intersect.big(allIds); - } else { - idsIntersection = intersect(allIds); + query.objectId['$nin'] = allIds; + return query; } - // Need to make sure we don't clobber existing $lt or other constraints on objectId. - // Clobbering $eq, $in and shorthand $eq (query.objectId === 'string') constraints - // is expected though. - if (!('objectId' in query) || typeof query.objectId === 'string') { - query.objectId = {}; + // Runs a query on the database. + // Returns a promise that resolves to a list of items. + // Options: + // skip number of results to skip. + // limit limit to this number of results. + // sort an object where keys are the fields to sort by. + // the value is +1 for ascending, -1 for descending. + // count run a count instead of returning results. + // acl restrict this operation with an ACL for the provided array + // of user objectIds and roles. acl: null means no user. + // when this field is not present, don't do anything regarding ACLs. + // caseInsensitive make string comparisons case insensitive + // TODO: make userIds not needed here. The db adapter shouldn't know + // anything about users, ideally. Then, improve the format of the ACL + // arg to work like the others. + find( + className: string, + query: any, + { + skip, + limit, + acl, + sort = {}, + count, + keys, + op, + distinct, + pipeline, + readPreference, + hint, + caseInsensitive = false, + explain, + comment, + rawValues, + rawFieldNames, + }: any = {}, + auth: any = {}, + validSchemaController: SchemaController.SchemaController + ): Promise { + const isMaintenance = auth.isMaintenance; + const isMaster = acl === undefined || isMaintenance; + const aclGroup = acl || []; + op = + op || (typeof query.objectId == 'string' && Object.keys(query).length === 1 ? 'get' : 'find'); + // Count operation if counting + op = count === true ? 'count' : op; + + let classExists = true; + return this.loadSchemaIfNeeded(validSchemaController).then(schemaController => { + //Allow volatile classes if querying with Master (for _PushStatus) + //TODO: Move volatile classes concept into mongo adapter, postgres adapter shouldn't care + //that api.parse.com breaks when _PushStatus exists in mongo. + return schemaController + .getOneSchema(className, isMaster) + .catch(error => { + // Behavior for non-existent classes is kinda weird on Parse.com. Probably doesn't matter too much. + // For now, pretend the class exists but has no objects, + if (error === undefined) { + classExists = false; + return { fields: {} }; + } + throw error; + }) + .then(schema => { + // Parse.com treats queries on _created_at and _updated_at as if they were queries on createdAt and updatedAt, + // so duplicate that behavior here. If both are specified, the correct behavior to match Parse.com is to + // use the one that appears first in the sort list. + if (sort._created_at) { + sort.createdAt = sort._created_at; + delete sort._created_at; + } + if (sort._updated_at) { + sort.updatedAt = sort._updated_at; + delete sort._updated_at; + } + const queryOptions = { + skip, + limit, + sort, + keys, + readPreference, + hint, + caseInsensitive: this.options.enableCollationCaseComparison ? false : caseInsensitive, + explain, + comment, + }; + Object.keys(sort).forEach(fieldName => { + if (fieldName.match(/^authData\.([a-zA-Z0-9_]+)\.id$/)) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Cannot sort by ${fieldName}`); + } + const rootFieldName = getRootFieldName(fieldName); + if (!SchemaController.fieldNameIsValid(rootFieldName, className)) { + throw new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + `Invalid field name: ${fieldName}.` + ); + } + if (!schema.fields[fieldName.split('.')[0]] && fieldName !== 'score') { + delete sort[fieldName]; + } + }); + return (isMaster + ? Promise.resolve() + : schemaController.validatePermission(className, aclGroup, op) + ) + .then(() => this.reduceRelationKeys(className, query, queryOptions)) + .then(() => this.reduceInRelation(className, query, schemaController)) + .then(() => { + let protectedFields; + if (!isMaster) { + query = this.addPointerPermissions( + schemaController, + className, + op, + query, + aclGroup + ); + /* Don't use projections to optimize the protectedFields since the protectedFields + based on pointer-permissions are determined after querying. The filtering can + overwrite the protected fields. */ + protectedFields = this.addProtectedFields( + schemaController, + className, + query, + aclGroup, + auth, + queryOptions + ); + } + if (!query) { + if (op === 'get') { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); + } else { + return []; + } + } + if (!isMaster) { + if (op === 'update' || op === 'delete') { + query = addWriteACL(query, aclGroup); + } else { + query = addReadACL(query, aclGroup); + } + } + validateQuery(query, isMaster, isMaintenance, false, this.options); + if (count) { + if (!classExists) { + return 0; + } else { + return this.adapter.count( + className, + schema, + query, + readPreference, + undefined, + hint, + comment + ); + } + } else if (distinct) { + if (!classExists) { + return []; + } else { + return this.adapter.distinct(className, schema, query, distinct); + } + } else if (pipeline) { + if (!classExists) { + return []; + } else { + return this.adapter.aggregate( + className, + schema, + pipeline, + readPreference, + hint, + explain, + comment, + rawValues, + rawFieldNames + ); + } + } else if (explain) { + return this.adapter.find(className, schema, query, queryOptions); + } else { + return this.adapter + .find(className, schema, query, queryOptions) + .then(objects => + objects.map(object => { + object = untransformObjectACL(object); + return filterSensitiveData( + isMaster, + isMaintenance, + aclGroup, + auth, + op, + schemaController, + className, + protectedFields, + object, + this.options.protectedFieldsOwnerExempt + ); + }) + ) + .catch(error => { + if (error instanceof Parse.Error) { + throw error; + } + const detailedMessage = + typeof error === 'string' + ? error + : error?.message || 'An internal server error occurred'; + throw createSanitizedError( + Parse.Error.INTERNAL_SERVER_ERROR, + detailedMessage, + this.options, + 'An internal server error occurred' + ); + }); + } + }); + }); + }); } - query.objectId['$in'] = idsIntersection; - return query; -} + deleteSchema(className: string): Promise { + let schemaController; + return this.loadSchema({ clearCache: true }) + .then(s => { + schemaController = s; + return schemaController.getOneSchema(className, true); + }) + .catch(error => { + if (error === undefined) { + return { fields: {} }; + } else { + throw error; + } + }) + .then((schema: any) => { + return this.collectionExists(className) + .then(() => this.adapter.count(className, { fields: {} }, null, '', false)) + .then(count => { + if (count > 0) { + throw new Parse.Error( + 255, + `Class ${className} is not empty, contains ${count} objects, cannot drop schema.` + ); + } + return this.adapter.deleteClass(className); + }) + .then(wasParseCollection => { + if (wasParseCollection) { + const relationFieldNames = Object.keys(schema.fields).filter( + fieldName => schema.fields[fieldName].type === 'Relation' + ); + return Promise.all( + relationFieldNames.map(name => + this.adapter.deleteClass(joinTableName(className, name)) + ) + ).then(() => { + SchemaCache.del(className); + return schemaController.reloadData(); + }); + } else { + return Promise.resolve(); + } + }); + }); + } -DatabaseController.prototype.addNotInObjectIdsIds = function(ids = null, query) { - let idsFromNin = query.objectId && query.objectId['$nin'] ? query.objectId['$nin'] : null; - let allIds = [idsFromNin, ids].filter(list => list !== null); - let totalLength = allIds.reduce((memo, list) => memo + list.length, 0); + // This helps to create intermediate objects for simpler comparison of + // key value pairs used in query objects. Each key value pair will represented + // in a similar way to json + objectToEntriesStrings(query: any): Array { + return Object.entries(query).map(a => a.map(s => JSON.stringify(s)).join(':')); + } - let idsIntersection = []; - if (totalLength > 125) { - idsIntersection = intersect.big(allIds); - } else { - idsIntersection = intersect(allIds); + // Naive logic reducer for OR operations meant to be used only for pointer permissions. + reduceOrOperation(query: { $or: Array }): any { + if (!query.$or) { + return query; + } + const queries = query.$or.map(q => this.objectToEntriesStrings(q)); + let repeat = false; + do { + repeat = false; + for (let i = 0; i < queries.length - 1; i++) { + for (let j = i + 1; j < queries.length; j++) { + const [shorter, longer] = queries[i].length > queries[j].length ? [j, i] : [i, j]; + const foundEntries = queries[shorter].reduce( + (acc, entry) => acc + (queries[longer].includes(entry) ? 1 : 0), + 0 + ); + const shorterEntries = queries[shorter].length; + if (foundEntries === shorterEntries) { + // If the shorter query is completely contained in the longer one, we can strike + // out the longer query. + query.$or.splice(longer, 1); + queries.splice(longer, 1); + repeat = true; + break; + } + } + } + } while (repeat); + if (query.$or.length === 1) { + query = { ...query, ...query.$or[0] }; + delete query.$or; + } + return query; } - // Need to make sure we don't clobber existing $lt or other constraints on objectId. - // Clobbering $eq, $in and shorthand $eq (query.objectId === 'string') constraints - // is expected though. - if (!('objectId' in query) || typeof query.objectId === 'string') { - query.objectId = {}; + // Naive logic reducer for AND operations meant to be used only for pointer permissions. + reduceAndOperation(query: { $and: Array }): any { + if (!query.$and) { + return query; + } + const queries = query.$and.map(q => this.objectToEntriesStrings(q)); + let repeat = false; + do { + repeat = false; + for (let i = 0; i < queries.length - 1; i++) { + for (let j = i + 1; j < queries.length; j++) { + const [shorter, longer] = queries[i].length > queries[j].length ? [j, i] : [i, j]; + const foundEntries = queries[shorter].reduce( + (acc, entry) => acc + (queries[longer].includes(entry) ? 1 : 0), + 0 + ); + const shorterEntries = queries[shorter].length; + if (foundEntries === shorterEntries) { + // If the shorter query is completely contained in the longer one, we can strike + // out the shorter query. + query.$and.splice(shorter, 1); + queries.splice(shorter, 1); + repeat = true; + break; + } + } + } + } while (repeat); + if (query.$and.length === 1) { + query = { ...query, ...query.$and[0] }; + delete query.$and; + } + return query; } - query.objectId['$nin'] = idsIntersection; - return query; -} + // Constraints query using CLP's pointer permissions (PP) if any. + // 1. Etract the user id from caller's ACLgroup; + // 2. Exctract a list of field names that are PP for target collection and operation; + // 3. Constraint the original query so that each PP field must + // point to caller's id (or contain it in case of PP field being an array) + addPointerPermissions( + schema: SchemaController.SchemaController, + className: string, + operation: string, + query: any, + aclGroup: any[] = [] + ): any { + // Check if class has public permission for operation + // If the BaseCLP pass, let go through + if (schema.testPermissionsForClassName(className, aclGroup, operation)) { + return query; + } + const perms = schema.getClassLevelPermissions(className); -// Runs a query on the database. -// Returns a promise that resolves to a list of items. -// Options: -// skip number of results to skip. -// limit limit to this number of results. -// sort an object where keys are the fields to sort by. -// the value is +1 for ascending, -1 for descending. -// count run a count instead of returning results. -// acl restrict this operation with an ACL for the provided array -// of user objectIds and roles. acl: null means no user. -// when this field is not present, don't do anything regarding ACLs. -// TODO: make userIds not needed here. The db adapter shouldn't know -// anything about users, ideally. Then, improve the format of the ACL -// arg to work like the others. -DatabaseController.prototype.find = function(className, query, { - skip, - limit, - acl, - sort = {}, - count, -} = {}) { - let isMaster = acl === undefined; - let aclGroup = acl || []; - let op = typeof query.objectId == 'string' && Object.keys(query).length === 1 ? 'get' : 'find'; - let classExists = true; - return this.loadSchema() - .then(schemaController => { - //Allow volatile classes if querying with Master (for _PushStatus) - //TODO: Move volatile classes concept into mongo adatper, postgres adapter shouldn't care - //that api.parse.com breaks when _PushStatus exists in mongo. - return schemaController.getOneSchema(className, isMaster) - .catch(error => { - // Behaviour for non-existent classes is kinda weird on Parse.com. Probably doesn't matter too much. - // For now, pretend the class exists but has no objects, - if (error === undefined) { - classExists = false; - return { fields: {} }; - } - throw error; - }) - .then(schema => { - // Parse.com treats queries on _created_at and _updated_at as if they were queries on createdAt and updatedAt, - // so duplicate that behaviour here. If both are specified, the corrent behaviour to match Parse.com is to - // use the one that appears first in the sort list. - if (sort._created_at) { - sort.createdAt = sort._created_at; - delete sort._created_at; - } - if (sort._updated_at) { - sort.updatedAt = sort._updated_at; - delete sort._updated_at; - } - Object.keys(sort).forEach(fieldName => { - if (fieldName.match(/^authData\.([a-zA-Z0-9_]+)\.id$/)) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Cannot sort by ${fieldName}`); + const userACL = aclGroup.filter(acl => { + return acl.indexOf('role:') != 0 && acl != '*'; + }); + + const groupKey = + ['get', 'find', 'count'].indexOf(operation) > -1 ? 'readUserFields' : 'writeUserFields'; + + const permFields = []; + + if (perms[operation] && perms[operation].pointerFields) { + permFields.push(...perms[operation].pointerFields); + } + + if (perms[groupKey]) { + for (const field of perms[groupKey]) { + if (!permFields.includes(field)) { + permFields.push(field); + } + } + } + // the ACL should have exactly 1 user + if (permFields.length > 0) { + // the ACL should have exactly 1 user + // No user set return undefined + // If the length is > 1, that means we didn't de-dupe users correctly + if (userACL.length != 1) { + return; + } + const userId = userACL[0]; + const userPointer = { + __type: 'Pointer', + className: '_User', + objectId: userId, + }; + + const queries = permFields.map(key => { + const fieldDescriptor = schema.getExpectedType(className, key); + const fieldType = + fieldDescriptor && + typeof fieldDescriptor === 'object' && + Object.prototype.hasOwnProperty.call(fieldDescriptor, 'type') + ? fieldDescriptor.type + : null; + + let queryClause; + + if (fieldType === 'Pointer') { + // constraint for single pointer setup + queryClause = { [key]: userPointer }; + } else if (fieldType === 'Array') { + // constraint for users-array setup + queryClause = { [key]: { $all: [userPointer] } }; + } else if (fieldType === 'Object') { + // constraint for object setup + queryClause = { [key]: userPointer }; + } else { + // This means that there is a CLP field of an unexpected type. This condition should not happen, which is + // why is being treated as an error. + throw Error( + `An unexpected condition occurred when resolving pointer permissions: ${className} ${key}` + ); } - if (!SchemaController.fieldNameIsValid(fieldName)) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}.`); + // if we already have a constraint on the key, use the $and + if (Object.prototype.hasOwnProperty.call(query, key)) { + return this.reduceAndOperation({ $and: [queryClause, query] }); } + // otherwise just add the constaint + return Object.assign({}, query, queryClause); }); - return (isMaster ? Promise.resolve() : schemaController.validatePermission(className, aclGroup, op)) - .then(() => this.reduceRelationKeys(className, query)) - .then(() => this.reduceInRelation(className, query, schemaController)) - .then(() => { - if (!isMaster) { - query = this.addPointerPermissions(schemaController, className, op, query, aclGroup); - } - if (!query) { - if (op == 'get') { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); - } else { - return []; + + return queries.length === 1 ? queries[0] : this.reduceOrOperation({ $or: queries }); + } else { + return query; + } + } + + addProtectedFields( + schema: SchemaController.SchemaController | any, + className: string, + query: any = {}, + aclGroup: any[] = [], + auth: any = {}, + queryOptions: FullQueryOptions = {} + ): null | string[] { + const perms = + schema && schema.getClassLevelPermissions + ? schema.getClassLevelPermissions(className) + : schema; + if (!perms) { return null; } + + const protectedFields = perms.protectedFields; + if (!protectedFields) { return null; } + + if (className === '_User' && this.options.protectedFieldsOwnerExempt !== false && aclGroup.indexOf(query.objectId) > -1) { return null; } + + // for queries where "keys" are set and do not include all 'userField':{field}, + // we have to transparently include it, and then remove before returning to client + // Because if such key not projected the permission won't be enforced properly + // PS this is called when 'excludeKeys' already reduced to 'keys' + const preserveKeys = queryOptions.keys; + + // these are keys that need to be included only + // to be able to apply protectedFields by pointer + // and then unset before returning to client (later in filterSensitiveFields) + const serverOnlyKeys = []; + + const authenticated = auth.user; + + // map to allow check without array search + const roles = (auth.userRoles || []).reduce((acc, r) => { + acc[r] = protectedFields[r]; + return acc; + }, {}); + + // array of sets of protected fields. separate item for each applicable criteria + const protectedKeysSets = []; + + for (const key in protectedFields) { + // skip userFields + if (key.startsWith('userField:')) { + if (preserveKeys) { + const fieldName = key.substring(10); + if (!preserveKeys.includes(fieldName)) { + // 1. put it there temporarily + queryOptions.keys && queryOptions.keys.push(fieldName); + // 2. preserve it delete later + serverOnlyKeys.push(fieldName); } } - if (!isMaster) { - query = addReadACL(query, aclGroup); + continue; + } + + // add public tier + if (key === '*') { + protectedKeysSets.push(protectedFields[key]); + continue; + } + + if (authenticated) { + if (key === 'authenticated') { + // for logged in users + protectedKeysSets.push(protectedFields[key]); + continue; } - validateQuery(query); - if (count) { - if (!classExists) { - return 0; - } else { - return this.adapter.count(className, schema, query); - } - } else { - if (!classExists) { - return []; - } else { - return this.adapter.find(className, schema, query, { skip, limit, sort }) - .then(objects => objects.map(object => { - object = untransformObjectACL(object); - return filterSensitiveData(isMaster, aclGroup, className, object) - })); - } + + if (roles[key] && key.startsWith('role:')) { + // add applicable roles + protectedKeysSets.push(roles[key]); } - }); - }); - }); -}; + } + } -// Transforms a Database format ACL to a REST API format ACL -const untransformObjectACL = ({_rperm, _wperm, ...output}) => { - if (_rperm || _wperm) { - output.ACL = {}; + // check if there's a rule for current user's id + if (authenticated) { + const userId = auth.user.id; + if (perms.protectedFields[userId]) { + protectedKeysSets.push(perms.protectedFields[userId]); + } + } - (_rperm || []).forEach(entry => { - if (!output.ACL[entry]) { - output.ACL[entry] = { read: true }; - } else { - output.ACL[entry]['read'] = true; + // preserve fields to be removed before sending response to client + if (serverOnlyKeys.length > 0) { + perms.protectedFields.temporaryKeys = serverOnlyKeys; + } + + let protectedKeys = protectedKeysSets.reduce((acc, next) => { + if (next) { + acc.push(...next); } - }); + return acc; + }, []); - (_wperm || []).forEach(entry => { - if (!output.ACL[entry]) { - output.ACL[entry] = { write: true }; - } else { - output.ACL[entry]['write'] = true; + // intersect all sets of protectedFields + protectedKeysSets.forEach(fields => { + if (fields) { + protectedKeys = protectedKeys.filter(v => fields.includes(v)); } }); + + return protectedKeys; } - return output; -} -DatabaseController.prototype.deleteSchema = function(className) { - return this.loadSchema(true) - .then(schemaController => schemaController.getOneSchema(className, true)) - .catch(error => { - if (error === undefined) { - return { fields: {} }; - } else { - throw error; - } - }) - .then(schema => { - return this.collectionExists(className) - .then(exist => this.adapter.count(className, { fields: {} })) - .then(count => { - if (count > 0) { - throw new Parse.Error(255, `Class ${className} is not empty, contains ${count} objects, cannot drop schema.`); - } - return this.adapter.deleteClass(className); - }) - .then(wasParseCollection => { - if (wasParseCollection) { - const relationFieldNames = Object.keys(schema.fields).filter(fieldName => schema.fields[fieldName].type === 'Relation'); - return Promise.all(relationFieldNames.map(name => this.adapter.deleteClass(joinTableName(className, name)))); - } else { - return Promise.resolve(); - } + createTransactionalSession() { + return this.adapter.createTransactionalSession().then(transactionalSession => { + this._transactionalSession = transactionalSession; }); - }) -} + } -DatabaseController.prototype.addPointerPermissions = function(schema, className, operation, query, aclGroup = []) { - // Check if class has public permission for operation - // If the BaseCLP pass, let go through - if (schema.testBaseCLP(className, aclGroup, operation)) { - return query; + commitTransactionalSession() { + if (!this._transactionalSession) { + throw new Error('There is no transactional session to commit'); + } + return this.adapter.commitTransactionalSession(this._transactionalSession).then(() => { + this._transactionalSession = null; + }); } - let perms = schema.perms[className]; - let field = ['get', 'find'].indexOf(operation) > -1 ? 'readUserFields' : 'writeUserFields'; - let userACL = aclGroup.filter((acl) => { - return acl.indexOf('role:') != 0 && acl != '*'; - }); - // the ACL should have exactly 1 user - if (perms && perms[field] && perms[field].length > 0) { - // No user set return undefined - if (userACL.length != 1) { - return; - } - let userId = userACL[0]; - let userPointer = { - "__type": "Pointer", - "className": "_User", - "objectId": userId - }; - let constraints = {}; - let permFields = perms[field]; - let ors = permFields.map((key) => { - let q = { - [key]: userPointer - }; - return {'$and': [q, query]}; + abortTransactionalSession() { + if (!this._transactionalSession) { + throw new Error('There is no transactional session to abort'); + } + return this.adapter.abortTransactionalSession(this._transactionalSession).then(() => { + this._transactionalSession = null; }); - if (ors.length > 1) { - return {'$or': ors}; + } + + // TODO: create indexes on first creation of a _User object. Otherwise it's impossible to + // have a Parse app without it having a _User collection. + async performInitialization() { + await this.adapter.performInitialization({ + VolatileClassesSchemas: SchemaController.VolatileClassesSchemas, + }); + const requiredUserFields = { + fields: { + ...SchemaController.defaultColumns._Default, + ...SchemaController.defaultColumns._User, + }, + }; + const requiredRoleFields = { + fields: { + ...SchemaController.defaultColumns._Default, + ...SchemaController.defaultColumns._Role, + }, + }; + const requiredIdempotencyFields = { + fields: { + ...SchemaController.defaultColumns._Default, + ...SchemaController.defaultColumns._Idempotency, + }, + }; + await this.loadSchema().then(schema => schema.enforceClassExists('_User')); + await this.loadSchema().then(schema => schema.enforceClassExists('_Role')); + await this.loadSchema().then(schema => schema.enforceClassExists('_Idempotency')); + + const databaseOptions = this.options.databaseOptions || {}; + + if (databaseOptions.createIndexUserUsername !== false) { + await this.adapter.ensureUniqueness('_User', requiredUserFields, ['username']).catch(error => { + logger.warn('Unable to ensure uniqueness for usernames: ', error); + throw error; + }); } - return ors[0]; - } else { - return query; + + if (!this.options.enableCollationCaseComparison) { + if (databaseOptions.createIndexUserUsernameCaseInsensitive !== false) { + await this.adapter + .ensureIndex('_User', requiredUserFields, ['username'], 'case_insensitive_username', true) + .catch(error => { + logger.warn('Unable to create case insensitive username index: ', error); + throw error; + }); + } + + if (databaseOptions.createIndexUserEmailCaseInsensitive !== false) { + await this.adapter + .ensureIndex('_User', requiredUserFields, ['email'], 'case_insensitive_email', true) + .catch(error => { + logger.warn('Unable to create case insensitive email index: ', error); + throw error; + }); + } + } + + if (databaseOptions.createIndexUserEmail !== false) { + await this.adapter.ensureUniqueness('_User', requiredUserFields, ['email']).catch(error => { + logger.warn('Unable to ensure uniqueness for user email addresses: ', error); + throw error; + }); + } + + if (databaseOptions.createIndexUserEmailVerifyToken !== false) { + await this.adapter + .ensureIndex('_User', requiredUserFields, ['_email_verify_token'], '_email_verify_token', false) + .catch(error => { + logger.warn('Unable to create index for email verification token: ', error); + throw error; + }); + } + + if (databaseOptions.createIndexUserPasswordResetToken !== false) { + await this.adapter + .ensureIndex('_User', requiredUserFields, ['_perishable_token'], '_perishable_token', false) + .catch(error => { + logger.warn('Unable to create index for password reset token: ', error); + throw error; + }); + } + + if (databaseOptions.createIndexRoleName !== false) { + await this.adapter.ensureUniqueness('_Role', requiredRoleFields, ['name']).catch(error => { + logger.warn('Unable to ensure uniqueness for role name: ', error); + throw error; + }); + } + + await this.adapter + .ensureUniqueness('_Idempotency', requiredIdempotencyFields, ['reqId']) + .catch(error => { + logger.warn('Unable to ensure uniqueness for idempotency request ID: ', error); + throw error; + }); + + const isMongoAdapter = this.adapter instanceof MongoStorageAdapter; + const isPostgresAdapter = this.adapter instanceof PostgresStorageAdapter; + if (isMongoAdapter || isPostgresAdapter) { + let options = {}; + if (isMongoAdapter) { + options = { + ttl: 0, + }; + } else if (isPostgresAdapter) { + options = this.idempotencyOptions; + options.setIdempotencyFunction = true; + } + await this.adapter + .ensureIndex('_Idempotency', requiredIdempotencyFields, ['expire'], 'ttl', false, options) + .catch(error => { + logger.warn('Unable to create TTL index for idempotency expire date: ', error); + throw error; + }); + } + // Create unique indexes for authData providers to prevent race conditions + // during concurrent signups with the same authData + if ( + databaseOptions.createIndexAuthDataUniqueness !== false && + typeof this.adapter.ensureAuthDataUniqueness === 'function' + ) { + const authProviders = Object.keys(this.options.auth || {}); + if (this.options.enableAnonymousUsers !== false) { + if (!authProviders.includes('anonymous')) { + authProviders.push('anonymous'); + } + } + await Promise.all( + authProviders.map(provider => + this.adapter.ensureAuthDataUniqueness(provider).catch(error => { + logger.warn( + `Unable to ensure uniqueness for auth data provider "${provider}": `, + error + ); + }) + ) + ); + } + + await this.adapter.updateSchemaWithIndexes(); } -} -function joinTableName(className, key) { - return `_Join:${key}:${className}`; + _expandResultOnKeyPath(object: any, key: string, value: any): any { + if (key.indexOf('.') < 0) { + object[key] = value[key]; + return object; + } + const path = key.split('.'); + const firstKey = path[0]; + const nextPath = path.slice(1).join('.'); + + // Scan request data for denied keywords + if (this.options && this.options.requestKeywordDenylist) { + // Scan request data for denied keywords + for (const keyword of this.options.requestKeywordDenylist) { + const match = Utils.objectContainsKeyValue( + { [firstKey]: true, [nextPath]: true }, + keyword.key, + true + ); + if (match) { + throw new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + `Prohibited keyword in request data: ${JSON.stringify(keyword)}.` + ); + } + } + } + + object[firstKey] = this._expandResultOnKeyPath( + object[firstKey] || {}, + nextPath, + value[firstKey] + ); + delete object[key]; + return object; + } + + _sanitizeDatabaseResult(originalObject: any, result: any): Promise { + const response = {}; + if (!result) { + return Promise.resolve(response); + } + Object.keys(originalObject).forEach(key => { + const keyUpdate = originalObject[key]; + // determine if that was an op + if ( + keyUpdate && + typeof keyUpdate === 'object' && + keyUpdate.__op && + ['Add', 'AddUnique', 'Remove', 'Increment', 'SetOnInsert'].indexOf(keyUpdate.__op) > -1 + ) { + // only valid ops that produce an actionable result + // the op may have happened on a keypath + this._expandResultOnKeyPath(response, key, result); + // Revert array to object conversion on dot notation for arrays (e.g. "field.0.key") + if (key.includes('.')) { + const [field, index] = key.split('.'); + const isArrayIndex = Array.from(index).every(c => c >= '0' && c <= '9'); + if (isArrayIndex && Array.isArray(result[field]) && !Array.isArray(response[field])) { + response[field] = result[field]; + } + } + } + }); + return Promise.resolve(response); + } + + static _validateQuery: (any, boolean, boolean, boolean) => void; + static filterSensitiveData: (boolean, boolean, any[], any, any, any, string, any[], any, ?boolean) => void; } module.exports = DatabaseController; +// Expose validateQuery for tests +module.exports._validateQuery = validateQuery; +module.exports.filterSensitiveData = filterSensitiveData; diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index a355396e14..2c73eb365f 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -1,78 +1,104 @@ // FilesController.js -import { Parse } from 'parse/node'; import { randomHexString } from '../cryptoUtils'; import AdaptableController from './AdaptableController'; -import { FilesAdapter } from '../Adapters/Files/FilesAdapter'; -import path from 'path'; -import mime from 'mime'; +import { validateFilename, FilesAdapter } from '../Adapters/Files/FilesAdapter'; +import path from 'path'; +const Parse = require('parse/node').Parse; -const legacyFilesRegex = new RegExp("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}-.*"); +const legacyFilesRegex = new RegExp( + '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}-.*' +); export class FilesController extends AdaptableController { - getFileData(config, filename) { return this.adapter.getFileData(filename); } - createFile(config, filename, data, contentType) { - - let extname = path.extname(filename); + async createFile(config, filename, data, contentType, options) { + const extname = path.extname(filename); const hasExtension = extname.length > 0; + const mime = (await import('mime')).default + if (!hasExtension && contentType && mime.getExtension(contentType)) { + filename = filename + '.' + mime.getExtension(contentType); + } else if (hasExtension) { + contentType = mime.getType(filename) || contentType; + } - if (!hasExtension && contentType && mime.extension(contentType)) { - filename = filename + '.' + mime.extension(contentType); - } else if (hasExtension && !contentType) { - contentType = mime.lookup(filename); + if (!this.options.preserveFileName) { + filename = randomHexString(32) + '_' + filename; } - filename = randomHexString(32) + '_' + filename; + // Prepend directory if provided + if (options && options.directory) { + filename = options.directory + '/' + filename; + delete options.directory; + } - var location = this.adapter.getFileLocation(config, filename); - return this.adapter.createFile(filename, data, contentType).then(() => { - return Promise.resolve({ - url: location, - name: filename + // Fallback: buffer stream for adapters that don't support streaming + if (typeof data?.pipe === 'function' && !this.adapter.supportsStreaming) { + data = await new Promise((resolve, reject) => { + const chunks = []; + data.on('data', chunk => chunks.push(chunk)); + data.on('end', () => resolve(Buffer.concat(chunks))); + data.on('error', reject); }); - }); + } + + const location = await this.adapter.getFileLocation(config, filename); + await this.adapter.createFile(filename, data, contentType, options); + return { + url: location, + name: filename, + } } deleteFile(config, filename) { return this.adapter.deleteFile(filename); } + getMetadata(filename) { + if (typeof this.adapter.getMetadata === 'function') { + return this.adapter.getMetadata(filename); + } + return Promise.resolve({}); + } + /** * Find file references in REST-format object and adds the url key * with the current mount point and app id. * Object may be a single object or list of REST-format objects. */ - expandFilesInObject(config, object) { - if (object instanceof Array) { - object.map((obj) => this.expandFilesInObject(config, obj)); + async expandFilesInObject(config, object) { + if (Array.isArray(object)) { + const promises = object.map(obj => this.expandFilesInObject(config, obj)); + await Promise.all(promises); return; } if (typeof object !== 'object') { return; } - for (let key in object) { - let fileObject = object[key]; + for (const key in object) { + const fileObject = object[key]; if (fileObject && fileObject['__type'] === 'File') { if (fileObject['url']) { continue; } - let filename = fileObject['name']; + const filename = fileObject['name']; // all filenames starting with "tfss-" should be from files.parsetfss.com // all filenames starting with a "-" seperated UUID should be from files.parse.com // all other filenames have been migrated or created from Parse Server if (config.fileKey === undefined) { - fileObject['url'] = this.adapter.getFileLocation(config, filename); + fileObject['url'] = await this.adapter.getFileLocation(config, filename); } else { if (filename.indexOf('tfss-') === 0) { - fileObject['url'] = 'http://files.parsetfss.com/' + config.fileKey + '/' + encodeURIComponent(filename); + fileObject['url'] = + 'http://files.parsetfss.com/' + config.fileKey + '/' + encodeURIComponent(filename); } else if (legacyFilesRegex.test(filename)) { - fileObject['url'] = 'http://files.parse.com/' + config.fileKey + '/' + encodeURIComponent(filename); + fileObject['url'] = + 'http://files.parse.com/' + config.fileKey + '/' + encodeURIComponent(filename); } else { - fileObject['url'] = this.adapter.getFileLocation(config, filename); + fileObject['url'] = await this.adapter.getFileLocation(config, filename); } } } @@ -82,6 +108,21 @@ export class FilesController extends AdaptableController { expectedAdapterType() { return FilesAdapter; } + + handleFileStream(config, filename, req, res, contentType) { + return this.adapter.handleFileStream(filename, req, res, contentType); + } + + validateFilename(filename) { + if (typeof this.adapter.validateFilename === 'function') { + const error = this.adapter.validateFilename(filename); + if (typeof error !== 'string') { + return error; + } + return new Parse.Error(Parse.Error.INVALID_FILE_NAME, error); + } + return validateFilename(filename); + } } export default FilesController; diff --git a/src/Controllers/HooksController.js b/src/Controllers/HooksController.js index 718336e53c..6497784754 100644 --- a/src/Controllers/HooksController.js +++ b/src/Controllers/HooksController.js @@ -1,17 +1,26 @@ /** @flow weak */ -import * as DatabaseAdapter from "../DatabaseAdapter"; -import * as triggers from "../triggers"; -import * as Parse from "parse/node"; -import * as request from "request"; -import { logger } from '../logger'; +import * as triggers from '../triggers'; +// @flow-disable-next +import * as Parse from 'parse/node'; +// @flow-disable-next +import request from '../request'; +import { logger } from '../logger'; +import http from 'http'; +import https from 'https'; -const DefaultHooksCollectionName = "_Hooks"; +const DefaultHooksCollectionName = '_Hooks'; +const HTTPAgents = { + http: new http.Agent({ keepAlive: true }), + https: new https.Agent({ keepAlive: true }), +}; export class HooksController { - _applicationId:string; + _applicationId: string; + _webhookKey: string; + database: any; - constructor(applicationId:string, databaseController, webhookKey) { + constructor(applicationId: string, databaseController, webhookKey) { this._applicationId = applicationId; this._webhookKey = webhookKey; this.database = databaseController; @@ -20,14 +29,14 @@ export class HooksController { load() { return this._getHooks().then(hooks => { hooks = hooks || []; - hooks.forEach((hook) => { + hooks.forEach(hook => { this.addHookToTriggers(hook); }); }); } getFunction(functionName) { - return this._getHooks({ functionName: functionName }, 1).then(results => results[0]); + return this._getHooks({ functionName: functionName }).then(results => results[0]); } getFunctions() { @@ -35,11 +44,17 @@ export class HooksController { } getTrigger(className, triggerName) { - return this._getHooks({ className: className, triggerName: triggerName }, 1).then(results => results[0]); + return this._getHooks({ + className: className, + triggerName: triggerName, + }).then(results => results[0]); } getTriggers() { - return this._getHooks({ className: { $exists: true }, triggerName: { $exists: true } }); + return this._getHooks({ + className: { $exists: true }, + triggerName: { $exists: true }, + }); } deleteFunction(functionName) { @@ -49,13 +64,15 @@ export class HooksController { deleteTrigger(className, triggerName) { triggers.removeTrigger(triggerName, className, this._applicationId); - return this._removeHooks({ className: className, triggerName: triggerName }); + return this._removeHooks({ + className: className, + triggerName: triggerName, + }); } - _getHooks(query = {}, limit) { - let options = limit ? { limit: limit } : undefined; - return this.database.find(DefaultHooksCollectionName, query).then((results) => { - return results.map((result) => { + _getHooks(query = {}) { + return this.database.find(DefaultHooksCollectionName, query).then(results => { + return results.map(result => { delete result.objectId; return result; }); @@ -71,22 +88,24 @@ export class HooksController { saveHook(hook) { var query; if (hook.functionName && hook.url) { - query = { functionName: hook.functionName } + query = { functionName: hook.functionName }; } else if (hook.triggerName && hook.className && hook.url) { - query = { className: hook.className, triggerName: hook.triggerName } + query = { className: hook.className, triggerName: hook.triggerName }; } else { - throw new Parse.Error(143, "invalid hook declaration"); + throw new Parse.Error(143, 'invalid hook declaration'); } - return this.database.update(DefaultHooksCollectionName, query, hook, {upsert: true}).then(() => { - return Promise.resolve(hook); - }) + return this.database + .update(DefaultHooksCollectionName, query, hook, { upsert: true }) + .then(() => { + return Promise.resolve(hook); + }); } addHookToTriggers(hook) { var wrappedFunction = wrapToHTTPRequest(hook, this._webhookKey); wrappedFunction.url = hook.url; if (hook.className) { - triggers.addTrigger(hook.triggerName, hook.className, wrappedFunction, this._applicationId) + triggers.addTrigger(hook.triggerName, hook.className, wrappedFunction, this._applicationId); } else { triggers.addFunction(hook.functionName, wrappedFunction, null, this._applicationId); } @@ -103,64 +122,74 @@ export class HooksController { hook = {}; hook.functionName = aHook.functionName; hook.url = aHook.url; - } else if (aHook && aHook.className && aHook.url && aHook.triggerName && triggers.Types[aHook.triggerName]) { + } else if ( + aHook && + aHook.className && + aHook.url && + aHook.triggerName && + triggers.Types[aHook.triggerName] + ) { hook = {}; hook.className = aHook.className; hook.url = aHook.url; hook.triggerName = aHook.triggerName; - } else { - throw new Parse.Error(143, "invalid hook declaration"); + throw new Parse.Error(143, 'invalid hook declaration'); } return this.addHook(hook); - }; + } createHook(aHook) { if (aHook.functionName) { - return this.getFunction(aHook.functionName).then((result) => { + return this.getFunction(aHook.functionName).then(result => { if (result) { - throw new Parse.Error(143, `function name: ${aHook.functionName} already exits`); + throw new Parse.Error(143, `function name: ${aHook.functionName} already exists`); } else { return this.createOrUpdateHook(aHook); } }); } else if (aHook.className && aHook.triggerName) { - return this.getTrigger(aHook.className, aHook.triggerName).then((result) => { + return this.getTrigger(aHook.className, aHook.triggerName).then(result => { if (result) { - throw new Parse.Error(143, `class ${aHook.className} already has trigger ${aHook.triggerName}`); + throw new Parse.Error( + 143, + `class ${aHook.className} already has trigger ${aHook.triggerName}` + ); } return this.createOrUpdateHook(aHook); }); } - throw new Parse.Error(143, "invalid hook declaration"); - }; + throw new Parse.Error(143, 'invalid hook declaration'); + } updateHook(aHook) { if (aHook.functionName) { - return this.getFunction(aHook.functionName).then((result) => { + return this.getFunction(aHook.functionName).then(result => { if (result) { return this.createOrUpdateHook(aHook); } throw new Parse.Error(143, `no function named: ${aHook.functionName} is defined`); }); } else if (aHook.className && aHook.triggerName) { - return this.getTrigger(aHook.className, aHook.triggerName).then((result) => { + return this.getTrigger(aHook.className, aHook.triggerName).then(result => { if (result) { return this.createOrUpdateHook(aHook); } throw new Parse.Error(143, `class ${aHook.className} does not exist`); }); } - throw new Parse.Error(143, "invalid hook declaration"); - }; + throw new Parse.Error(143, 'invalid hook declaration'); + } } function wrapToHTTPRequest(hook, key) { - return (req, res) => { - let jsonBody = {}; + return req => { + const jsonBody = {}; for (var i in req) { + // Parse Server config is not serializable + if (i === 'config') { continue; } jsonBody[i] = req[i]; } if (req.object) { @@ -171,27 +200,37 @@ function wrapToHTTPRequest(hook, key) { jsonBody.original = req.original.toJSON(); jsonBody.original.className = req.original.className; } - let jsonRequest = { + const jsonRequest: any = { + url: hook.url, headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', }, - body: JSON.stringify(jsonBody) + body: jsonBody, + method: 'POST', }; + const agent = hook.url.startsWith('https') ? HTTPAgents['https'] : HTTPAgents['http']; + jsonRequest.agent = agent; + if (key) { jsonRequest.headers['X-Parse-Webhook-Key'] = key; } else { logger.warn('Making outgoing webhook request without webhookKey being set!'); } - - request.post(hook.url, jsonRequest, function (err, httpResponse, body) { - var result; + return request(jsonRequest).then(response => { + let err; + let result; + let body = response.data; if (body) { - if (typeof body === "string") { + if (typeof body === 'string') { try { body = JSON.parse(body); - } catch (e) { - err = { error: "Malformed response", code: -1 }; + } catch { + err = { + error: 'Malformed response', + code: -1, + partialResponse: body.substring(0, 100), + }; } } if (!err) { @@ -199,20 +238,20 @@ function wrapToHTTPRequest(hook, key) { err = body.error; } } - if (err) { - return res.error(err); + throw err; } else if (hook.triggerName === 'beforeSave') { if (typeof result === 'object') { delete result.createdAt; delete result.updatedAt; + delete result.className; } - return res.success({object: result}); + return { object: result }; } else { - return res.success(result); + return result; } }); - } + }; } export default HooksController; diff --git a/src/Controllers/LiveQueryController.js b/src/Controllers/LiveQueryController.js index e68c1466a7..5d11a5e877 100644 --- a/src/Controllers/LiveQueryController.js +++ b/src/Controllers/LiveQueryController.js @@ -1,49 +1,82 @@ import { ParseCloudCodePublisher } from '../LiveQuery/ParseCloudCodePublisher'; - +import { LiveQueryOptions } from '../Options'; +import { getClassName } from './../triggers'; export class LiveQueryController { classNames: any; liveQueryPublisher: any; - constructor(config: any) { - let classNames; + constructor(config: ?LiveQueryOptions) { // If config is empty, we just assume no classs needs to be registered as LiveQuery if (!config || !config.classNames) { this.classNames = new Set(); - } else if (config.classNames instanceof Array) { - this.classNames = new Set(config.classNames); + } else if (Array.isArray(config.classNames)) { + const classNames = config.classNames.map(name => { + const _name = getClassName(name); + return new RegExp(`^${_name}$`); + }); + this.classNames = new Set(classNames); } else { - throw 'liveQuery.classes should be an array of string' + throw 'liveQuery.classes should be an array of string'; } this.liveQueryPublisher = new ParseCloudCodePublisher(config); } - onAfterSave(className: string, currentObject: any, originalObject: any) { + connect() { + return this.liveQueryPublisher.connect(); + } + + onAfterSave( + className: string, + currentObject: any, + originalObject: any, + classLevelPermissions: ?any + ) { if (!this.hasLiveQuery(className)) { return; } - let req = this._makePublisherRequest(currentObject, originalObject); + const req = this._makePublisherRequest(currentObject, originalObject, classLevelPermissions); this.liveQueryPublisher.onCloudCodeAfterSave(req); } - onAfterDelete(className: string, currentObject: any, originalObject: any) { + onAfterDelete( + className: string, + currentObject: any, + originalObject: any, + classLevelPermissions: any + ) { if (!this.hasLiveQuery(className)) { return; } - let req = this._makePublisherRequest(currentObject, originalObject); + const req = this._makePublisherRequest(currentObject, originalObject, classLevelPermissions); this.liveQueryPublisher.onCloudCodeAfterDelete(req); } hasLiveQuery(className: string): boolean { - return this.classNames.has(className); + for (const name of this.classNames) { + if (name.test(className)) { + return true; + } + } + return false; } - _makePublisherRequest(currentObject: any, originalObject: any): any { - let req = { - object: currentObject + clearCachedRoles(user: any) { + if (!user) { + return; + } + return this.liveQueryPublisher.onClearCachedRoles(user); + } + + _makePublisherRequest(currentObject: any, originalObject: any, classLevelPermissions: ?any): any { + const req = { + object: currentObject, }; if (currentObject) { req.original = originalObject; } + if (classLevelPermissions) { + req.classLevelPermissions = classLevelPermissions; + } return req; } } diff --git a/src/Controllers/LoggerController.js b/src/Controllers/LoggerController.js index 7cfbe41ec0..6dac43518f 100644 --- a/src/Controllers/LoggerController.js +++ b/src/Controllers/LoggerController.js @@ -1,22 +1,178 @@ import { Parse } from 'parse/node'; -import PromiseRouter from '../PromiseRouter'; import AdaptableController from './AdaptableController'; import { LoggerAdapter } from '../Adapters/Logger/LoggerAdapter'; const MILLISECONDS_IN_A_DAY = 24 * 60 * 60 * 1000; +const LOG_STRING_TRUNCATE_LENGTH = 1000; +const truncationMarker = '... (truncated)'; export const LogLevel = { INFO: 'info', - ERROR: 'error' -} + ERROR: 'error', +}; export const LogOrder = { DESCENDING: 'desc', - ASCENDING: 'asc' -} + ASCENDING: 'asc', +}; + +export const logLevels = ['error', 'warn', 'info', 'debug', 'verbose', 'silly', 'silent']; export class LoggerController extends AdaptableController { + constructor(adapter, appId, options = { logLevel: 'info' }) { + super(adapter, appId, options); + let level = 'info'; + if (options.verbose) { + level = 'verbose'; + } + if (options.logLevel) { + level = options.logLevel; + } + const index = logLevels.indexOf(level); // info by default + logLevels.forEach((level, levelIndex) => { + if (levelIndex > index) { + // silence the levels that are > maxIndex + this[level] = () => {}; + } + }); + } + + maskSensitiveUrl(path) { + const urlString = 'http://localhost' + path; // prepend dummy string to make a real URL + const urlObj = new URL(urlString); + const query = urlObj.searchParams; + let sanitizedQuery = '?'; + + for (const [key, value] of query) { + if (key !== 'password') { + // normal value + sanitizedQuery += key + '=' + value + '&'; + } else { + // password value, redact it + sanitizedQuery += key + '=' + '********' + '&'; + } + } + + // trim last character, ? or & + sanitizedQuery = sanitizedQuery.slice(0, -1); + + // return original path name with sanitized params attached + return urlObj.pathname + sanitizedQuery; + } + + maskSensitive(argArray) { + return argArray.map(e => { + if (!e) { + return e; + } + + if (typeof e === 'string') { + return e.replace(/(password".?:.?")[^"]*"/g, '$1********"'); + } + // else it is an object... + + // check the url + if (e.url) { + // for strings + if (typeof e.url === 'string') { + e.url = this.maskSensitiveUrl(e.url); + } else if (Array.isArray(e.url)) { + // for strings in array + e.url = e.url.map(item => { + if (typeof item === 'string') { + return this.maskSensitiveUrl(item); + } + + return item; + }); + } + } + + if (e.body) { + for (const key of Object.keys(e.body)) { + if (key === 'password') { + e.body[key] = '********'; + break; + } + } + } + if (e.params) { + for (const key of Object.keys(e.params)) { + if (key === 'password') { + e.params[key] = '********'; + break; + } + } + } + + return e; + }); + } + + log(level, args) { + // make the passed in arguments object an array with the spread operator + args = this.maskSensitive([...args]); + args = [].concat( + level, + args.map(arg => { + if (typeof arg === 'function') { + return arg(); + } + return arg; + }) + ); + this.adapter.log.apply(this.adapter, args); + } + + info() { + return this.log('info', arguments); + } + + error() { + return this.log('error', arguments); + } + + warn() { + return this.log('warn', arguments); + } + + verbose() { + return this.log('verbose', arguments); + } + + debug() { + return this.log('debug', arguments); + } + + silly() { + return this.log('silly', arguments); + } + + logRequest({ method, url, headers, body }) { + this.verbose( + () => { + const stringifiedBody = JSON.stringify(body, null, 2); + return `REQUEST for [${method}] ${url}: ${stringifiedBody}`; + }, + { + method, + url, + headers, + body, + } + ); + } + + logResponse({ method, url, result }) { + this.verbose( + () => { + const stringifiedResponse = JSON.stringify(result, null, 2); + return `RESPONSE from [${method}] ${url}: ${stringifiedResponse}`; + }, + { result: result } + ); + } // check that date input is valid static validDateTime(date) { if (!date) { @@ -31,13 +187,23 @@ export class LoggerController extends AdaptableController { return null; } + truncateLogMessage(string) { + if (string && string.length > LOG_STRING_TRUNCATE_LENGTH) { + const truncated = string.substring(0, LOG_STRING_TRUNCATE_LENGTH) + truncationMarker; + return truncated; + } + + return string; + } + static parseOptions(options = {}) { - let from = LoggerController.validDateTime(options.from) || + const from = + LoggerController.validDateTime(options.from) || new Date(Date.now() - 7 * MILLISECONDS_IN_A_DAY); - let until = LoggerController.validDateTime(options.until) || new Date(); - let size = Number(options.size) || 10; - let order = options.order || LogOrder.DESCENDING; - let level = options.level || LogLevel.INFO; + const until = LoggerController.validDateTime(options.until) || new Date(); + const size = Number(options.size) || 10; + const order = options.order || LogOrder.DESCENDING; + const level = options.level || LogLevel.INFO; return { from, @@ -55,10 +221,15 @@ export class LoggerController extends AdaptableController { // until (optional) End time for the search. Defaults to current time. // order (optional) Direction of results returned, either “asc” or “desc”. Defaults to “desc”. // size (optional) Number of rows returned by search. Defaults to 10 - getLogs(options= {}) { + getLogs(options = {}) { if (!this.adapter) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - 'Logger adapter is not availabe'); + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'Logger adapter is not available'); + } + if (typeof this.adapter.query !== 'function') { + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + 'Querying logs is not supported with this adapter' + ); } options = LoggerController.parseOptions(options); return this.adapter.query(options); diff --git a/src/Controllers/ParseGraphQLController.js b/src/Controllers/ParseGraphQLController.js new file mode 100644 index 0000000000..4445f4763c --- /dev/null +++ b/src/Controllers/ParseGraphQLController.js @@ -0,0 +1,362 @@ +import requiredParameter from '../../lib/requiredParameter'; +import Utils from '../Utils'; +import DatabaseController from './DatabaseController'; +import CacheController from './CacheController'; + +const GraphQLConfigClassName = '_GraphQLConfig'; +const GraphQLConfigId = '1'; +const GraphQLConfigKey = 'config'; + +class ParseGraphQLController { + databaseController: DatabaseController; + cacheController: CacheController; + isMounted: boolean; + configCacheKey: string; + + constructor( + params: { + databaseController: DatabaseController, + cacheController: CacheController, + } = {} + ) { + this.databaseController = + params.databaseController || + requiredParameter( + `ParseGraphQLController requires a "databaseController" to be instantiated.` + ); + this.cacheController = params.cacheController; + this.isMounted = !!params.mountGraphQL; + this.configCacheKey = GraphQLConfigKey; + } + + async getGraphQLConfig(): Promise { + if (this.isMounted) { + const _cachedConfig = await this._getCachedGraphQLConfig(); + if (_cachedConfig) { + return _cachedConfig; + } + } + + const results = await this.databaseController.find( + GraphQLConfigClassName, + { objectId: GraphQLConfigId }, + { limit: 1 } + ); + + let graphQLConfig; + if (results.length != 1) { + // If there is no config in the database - return empty config. + return {}; + } else { + graphQLConfig = results[0][GraphQLConfigKey]; + } + + if (this.isMounted) { + this._putCachedGraphQLConfig(graphQLConfig); + } + + return graphQLConfig; + } + + async updateGraphQLConfig(graphQLConfig: ParseGraphQLConfig): Promise { + // throws if invalid + this._validateGraphQLConfig( + graphQLConfig || requiredParameter('You must provide a graphQLConfig!') + ); + + // Transform in dot notation to make sure it works + const update = Object.keys(graphQLConfig).reduce( + (acc, key) => { + return { + [GraphQLConfigKey]: { + ...acc[GraphQLConfigKey], + [key]: graphQLConfig[key], + }, + }; + }, + { [GraphQLConfigKey]: {} } + ); + + await this.databaseController.update( + GraphQLConfigClassName, + { objectId: GraphQLConfigId }, + update, + { upsert: true } + ); + + if (this.isMounted) { + this._putCachedGraphQLConfig(graphQLConfig); + } + + return { response: { result: true } }; + } + + _getCachedGraphQLConfig() { + return this.cacheController.graphQL.get(this.configCacheKey); + } + + _putCachedGraphQLConfig(graphQLConfig: ParseGraphQLConfig) { + return this.cacheController.graphQL.put(this.configCacheKey, graphQLConfig, 60000); + } + + _validateGraphQLConfig(graphQLConfig: ?ParseGraphQLConfig): void { + const errorMessages: string = []; + if (!graphQLConfig) { + errorMessages.push('cannot be undefined, null or empty'); + } else if (!isValidSimpleObject(graphQLConfig)) { + errorMessages.push('must be a valid object'); + } else { + const { + enabledForClasses = null, + disabledForClasses = null, + classConfigs = null, + ...invalidKeys + } = graphQLConfig; + + if (Object.keys(invalidKeys).length) { + errorMessages.push(`encountered invalid keys: [${Object.keys(invalidKeys)}]`); + } + if (enabledForClasses !== null && !isValidStringArray(enabledForClasses)) { + errorMessages.push(`"enabledForClasses" is not a valid array`); + } + if (disabledForClasses !== null && !isValidStringArray(disabledForClasses)) { + errorMessages.push(`"disabledForClasses" is not a valid array`); + } + if (classConfigs !== null) { + if (Array.isArray(classConfigs)) { + classConfigs.forEach(classConfig => { + const errorMessage = this._validateClassConfig(classConfig); + if (errorMessage) { + errorMessages.push( + `classConfig:${classConfig.className} is invalid because ${errorMessage}` + ); + } + }); + } else { + errorMessages.push(`"classConfigs" is not a valid array`); + } + } + } + if (errorMessages.length) { + throw new Error(`Invalid graphQLConfig: ${errorMessages.join('; ')}`); + } + } + + _validateClassConfig(classConfig: ?ParseGraphQLClassConfig): string | void { + if (!isValidSimpleObject(classConfig)) { + return 'it must be a valid object'; + } else { + const { className, type = null, query = null, mutation = null, ...invalidKeys } = classConfig; + if (Object.keys(invalidKeys).length) { + return `"invalidKeys" [${Object.keys(invalidKeys)}] should not be present`; + } + if (typeof className !== 'string' || !className.trim().length) { + // TODO consider checking class exists in schema? + return `"className" must be a valid string`; + } + if (type !== null) { + if (!isValidSimpleObject(type)) { + return `"type" must be a valid object`; + } + const { + inputFields = null, + outputFields = null, + constraintFields = null, + sortFields = null, + ...invalidKeys + } = type; + if (Object.keys(invalidKeys).length) { + return `"type" contains invalid keys, [${Object.keys(invalidKeys)}]`; + } else if (outputFields !== null && !isValidStringArray(outputFields)) { + return `"outputFields" must be a valid string array`; + } else if (constraintFields !== null && !isValidStringArray(constraintFields)) { + return `"constraintFields" must be a valid string array`; + } + if (sortFields !== null) { + if (Array.isArray(sortFields)) { + let errorMessage; + sortFields.every((sortField, index) => { + if (!isValidSimpleObject(sortField)) { + errorMessage = `"sortField" at index ${index} is not a valid object`; + return false; + } else { + const { field, asc, desc, ...invalidKeys } = sortField; + if (Object.keys(invalidKeys).length) { + errorMessage = `"sortField" at index ${index} contains invalid keys, [${Object.keys( + invalidKeys + )}]`; + return false; + } else { + if (typeof field !== 'string' || field.trim().length === 0) { + errorMessage = `"sortField" at index ${index} did not provide the "field" as a string`; + return false; + } else if (typeof asc !== 'boolean' || typeof desc !== 'boolean') { + errorMessage = `"sortField" at index ${index} did not provide "asc" or "desc" as booleans`; + return false; + } + } + } + return true; + }); + if (errorMessage) { + return errorMessage; + } + } else { + return `"sortFields" must be a valid array.`; + } + } + if (inputFields !== null) { + if (isValidSimpleObject(inputFields)) { + const { create = null, update = null, ...invalidKeys } = inputFields; + if (Object.keys(invalidKeys).length) { + return `"inputFields" contains invalid keys: [${Object.keys(invalidKeys)}]`; + } else { + if (update !== null && !isValidStringArray(update)) { + return `"inputFields.update" must be a valid string array`; + } else if (create !== null) { + if (!isValidStringArray(create)) { + return `"inputFields.create" must be a valid string array`; + } else if (className === '_User') { + if (!create.includes('username') || !create.includes('password')) { + return `"inputFields.create" must include required fields, username and password`; + } + } + } + } + } else { + return `"inputFields" must be a valid object`; + } + } + } + if (query !== null) { + if (isValidSimpleObject(query)) { + const { + find = null, + get = null, + findAlias = null, + getAlias = null, + ...invalidKeys + } = query; + if (Object.keys(invalidKeys).length) { + return `"query" contains invalid keys, [${Object.keys(invalidKeys)}]`; + } else if (find !== null && typeof find !== 'boolean') { + return `"query.find" must be a boolean`; + } else if (get !== null && typeof get !== 'boolean') { + return `"query.get" must be a boolean`; + } else if (findAlias !== null && typeof findAlias !== 'string') { + return `"query.findAlias" must be a string`; + } else if (getAlias !== null && typeof getAlias !== 'string') { + return `"query.getAlias" must be a string`; + } + } else { + return `"query" must be a valid object`; + } + } + if (mutation !== null) { + if (isValidSimpleObject(mutation)) { + const { + create = null, + update = null, + destroy = null, + createAlias = null, + updateAlias = null, + destroyAlias = null, + ...invalidKeys + } = mutation; + if (Object.keys(invalidKeys).length) { + return `"mutation" contains invalid keys, [${Object.keys(invalidKeys)}]`; + } + if (create !== null && typeof create !== 'boolean') { + return `"mutation.create" must be a boolean`; + } + if (update !== null && typeof update !== 'boolean') { + return `"mutation.update" must be a boolean`; + } + if (destroy !== null && typeof destroy !== 'boolean') { + return `"mutation.destroy" must be a boolean`; + } + if (createAlias !== null && typeof createAlias !== 'string') { + return `"mutation.createAlias" must be a string`; + } + if (updateAlias !== null && typeof updateAlias !== 'string') { + return `"mutation.updateAlias" must be a string`; + } + if (destroyAlias !== null && typeof destroyAlias !== 'string') { + return `"mutation.destroyAlias" must be a string`; + } + } else { + return `"mutation" must be a valid object`; + } + } + } + } +} + +const isValidStringArray = function (array): boolean { + return Array.isArray(array) + ? !array.some(s => typeof s !== 'string' || s.trim().length < 1) + : false; +}; +/** + * Ensures the obj is a simple JSON/{} + * object, i.e. not an array, null, date + * etc. + */ +const isValidSimpleObject = function (obj): boolean { + return ( + typeof obj === 'object' && + !Array.isArray(obj) && + obj !== null && + Utils.isDate(obj) !== true && + Utils.isPromise(obj) !== true + ); +}; + +export interface ParseGraphQLConfig { + enabledForClasses?: string[]; + disabledForClasses?: string[]; + classConfigs?: ParseGraphQLClassConfig[]; +} + +export interface ParseGraphQLClassConfig { + className: string; + /* The `type` object contains options for how the class types are generated */ + type: ?{ + /* Fields that are allowed when creating or updating an object. */ + inputFields: ?{ + /* Leave blank to allow all available fields in the schema. */ + create?: string[], + update?: string[], + }, + /* Fields on the edges that can be resolved from a query, i.e. the Result Type. */ + outputFields: ?(string[]), + /* Fields by which a query can be filtered, i.e. the `where` object. */ + constraintFields: ?(string[]), + /* Fields by which a query can be sorted; */ + sortFields: ?({ + field: string, + asc: boolean, + desc: boolean, + }[]), + }; + /* The `query` object contains options for which class queries are generated */ + query: ?{ + get: ?boolean, + find: ?boolean, + findAlias: ?String, + getAlias: ?String, + }; + /* The `mutation` object contains options for which class mutations are generated */ + mutation: ?{ + create: ?boolean, + update: ?boolean, + // delete is a reserved key word in js + destroy: ?boolean, + createAlias: ?String, + updateAlias: ?String, + destroyAlias: ?String, + }; +} + +export default ParseGraphQLController; +export { GraphQLConfigClassName, GraphQLConfigId, GraphQLConfigKey }; diff --git a/src/Controllers/PushController.js b/src/Controllers/PushController.js index 6827e77036..04fb5c4fd0 100644 --- a/src/Controllers/PushController.js +++ b/src/Controllers/PushController.js @@ -1,134 +1,130 @@ -import { Parse } from 'parse/node'; -import PromiseRouter from '../PromiseRouter'; -import rest from '../rest'; -import AdaptableController from './AdaptableController'; -import { PushAdapter } from '../Adapters/Push/PushAdapter'; -import deepcopy from 'deepcopy'; -import RestQuery from '../RestQuery'; -import RestWrite from '../RestWrite'; -import { master } from '../Auth'; -import pushStatusHandler from '../pushStatusHandler'; - -const FEATURE_NAME = 'push'; -const UNSUPPORTED_BADGE_KEY = "unsupported"; - -export class PushController extends AdaptableController { +import { Parse } from 'parse/node'; +import RestQuery from '../RestQuery'; +import RestWrite from '../RestWrite'; +import { master } from '../Auth'; +import { pushStatusHandler } from '../StatusHandler'; +import { applyDeviceTokenExists } from '../Push/utils'; - /** - * Check whether the deviceType parameter in qury condition is valid or not. - * @param {Object} where A query condition - * @param {Array} validPushTypes An array of valid push types(string) - */ - static validatePushType(where = {}, validPushTypes = []) { - var deviceTypeField = where.deviceType || {}; - var deviceTypes = []; - if (typeof deviceTypeField === 'string') { - deviceTypes.push(deviceTypeField); - } else if (typeof deviceTypeField['$in'] === 'array') { - deviceTypes.concat(deviceTypeField['$in']); - } - for (var i = 0; i < deviceTypes.length; i++) { - var deviceType = deviceTypes[i]; - if (validPushTypes.indexOf(deviceType) < 0) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - deviceType + ' is not supported push type.'); - } +export class PushController { + sendPush(body = {}, where = {}, config, auth, onPushStatusSaved = () => {}, now = new Date()) { + if (!config.hasPushSupport) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'Missing push configuration'); } - } - get pushIsAvailable() { - return !!this.adapter; - } + // Replace the expiration_time and push_time with a valid Unix epoch milliseconds time + body.expiration_time = PushController.getExpirationTime(body); + body.expiration_interval = PushController.getExpirationInterval(body); + if (body.expiration_time && body.expiration_interval) { + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + 'Both expiration_time and expiration_interval cannot be set' + ); + } - sendPush(body = {}, where = {}, config, auth, onPushStatusSaved = () => {}) { - var pushAdapter = this.adapter; - if (!this.pushIsAvailable) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - 'Push adapter is not available'); + // Immediate push + if (body.expiration_interval && !Object.prototype.hasOwnProperty.call(body, 'push_time')) { + const ttlMs = body.expiration_interval * 1000; + body.expiration_time = new Date(now.valueOf() + ttlMs).valueOf(); } - if (!this.options) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - 'Missing push configuration'); + + const pushTime = PushController.getPushTime(body); + if (pushTime && pushTime.date !== 'undefined') { + body['push_time'] = PushController.formatPushTime(pushTime); } - PushController.validatePushType(where, pushAdapter.getValidPushTypes()); - // Replace the expiration_time with a valid Unix epoch milliseconds time - body['expiration_time'] = PushController.getExpirationTime(body); + // TODO: If the req can pass the checking, we return immediately instead of waiting // pushes to be sent. We probably change this behaviour in the future. let badgeUpdate = () => { return Promise.resolve(); - } + }; + if (body.data && body.data.badge) { - let badge = body.data.badge; + const badge = body.data.badge; let restUpdate = {}; if (typeof badge == 'string' && badge.toLowerCase() === 'increment') { - restUpdate = { badge: { __op: 'Increment', amount: 1 } } + restUpdate = { badge: { __op: 'Increment', amount: 1 } }; + } else if ( + typeof badge == 'object' && + typeof badge.__op == 'string' && + badge.__op.toLowerCase() == 'increment' && + Number(badge.amount) + ) { + restUpdate = { badge: { __op: 'Increment', amount: badge.amount } }; } else if (Number(badge)) { - restUpdate = { badge: badge } + restUpdate = { badge: badge }; } else { - throw "Invalid value for badge, expected number or 'Increment'"; + throw "Invalid value for badge, expected number or 'Increment' or {increment: number}"; } - let updateWhere = deepcopy(where); - badgeUpdate = () => { - updateWhere.deviceType = 'ios'; + // Force filtering on only valid device tokens + const updateWhere = applyDeviceTokenExists(where); + badgeUpdate = async () => { // Build a real RestQuery so we can use it in RestWrite - let restQuery = new RestQuery(config, master(config), '_Installation', updateWhere); + const restQuery = await RestQuery({ + method: RestQuery.Method.find, + config, + runBeforeFind: false, + auth: master(config), + className: '_Installation', + restWhere: updateWhere, + }); return restQuery.buildRestWhere().then(() => { - let write = new RestWrite(config, master(config), '_Installation', restQuery.restWhere, restUpdate); + const write = new RestWrite( + config, + master(config), + '_Installation', + restQuery.restWhere, + restUpdate + ); write.runOptions.many = true; return write.execute(); }); - } + }; } - let pushStatus = pushStatusHandler(config); - return Promise.resolve().then(() => { - return pushStatus.setInitial(body, where); - }).then(() => { - onPushStatusSaved(pushStatus.objectId); - return badgeUpdate(); - }).then(() => { - return rest.find(config, auth, '_Installation', where); - }).then((response) => { - if (!response.results) { - return Promise.reject({error: 'PushController: no results in query'}) - } - pushStatus.setRunning(response.results); - return this.sendToAdapter(body, response.results, pushStatus, config); - }).then((results) => { - return pushStatus.complete(results); - }).catch((err) => { - pushStatus.fail(err); - return Promise.reject(err); - }); - } + const pushStatus = pushStatusHandler(config); + return Promise.resolve() + .then(() => { + return pushStatus.setInitial(body, where); + }) + .then(() => { + onPushStatusSaved(pushStatus.objectId); + return badgeUpdate(); + }) + .then(() => { + // Update audience lastUsed and timesUsed + if (body.audience_id) { + const audienceId = body.audience_id; - sendToAdapter(body, installations, pushStatus, config) { - if (body.data && body.data.badge && typeof body.data.badge == 'string' && body.data.badge.toLowerCase() == "increment") { - // Collect the badges to reduce the # of calls - let badgeInstallationsMap = installations.reduce((map, installation) => { - let badge = installation.badge; - if (installation.deviceType != "ios") { - badge = UNSUPPORTED_BADGE_KEY; + var updateAudience = { + lastUsed: { __type: 'Date', iso: new Date().toISOString() }, + timesUsed: { __op: 'Increment', amount: 1 }, + }; + const write = new RestWrite( + config, + master(config), + '_Audience', + { objectId: audienceId }, + updateAudience + ); + write.execute(); } - map[badge+''] = map[badge+''] || []; - map[badge+''].push(installation); - return map; - }, {}); - - // Map the on the badges count and return the send result - let promises = Object.keys(badgeInstallationsMap).map((badge) => { - let payload = deepcopy(body); - if (badge == UNSUPPORTED_BADGE_KEY) { - delete payload.data.badge; - } else { - payload.data.badge = parseInt(badge); + // Don't wait for the audience update promise to resolve. + return Promise.resolve(); + }) + .then(() => { + if ( + Object.prototype.hasOwnProperty.call(body, 'push_time') && + config.hasPushScheduledSupport + ) { + return Promise.resolve(); } - return this.adapter.send(payload, badgeInstallationsMap[badge], pushStatus.objectId); + return config.pushControllerQueue.enqueue(body, where, config, auth, pushStatus); + }) + .catch(err => { + return pushStatus.fail(err).then(() => { + throw err; + }); }); - return Promise.all(promises); - } - return this.adapter.send(body, installations, pushStatus.objectId); } /** @@ -137,7 +133,7 @@ export class PushController extends AdaptableController { * @returns {Number|undefined} The expiration time if it exists in the request */ static getExpirationTime(body = {}) { - var hasExpirationTime = !!body['expiration_time']; + var hasExpirationTime = Object.prototype.hasOwnProperty.call(body, 'expiration_time'); if (!hasExpirationTime) { return; } @@ -148,19 +144,101 @@ export class PushController extends AdaptableController { } else if (typeof expirationTimeParam === 'string') { expirationTime = new Date(expirationTimeParam); } else { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - body['expiration_time'] + ' is not valid time.'); + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + body['expiration_time'] + ' is not valid time.' + ); } // Check expirationTime is valid or not, if it is not valid, expirationTime is NaN if (!isFinite(expirationTime)) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - body['expiration_time'] + ' is not valid time.'); + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + body['expiration_time'] + ' is not valid time.' + ); } return expirationTime.valueOf(); } - expectedAdapterType() { - return PushAdapter; + static getExpirationInterval(body = {}) { + const hasExpirationInterval = Object.prototype.hasOwnProperty.call(body, 'expiration_interval'); + if (!hasExpirationInterval) { + return; + } + + var expirationIntervalParam = body['expiration_interval']; + if (typeof expirationIntervalParam !== 'number' || expirationIntervalParam <= 0) { + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + `expiration_interval must be a number greater than 0` + ); + } + return expirationIntervalParam; + } + + /** + * Get push time from the request body. + * @param {Object} request A request object + * @returns {Number|undefined} The push time if it exists in the request + */ + static getPushTime(body = {}) { + var hasPushTime = Object.prototype.hasOwnProperty.call(body, 'push_time'); + if (!hasPushTime) { + return; + } + var pushTimeParam = body['push_time']; + var date; + var isLocalTime = true; + + if (typeof pushTimeParam === 'number') { + date = new Date(pushTimeParam * 1000); + } else if (typeof pushTimeParam === 'string') { + isLocalTime = !PushController.pushTimeHasTimezoneComponent(pushTimeParam); + date = new Date(pushTimeParam); + } else { + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + body['push_time'] + ' is not valid time.' + ); + } + // Check pushTime is valid or not, if it is not valid, pushTime is NaN + if (!isFinite(date)) { + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + body['push_time'] + ' is not valid time.' + ); + } + + return { + date, + isLocalTime, + }; + } + + /** + * Checks if a ISO8601 formatted date contains a timezone component + * @param pushTimeParam {string} + * @returns {boolean} + */ + static pushTimeHasTimezoneComponent(pushTimeParam: string): boolean { + const offsetPattern = /(.+)([+-])\d\d:\d\d$/; + return ( + pushTimeParam.indexOf('Z') === pushTimeParam.length - 1 || offsetPattern.test(pushTimeParam) // 2007-04-05T12:30Z + ); // 2007-04-05T12:30.000+02:00, 2007-04-05T12:30.000-02:00 + } + + /** + * Converts a date to ISO format in UTC time and strips the timezone if `isLocalTime` is true + * @param date {Date} + * @param isLocalTime {boolean} + * @returns {string} + */ + static formatPushTime({ date, isLocalTime }: { date: Date, isLocalTime: boolean }) { + if (isLocalTime) { + // Strip 'Z' + const isoString = date.toISOString(); + return isoString.substring(0, isoString.indexOf('Z')); + } + return date.toISOString(); } } diff --git a/src/Controllers/SchemaCache.js b/src/Controllers/SchemaCache.js deleted file mode 100644 index 7a56f10710..0000000000 --- a/src/Controllers/SchemaCache.js +++ /dev/null @@ -1,68 +0,0 @@ -const MAIN_SCHEMA = "__MAIN_SCHEMA"; -const SCHEMA_CACHE_PREFIX = "__SCHEMA"; -const ALL_KEYS = "__ALL_KEYS"; - -import { randomString } from '../cryptoUtils'; - -export default class SchemaCache { - cache: Object; - - constructor(cacheController, ttl = 30) { - this.ttl = ttl; - if (typeof ttl == 'string') { - this.ttl = parseInt(ttl); - } - this.cache = cacheController; - this.prefix = SCHEMA_CACHE_PREFIX+randomString(20); - } - - put(key, value) { - return this.cache.get(this.prefix+ALL_KEYS).then((allKeys) => { - allKeys = allKeys || {}; - allKeys[key] = true; - return Promise.all([this.cache.put(this.prefix+ALL_KEYS, allKeys, this.ttl), this.cache.put(key, value, this.ttl)]); - }); - } - - getAllClasses() { - if (!this.ttl) { - return Promise.resolve(null); - } - return this.cache.get(this.prefix+MAIN_SCHEMA); - } - - setAllClasses(schema) { - if (!this.ttl) { - return Promise.resolve(null); - } - return this.put(this.prefix+MAIN_SCHEMA, schema); - } - - setOneSchema(className, schema) { - if (!this.ttl) { - return Promise.resolve(null); - } - return this.put(this.prefix+className, schema); - } - - getOneSchema(className) { - if (!this.ttl) { - return Promise.resolve(null); - } - return this.cache.get(this.prefix+className); - } - - clear() { - // That clears all caches... - let promise = Promise.resolve(); - return this.cache.get(this.prefix+ALL_KEYS).then((allKeys) => { - if (!allKeys) { - return; - } - let promises = Object.keys(allKeys).map((key) => { - return this.cache.del(key); - }); - return Promise.all(promises); - }); - } -} diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index d321621f4f..8ad7352647 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -1,3 +1,4 @@ +// @flow // This class handles schema validation, persistence, and modification. // // Each individual Schema object should be immutable. The helpers to @@ -13,145 +14,436 @@ // DatabaseController. This will let us replace the schema logic for // different databases. // TODO: hide all schema logic inside the database adapter. - +// @flow-disable-next const Parse = require('parse/node').Parse; -import _ from 'lodash'; - -const defaultColumns = Object.freeze({ +import { StorageAdapter } from '../Adapters/Storage/StorageAdapter'; +import SchemaCache from '../Adapters/Cache/SchemaCache'; +import DatabaseController from './DatabaseController'; +import Config from '../Config'; +import { createSanitizedError } from '../Error'; +import type { + Schema, + SchemaFields, + ClassLevelPermissions, + SchemaField, + LoadSchemaOptions, +} from './types'; + +const defaultColumns: { [string]: SchemaFields } = Object.freeze({ // Contain the default columns for every parse object type (except _Join collection) _Default: { - "objectId": {type:'String'}, - "createdAt": {type:'Date'}, - "updatedAt": {type:'Date'}, - "ACL": {type:'ACL'}, + objectId: { type: 'String' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + ACL: { type: 'ACL' }, }, // The additional default columns for the _User collection (in addition to DefaultCols) _User: { - "username": {type:'String'}, - "password": {type:'String'}, - "email": {type:'String'}, - "emailVerified": {type:'Boolean'}, + username: { type: 'String' }, + password: { type: 'String' }, + email: { type: 'String' }, + emailVerified: { type: 'Boolean' }, + authData: { type: 'Object' }, }, // The additional default columns for the _Installation collection (in addition to DefaultCols) _Installation: { - "installationId": {type:'String'}, - "deviceToken": {type:'String'}, - "channels": {type:'Array'}, - "deviceType": {type:'String'}, - "pushType": {type:'String'}, - "GCMSenderId": {type:'String'}, - "timeZone": {type:'String'}, - "localeIdentifier": {type:'String'}, - "badge": {type:'Number'}, - "appVersion": {type:'String'}, - "appName": {type:'String'}, - "appIdentifier": {type:'String'}, - "parseVersion": {type:'String'}, + installationId: { type: 'String' }, + deviceToken: { type: 'String' }, + channels: { type: 'Array' }, + deviceType: { type: 'String' }, + pushType: { type: 'String' }, + GCMSenderId: { type: 'String' }, + timeZone: { type: 'String' }, + localeIdentifier: { type: 'String' }, + badge: { type: 'Number' }, + appVersion: { type: 'String' }, + appName: { type: 'String' }, + appIdentifier: { type: 'String' }, + parseVersion: { type: 'String' }, }, // The additional default columns for the _Role collection (in addition to DefaultCols) _Role: { - "name": {type:'String'}, - "users": {type:'Relation', targetClass:'_User'}, - "roles": {type:'Relation', targetClass:'_Role'} + name: { type: 'String' }, + users: { type: 'Relation', targetClass: '_User' }, + roles: { type: 'Relation', targetClass: '_Role' }, }, // The additional default columns for the _Session collection (in addition to DefaultCols) _Session: { - "restricted": {type:'Boolean'}, - "user": {type:'Pointer', targetClass:'_User'}, - "installationId": {type:'String'}, - "sessionToken": {type:'String'}, - "expiresAt": {type:'Date'}, - "createdWith": {type:'Object'} + user: { type: 'Pointer', targetClass: '_User' }, + installationId: { type: 'String' }, + sessionToken: { type: 'String' }, + expiresAt: { type: 'Date' }, + createdWith: { type: 'Object' }, }, _Product: { - "productIdentifier": {type:'String'}, - "download": {type:'File'}, - "downloadName": {type:'String'}, - "icon": {type:'File'}, - "order": {type:'Number'}, - "title": {type:'String'}, - "subtitle": {type:'String'}, + productIdentifier: { type: 'String' }, + download: { type: 'File' }, + downloadName: { type: 'String' }, + icon: { type: 'File' }, + order: { type: 'Number' }, + title: { type: 'String' }, + subtitle: { type: 'String' }, }, _PushStatus: { - "pushTime": {type:'String'}, - "source": {type:'String'}, // rest or webui - "query": {type:'String'}, // the stringified JSON query - "payload": {type:'Object'}, // the JSON payload, - "title": {type:'String'}, - "expiry": {type:'Number'}, - "status": {type:'String'}, - "numSent": {type:'Number'}, - "numFailed": {type:'Number'}, - "pushHash": {type:'String'}, - "errorMessage": {type:'Object'}, - "sentPerType": {type:'Object'}, - "failedPerType":{type:'Object'}, - } + pushTime: { type: 'String' }, + source: { type: 'String' }, // rest or webui + query: { type: 'String' }, // the stringified JSON query + payload: { type: 'String' }, // the stringified JSON payload, + title: { type: 'String' }, + expiry: { type: 'Number' }, + expiration_interval: { type: 'Number' }, + status: { type: 'String' }, + numSent: { type: 'Number' }, + numFailed: { type: 'Number' }, + pushHash: { type: 'String' }, + errorMessage: { type: 'Object' }, + sentPerType: { type: 'Object' }, + failedPerType: { type: 'Object' }, + sentPerUTCOffset: { type: 'Object' }, + failedPerUTCOffset: { type: 'Object' }, + count: { type: 'Number' }, // tracks # of batches queued and pending + }, + _JobStatus: { + jobName: { type: 'String' }, + source: { type: 'String' }, + status: { type: 'String' }, + message: { type: 'String' }, + params: { type: 'Object' }, // params received when calling the job + finishedAt: { type: 'Date' }, + }, + _JobSchedule: { + jobName: { type: 'String' }, + description: { type: 'String' }, + params: { type: 'String' }, + startAfter: { type: 'String' }, + daysOfWeek: { type: 'Array' }, + timeOfDay: { type: 'String' }, + lastRun: { type: 'Number' }, + repeatMinutes: { type: 'Number' }, + }, + _Hooks: { + functionName: { type: 'String' }, + className: { type: 'String' }, + triggerName: { type: 'String' }, + url: { type: 'String' }, + }, + _GlobalConfig: { + objectId: { type: 'String' }, + params: { type: 'Object' }, + masterKeyOnly: { type: 'Object' }, + }, + _GraphQLConfig: { + objectId: { type: 'String' }, + config: { type: 'Object' }, + }, + _Audience: { + objectId: { type: 'String' }, + name: { type: 'String' }, + query: { type: 'String' }, //storing query as JSON string to prevent "Nested keys should not contain the '$' or '.' characters" error + lastUsed: { type: 'Date' }, + timesUsed: { type: 'Number' }, + }, + _Idempotency: { + reqId: { type: 'String' }, + expire: { type: 'Date' }, + }, }); +// fields required for read or write operations on their respective classes. const requiredColumns = Object.freeze({ - _Product: ["productIdentifier", "icon", "order", "title", "subtitle"], - _Role: ["name", "ACL"] + read: { + _User: ['username'], + }, + write: { + _Product: ['productIdentifier', 'icon', 'order', 'title', 'subtitle'], + _Role: ['name', 'ACL'], + }, }); -const systemClasses = Object.freeze(['_User', '_Installation', '_Role', '_Session', '_Product', '_PushStatus']); - -const volatileClasses = Object.freeze(['_PushStatus', '_Hooks', '_GlobalConfig']); +const invalidColumns = ['length']; + +const systemClasses = Object.freeze([ + '_User', + '_Installation', + '_Role', + '_Session', + '_Product', + '_PushStatus', + '_JobStatus', + '_JobSchedule', + '_Audience', + '_Idempotency', +]); + +const volatileClasses = Object.freeze([ + '_JobStatus', + '_PushStatus', + '_Hooks', + '_GlobalConfig', + '_GraphQLConfig', + '_JobSchedule', + '_Audience', + '_Idempotency', +]); -// 10 alpha numberic chars + uppercase -const userIdRegex = /^[a-zA-Z0-9]{10}$/; // Anything that start with role const roleRegex = /^role:.*/; +// Anything that starts with userField (allowed for protected fields only) +const protectedFieldsPointerRegex = /^userField:.*/; // * permission -const publicRegex = /^\*$/ +const publicRegex = /^\*$/; + +const authenticatedRegex = /^authenticated$/; + +const requiresAuthenticationRegex = /^requiresAuthentication$/; + +const clpPointerRegex = /^pointerFields$/; + +// regex for validating entities in protectedFields object +const protectedFieldsRegex = Object.freeze([ + protectedFieldsPointerRegex, + publicRegex, + authenticatedRegex, + roleRegex, +]); + +// clp regex +const clpFieldsRegex = Object.freeze([ + clpPointerRegex, + publicRegex, + requiresAuthenticationRegex, + roleRegex, +]); + +function validatePermissionKey(key, userIdRegExp) { + let matchesSome = false; + for (const regEx of clpFieldsRegex) { + if (key.match(regEx) !== null) { + matchesSome = true; + break; + } + } + + // userId depends on startup options so it's dynamic + const valid = matchesSome || key.match(userIdRegExp) !== null; + if (!valid) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `'${key}' is not a valid key for class level permissions` + ); + } +} -const permissionKeyRegex = Object.freeze([userIdRegex, roleRegex, publicRegex]); +function validateProtectedFieldsKey(key, userIdRegExp) { + let matchesSome = false; + for (const regEx of protectedFieldsRegex) { + if (key.match(regEx) !== null) { + matchesSome = true; + break; + } + } -function verifyPermissionKey(key) { - let result = permissionKeyRegex.reduce((isGood, regEx) => { - isGood = isGood || key.match(regEx) != null; - return isGood; - }, false); - if (!result) { - throw new Parse.Error(Parse.Error.INVALID_JSON, `'${key}' is not a valid key for class level permissions`); + // userId regex depends on launch options so it's dynamic + const valid = matchesSome || key.match(userIdRegExp) !== null; + if (!valid) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `'${key}' is not a valid key for class level permissions` + ); } } -const CLPValidKeys = Object.freeze(['find', 'get', 'create', 'update', 'delete', 'addField', 'readUserFields', 'writeUserFields']); -function validateCLP(perms, fields) { +const CLPValidKeys = Object.freeze([ + 'ACL', + 'find', + 'count', + 'get', + 'create', + 'update', + 'delete', + 'addField', + 'readUserFields', + 'writeUserFields', + 'protectedFields', +]); + +// validation before setting class-level permissions on collection +function validateCLP(perms: ClassLevelPermissions, fields: SchemaFields, userIdRegExp: RegExp) { if (!perms) { return; } - Object.keys(perms).forEach((operation) => { - if (CLPValidKeys.indexOf(operation) == -1) { - throw new Parse.Error(Parse.Error.INVALID_JSON, `${operation} is not a valid operation for class level permissions`); + for (const operationKey in perms) { + if (CLPValidKeys.indexOf(operationKey) == -1) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `${operationKey} is not a valid operation for class level permissions` + ); } - if (operation === 'readUserFields' || operation === 'writeUserFields') { - if (!Array.isArray(perms[operation])) { - throw new Parse.Error(Parse.Error.INVALID_JSON, `'${perms[operation]}' is not a valid value for class level permissions ${operation}`); - } else { - perms[operation].forEach((key) => { - if (!fields[key] || fields[key].type != 'Pointer' || fields[key].targetClass != '_User') { - throw new Parse.Error(Parse.Error.INVALID_JSON, `'${key}' is not a valid column for class level pointer permissions ${operation}`); + const operation = perms[operationKey]; + // proceed with next operationKey + + // throws when root fields are of wrong type + validateCLPjson(operation, operationKey); + + if (operationKey === 'readUserFields' || operationKey === 'writeUserFields') { + // validate grouped pointer permissions + // must be an array with field names + for (const fieldName of operation) { + validatePointerPermission(fieldName, fields, operationKey); + } + // readUserFields and writerUserFields do not have nesdted fields + // proceed with next operationKey + continue; + } + + // validate protected fields + if (operationKey === 'protectedFields') { + for (const entity in operation) { + // throws on unexpected key + validateProtectedFieldsKey(entity, userIdRegExp); + + const protectedFields = operation[entity]; + + if (!Array.isArray(protectedFields)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `'${protectedFields}' is not a valid value for protectedFields[${entity}] - expected an array.` + ); + } + + // if the field is in form of array + for (const field of protectedFields) { + // do not alloow to protect default fields + if (defaultColumns._Default[field]) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `Default field '${field}' can not be protected` + ); } - }); + // field should exist on collection + if (!Object.prototype.hasOwnProperty.call(fields, field)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `Field '${field}' in protectedFields:${entity} does not exist` + ); + } + } } - return; + // proceed with next operationKey + continue; } - Object.keys(perms[operation]).forEach((key) => { - verifyPermissionKey(key); - let perm = perms[operation][key]; - if (perm !== true) { - throw new Parse.Error(Parse.Error.INVALID_JSON, `'${perm}' is not a valid value for class level permissions ${operation}:${key}:${perm}`); + // validate other fields + // Entity can be: + // "*" - Public, + // "requiresAuthentication" - authenticated users, + // "objectId" - _User id, + // "role:rolename", + // "pointerFields" - array of field names containing pointers to users + for (const entity in operation) { + // throws on unexpected key + validatePermissionKey(entity, userIdRegExp); + + // entity can be either: + // "pointerFields": string[] + if (entity === 'pointerFields') { + const pointerFields = operation[entity]; + + if (Array.isArray(pointerFields)) { + for (const pointerField of pointerFields) { + validatePointerPermission(pointerField, fields, operation); + } + } else { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `'${pointerFields}' is not a valid value for ${operationKey}[${entity}] - expected an array.` + ); + } + // proceed with next entity key + continue; } - }); - }); + + const permit = operation[entity]; + + if (operationKey === 'ACL') { + if (Object.prototype.toString.call(permit) !== '[object Object]') { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `'${permit}' is not a valid value for class level permissions acl` + ); + } + const invalidKeys = Object.keys(permit).filter(key => !['read', 'write'].includes(key)); + const invalidValues = Object.values(permit).filter(key => typeof key !== 'boolean'); + if (invalidKeys.length) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `'${invalidKeys.join(',')}' is not a valid key for class level permissions acl` + ); + } + + if (invalidValues.length) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `'${invalidValues.join(',')}' is not a valid value for class level permissions acl` + ); + } + } else if (permit !== true) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `'${permit}' is not a valid value for class level permissions acl ${operationKey}:${entity}` + ); + } + } + } } + +function validateCLPjson(operation: any, operationKey: string) { + if (operationKey === 'readUserFields' || operationKey === 'writeUserFields') { + if (!Array.isArray(operation)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `'${operation}' is not a valid value for class level permissions ${operationKey} - must be an array` + ); + } + } else { + if (typeof operation === 'object' && operation !== null) { + // ok to proceed + return; + } else { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `'${operation}' is not a valid value for class level permissions ${operationKey} - must be an object` + ); + } + } +} + +function validatePointerPermission(fieldName: string, fields: Object, operation: string) { + // Uses collection schema to ensure the field is of type: + // - Pointer<_User> (pointers) + // - Array + // + // It's not possible to enforce type on Array's items in schema + // so we accept any Array field, and later when applying permissions + // only items that are pointers to _User are considered. + if ( + !( + fields[fieldName] && + ((fields[fieldName].type == 'Pointer' && fields[fieldName].targetClass == '_User') || + fields[fieldName].type == 'Array') + ) + ) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `'${fieldName}' is not a valid column for class level pointer permissions ${operation}` + ); + } +} + const joinClassRegex = /^_Join:[A-Za-z0-9_]+:[A-Za-z0-9_]+/; const classAndFieldRegex = /^[A-Za-z][A-Za-z0-9_]*$/; -function classNameIsValid(className) { +function classNameIsValid(className: string): boolean { // Valid classes must: return ( // Be one of _User, _Installation, _Role, _Session OR @@ -159,18 +451,24 @@ function classNameIsValid(className) { // Be a join table OR joinClassRegex.test(className) || // Include only alpha-numeric and underscores, and not start with an underscore or number - fieldNameIsValid(className) + fieldNameIsValid(className, className) ); } // Valid fields must be alpha-numeric, and not start with an underscore or number -function fieldNameIsValid(fieldName) { - return classAndFieldRegex.test(fieldName); +// must not be a reserved key +function fieldNameIsValid(fieldName: string, className: string): boolean { + if (className && className !== '_Hooks') { + if (fieldName === 'className') { + return false; + } + } + return classAndFieldRegex.test(fieldName) && !invalidColumns.includes(fieldName); } // Checks that it's not trying to clobber one of the default fields of the class. -function fieldNameIsValidForClass(fieldName, className) { - if (!fieldNameIsValid(fieldName)) { +function fieldNameIsValidForClass(fieldName: string, className: string): boolean { + if (!fieldNameIsValid(fieldName, className)) { return false; } if (defaultColumns._Default[fieldName]) { @@ -182,11 +480,15 @@ function fieldNameIsValidForClass(fieldName, className) { return true; } -function invalidClassNameMessage(className) { - return 'Invalid classname: ' + className + ', classnames can only have alphanumeric characters and _, and must start with an alpha character '; +function invalidClassNameMessage(className: string): string { + return ( + 'Invalid classname: ' + + className + + ', classnames can only have alphanumeric characters and _, and must start with an alpha character ' + ); } -const invalidJsonError = new Parse.Error(Parse.Error.INVALID_JSON, "invalid JSON"); +const invalidJsonError = new Parse.Error(Parse.Error.INVALID_JSON, 'invalid JSON'); const validNonRelationOrPointerTypes = [ 'Number', 'String', @@ -196,30 +498,32 @@ const validNonRelationOrPointerTypes = [ 'Array', 'GeoPoint', 'File', + 'Bytes', + 'Polygon', ]; // Returns an error suitable for throwing if the type is invalid const fieldTypeIsInvalid = ({ type, targetClass }) => { - if (['Pointer', 'Relation'].includes(type)) { + if (['Pointer', 'Relation'].indexOf(type) >= 0) { if (!targetClass) { return new Parse.Error(135, `type ${type} needs a class name`); } else if (typeof targetClass !== 'string') { - return invalidJsonError; + return invalidJsonError; } else if (!classNameIsValid(targetClass)) { return new Parse.Error(Parse.Error.INVALID_CLASS_NAME, invalidClassNameMessage(targetClass)); - } else { + } else { return undefined; - } - } - if (typeof type !== 'string') { + } + } + if (typeof type !== 'string') { return invalidJsonError; - } - if (!validNonRelationOrPointerTypes.includes(type)) { + } + if (validNonRelationOrPointerTypes.indexOf(type) < 0) { return new Parse.Error(Parse.Error.INCORRECT_TYPE, `invalid field type: ${type}`); - } + } return undefined; -} +}; -const convertSchemaToAdapterSchema = schema => { +const convertSchemaToAdapterSchema = (schema: any) => { schema = injectDefaultSchema(schema); delete schema.fields.ACL; schema.fields._rperm = { type: 'Array' }; @@ -231,9 +535,9 @@ const convertSchemaToAdapterSchema = schema => { } return schema; -} +}; -const convertAdapterSchemaToParseSchema = ({...schema}) => { +const convertAdapterSchemaToParseSchema = ({ ...schema }) => { delete schema.fields._rperm; delete schema.fields._wperm; @@ -245,111 +549,276 @@ const convertAdapterSchemaToParseSchema = ({...schema}) => { schema.fields.password = { type: 'String' }; } + if (schema.indexes && Object.keys(schema.indexes).length === 0) { + delete schema.indexes; + } + return schema; +}; + +class SchemaData { + __data: any; + __protectedFields: any; + constructor(allSchemas = [], protectedFields = {}) { + this.__data = {}; + this.__protectedFields = protectedFields; + allSchemas.forEach(schema => { + if (volatileClasses.includes(schema.className)) { + return; + } + Object.defineProperty(this, schema.className, { + get: () => { + if (!this.__data[schema.className]) { + const data = {}; + data.fields = injectDefaultSchema(schema).fields; + data.classLevelPermissions = structuredClone(schema.classLevelPermissions); + data.indexes = schema.indexes; + + const classProtectedFields = this.__protectedFields[schema.className]; + if (classProtectedFields) { + for (const key in classProtectedFields) { + const unq = new Set([ + ...(data.classLevelPermissions.protectedFields[key] || []), + ...classProtectedFields[key], + ]); + data.classLevelPermissions.protectedFields[key] = Array.from(unq); + } + } + + this.__data[schema.className] = data; + } + return this.__data[schema.className]; + }, + }); + }); + + // Inject the in-memory classes + volatileClasses.forEach(className => { + Object.defineProperty(this, className, { + get: () => { + if (!this.__data[className]) { + const schema = injectDefaultSchema({ + className, + fields: {}, + classLevelPermissions: {}, + }); + const data = {}; + data.fields = schema.fields; + data.classLevelPermissions = schema.classLevelPermissions; + data.indexes = schema.indexes; + this.__data[className] = data; + } + return this.__data[className]; + }, + }); + }); + } } -const injectDefaultSchema = ({className, fields, classLevelPermissions}) => ({ - className, - fields: { - ...defaultColumns._Default, - ...(defaultColumns[className] || {}), - ...fields, - }, - classLevelPermissions, -}) - -const dbTypeMatchesObjectType = (dbType, objectType) => { - if (dbType.type !== objectType.type) return false; - if (dbType.targetClass !== objectType.targetClass) return false; - if (dbType === objectType.type) return true; - if (dbType.type === objectType.type) return true; +const injectDefaultSchema = ({ className, fields, classLevelPermissions, indexes }: Schema) => { + const defaultSchema: Schema = { + className, + fields: { + ...defaultColumns._Default, + ...(defaultColumns[className] || {}), + ...fields, + }, + classLevelPermissions, + }; + if (indexes && Object.keys(indexes).length !== 0) { + defaultSchema.indexes = indexes; + } + return defaultSchema; +}; + +const _HooksSchema = { className: '_Hooks', fields: defaultColumns._Hooks }; +const _GlobalConfigSchema = { + className: '_GlobalConfig', + fields: defaultColumns._GlobalConfig, +}; +const _GraphQLConfigSchema = { + className: '_GraphQLConfig', + fields: defaultColumns._GraphQLConfig, +}; +const _PushStatusSchema = convertSchemaToAdapterSchema( + injectDefaultSchema({ + className: '_PushStatus', + fields: {}, + classLevelPermissions: {}, + }) +); +const _JobStatusSchema = convertSchemaToAdapterSchema( + injectDefaultSchema({ + className: '_JobStatus', + fields: {}, + classLevelPermissions: {}, + }) +); +const _JobScheduleSchema = convertSchemaToAdapterSchema( + injectDefaultSchema({ + className: '_JobSchedule', + fields: {}, + classLevelPermissions: {}, + }) +); +const _AudienceSchema = convertSchemaToAdapterSchema( + injectDefaultSchema({ + className: '_Audience', + fields: defaultColumns._Audience, + classLevelPermissions: {}, + }) +); +const _IdempotencySchema = convertSchemaToAdapterSchema( + injectDefaultSchema({ + className: '_Idempotency', + fields: defaultColumns._Idempotency, + classLevelPermissions: {}, + }) +); +const VolatileClassesSchemas = [ + _HooksSchema, + _JobStatusSchema, + _JobScheduleSchema, + _PushStatusSchema, + _GlobalConfigSchema, + _GraphQLConfigSchema, + _AudienceSchema, + _IdempotencySchema, +]; + +const dbTypeMatchesObjectType = (dbType: SchemaField | string, objectType: SchemaField) => { + if (dbType.type !== objectType.type) { return false; } + if (dbType.targetClass !== objectType.targetClass) { return false; } + if (dbType === objectType.type) { return true; } + if (dbType.type === objectType.type) { return true; } return false; -} +}; + +const typeToString = (type: SchemaField | string): string => { + if (typeof type === 'string') { + return type; + } + if (type.targetClass) { + return `${type.type}<${type.targetClass}>`; + } + return `${type.type}`; +}; +const ttl = { + date: Date.now(), + duration: undefined, +}; // Stores the entire schema of the app in a weird hybrid format somewhere between // the mongo format and the Parse format. Soon, this will all be Parse format. -class SchemaController { - _dbAdapter; - data; - perms; - - constructor(databaseAdapter, schemaCache) { +export default class SchemaController { + _dbAdapter: StorageAdapter; + schemaData: { [string]: Schema }; + reloadDataPromise: ?Promise; + protectedFields: any; + userIdRegEx: RegExp; + + constructor(databaseAdapter: StorageAdapter) { this._dbAdapter = databaseAdapter; - this._cache = schemaCache; - // this.data[className][fieldName] tells you the type of that field, in mongo format - this.data = {}; - // this.perms[className][operation] tells you the acl-style permissions - this.perms = {}; + const config = Config.get(Parse.applicationId); + this.schemaData = new SchemaData(SchemaCache.all(), this.protectedFields); + this.protectedFields = config.protectedFields; + + const customIds = config.allowCustomObjectId; + + const customIdRegEx = /^.{1,}$/u; // 1+ chars + const autoIdRegEx = /^[a-zA-Z0-9]{1,}$/; + + this.userIdRegEx = customIds ? customIdRegEx : autoIdRegEx; + + this._dbAdapter.watch(() => { + this.reloadData({ clearCache: true }); + }); } - reloadData(options = {clearCache: false}) { - if (options.clearCache) { - this._cache.clear(); + async reloadDataIfNeeded() { + if (this._dbAdapter.enableSchemaHooks) { + return; + } + const { date, duration } = ttl || {}; + if (!duration) { + return; } + const now = Date.now(); + if (now - date > duration) { + ttl.date = now; + await this.reloadData({ clearCache: true }); + } + } + + reloadData(options: LoadSchemaOptions = { clearCache: false }): Promise { if (this.reloadDataPromise && !options.clearCache) { return this.reloadDataPromise; } - this.data = {}; - this.perms = {}; this.reloadDataPromise = this.getAllClasses(options) - .then(allSchemas => { - allSchemas.forEach(schema => { - this.data[schema.className] = injectDefaultSchema(schema).fields; - this.perms[schema.className] = schema.classLevelPermissions; - }); - - // Inject the in-memory classes - volatileClasses.forEach(className => { - this.data[className] = injectDefaultSchema({ - className, - fields: {}, - classLevelPermissions: {} - }); - }); - delete this.reloadDataPromise; - }, (err) => { - delete this.reloadDataPromise; - throw err; - }); + .then( + allSchemas => { + this.schemaData = new SchemaData(allSchemas, this.protectedFields); + delete this.reloadDataPromise; + }, + err => { + this.schemaData = new SchemaData(); + delete this.reloadDataPromise; + throw err; + } + ) + .then(() => {}); return this.reloadDataPromise; } - getAllClasses(options = {clearCache: false}) { + async getAllClasses(options: LoadSchemaOptions = { clearCache: false }): Promise> { if (options.clearCache) { - this._cache.clear(); + return this.setAllClasses(); } - return this._cache.getAllClasses().then((allClasses) => { - if (allClasses && allClasses.length && !options.clearCache) { - return Promise.resolve(allClasses); - } - return this._dbAdapter.getAllClasses() - .then(allSchemas => allSchemas.map(injectDefaultSchema)) - .then(allSchemas => { - return this._cache.setAllClasses(allSchemas).then(() => { - return allSchemas; - }); - }) - }); + await this.reloadDataIfNeeded(); + const cached = SchemaCache.all(); + if (cached && cached.length) { + return Promise.resolve(cached); + } + return this.setAllClasses(); + } + + setAllClasses(): Promise> { + return this._dbAdapter + .getAllClasses() + .then(allSchemas => allSchemas.map(injectDefaultSchema)) + .then(allSchemas => { + SchemaCache.put(allSchemas); + return allSchemas; + }); } - getOneSchema(className, allowVolatileClasses = false, options = {clearCache: false}) { + getOneSchema( + className: string, + allowVolatileClasses: boolean = false, + options: LoadSchemaOptions = { clearCache: false } + ): Promise { if (options.clearCache) { - this._cache.clear(); + SchemaCache.clear(); } if (allowVolatileClasses && volatileClasses.indexOf(className) > -1) { - return Promise.resolve(this.data[className]); + const data = this.schemaData[className]; + return Promise.resolve({ + className, + fields: data.fields, + classLevelPermissions: data.classLevelPermissions, + indexes: data.indexes, + }); + } + const cached = SchemaCache.get(className); + if (cached && !options.clearCache) { + return Promise.resolve(cached); } - return this._cache.getOneSchema(className).then((cached) => { - if (cached && !options.clearCache) { - return Promise.resolve(cached); + return this.setAllClasses().then(allSchemas => { + const oneSchema = allSchemas.find(schema => schema.className === className); + if (!oneSchema) { + return Promise.reject(undefined); } - return this._dbAdapter.getClass(className) - .then(injectDefaultSchema) - .then((result) => { - return this._cache.setOneSchema(className, result).then(() => { - return result; - }) - }); + return oneSchema; }); } @@ -360,121 +829,185 @@ class SchemaController { // on success, and rejects with an error on fail. Ensure you // have authorization (master key, or client class creation // enabled) before calling this function. - addClassIfNotExists(className, fields = {}, classLevelPermissions) { + async addClassIfNotExists( + className: string, + fields: SchemaFields = {}, + classLevelPermissions: any, + indexes: any = {} + ): Promise { var validationError = this.validateNewClass(className, fields, classLevelPermissions); if (validationError) { + if (validationError instanceof Parse.Error) { + return Promise.reject(validationError); + } else if (validationError.code && validationError.error) { + return Promise.reject(new Parse.Error(validationError.code, validationError.error)); + } return Promise.reject(validationError); } - - return this._dbAdapter.createClass(className, convertSchemaToAdapterSchema({ fields, classLevelPermissions, className })) - .then(convertAdapterSchemaToParseSchema) - .then((res) => { - this._cache.clear(); - return res; - }) - .catch(error => { + try { + const adapterSchema = await this._dbAdapter.createClass( + className, + convertSchemaToAdapterSchema({ + fields, + classLevelPermissions, + indexes, + className, + }) + ); + // TODO: Remove by updating schema cache directly + await this.reloadData({ clearCache: true }); + const parseSchema = convertAdapterSchemaToParseSchema(adapterSchema); + return parseSchema; + } catch (error) { if (error && error.code === Parse.Error.DUPLICATE_VALUE) { throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`); } else { throw error; } - }); + } } - updateClass(className, submittedFields, classLevelPermissions, database) { + updateClass( + className: string, + submittedFields: SchemaFields, + classLevelPermissions: any, + indexes: any, + database: DatabaseController + ) { return this.getOneSchema(className) - .then(schema => { - let existingFields = schema.fields; - Object.keys(submittedFields).forEach(name => { - let field = submittedFields[name]; - if (existingFields[name] && field.__op !== 'Delete') { - throw new Parse.Error(255, `Field ${name} exists, cannot update.`); - } - if (!existingFields[name] && field.__op === 'Delete') { - throw new Parse.Error(255, `Field ${name} does not exist, cannot delete.`); + .then(schema => { + const existingFields = schema.fields; + Object.keys(submittedFields).forEach(name => { + const field = submittedFields[name]; + if ( + existingFields[name] && + existingFields[name].type !== field.type && + field.__op !== 'Delete' + ) { + throw new Parse.Error(255, `Field ${name} exists, cannot update.`); + } + if (!existingFields[name] && field.__op === 'Delete') { + throw new Parse.Error(255, `Field ${name} does not exist, cannot delete.`); + } + }); + + delete existingFields._rperm; + delete existingFields._wperm; + const newSchema = buildMergedSchemaObject(existingFields, submittedFields); + const defaultFields = defaultColumns[className] || defaultColumns._Default; + const fullNewSchema = Object.assign({}, newSchema, defaultFields); + const validationError = this.validateSchemaData( + className, + newSchema, + classLevelPermissions, + Object.keys(existingFields) + ); + if (validationError) { + throw new Parse.Error(validationError.code, validationError.error); } - }); - delete existingFields._rperm; - delete existingFields._wperm; - let newSchema = buildMergedSchemaObject(existingFields, submittedFields); - let validationError = this.validateSchemaData(className, newSchema, classLevelPermissions, Object.keys(existingFields)); - if (validationError) { - throw new Parse.Error(validationError.code, validationError.error); - } + // Finally we have checked to make sure the request is valid and we can start deleting fields. + // Do all deletions first, then a single save to _SCHEMA collection to handle all additions. + const deletedFields: string[] = []; + const insertedFields = []; + Object.keys(submittedFields).forEach(fieldName => { + if (submittedFields[fieldName].__op === 'Delete') { + deletedFields.push(fieldName); + } else { + insertedFields.push(fieldName); + } + }); - // Finally we have checked to make sure the request is valid and we can start deleting fields. - // Do all deletions first, then a single save to _SCHEMA collection to handle all additions. - let deletePromises = []; - let insertedFields = []; - Object.keys(submittedFields).forEach(fieldName => { - if (submittedFields[fieldName].__op === 'Delete') { - const promise = this.deleteField(fieldName, className, database); - deletePromises.push(promise); + let deletePromise = Promise.resolve(); + if (deletedFields.length > 0) { + deletePromise = this.deleteFields(deletedFields, className, database); + } + let enforceFields = []; + return ( + deletePromise // Delete Everything + .then(() => this.reloadData({ clearCache: true })) // Reload our Schema, so we have all the new values + .then(() => { + const promises = insertedFields.map(fieldName => { + const type = submittedFields[fieldName]; + return this.enforceFieldExists(className, fieldName, type); + }); + return Promise.all(promises); + }) + .then(results => { + enforceFields = results.filter(result => !!result); + return this.setPermissions(className, classLevelPermissions, newSchema); + }) + .then(() => + this._dbAdapter.setIndexesWithSchemaFormat( + className, + indexes, + schema.indexes, + fullNewSchema + ) + ) + .then(() => this.reloadData({ clearCache: true })) + //TODO: Move this logic into the database adapter + .then(() => { + this.ensureFields(enforceFields); + const schema = this.schemaData[className]; + const reloadedSchema: Schema = { + className: className, + fields: schema.fields, + classLevelPermissions: schema.classLevelPermissions, + }; + if (schema.indexes && Object.keys(schema.indexes).length !== 0) { + reloadedSchema.indexes = schema.indexes; + } + return reloadedSchema; + }) + ); + }) + .catch(error => { + if (error === undefined) { + throw new Parse.Error( + Parse.Error.INVALID_CLASS_NAME, + `Class ${className} does not exist.` + ); } else { - insertedFields.push(fieldName); + throw error; } }); - - return Promise.all(deletePromises) // Delete Everything - .then(() => this.reloadData({ clearCache: true })) // Reload our Schema, so we have all the new values - .then(() => { - let promises = insertedFields.map(fieldName => { - const type = submittedFields[fieldName]; - return this.enforceFieldExists(className, fieldName, type); - }); - return Promise.all(promises); - }) - .then(() => this.setPermissions(className, classLevelPermissions, newSchema)) - //TODO: Move this logic into the database adapter - .then(() => ({ - className: className, - fields: this.data[className], - classLevelPermissions: this.perms[className] - })); - }) - .catch(error => { - if (error === undefined) { - throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} does not exist.`); - } else { - throw error; - } - }) } // Returns a promise that resolves successfully to the new schema // object or fails with a reason. - enforceClassExists(className) { - if (this.data[className]) { + enforceClassExists(className: string): Promise { + if (this.schemaData[className]) { return Promise.resolve(this); } // We don't have this class. Update the schema - return this.addClassIfNotExists(className) - // The schema update succeeded. Reload the schema - .then(() => this.reloadData({ clearCache: true })) - .catch(error => { - // The schema update failed. This can be okay - it might - // have failed because there's a race condition and a different - // client is making the exact same schema update that we want. - // So just reload the schema. - return this.reloadData({ clearCache: true }); - }) - .then(() => { - // Ensure that the schema now validates - if (this.data[className]) { - return this; - } else { - throw new Parse.Error(Parse.Error.INVALID_JSON, `Failed to add ${className}`); - } - }) - .catch(error => { - // The schema still doesn't validate. Give up - throw new Parse.Error(Parse.Error.INVALID_JSON, 'schema class name does not revalidate'); - }); + return ( + // The schema update succeeded. Reload the schema + this.addClassIfNotExists(className) + .catch(() => { + // The schema update failed. This can be okay - it might + // have failed because there's a race condition and a different + // client is making the exact same schema update that we want. + // So just reload the schema. + return this.reloadData({ clearCache: true }); + }) + .then(() => { + // Ensure that the schema now validates + if (this.schemaData[className]) { + return this; + } else { + throw new Parse.Error(Parse.Error.INVALID_JSON, `Failed to add ${className}`); + } + }) + .catch(() => { + // The schema still doesn't validate. Give up + throw new Parse.Error(Parse.Error.INVALID_JSON, 'schema class name does not revalidate'); + }) + ); } - validateNewClass(className, fields = {}, classLevelPermissions) { - if (this.data[className]) { + validateNewClass(className: string, fields: SchemaFields = {}, classLevelPermissions: any): any { + if (this.schemaData[className]) { throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`); } if (!classNameIsValid(className)) { @@ -486,10 +1019,15 @@ class SchemaController { return this.validateSchemaData(className, fields, classLevelPermissions, []); } - validateSchemaData(className, fields, classLevelPermissions, existingFieldNames) { - for (let fieldName in fields) { - if (!existingFieldNames.includes(fieldName)) { - if (!fieldNameIsValid(fieldName)) { + validateSchemaData( + className: string, + fields: SchemaFields, + classLevelPermissions: ClassLevelPermissions, + existingFieldNames: Array + ) { + for (const fieldName in fields) { + if (existingFieldNames.indexOf(fieldName) < 0) { + if (!fieldNameIsValid(fieldName, className)) { return { code: Parse.Error.INVALID_KEY_NAME, error: 'invalid field name: ' + fieldName, @@ -501,156 +1039,273 @@ class SchemaController { error: 'field ' + fieldName + ' cannot be added', }; } - const error = fieldTypeIsInvalid(fields[fieldName]); - if (error) return { code: error.code, error: error.message }; + const fieldType = fields[fieldName]; + const error = fieldTypeIsInvalid(fieldType); + if (error) { return { code: error.code, error: error.message }; } + if (fieldType.defaultValue !== undefined) { + let defaultValueType = getType(fieldType.defaultValue); + if (typeof defaultValueType === 'string') { + defaultValueType = { type: defaultValueType }; + } else if (typeof defaultValueType === 'object' && fieldType.type === 'Relation') { + return { + code: Parse.Error.INCORRECT_TYPE, + error: `The 'default value' option is not applicable for ${typeToString(fieldType)}`, + }; + } + if (!dbTypeMatchesObjectType(fieldType, defaultValueType)) { + return { + code: Parse.Error.INCORRECT_TYPE, + error: `schema mismatch for ${className}.${fieldName} default value; expected ${typeToString( + fieldType + )} but got ${typeToString(defaultValueType)}`, + }; + } + } else if (fieldType.required) { + if (typeof fieldType === 'object' && fieldType.type === 'Relation') { + return { + code: Parse.Error.INCORRECT_TYPE, + error: `The 'required' option is not applicable for ${typeToString(fieldType)}`, + }; + } + } } } - for (let fieldName in defaultColumns[className]) { + for (const fieldName in defaultColumns[className]) { fields[fieldName] = defaultColumns[className][fieldName]; } - let geoPoints = Object.keys(fields).filter(key => fields[key] && fields[key].type === 'GeoPoint'); + const geoPoints = Object.keys(fields).filter( + key => fields[key] && fields[key].type === 'GeoPoint' + ); if (geoPoints.length > 1) { return { code: Parse.Error.INCORRECT_TYPE, - error: 'currently, only one GeoPoint field may exist in an object. Adding ' + geoPoints[1] + ' when ' + geoPoints[0] + ' already exists.', + error: + 'currently, only one GeoPoint field may exist in an object. Adding ' + + geoPoints[1] + + ' when ' + + geoPoints[0] + + ' already exists.', }; } - validateCLP(classLevelPermissions, fields); + validateCLP(classLevelPermissions, fields, this.userIdRegEx); } // Sets the Class-level permissions for a given className, which must exist. - setPermissions(className, perms, newSchema) { + async setPermissions(className: string, perms: any, newSchema: SchemaFields) { if (typeof perms === 'undefined') { return Promise.resolve(); } - validateCLP(perms, newSchema); - return this._dbAdapter.setClassLevelPermissions(className, perms) - .then(() => this.reloadData({ clearCache: true })); + validateCLP(perms, newSchema, this.userIdRegEx); + await this._dbAdapter.setClassLevelPermissions(className, perms); + const cached = SchemaCache.get(className); + if (cached) { + cached.classLevelPermissions = perms; + } } // Returns a promise that resolves successfully to the new schema // object if the provided className-fieldName-type tuple is valid. // The className must already be validated. // If 'freeze' is true, refuse to update the schema for this field. - enforceFieldExists(className, fieldName, type, freeze) { - if (fieldName.indexOf(".") > 0) { - // subdocument key (x.y) => ok if x is of type 'object' - fieldName = fieldName.split(".")[ 0 ]; - type = 'Object'; + enforceFieldExists( + className: string, + fieldName: string, + type: string | SchemaField, + isValidation?: boolean, + maintenance?: boolean + ) { + if (fieldName.indexOf('.') > 0) { + // "." for Nested Arrays + // "." for Nested Objects + // JSON Arrays are treated as Nested Objects + const [x, y] = fieldName.split('.'); + fieldName = x; + const isArrayIndex = Array.from(y).every(c => c >= '0' && c <= '9'); + if (isArrayIndex && !['sentPerUTCOffset', 'failedPerUTCOffset'].includes(fieldName)) { + type = 'Array'; + } else { + type = 'Object'; + } } - if (!fieldNameIsValid(fieldName)) { + let fieldNameToValidate = `${fieldName}`; + if (maintenance && fieldNameToValidate.charAt(0) === '_') { + fieldNameToValidate = fieldNameToValidate.substring(1); + } + if (!fieldNameIsValid(fieldNameToValidate, className)) { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}.`); } // If someone tries to create a new field with null/undefined as the value, return; if (!type) { - return Promise.resolve(this); + return undefined; } - return this.reloadData().then(() => { - let expectedType = this.getExpectedType(className, fieldName); - if (typeof type === 'string') { - type = { type }; - } + const expectedType = this.getExpectedType(className, fieldName); + if (typeof type === 'string') { + type = ({ type }: SchemaField); + } - if (expectedType) { - if (!dbTypeMatchesObjectType(expectedType, type)) { - throw new Parse.Error( - Parse.Error.INCORRECT_TYPE, - `schema mismatch for ${className}.${fieldName}; expected ${expectedType.type || expectedType} but got ${type.type}` - ); - } - return this; + if (type.defaultValue !== undefined) { + let defaultValueType = getType(type.defaultValue); + if (typeof defaultValueType === 'string') { + defaultValueType = { type: defaultValueType }; + } + if (!dbTypeMatchesObjectType(type, defaultValueType)) { + throw new Parse.Error( + Parse.Error.INCORRECT_TYPE, + `schema mismatch for ${className}.${fieldName} default value; expected ${typeToString( + type + )} but got ${typeToString(defaultValueType)}` + ); } + } - return this._dbAdapter.addFieldIfNotExists(className, fieldName, type).then(() => { - // The update succeeded. Reload the schema - return this.reloadData({ clearCache: true }); - }, error => { - //TODO: introspect the error and only reload if the error is one for which is makes sense to reload + if (expectedType) { + if (!dbTypeMatchesObjectType(expectedType, type)) { + throw new Parse.Error( + Parse.Error.INCORRECT_TYPE, + `schema mismatch for ${className}.${fieldName}; expected ${typeToString( + expectedType + )} but got ${typeToString(type)}` + ); + } + // If type options do not change + // we can safely return + if (isValidation || JSON.stringify(expectedType) === JSON.stringify(type)) { + return undefined; + } + // Field options are may be changed + // ensure to have an update to date schema field + return this._dbAdapter.updateFieldOptions(className, fieldName, type); + } + return this._dbAdapter + .addFieldIfNotExists(className, fieldName, type) + .catch(error => { + if (error.code == Parse.Error.INCORRECT_TYPE) { + // Make sure that we throw errors when it is appropriate to do so. + throw error; + } // The update failed. This can be okay - it might have been a race // condition where another client updated the schema in the same // way that we wanted to. So, just reload the schema - return this.reloadData({ clearCache: true }); - }).then(error => { - // Ensure that the schema now validates - if (!dbTypeMatchesObjectType(this.getExpectedType(className, fieldName), type)) { - throw new Parse.Error(Parse.Error.INVALID_JSON, `Could not add field ${fieldName}`); - } - // Remove the cached schema - this._cache.clear(); - return this; + return Promise.resolve(); + }) + .then(() => { + return { + className, + fieldName, + type, + }; }); - }); } - // Delete a field, and remove that data from all objects. This is intended + ensureFields(fields: any) { + for (let i = 0; i < fields.length; i += 1) { + const { className, fieldName } = fields[i]; + let { type } = fields[i]; + const expectedType = this.getExpectedType(className, fieldName); + if (typeof type === 'string') { + type = { type: type }; + } + if (!expectedType || !dbTypeMatchesObjectType(expectedType, type)) { + throw new Parse.Error(Parse.Error.INVALID_JSON, `Could not add field ${fieldName}`); + } + } + } + + // maintain compatibility + deleteField(fieldName: string, className: string, database: DatabaseController) { + return this.deleteFields([fieldName], className, database); + } + + // Delete fields, and remove that data from all objects. This is intended // to remove unused fields, if other writers are writing objects that include // this field, the field may reappear. Returns a Promise that resolves with // no object on success, or rejects with { code, error } on failure. // Passing the database and prefix is necessary in order to drop relation collections // and remove fields from objects. Ideally the database would belong to // a database adapter and this function would close over it or access it via member. - deleteField(fieldName, className, database) { + deleteFields(fieldNames: Array, className: string, database: DatabaseController) { if (!classNameIsValid(className)) { throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, invalidClassNameMessage(className)); } - if (!fieldNameIsValid(fieldName)) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `invalid field name: ${fieldName}`); - } - //Don't allow deleting the default fields. - if (!fieldNameIsValidForClass(fieldName, className)) { - throw new Parse.Error(136, `field ${fieldName} cannot be changed`); - } - return this.getOneSchema(className, false, {clearCache: true}) - .catch(error => { - if (error === undefined) { - throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} does not exist.`); - } else { - throw error; - } - }) - .then(schema => { - if (!schema.fields[fieldName]) { - throw new Parse.Error(255, `Field ${fieldName} does not exist, cannot delete.`); + fieldNames.forEach(fieldName => { + if (!fieldNameIsValid(fieldName, className)) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `invalid field name: ${fieldName}`); } - if (schema.fields[fieldName].type == 'Relation') { - //For relations, drop the _Join table - return database.adapter.deleteFields(className, schema, [fieldName]) - .then(() => database.adapter.deleteClass(`_Join:${fieldName}:${className}`)); + //Don't allow deleting the default fields. + if (!fieldNameIsValidForClass(fieldName, className)) { + throw new Parse.Error(136, `field ${fieldName} cannot be changed`); } - return database.adapter.deleteFields(className, schema, [fieldName]); - }).then(() => { - this._cache.clear(); }); + + return this.getOneSchema(className, false, { clearCache: true }) + .catch(error => { + if (error === undefined) { + throw new Parse.Error( + Parse.Error.INVALID_CLASS_NAME, + `Class ${className} does not exist.` + ); + } else { + throw error; + } + }) + .then(schema => { + fieldNames.forEach(fieldName => { + if (!schema.fields[fieldName]) { + throw new Parse.Error(255, `Field ${fieldName} does not exist, cannot delete.`); + } + }); + + const schemaFields = { ...schema.fields }; + return database.adapter.deleteFields(className, schema, fieldNames).then(() => { + return Promise.all( + fieldNames.map(fieldName => { + const field = schemaFields[fieldName]; + if (field && field.type === 'Relation') { + //For relations, drop the _Join table + return database.adapter.deleteClass(`_Join:${fieldName}:${className}`); + } + return Promise.resolve(); + }) + ); + }); + }) + .then(() => { + SchemaCache.clear(); + }); } // Validates an object provided in REST format. // Returns a promise that resolves to the new schema if this object is // valid. - validateObject(className, object, query) { + async validateObject(className: string, object: any, query: any, maintenance: boolean) { let geocount = 0; - let promise = this.enforceClassExists(className); - for (let fieldName in object) { - if (object[fieldName] === undefined) { - continue; - } - let expected = getType(object[fieldName]); - if (expected === 'GeoPoint') { + const schema = await this.enforceClassExists(className); + const promises = []; + + for (const fieldName in object) { + if (object[fieldName] && getType(object[fieldName]) === 'GeoPoint') { geocount++; } if (geocount > 1) { - // Make sure all field validation operations run before we return. - // If not - we are continuing to run logic, but already provided response from the server. - return promise.then(() => { - return Promise.reject(new Parse.Error(Parse.Error.INCORRECT_TYPE, - 'there can only be one geopoint field in a class')); - }); + return Promise.reject( + new Parse.Error( + Parse.Error.INCORRECT_TYPE, + 'there can only be one geopoint field in a class' + ) + ); + } + } + for (const fieldName in object) { + if (object[fieldName] === undefined) { + continue; } + const expected = getType(object[fieldName]); if (!expected) { continue; } @@ -658,129 +1313,222 @@ class SchemaController { // Every object has ACL implicitly. continue; } + promises.push(schema.enforceFieldExists(className, fieldName, expected, true, maintenance)); + } + const results = await Promise.all(promises); + const enforceFields = results.filter(result => !!result); - promise = promise.then(schema => schema.enforceFieldExists(className, fieldName, expected)); + if (enforceFields.length !== 0) { + // TODO: Remove by updating schema cache directly + await this.reloadData({ clearCache: true }); } - promise = thenValidateRequiredColumns(promise, className, object, query); - return promise; + this.ensureFields(enforceFields); + + const promise = Promise.resolve(schema); + return thenValidateRequiredColumns(promise, className, object, query); } // Validates that all the properties are set for the object - validateRequiredColumns(className, object, query) { - let columns = requiredColumns[className]; + validateRequiredColumns(className: string, object: any, query: any) { + const columns = requiredColumns.write[className]; if (!columns || columns.length == 0) { return Promise.resolve(this); } - let missingColumns = columns.filter(function(column){ + const missingColumns = columns.filter(function (column) { if (query && query.objectId) { - if (object[column] && typeof object[column] === "object") { + if (object[column] && typeof object[column] === 'object') { // Trying to delete a required column return object[column].__op == 'Delete'; } // Not trying to do anything there return false; } - return !object[column] + return !object[column]; }); if (missingColumns.length > 0) { - throw new Parse.Error( - Parse.Error.INCORRECT_TYPE, - missingColumns[0]+' is required.'); + throw new Parse.Error(Parse.Error.INCORRECT_TYPE, missingColumns[0] + ' is required.'); } return Promise.resolve(this); } - // Validates the base CLP for an operation - testBaseCLP(className, aclGroup, operation) { - if (!this.perms[className] || !this.perms[className][operation]) { + testPermissionsForClassName(className: string, aclGroup: string[], operation: string) { + return SchemaController.testPermissions( + this.getClassLevelPermissions(className), + aclGroup, + operation + ); + } + + // Tests that the class level permission let pass the operation for a given aclGroup + static testPermissions(classPermissions: ?any, aclGroup: string[], operation: string): boolean { + if (!classPermissions || !classPermissions[operation]) { return true; } - let classPerms = this.perms[className]; - let perms = classPerms[operation]; - // Handle the public scenario quickly + const perms = classPermissions[operation]; if (perms['*']) { return true; } // Check permissions against the aclGroup provided (array of userId/roles) - if (aclGroup.some(acl => { return perms[acl] === true })) { + if ( + aclGroup.some(acl => { + return perms[acl] === true; + }) + ) { return true; } return false; } // Validates an operation passes class-level-permissions set in the schema - validatePermission(className, aclGroup, operation) { - if (this.testBaseCLP(className, aclGroup, operation)) { + static validatePermission( + classPermissions: ?any, + className: string, + aclGroup: string[], + operation: string, + action?: string + ) { + if (SchemaController.testPermissions(classPermissions, aclGroup, operation)) { return Promise.resolve(); } - if (!this.perms[className] || !this.perms[className][operation]) { + if (!classPermissions || !classPermissions[operation]) { return true; } - let classPerms = this.perms[className]; - let perms = classPerms[operation]; + const perms = classPermissions[operation]; + const config = Config.get(Parse.applicationId) + // If only for authenticated users + // make sure we have an aclGroup + if (perms['requiresAuthentication']) { + // If aclGroup has * (public) + if (!aclGroup || aclGroup.length == 0) { + throw createSanitizedError( + Parse.Error.OBJECT_NOT_FOUND, + 'Permission denied, user needs to be authenticated.', + config + ); + } else if (aclGroup.indexOf('*') > -1 && aclGroup.length == 1) { + throw createSanitizedError( + Parse.Error.OBJECT_NOT_FOUND, + 'Permission denied, user needs to be authenticated.', + config + ); + } + // requiresAuthentication passed, just move forward + // probably would be wise at some point to rename to 'authenticatedUser' + return Promise.resolve(); + } + // No matching CLP, let's check the Pointer permissions // And handle those later - let permissionField = ['get', 'find'].indexOf(operation) > -1 ? 'readUserFields' : 'writeUserFields'; + const permissionField = + ['get', 'find', 'count'].indexOf(operation) > -1 ? 'readUserFields' : 'writeUserFields'; // Reject create when write lockdown if (permissionField == 'writeUserFields' && operation == 'create') { - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, - `Permission denied for action ${operation} on class ${className}.`); + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + `Permission denied for action ${operation} on class ${className}.`, + config + ); } // Process the readUserFields later - if (Array.isArray(classPerms[permissionField]) && classPerms[permissionField].length > 0) { + if ( + Array.isArray(classPermissions[permissionField]) && + classPermissions[permissionField].length > 0 + ) { + return Promise.resolve(); + } + + const pointerFields = classPermissions[operation].pointerFields; + if (Array.isArray(pointerFields) && pointerFields.length > 0) { + // any op except 'addField as part of create' is ok. + if (operation !== 'addField' || action === 'update') { + // We can allow adding field on update flow only. return Promise.resolve(); + } } - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, - `Permission denied for action ${operation} on class ${className}.`); - }; + + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + `Permission denied for action ${operation} on class ${className}.`, + config + ); + } + + // Validates an operation passes class-level-permissions set in the schema + validatePermission(className: string, aclGroup: string[], operation: string, action?: string) { + return SchemaController.validatePermission( + this.getClassLevelPermissions(className), + className, + aclGroup, + operation, + action + ); + } + + getClassLevelPermissions(className: string): any { + return this.schemaData[className] && this.schemaData[className].classLevelPermissions; + } // Returns the expected type for a className+key combination // or undefined if the schema is not set - getExpectedType(className, fieldName) { - if (this.data && this.data[className]) { - const expectedType = this.data[className][fieldName] + getExpectedType(className: string, fieldName: string): ?(SchemaField | string) { + if (this.schemaData[className]) { + const expectedType = this.schemaData[className].fields[fieldName]; return expectedType === 'map' ? 'Object' : expectedType; } return undefined; - }; + } // Checks if a given class is in the schema. - hasClass(className) { - return this.reloadData().then(() => !!(this.data[className])); + hasClass(className: string) { + if (this.schemaData[className]) { + return Promise.resolve(true); + } + return this.reloadData().then(() => !!this.schemaData[className]); } } // Returns a promise for a new Schema. -const load = (dbAdapter, schemaCache, options) => { - let schema = new SchemaController(dbAdapter, schemaCache); +const load = (dbAdapter: StorageAdapter, options: any): Promise => { + const schema = new SchemaController(dbAdapter); + ttl.duration = dbAdapter.schemaCacheTtl; return schema.reloadData(options).then(() => schema); -} +}; // Builds a new schema (in schema API response format) out of an // existing mongo schema + a schemas API put request. This response // does not include the default fields, as it is intended to be passed // to mongoSchemaFromFieldsAndClassName. No validation is done here, it // is done in mongoSchemaFromFieldsAndClassName. -function buildMergedSchemaObject(existingFields, putRequest) { - let newSchema = {}; - let sysSchemaField = Object.keys(defaultColumns).indexOf(existingFields._id) === -1 ? [] : Object.keys(defaultColumns[existingFields._id]); - for (let oldField in existingFields) { - if (oldField !== '_id' && oldField !== 'ACL' && oldField !== 'updatedAt' && oldField !== 'createdAt' && oldField !== 'objectId') { +function buildMergedSchemaObject(existingFields: SchemaFields, putRequest: any): SchemaFields { + const newSchema = {}; + // @flow-disable-next + const sysSchemaField = + Object.keys(defaultColumns).indexOf(existingFields._id) === -1 + ? [] + : Object.keys(defaultColumns[existingFields._id]); + for (const oldField in existingFields) { + if ( + oldField !== '_id' && + oldField !== 'ACL' && + oldField !== 'updatedAt' && + oldField !== 'createdAt' && + oldField !== 'objectId' + ) { if (sysSchemaField.length > 0 && sysSchemaField.indexOf(oldField) !== -1) { continue; } - let fieldIsDeleted = putRequest[oldField] && putRequest[oldField].__op === 'Delete' + const fieldIsDeleted = putRequest[oldField] && putRequest[oldField].__op === 'Delete'; if (!fieldIsDeleted) { newSchema[oldField] = existingFields[oldField]; } } } - for (let newField in putRequest) { + for (const newField in putRequest) { if (newField !== 'objectId' && putRequest[newField].__op !== 'Delete') { if (sysSchemaField.length > 0 && sysSchemaField.indexOf(newField) !== -1) { continue; @@ -794,7 +1542,7 @@ function buildMergedSchemaObject(existingFields, putRequest) { // Given a schema promise, construct another schema promise that // validates this field once the schema loads. function thenValidateRequiredColumns(schemaPromise, className, object, query) { - return schemaPromise.then((schema) => { + return schemaPromise.then(schema => { return schema.validateRequiredColumns(className, object, query); }); } @@ -804,9 +1552,9 @@ function thenValidateRequiredColumns(schemaPromise, className, object, query) { // type system. // The output should be a valid schema value. // TODO: ensure that this is compatible with the format used in Open DB -function getType(obj) { - let type = typeof obj; - switch(type) { +function getType(obj: any): ?(SchemaField | string) { + const type = typeof obj; + switch (type) { case 'boolean': return 'Boolean'; case 'string': @@ -830,44 +1578,61 @@ function getType(obj) { // This gets the type for non-JSON types like pointers and files, but // also gets the appropriate type for $ operators. // Returns null if the type is unknown. -function getObjectType(obj) { - if (obj instanceof Array) { +function getObjectType(obj): ?(SchemaField | string) { + if (Array.isArray(obj)) { return 'Array'; } - if (obj.__type){ - switch(obj.__type) { - case 'Pointer' : - if(obj.className) { + if (obj.__type) { + switch (obj.__type) { + case 'Pointer': + if (obj.className) { return { type: 'Pointer', - targetClass: obj.className - } + targetClass: obj.className, + }; + } + break; + case 'Relation': + if (obj.className) { + return { + type: 'Relation', + targetClass: obj.className, + }; } - case 'File' : - if(obj.name) { + break; + case 'File': + if (obj.name) { return 'File'; } - case 'Date' : - if(obj.iso) { + break; + case 'Date': + if (obj.iso) { return 'Date'; } - case 'GeoPoint' : - if(obj.latitude != null && obj.longitude != null) { + break; + case 'GeoPoint': + if (obj.latitude != null && obj.longitude != null) { return 'GeoPoint'; } - case 'Bytes' : - if(obj.base64) { - return; + break; + case 'Bytes': + if (obj.base64) { + return 'Bytes'; } - default: - throw new Parse.Error(Parse.Error.INCORRECT_TYPE, "This is not a valid "+obj.__type); + break; + case 'Polygon': + if (obj.coordinates) { + return 'Polygon'; + } + break; } + throw new Parse.Error(Parse.Error.INCORRECT_TYPE, 'This is not a valid ' + obj.__type); } if (obj['$ne']) { return getObjectType(obj['$ne']); } if (obj.__op) { - switch(obj.__op) { + switch (obj.__op) { case 'Increment': return 'Number'; case 'Delete': @@ -880,8 +1645,8 @@ function getObjectType(obj) { case 'RemoveRelation': return { type: 'Relation', - targetClass: obj.objects[0].className - } + targetClass: obj.objects[0].className, + }; case 'Batch': return getObjectType(obj.ops[0]); default: @@ -900,4 +1665,7 @@ export { systemClasses, defaultColumns, convertSchemaToAdapterSchema, + VolatileClassesSchemas, + SchemaController, + requiredColumns, }; diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index 27ecad7100..c8b74d2ab4 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -1,21 +1,24 @@ -import { randomString } from '../cryptoUtils'; -import { inflate } from '../triggers'; +import { randomString } from '../cryptoUtils'; +import { inflate } from '../triggers'; import AdaptableController from './AdaptableController'; -import MailAdapter from '../Adapters/Email/MailAdapter'; -import rest from '../rest'; +import MailAdapter from '../Adapters/Email/MailAdapter'; +import rest from '../rest'; +import Parse from 'parse/node'; +import AccountLockout from '../AccountLockout'; +import Config from '../Config'; -var DatabaseAdapter = require('../DatabaseAdapter'); -var RestWrite = require('../RestWrite'); var RestQuery = require('../RestQuery'); -var hash = require('../password').hash; var Auth = require('../Auth'); export class UserController extends AdaptableController { - constructor(adapter, appId, options = {}) { super(adapter, appId, options); } + get config() { + return Config.get(this.appId); + } + validateAdapter(adapter) { // Allow no adapter if (!adapter && !this.shouldVerifyEmails) { @@ -29,29 +32,46 @@ export class UserController extends AdaptableController { } get shouldVerifyEmails() { - return this.options.verifyUserEmails; + return (this.config || this.options).verifyUserEmails; } - setEmailVerifyToken(user) { - if (this.shouldVerifyEmails) { - user._email_verify_token = randomString(25); + async setEmailVerifyToken(user, req, storage = {}) { + const shouldSendEmail = + this.shouldVerifyEmails === true || + (typeof this.shouldVerifyEmails === 'function' && + (await Promise.resolve(this.shouldVerifyEmails(req))) === true); + if (!shouldSendEmail) { + return false; + } + storage.sendVerificationEmail = true; + user._email_verify_token = randomString(25); + if ( + !storage.fieldsChangedByTrigger || + !storage.fieldsChangedByTrigger.includes('emailVerified') + ) { user.emailVerified = false; + } - if (this.config.emailVerifyTokenValidityDuration) { - user._email_verify_token_expires_at = Parse._encode(this.config.generateEmailVerifyTokenExpiresAt()); - } + if (this.config.emailVerifyTokenValidityDuration) { + user._email_verify_token_expires_at = Parse._encode( + this.config.generateEmailVerifyTokenExpiresAt() + ); } + return true; } - verifyEmail(username, token) { + async verifyEmail(token) { if (!this.shouldVerifyEmails) { // Trying to verify email when not enabled // TODO: Better error here. throw undefined; } - let query = {username: username, _email_verify_token: token}; - let updateFields = { emailVerified: true, _email_verify_token: {__op: 'Delete'}}; + const query = { _email_verify_token: token }; + const updateFields = { + emailVerified: true, + _email_verify_token: { __op: 'Delete' }, + }; // if the email verify token needs to be validated then // add additional query params and additional fields that need to be updated @@ -59,33 +79,51 @@ export class UserController extends AdaptableController { query.emailVerified = false; query._email_verify_token_expires_at = { $gt: Parse._encode(new Date()) }; - updateFields._email_verify_token_expires_at = {__op: 'Delete'}; + updateFields._email_verify_token_expires_at = { __op: 'Delete' }; } - - return this.config.database.update('_User', query, updateFields).then((document) => { - if (!document) { - throw undefined; - } - return Promise.resolve(document); + const maintenanceAuth = Auth.maintenance(this.config); + const restQuery = await RestQuery({ + method: RestQuery.Method.get, + config: this.config, + auth: maintenanceAuth, + className: '_User', + restWhere: query, }); + + const result = await restQuery.execute(); + if (result.results.length) { + query.objectId = result.results[0].objectId; + } + return await rest.update(this.config, maintenanceAuth, '_User', query, updateFields); } - checkResetTokenValidity(username, token) { - return this.config.database.find('_User', { - username: username, - _perishable_token: token - }, {limit: 1}).then(results => { - if (results.length != 1) { - throw undefined; + async checkResetTokenValidity(token) { + const results = await this.config.database.find( + '_User', + { + _perishable_token: token, + }, + { limit: 1 }, + Auth.maintenance(this.config) + ); + if (results.length !== 1) { + throw 'Failed to reset password: username / email / token is invalid'; + } + + if (this.config.passwordPolicy && this.config.passwordPolicy.resetTokenValidityDuration) { + let expiresDate = results[0]._perishable_token_expires_at; + if (expiresDate && expiresDate.__type == 'Date') { + expiresDate = new Date(expiresDate.iso); } - return results[0]; - }); + if (expiresDate < new Date()) { + throw 'The password reset link has expired'; + } + } + + return results[0]; } - getUserIfNeeded(user) { - if (user.username && user.email) { - return Promise.resolve(user); - } + async getUserIfNeeded(user) { var where = {}; if (user.username) { where.username = user.username; @@ -93,106 +131,253 @@ export class UserController extends AdaptableController { if (user.email) { where.email = user.email; } + if (user._email_verify_token) { + where._email_verify_token = user._email_verify_token; + } - var query = new RestQuery(this.config, Auth.master(this.config), '_User', where); - return query.execute().then(function(result){ - if (result.results.length != 1) { - throw undefined; - } - return result.results[0]; - }) + var query = await RestQuery({ + method: RestQuery.Method.get, + config: this.config, + runBeforeFind: false, + auth: Auth.master(this.config), + className: '_User', + restWhere: where, + }); + const result = await query.execute(); + if (result.results.length != 1) { + throw undefined; + } + return result.results[0]; } - sendVerificationEmail(user) { + async sendVerificationEmail(user, req) { if (!this.shouldVerifyEmails) { return; } const token = encodeURIComponent(user._email_verify_token); - // We may need to fetch the user in case of update email - this.getUserIfNeeded(user).then((user) => { - const username = encodeURIComponent(user.username); - let link = `${this.config.verifyEmailURL}?token=${token}&username=${username}`; - let options = { - appName: this.config.appName, - link: link, - user: inflate('_User', user), - }; - if (this.adapter.sendVerificationEmail) { - this.adapter.sendVerificationEmail(options); - } else { - this.adapter.sendMail(this.defaultVerificationEmail(options)); - } + // We may need to fetch the user in case of update email; only use the `fetchedUser` + // from this point onwards; do not use the `user` as it may not contain all fields. + const fetchedUser = await this.getUserIfNeeded(user); + let shouldSendEmail = this.config.sendUserEmailVerification; + if (typeof shouldSendEmail === 'function') { + const response = await Promise.resolve( + this.config.sendUserEmailVerification({ + user: Parse.Object.fromJSON({ className: '_User', ...fetchedUser }), + master: req.auth?.isMaster, + }) + ); + shouldSendEmail = !!response; + } + if (!shouldSendEmail) { + return; + } + const link = buildEmailLink(this.config.verifyEmailURL, token, this.config); + const options = { + appName: this.config.appName, + link: link, + user: inflate('_User', fetchedUser), + }; + if (this.adapter.sendVerificationEmail) { + this.adapter.sendVerificationEmail(options); + } else { + this.adapter.sendMail(this.defaultVerificationEmail(options)); + } + } + + /** + * Regenerates the given user's email verification token + * + * @param user + * @returns {*} + */ + async regenerateEmailVerifyToken(user, master, installationId, ip) { + const { _email_verify_token } = user; + let { _email_verify_token_expires_at } = user; + if (_email_verify_token_expires_at && _email_verify_token_expires_at.__type === 'Date') { + _email_verify_token_expires_at = _email_verify_token_expires_at.iso; + } + if ( + this.config.emailVerifyTokenReuseIfValid && + this.config.emailVerifyTokenValidityDuration && + _email_verify_token && + new Date() < new Date(_email_verify_token_expires_at) + ) { + return Promise.resolve(true); + } + const shouldSend = await this.setEmailVerifyToken(user, { + object: Parse.User.fromJSON(Object.assign({ className: '_User' }, user)), + master, + installationId, + ip, + resendRequest: true }); + if (!shouldSend) { + return; + } + return this.config.database.update('_User', { username: user.username }, user); + } + + async resendVerificationEmail(username, req, token) { + const aUser = await this.getUserIfNeeded({ username, _email_verify_token: token }); + if (!aUser || aUser.emailVerified) { + throw undefined; + } + const generate = await this.regenerateEmailVerifyToken(aUser, req.auth?.isMaster, req.auth?.installationId, req.ip); + if (generate) { + this.sendVerificationEmail(aUser, req); + } } setPasswordResetToken(email) { - return this.config.database.update('_User', { email }, { _perishable_token: randomString(25) }, {}, true) + const token = { _perishable_token: randomString(25) }; + + if (this.config.passwordPolicy && this.config.passwordPolicy.resetTokenValidityDuration) { + token._perishable_token_expires_at = Parse._encode( + this.config.generatePasswordResetTokenExpiresAt() + ); + } + + return this.config.database.update( + '_User', + { $or: [{ email }, { username: email, email: { $exists: false } }] }, + token, + {}, + true + ); } - sendPasswordResetEmail(email) { + async sendPasswordResetEmail(email) { if (!this.adapter) { - throw "Trying to send a reset password but no adapter is set"; + throw 'Trying to send a reset password but no adapter is set'; // TODO: No adapter? - return; } + let user; + if ( + this.config.passwordPolicy && + this.config.passwordPolicy.resetTokenReuseIfValid && + this.config.passwordPolicy.resetTokenValidityDuration + ) { + const results = await this.config.database.find( + '_User', + { + $or: [ + { email, _perishable_token: { $exists: true } }, + { username: email, email: { $exists: false }, _perishable_token: { $exists: true } }, + ], + }, + { limit: 1 }, + Auth.maintenance(this.config) + ); + if (results.length == 1) { + let expiresDate = results[0]._perishable_token_expires_at; + if (expiresDate && expiresDate.__type == 'Date') { + expiresDate = new Date(expiresDate.iso); + } + if (expiresDate > new Date()) { + user = results[0]; + } + } + } + if (!user || !user._perishable_token) { + user = await this.setPasswordResetToken(email); + } + const token = encodeURIComponent(user._perishable_token); + const link = buildEmailLink(this.config.requestResetPasswordURL, token, this.config); + const options = { + appName: this.config.appName, + link: link, + user: inflate('_User', user), + }; - return this.setPasswordResetToken(email) - .then(user => { - const token = encodeURIComponent(user._perishable_token); - const username = encodeURIComponent(user.username); - let link = `${this.config.requestResetPasswordURL}?token=${token}&username=${username}` + if (this.adapter.sendPasswordResetEmail) { + this.adapter.sendPasswordResetEmail(options); + } else { + this.adapter.sendMail(this.defaultResetPasswordEmail(options)); + } - let options = { - appName: this.config.appName, - link: link, - user: inflate('_User', user), - }; + return Promise.resolve(user); + } - if (this.adapter.sendPasswordResetEmail) { - this.adapter.sendPasswordResetEmail(options); - } else { - this.adapter.sendMail(this.defaultResetPasswordEmail(options)); + async updatePassword(token, password) { + try { + const rawUser = await this.checkResetTokenValidity(token); + let user; + try { + user = await updateUserPassword(rawUser, password, this.config); + } catch (error) { + if (error && error.code === Parse.Error.OBJECT_NOT_FOUND) { + throw 'Failed to reset password: username / email / token is invalid'; + } + throw error; } - return Promise.resolve(user); - }); - } - - updatePassword(username, token, password, config) { - return this.checkResetTokenValidity(username, token) - .then(user => updateUserPassword(user.objectId, password, this.config)) - // clear reset password token - .then(() => this.config.database.update('_User', { username }, { - _perishable_token: {__op: 'Delete'} - })); + const accountLockoutPolicy = new AccountLockout(user, this.config); + return await accountLockoutPolicy.unlockAccount(); + } catch (error) { + if (error && error.message) { + // in case of Parse.Error, fail with the error message only + return Promise.reject(error.message); + } + return Promise.reject(error); + } } - defaultVerificationEmail({link, user, appName, }) { - let text = "Hi,\n\n" + - "You are being asked to confirm the e-mail address " + user.get("email") + " with " + appName + "\n\n" + - "" + - "Click here to confirm it:\n" + link; - let to = user.get("email"); - let subject = 'Please verify your e-mail for ' + appName; + defaultVerificationEmail({ link, user, appName }) { + const text = + 'Hi,\n\n' + + 'You are being asked to confirm the e-mail address ' + + user.get('email') + + ' with ' + + appName + + '\n\n' + + '' + + 'Click here to confirm it:\n' + + link; + const to = user.get('email'); + const subject = 'Please verify your e-mail for ' + appName; return { text, to, subject }; } - defaultResetPasswordEmail({link, user, appName, }) { - let text = "Hi,\n\n" + - "You requested to reset your password for " + appName + ".\n\n" + - "" + - "Click here to reset it:\n" + link; - let to = user.get("email"); - let subject = 'Password Reset for ' + appName; + defaultResetPasswordEmail({ link, user, appName }) { + const text = + 'Hi,\n\n' + + 'You requested to reset your password for ' + + appName + + (user.get('username') ? " (your username is '" + user.get('username') + "')" : '') + + '.\n\n' + + '' + + 'Click here to reset it:\n' + + link; + const to = user.get('email') || user.get('username'); + const subject = 'Password Reset for ' + appName; return { text, to, subject }; } } // Mark this private -function updateUserPassword(userId, password, config) { - return rest.update(config, Auth.master(config), '_User', userId, { - password: password - }); - } +function updateUserPassword(user, password, config) { + return rest + .update( + config, + Auth.master(config), + '_User', + { objectId: user.objectId, _perishable_token: user._perishable_token }, + { + password: password, + } + ) + .then(() => user); +} + +function buildEmailLink(destination, token, config) { + token = `token=${token}`; + if (config.parseFrameURL) { + const destinationWithoutHost = destination.replace(config.publicServerURL, ''); + + return `${config.parseFrameURL}?link=${encodeURIComponent(destinationWithoutHost)}&${token}`; + } else { + return `${destination}?${token}`; + } +} export default UserController; diff --git a/src/Controllers/index.js b/src/Controllers/index.js new file mode 100644 index 0000000000..9397dac561 --- /dev/null +++ b/src/Controllers/index.js @@ -0,0 +1,238 @@ +import authDataManager from '../Adapters/Auth'; +import { ParseServerOptions } from '../Options'; +import { loadAdapter, loadModule } from '../Adapters/AdapterLoader'; +import defaults from '../defaults'; +// Controllers +import { LoggerController } from './LoggerController'; +import { FilesController } from './FilesController'; +import { HooksController } from './HooksController'; +import { UserController } from './UserController'; +import { CacheController } from './CacheController'; +import { LiveQueryController } from './LiveQueryController'; +import { AnalyticsController } from './AnalyticsController'; +import { PushController } from './PushController'; +import { PushQueue } from '../Push/PushQueue'; +import { PushWorker } from '../Push/PushWorker'; +import DatabaseController from './DatabaseController'; + +// Adapters +import { GridFSBucketAdapter } from '../Adapters/Files/GridFSBucketAdapter'; +import { WinstonLoggerAdapter } from '../Adapters/Logger/WinstonLoggerAdapter'; +import { InMemoryCacheAdapter } from '../Adapters/Cache/InMemoryCacheAdapter'; +import { AnalyticsAdapter } from '../Adapters/Analytics/AnalyticsAdapter'; +import MongoStorageAdapter from '../Adapters/Storage/Mongo/MongoStorageAdapter'; +import PostgresStorageAdapter from '../Adapters/Storage/Postgres/PostgresStorageAdapter'; +import ParseGraphQLController from './ParseGraphQLController'; +import SchemaCache from '../Adapters/Cache/SchemaCache'; + +export function getControllers(options: ParseServerOptions) { + const loggerController = getLoggerController(options); + const filesController = getFilesController(options); + const userController = getUserController(options); + const cacheController = getCacheController(options); + const analyticsController = getAnalyticsController(options); + const liveQueryController = getLiveQueryController(options); + const databaseController = getDatabaseController(options); + const hooksController = getHooksController(options, databaseController); + const authDataManager = getAuthDataManager(options); + const parseGraphQLController = getParseGraphQLController(options, { + databaseController, + cacheController, + }); + return { + loggerController, + filesController, + userController, + analyticsController, + cacheController, + parseGraphQLController, + liveQueryController, + databaseController, + hooksController, + authDataManager, + schemaCache: SchemaCache, + }; +} + +export function getLoggerController(options: ParseServerOptions): LoggerController { + const { + appId, + jsonLogs, + logsFolder, + verbose, + logLevel, + maxLogFiles, + silent, + loggerAdapter, + } = options; + const loggerOptions = { + jsonLogs, + logsFolder, + verbose, + logLevel, + silent, + maxLogFiles, + }; + const loggerControllerAdapter = loadAdapter(loggerAdapter, WinstonLoggerAdapter, loggerOptions); + return new LoggerController(loggerControllerAdapter, appId, loggerOptions); +} + +export function getFilesController(options: ParseServerOptions): FilesController { + const { + appId, + databaseURI, + databaseOptions = {}, + filesAdapter, + databaseAdapter, + preserveFileName, + fileKey, + } = options; + if (!filesAdapter && databaseAdapter) { + throw 'When using an explicit database adapter, you must also use an explicit filesAdapter.'; + } + const filesControllerAdapter = loadAdapter(filesAdapter, () => { + return new GridFSBucketAdapter(databaseURI, databaseOptions, fileKey); + }); + return new FilesController(filesControllerAdapter, appId, { + preserveFileName, + }); +} + +export function getUserController(options: ParseServerOptions): UserController { + const { appId, emailAdapter, verifyUserEmails } = options; + const emailControllerAdapter = loadAdapter(emailAdapter); + return new UserController(emailControllerAdapter, appId, { + verifyUserEmails, + }); +} + +export function getCacheController(options: ParseServerOptions): CacheController { + const { appId, cacheAdapter, cacheTTL, cacheMaxSize } = options; + const cacheControllerAdapter = loadAdapter(cacheAdapter, InMemoryCacheAdapter, { + appId: appId, + ttl: cacheTTL, + maxSize: cacheMaxSize, + }); + return new CacheController(cacheControllerAdapter, appId); +} + +export function getParseGraphQLController( + options: ParseServerOptions, + controllerDeps +): ParseGraphQLController { + return new ParseGraphQLController({ + mountGraphQL: options.mountGraphQL, + ...controllerDeps, + }); +} + +export function getAnalyticsController(options: ParseServerOptions): AnalyticsController { + const { analyticsAdapter } = options; + const analyticsControllerAdapter = loadAdapter(analyticsAdapter, AnalyticsAdapter); + return new AnalyticsController(analyticsControllerAdapter); +} + +export function getLiveQueryController(options: ParseServerOptions): LiveQueryController { + return new LiveQueryController(options.liveQuery); +} + +export function getDatabaseController(options: ParseServerOptions): DatabaseController { + const { databaseURI, collectionPrefix, databaseOptions } = options; + let { databaseAdapter } = options; + if ( + (databaseOptions || + (databaseURI && databaseURI !== defaults.databaseURI) || + collectionPrefix !== defaults.collectionPrefix) && + databaseAdapter + ) { + throw 'You cannot specify both a databaseAdapter and a databaseURI/databaseOptions/collectionPrefix.'; + } else if (!databaseAdapter) { + databaseAdapter = getDatabaseAdapter(databaseURI, collectionPrefix, databaseOptions); + } else { + databaseAdapter = loadAdapter(databaseAdapter); + } + return new DatabaseController(databaseAdapter, options); +} + +export function getHooksController( + options: ParseServerOptions, + databaseController: DatabaseController +): HooksController { + const { appId, webhookKey } = options; + return new HooksController(appId, databaseController, webhookKey); +} + +interface PushControlling { + pushController: PushController; + hasPushScheduledSupport: boolean; + pushControllerQueue: PushQueue; + pushWorker: PushWorker; +} + +export async function getPushController(options: ParseServerOptions): PushControlling { + const { scheduledPush, push } = options; + + const pushOptions = Object.assign({}, push); + const pushQueueOptions = pushOptions.queueOptions || {}; + if (pushOptions.queueOptions) { + delete pushOptions.queueOptions; + } + + // Pass the push options too as it works with the default + const ParsePushAdapter = await loadModule('@parse/push-adapter'); + const pushAdapter = loadAdapter( + pushOptions && pushOptions.adapter, + ParsePushAdapter, + pushOptions + ); + // We pass the options and the base class for the adatper, + // Note that passing an instance would work too + const pushController = new PushController(); + const hasPushSupport = !!(pushAdapter && push); + const hasPushScheduledSupport = hasPushSupport && scheduledPush === true; + + const { disablePushWorker } = pushQueueOptions; + + const pushControllerQueue = new PushQueue(pushQueueOptions); + let pushWorker; + if (!disablePushWorker) { + pushWorker = new PushWorker(pushAdapter, pushQueueOptions); + } + return { + pushController, + hasPushSupport, + hasPushScheduledSupport, + pushControllerQueue, + pushWorker, + }; +} + +export function getAuthDataManager(options: ParseServerOptions) { + const { auth, enableAnonymousUsers } = options; + return authDataManager(auth, enableAnonymousUsers); +} + +export function getDatabaseAdapter(databaseURI, collectionPrefix, databaseOptions) { + let protocol; + try { + const parsedURI = new URL(databaseURI); + protocol = parsedURI.protocol ? parsedURI.protocol.toLowerCase() : null; + } catch { + /* */ + } + switch (protocol) { + case 'postgres:': + case 'postgresql:': + return new PostgresStorageAdapter({ + uri: databaseURI, + collectionPrefix, + databaseOptions, + }); + default: + return new MongoStorageAdapter({ + uri: databaseURI, + collectionPrefix, + mongoOptions: databaseOptions, + }); + } +} diff --git a/src/Controllers/types.js b/src/Controllers/types.js new file mode 100644 index 0000000000..98e41fd2d3 --- /dev/null +++ b/src/Controllers/types.js @@ -0,0 +1,37 @@ +export type LoadSchemaOptions = { + clearCache: boolean, +}; + +export type SchemaField = { + type: string, + targetClass?: ?string, + required?: ?boolean, + defaultValue?: ?any, +}; + +export type SchemaFields = { [string]: SchemaField }; + +export type Schema = { + className: string, + fields: SchemaFields, + classLevelPermissions: ClassLevelPermissions, + indexes?: ?any, +}; + +export type ClassLevelPermissions = { + ACL?: { + [string]: { + [string]: boolean, + }, + }, + find?: { [string]: boolean }, + count?: { [string]: boolean }, + get?: { [string]: boolean }, + create?: { [string]: boolean }, + update?: { [string]: boolean }, + delete?: { [string]: boolean }, + addField?: { [string]: boolean }, + readUserFields?: string[], + writeUserFields?: string[], + protectedFields?: { [string]: string[] }, +}; diff --git a/src/DatabaseAdapter.js b/src/DatabaseAdapter.js deleted file mode 100644 index 88fcbe4280..0000000000 --- a/src/DatabaseAdapter.js +++ /dev/null @@ -1,21 +0,0 @@ -import AppCache from './cache'; - -//Used by tests -function destroyAllDataPermanently() { - if (process.env.TESTING) { - // This is super janky, but destroyAllDataPermanently is - // a janky interface, so we need to have some jankyness - // to support it - return Promise.all(Object.keys(AppCache.cache).map(appId => { - const app = AppCache.get(appId); - if (app.databaseController) { - return app.databaseController.deleteEverything(); - } else { - return Promise.resolve(); - } - })); - } - throw 'Only supported in test environment'; -} - -module.exports = { destroyAllDataPermanently }; diff --git a/src/Deprecator/Deprecations.js b/src/Deprecator/Deprecations.js new file mode 100644 index 0000000000..5eb78c88c0 --- /dev/null +++ b/src/Deprecator/Deprecations.js @@ -0,0 +1,116 @@ +/** + * The deprecations. + * + * Add deprecations to the array using the following keys: + * - `optionKey` {String}: The option key incl. its path, e.g. `security.enableCheck`. + * - `envKey` {String}: The environment key, e.g. `PARSE_SERVER_SECURITY`. + * - `changeNewKey` {String}: Set the new key name if the current key will be replaced, + * or set to an empty string if the current key will be removed without replacement. + * - `changeNewDefault` {String}: Set the new default value if the key's default value + * will change in a future version. + * - `resolvedValue` {any}: The option value that suppresses the deprecation warning, + * indicating the user has already adopted the future behavior. Only applicable when + * `changeNewKey` is an empty string (option will be removed without replacement). + * For example, `false` for an option that will be removed, if setting it to `false` + * disables the deprecated feature. + * - `solution`: The instruction to resolve this deprecation warning. Optional. This + * instruction must not include the deprecation warning which is auto-generated. + * It should only contain additional instruction regarding the deprecation if + * necessary. + * + * If there are no deprecations, this must return an empty array. + */ +module.exports = [ + { + optionKey: 'fileUpload.allowedFileUrlDomains', + changeNewDefault: '[]', + solution: "Set 'fileUpload.allowedFileUrlDomains' to the domains you want to allow, or to '[]' to block all file URLs.", + }, + { + optionKey: 'pages.encodePageParamHeaders', + changeNewDefault: 'true', + solution: "Set 'pages.encodePageParamHeaders' to 'true' to URI-encode non-ASCII characters in page parameter headers.", + }, + { + optionKey: 'readOnlyMasterKeyIps', + changeNewDefault: '["127.0.0.1", "::1"]', + solution: "Set 'readOnlyMasterKeyIps' to the IP addresses that should be allowed to use the read-only master key, or to '[\"127.0.0.1\", \"::1\"]' to restrict access to localhost.", + }, + { + optionKey: 'mountPlayground', + changeNewKey: '', + solution: "Use Parse Dashboard as GraphQL IDE or configure a third-party GraphQL client such as Apollo Sandbox, GraphiQL, or Insomnia with custom request headers.", + }, + { + optionKey: 'playgroundPath', + changeNewKey: '', + solution: "Use Parse Dashboard as GraphQL IDE or configure a third-party GraphQL client such as Apollo Sandbox, GraphiQL, or Insomnia with custom request headers.", + }, + { + optionKey: 'requestComplexity.includeDepth', + changeNewDefault: '10', + solution: "Set 'requestComplexity.includeDepth' to a positive integer appropriate for your app to limit include pointer chain depth, or to '-1' to disable.", + }, + { + optionKey: 'requestComplexity.includeCount', + changeNewDefault: '100', + solution: "Set 'requestComplexity.includeCount' to a positive integer appropriate for your app to limit the number of include paths per query, or to '-1' to disable.", + }, + { + optionKey: 'requestComplexity.subqueryDepth', + changeNewDefault: '10', + solution: "Set 'requestComplexity.subqueryDepth' to a positive integer appropriate for your app to limit subquery nesting depth, or to '-1' to disable.", + }, + { + optionKey: 'requestComplexity.queryDepth', + changeNewDefault: '10', + solution: "Set 'requestComplexity.queryDepth' to a positive integer appropriate for your app to limit query condition nesting depth, or to '-1' to disable.", + }, + { + optionKey: 'requestComplexity.graphQLDepth', + changeNewDefault: '20', + solution: "Set 'requestComplexity.graphQLDepth' to a positive integer appropriate for your app to limit GraphQL field selection depth, or to '-1' to disable.", + }, + { + optionKey: 'requestComplexity.graphQLFields', + changeNewDefault: '200', + solution: "Set 'requestComplexity.graphQLFields' to a positive integer appropriate for your app to limit the number of GraphQL field selections, or to '-1' to disable.", + }, + { + optionKey: 'requestComplexity.batchRequestLimit', + changeNewDefault: '100', + solution: "Set 'requestComplexity.batchRequestLimit' to a positive integer appropriate for your app to limit the number of sub-requests per batch request, or to '-1' to disable.", + }, + { + optionKey: 'enableProductPurchaseLegacyApi', + changeNewKey: '', + resolvedValue: false, + solution: "The product purchase API is an undocumented, unmaintained legacy feature that may not function as expected and will be removed in a future major version. We strongly advise against using it. Set 'enableProductPurchaseLegacyApi' to 'false' to disable it, or remove the option to accept the future removal.", + }, + { + optionKey: 'allowExpiredAuthDataToken', + changeNewKey: '', + resolvedValue: false, + solution: "Auth providers are always validated on login regardless of this setting. Set 'allowExpiredAuthDataToken' to 'false' or remove the option to accept the future removal.", + }, + { + optionKey: 'protectedFieldsOwnerExempt', + changeNewDefault: 'false', + solution: "Set 'protectedFieldsOwnerExempt' to 'false' to apply protectedFields consistently to the user's own _User object (same as all other classes), or to 'true' to keep the current behavior where a user can see all their own fields.", + }, + { + optionKey: 'protectedFieldsTriggerExempt', + changeNewDefault: 'true', + solution: "Set 'protectedFieldsTriggerExempt' to 'true' to make Cloud Code triggers (e.g. beforeSave, afterSave) receive the full object including protected fields, or to 'false' to keep the current behavior where protected fields are stripped from trigger objects.", + }, + { + optionKey: 'protectedFieldsSaveResponseExempt', + changeNewDefault: 'false', + solution: "Set 'protectedFieldsSaveResponseExempt' to 'false' to strip protected fields from write operation responses (create, update), consistent with how they are stripped from query results. Set to 'true' to keep the current behavior where protected fields are included in write responses.", + }, + { + optionKey: 'installation.duplicateDeviceTokenActionEnforceAuth', + changeNewDefault: 'true', + solution: "Set 'installation.duplicateDeviceTokenActionEnforceAuth' to 'true' to enforce the caller's auth context (and the resulting ACL and CLP) when Parse Server deduplicates _Installation records sharing the same deviceToken. Set to 'false' to keep the current behavior of bypassing permissions on the dedup operation.", + }, +]; diff --git a/src/Deprecator/Deprecator.js b/src/Deprecator/Deprecator.js new file mode 100644 index 0000000000..626f397a36 --- /dev/null +++ b/src/Deprecator/Deprecator.js @@ -0,0 +1,128 @@ +import logger from '../logger'; +import Deprecations from './Deprecations'; +import Utils from '../Utils'; + +/** + * The deprecator class. + */ +class Deprecator { + /** + * Scans the Parse Server for deprecated options. + * This needs to be called before setting option defaults, otherwise it + * becomes indistinguishable whether an option has been set manually or + * by default. + * @param {any} options The Parse Server options. + */ + static scanParseServerOptions(options) { + // Scan for deprecations + for (const deprecation of Deprecator._getDeprecations()) { + // Get deprecation properties + const solution = deprecation.solution; + const optionKey = deprecation.optionKey; + const changeNewDefault = deprecation.changeNewDefault; + const changeNewKey = deprecation.changeNewKey; + + // If default will change, only throw a warning if option is not set + if (changeNewDefault != null && Utils.getNestedProperty(options, optionKey) == null) { + Deprecator._logOption({ optionKey, changeNewDefault, solution }); + } + + // If key will be removed or renamed, only throw a warning if option is set; + // skip if option is set to the resolved value that suppresses the deprecation + const resolvedValue = deprecation.resolvedValue; + const optionValue = Utils.getNestedProperty(options, optionKey); + if (changeNewKey != null && optionValue != null && optionValue !== resolvedValue) { + Deprecator._logOption({ optionKey, changeNewKey, solution }); + } + } + } + + /** + * Logs a deprecation warning for a parameter that can only be determined dynamically + * during runtime. + * + * Note: Do not use this to log deprecations of Parse Server options, but add such + * deprecations to `Deprecations.js` instead. See the contribution docs for more + * details. + * + * For consistency, the deprecation warning is composed of the following parts: + * + * > DeprecationWarning: `usage` is deprecated and will be removed in a future version. + * `solution`. + * + * - `usage`: The deprecated usage. + * - `solution`: The instruction to resolve this deprecation warning. + * + * For example: + * > DeprecationWarning: `Prefixing field names with dollar sign ($) in aggregation query` + * is deprecated and will be removed in a future version. `Reference field names without + * dollar sign prefix.` + * + * @param {Object} options The deprecation options. + * @param {String} options.usage The usage that is deprecated. + * @param {String} [options.solution] The instruction to resolve this deprecation warning. + * Optional. It is recommended to add an instruction for the convenience of the developer. + */ + static logRuntimeDeprecation(options) { + Deprecator._logGeneric(options); + } + + /** + * Returns the deprecation definitions. + * @returns {Array} The deprecations. + */ + static _getDeprecations() { + return Deprecations; + } + + /** + * Logs a generic deprecation warning. + * + * @param {Object} options The deprecation options. + * @param {String} options.usage The usage that is deprecated. + * @param {String} [options.solution] The instruction to resolve this deprecation warning. + * Optional. It is recommended to add an instruction for the convenience of the developer. + */ + static _logGeneric({ usage, solution }) { + // Compose message + let output = `DeprecationWarning: ${usage} is deprecated and will be removed in a future version.`; + output += solution ? ` ${solution}` : ''; + logger.warn(output); + } + + /** + * Logs a deprecation warning for a Parse Server option. + * + * @param {String} optionKey The option key incl. its path, e.g. `security.enableCheck`. + * @param {String} envKey The environment key, e.g. `PARSE_SERVER_SECURITY`. + * @param {String} changeNewKey Set the new key name if the current key will be replaced, + * or set to an empty string if the current key will be removed without replacement. + * @param {String} changeNewDefault Set the new default value if the key's default value + * will change in a future version. + * @param {String} [solution] The instruction to resolve this deprecation warning. This + * message must not include the warning that the parameter is deprecated, that is + * automatically added to the message. It should only contain the instruction on how + * to resolve this warning. + */ + static _logOption({ optionKey, envKey, changeNewKey, changeNewDefault, solution }) { + const type = optionKey ? 'option' : 'environment key'; + const key = optionKey ? optionKey : envKey; + const keyAction = + changeNewKey == null + ? undefined + : changeNewKey.length > 0 + ? `renamed to '${changeNewKey}'` + : `removed`; + + // Compose message + let output = `DeprecationWarning: The Parse Server ${type} '${key}' `; + output += changeNewKey != null ? `is deprecated and will be ${keyAction} in a future version.` : ''; + output += changeNewDefault + ? `default will change to '${changeNewDefault}' in a future version.` + : ''; + output += solution ? ` ${solution}` : ''; + logger.warn(output); + } +} + +module.exports = Deprecator; diff --git a/src/Error.js b/src/Error.js new file mode 100644 index 0000000000..55bbd6ebea --- /dev/null +++ b/src/Error.js @@ -0,0 +1,46 @@ +import defaultLogger from './logger'; + +/** + * Creates a sanitized error that hides detailed information from clients + * while logging the detailed message server-side. + * + * @param {number} errorCode - The Parse.Error code (e.g., Parse.Error.OPERATION_FORBIDDEN) + * @param {string} detailedMessage - The detailed error message to log server-side + * @param {object} config - Parse Server config with enableSanitizedErrorResponse + * @param {string} [sanitizedMessage='Permission denied'] - The sanitized message to return to clients + * @returns {Parse.Error} A Parse.Error with sanitized message + */ +function createSanitizedError(errorCode, detailedMessage, config, sanitizedMessage = 'Permission denied') { + // On testing we need to add a prefix to the message to allow to find the correct call in the TestUtils.js file + if (process.env.TESTING) { + defaultLogger.error('Sanitized error:', detailedMessage); + } else { + defaultLogger.error(detailedMessage); + } + + return new Parse.Error(errorCode, config?.enableSanitizedErrorResponse !== false ? sanitizedMessage : detailedMessage); +} + +/** + * Creates a sanitized error from a regular Error object + * Used for non-Parse.Error errors (e.g., Express errors) + * + * @param {number} statusCode - HTTP status code (e.g., 403) + * @param {string} detailedMessage - The detailed error message to log server-side + * @returns {Error} An Error with sanitized message + */ +function createSanitizedHttpError(statusCode, detailedMessage, config) { + // On testing we need to add a prefix to the message to allow to find the correct call in the TestUtils.js file + if (process.env.TESTING) { + defaultLogger.error('Sanitized error:', detailedMessage); + } else { + defaultLogger.error(detailedMessage); + } + + const error = new Error(); + error.status = statusCode; + error.message = config?.enableSanitizedErrorResponse !== false ? 'Permission denied' : detailedMessage; + return error; +} + +export { createSanitizedError, createSanitizedHttpError }; diff --git a/src/FileUrlValidator.js b/src/FileUrlValidator.js new file mode 100644 index 0000000000..6554fea51c --- /dev/null +++ b/src/FileUrlValidator.js @@ -0,0 +1,68 @@ +const Parse = require('parse/node').Parse; + +/** + * Validates whether a File URL is allowed based on the configured allowed domains. + * @param {string} fileUrl - The URL to validate. + * @param {Object} config - The Parse Server config object. + * @throws {Parse.Error} If the URL is not allowed. + */ +function validateFileUrl(fileUrl, config) { + if (fileUrl == null || fileUrl === '') { + return; + } + + const domains = config?.fileUpload?.allowedFileUrlDomains; + if (!Array.isArray(domains) || domains.includes('*')) { + return; + } + + let parsedUrl; + try { + parsedUrl = new URL(fileUrl); + } catch { + throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `Invalid file URL.`); + } + + const fileHostname = parsedUrl.hostname.toLowerCase(); + for (const domain of domains) { + const d = domain.toLowerCase(); + if (fileHostname === d) { + return; + } + if (d.startsWith('*.') && fileHostname.endsWith(d.slice(1))) { + return; + } + } + + throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File URL domain '${parsedUrl.hostname}' is not allowed.`); +} + +/** + * Recursively scans an object for File type fields and validates their URLs. + * @param {any} obj - The object to scan. + * @param {Object} config - The Parse Server config object. + * @throws {Parse.Error} If any File URL is not allowed. + */ +function validateFileUrlsInObject(obj, config) { + if (obj == null || typeof obj !== 'object') { + return; + } + if (Array.isArray(obj)) { + for (const item of obj) { + validateFileUrlsInObject(item, config); + } + return; + } + if (obj.__type === 'File' && obj.url) { + validateFileUrl(obj.url, config); + return; + } + for (const key of Object.keys(obj)) { + const value = obj[key]; + if (value && typeof value === 'object') { + validateFileUrlsInObject(value, config); + } + } +} + +module.exports = { validateFileUrl, validateFileUrlsInObject }; diff --git a/src/GraphQL/ParseGraphQLSchema.js b/src/GraphQL/ParseGraphQLSchema.js new file mode 100644 index 0000000000..5ecdd78de5 --- /dev/null +++ b/src/GraphQL/ParseGraphQLSchema.js @@ -0,0 +1,500 @@ +import Parse from 'parse/node'; +import { GraphQLSchema, GraphQLObjectType, DocumentNode, GraphQLNamedType } from 'graphql'; +import { mergeSchemas } from '@graphql-tools/schema'; +import { mergeTypeDefs } from '@graphql-tools/merge'; +import { isDeepStrictEqual } from 'util'; +import requiredParameter from '../requiredParameter'; +import * as defaultGraphQLTypes from './loaders/defaultGraphQLTypes'; +import * as parseClassTypes from './loaders/parseClassTypes'; +import * as parseClassQueries from './loaders/parseClassQueries'; +import * as parseClassMutations from './loaders/parseClassMutations'; +import * as defaultGraphQLQueries from './loaders/defaultGraphQLQueries'; +import * as defaultGraphQLMutations from './loaders/defaultGraphQLMutations'; +import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController'; +import DatabaseController from '../Controllers/DatabaseController'; +import SchemaCache from '../Adapters/Cache/SchemaCache'; +import { toGraphQLError } from './parseGraphQLUtils'; +import * as schemaDirectives from './loaders/schemaDirectives'; +import * as schemaTypes from './loaders/schemaTypes'; +import { getFunctionNames } from '../triggers'; +import * as defaultRelaySchema from './loaders/defaultRelaySchema'; + +const RESERVED_GRAPHQL_TYPE_NAMES = [ + 'String', + 'Boolean', + 'Int', + 'Float', + 'ID', + 'ArrayResult', + 'Query', + 'Mutation', + 'Subscription', + 'CreateFileInput', + 'CreateFilePayload', + 'Viewer', + 'SignUpInput', + 'SignUpPayload', + 'LogInInput', + 'LogInPayload', + 'LogOutInput', + 'LogOutPayload', + 'CloudCodeFunction', + 'CallCloudCodeInput', + 'CallCloudCodePayload', + 'CreateClassInput', + 'CreateClassPayload', + 'UpdateClassInput', + 'UpdateClassPayload', + 'DeleteClassInput', + 'DeleteClassPayload', + 'PageInfo', +]; +const RESERVED_GRAPHQL_QUERY_NAMES = ['health', 'viewer', 'class', 'classes', 'cloudConfig']; +const RESERVED_GRAPHQL_MUTATION_NAMES = [ + 'signUp', + 'logIn', + 'logOut', + 'createFile', + 'callCloudCode', + 'createClass', + 'updateClass', + 'deleteClass', + 'updateCloudConfig', +]; + +class ParseGraphQLSchema { + databaseController: DatabaseController; + parseGraphQLController: ParseGraphQLController; + parseGraphQLConfig: ParseGraphQLConfig; + log: any; + appId: string; + graphQLCustomTypeDefs: ?(string | GraphQLSchema | DocumentNode | GraphQLNamedType[]); + schemaCache: any; + + constructor( + params: { + databaseController: DatabaseController, + parseGraphQLController: ParseGraphQLController, + log: any, + appId: string, + graphQLCustomTypeDefs: ?(string | GraphQLSchema | DocumentNode | GraphQLNamedType[]), + } = {} + ) { + this.parseGraphQLController = + params.parseGraphQLController || + requiredParameter('You must provide a parseGraphQLController instance!'); + this.databaseController = + params.databaseController || + requiredParameter('You must provide a databaseController instance!'); + this.log = params.log || requiredParameter('You must provide a log instance!'); + this.graphQLCustomTypeDefs = params.graphQLCustomTypeDefs; + this.appId = params.appId || requiredParameter('You must provide the appId!'); + this.schemaCache = SchemaCache; + this.logCache = {}; + } + + async load() { + const { parseGraphQLConfig } = await this._initializeSchemaAndConfig(); + const parseClassesArray = await this._getClassesForSchema(parseGraphQLConfig); + const functionNames = await this._getFunctionNames(); + const functionNamesString = functionNames.join(); + + const parseClasses = parseClassesArray.reduce((acc, clazz) => { + acc[clazz.className] = clazz; + return acc; + }, {}); + if ( + !this._hasSchemaInputChanged({ + parseClasses, + parseGraphQLConfig, + functionNamesString, + }) + ) { + return this.graphQLSchema; + } + + this.parseClasses = parseClasses; + this.parseGraphQLConfig = parseGraphQLConfig; + this.functionNames = functionNames; + this.functionNamesString = functionNamesString; + this.parseClassTypes = {}; + this.viewerType = null; + this.cloudConfigType = null; + this.graphQLAutoSchema = null; + this.graphQLSchema = null; + this.graphQLTypes = []; + this.graphQLQueries = {}; + this.graphQLMutations = {}; + this.graphQLSubscriptions = {}; + this.graphQLSchemaDirectivesDefinitions = null; + this.graphQLSchemaDirectives = {}; + this.relayNodeInterface = null; + + defaultGraphQLTypes.load(this); + defaultRelaySchema.load(this); + schemaTypes.load(this); + + this._getParseClassesWithConfig(parseClassesArray, parseGraphQLConfig).forEach( + ([parseClass, parseClassConfig]) => { + // Some times schema return the _auth_data_ field + // it will lead to unstable graphql generation order + if (parseClass.className === '_User') { + Object.keys(parseClass.fields).forEach(fieldName => { + if (fieldName.startsWith('_auth_data_')) { + delete parseClass.fields[fieldName]; + } + }); + } + + // Fields order inside the schema seems to not be consistent across + // restart so we need to ensure an alphabetical order + // also it's better for the playground documentation + const orderedFields = {}; + Object.keys(parseClass.fields) + .sort() + .forEach(fieldName => { + orderedFields[fieldName] = parseClass.fields[fieldName]; + }); + parseClass.fields = orderedFields; + parseClassTypes.load(this, parseClass, parseClassConfig); + parseClassQueries.load(this, parseClass, parseClassConfig); + parseClassMutations.load(this, parseClass, parseClassConfig); + } + ); + + defaultGraphQLTypes.loadArrayResult(this, parseClassesArray); + defaultGraphQLQueries.load(this); + defaultGraphQLMutations.load(this); + + let graphQLQuery = undefined; + if (Object.keys(this.graphQLQueries).length > 0) { + graphQLQuery = new GraphQLObjectType({ + name: 'Query', + description: 'Query is the top level type for queries.', + fields: this.graphQLQueries, + }); + this.addGraphQLType(graphQLQuery, true, true); + } + + let graphQLMutation = undefined; + if (Object.keys(this.graphQLMutations).length > 0) { + graphQLMutation = new GraphQLObjectType({ + name: 'Mutation', + description: 'Mutation is the top level type for mutations.', + fields: this.graphQLMutations, + }); + this.addGraphQLType(graphQLMutation, true, true); + } + + let graphQLSubscription = undefined; + if (Object.keys(this.graphQLSubscriptions).length > 0) { + graphQLSubscription = new GraphQLObjectType({ + name: 'Subscription', + description: 'Subscription is the top level type for subscriptions.', + fields: this.graphQLSubscriptions, + }); + this.addGraphQLType(graphQLSubscription, true, true); + } + + this.graphQLAutoSchema = new GraphQLSchema({ + types: this.graphQLTypes, + query: graphQLQuery, + mutation: graphQLMutation, + subscription: graphQLSubscription, + }); + + if (this.graphQLCustomTypeDefs) { + schemaDirectives.load(this); + if (typeof this.graphQLCustomTypeDefs.getTypeMap === 'function') { + // In following code we use underscore attr to keep the direct variable reference + const customGraphQLSchemaTypeMap = this.graphQLCustomTypeDefs._typeMap; + const findAndReplaceLastType = (parent, key) => { + if (parent[key].name) { + if ( + this.graphQLAutoSchema._typeMap[parent[key].name] && + this.graphQLAutoSchema._typeMap[parent[key].name] !== parent[key] + ) { + // To avoid unresolved field on overloaded schema + // replace the final type with the auto schema one + parent[key] = this.graphQLAutoSchema._typeMap[parent[key].name]; + } + } else { + if (parent[key].ofType) { + findAndReplaceLastType(parent[key], 'ofType'); + } + } + }; + // Add non shared types from custom schema to auto schema + // note: some non shared types can use some shared types + // so this code need to be ran before the shared types addition + // we use sort to ensure schema consistency over restarts + Object.keys(customGraphQLSchemaTypeMap) + .sort() + .forEach(customGraphQLSchemaTypeKey => { + const customGraphQLSchemaType = customGraphQLSchemaTypeMap[customGraphQLSchemaTypeKey]; + if ( + !customGraphQLSchemaType || + !customGraphQLSchemaType.name || + customGraphQLSchemaType.name.startsWith('__') + ) { + return; + } + const autoGraphQLSchemaType = this.graphQLAutoSchema._typeMap[ + customGraphQLSchemaType.name + ]; + if (!autoGraphQLSchemaType) { + this.graphQLAutoSchema._typeMap[ + customGraphQLSchemaType.name + ] = customGraphQLSchemaType; + } + }); + // Handle shared types + // We pass through each type and ensure that all sub field types are replaced + // we use sort to ensure schema consistency over restarts + Object.keys(customGraphQLSchemaTypeMap) + .sort() + .forEach(customGraphQLSchemaTypeKey => { + const customGraphQLSchemaType = customGraphQLSchemaTypeMap[customGraphQLSchemaTypeKey]; + if ( + !customGraphQLSchemaType || + !customGraphQLSchemaType.name || + customGraphQLSchemaType.name.startsWith('__') + ) { + return; + } + const autoGraphQLSchemaType = this.graphQLAutoSchema._typeMap[ + customGraphQLSchemaType.name + ]; + + if (autoGraphQLSchemaType && typeof customGraphQLSchemaType.getFields === 'function') { + Object.keys(customGraphQLSchemaType._fields) + .sort() + .forEach(fieldKey => { + const field = customGraphQLSchemaType._fields[fieldKey]; + findAndReplaceLastType(field, 'type'); + autoGraphQLSchemaType._fields[field.name] = field; + }); + } + }); + this.graphQLSchema = this.graphQLAutoSchema; + } else if (typeof this.graphQLCustomTypeDefs === 'function') { + this.graphQLSchema = await this.graphQLCustomTypeDefs({ + directivesDefinitionsSchema: this.graphQLSchemaDirectivesDefinitions, + autoSchema: this.graphQLAutoSchema, + graphQLSchemaDirectives: this.graphQLSchemaDirectives, + }); + } else { + this.graphQLSchema = mergeSchemas({ + schemas: [this.graphQLAutoSchema], + typeDefs: mergeTypeDefs([ + this.graphQLCustomTypeDefs, + this.graphQLSchemaDirectivesDefinitions, + ]), + }); + this.graphQLSchema = this.graphQLSchemaDirectives(this.graphQLSchema); + } + } else { + this.graphQLSchema = this.graphQLAutoSchema; + } + + return this.graphQLSchema; + } + + _logOnce(severity, message) { + if (this.logCache[message]) { + return; + } + this.log[severity](message); + this.logCache[message] = true; + } + + addGraphQLType(type, throwError = false, ignoreReserved = false, ignoreConnection = false) { + if ( + (!ignoreReserved && RESERVED_GRAPHQL_TYPE_NAMES.includes(type.name)) || + this.graphQLTypes.find(existingType => existingType.name === type.name) || + (!ignoreConnection && type.name.endsWith('Connection')) + ) { + const message = `Type ${type.name} could not be added to the auto schema because it collided with an existing type.`; + if (throwError) { + throw new Error(message); + } + this._logOnce('warn', message); + return undefined; + } + this.graphQLTypes.push(type); + return type; + } + + addGraphQLQuery(fieldName, field, throwError = false, ignoreReserved = false) { + if ( + (!ignoreReserved && RESERVED_GRAPHQL_QUERY_NAMES.includes(fieldName)) || + this.graphQLQueries[fieldName] + ) { + const message = `Query ${fieldName} could not be added to the auto schema because it collided with an existing field.`; + if (throwError) { + throw new Error(message); + } + this._logOnce('warn', message); + return undefined; + } + this.graphQLQueries[fieldName] = field; + return field; + } + + addGraphQLMutation(fieldName, field, throwError = false, ignoreReserved = false) { + if ( + (!ignoreReserved && RESERVED_GRAPHQL_MUTATION_NAMES.includes(fieldName)) || + this.graphQLMutations[fieldName] + ) { + const message = `Mutation ${fieldName} could not be added to the auto schema because it collided with an existing field.`; + if (throwError) { + throw new Error(message); + } + this._logOnce('warn', message); + return undefined; + } + this.graphQLMutations[fieldName] = field; + return field; + } + + handleError(error) { + if (error instanceof Parse.Error) { + this.log.error('Parse error: ', error); + } else { + this.log.error('Uncaught internal server error.', error, error.stack); + } + throw toGraphQLError(error); + } + + async _initializeSchemaAndConfig() { + const [schemaController, parseGraphQLConfig] = await Promise.all([ + this.databaseController.loadSchema(), + this.parseGraphQLController.getGraphQLConfig(), + ]); + + this.schemaController = schemaController; + + return { + parseGraphQLConfig, + }; + } + + /** + * Gets all classes found by the `schemaController` + * minus those filtered out by the app's parseGraphQLConfig. + */ + async _getClassesForSchema(parseGraphQLConfig: ParseGraphQLConfig) { + const { enabledForClasses, disabledForClasses } = parseGraphQLConfig; + const allClasses = await this.schemaController.getAllClasses(); + + if (Array.isArray(enabledForClasses) || Array.isArray(disabledForClasses)) { + let includedClasses = allClasses; + if (enabledForClasses) { + includedClasses = allClasses.filter(clazz => { + return enabledForClasses.includes(clazz.className); + }); + } + if (disabledForClasses) { + // Classes included in `enabledForClasses` that + // are also present in `disabledForClasses` will + // still be filtered out + includedClasses = includedClasses.filter(clazz => { + return !disabledForClasses.includes(clazz.className); + }); + } + + this.isUsersClassDisabled = !includedClasses.some(clazz => { + return clazz.className === '_User'; + }); + + return includedClasses; + } else { + return allClasses; + } + } + + /** + * This method returns a list of tuples + * that provide the parseClass along with + * its parseClassConfig where provided. + */ + _getParseClassesWithConfig(parseClasses, parseGraphQLConfig: ParseGraphQLConfig) { + const { classConfigs } = parseGraphQLConfig; + + // Make sures that the default classes and classes that + // starts with capitalized letter will be generated first. + const sortClasses = (a, b) => { + a = a.className; + b = b.className; + if (a[0] === '_') { + if (b[0] !== '_') { + return -1; + } + } + if (b[0] === '_') { + if (a[0] !== '_') { + return 1; + } + } + if (a === b) { + return 0; + } else if (a < b) { + return -1; + } else { + return 1; + } + }; + + return parseClasses.sort(sortClasses).map(parseClass => { + let parseClassConfig; + if (classConfigs) { + parseClassConfig = classConfigs.find(c => c.className === parseClass.className); + } + return [parseClass, parseClassConfig]; + }); + } + + async _getFunctionNames() { + return await getFunctionNames(this.appId).filter(functionName => { + if (/^[_a-zA-Z][_a-zA-Z0-9]*$/.test(functionName)) { + return true; + } else { + this._logOnce( + 'warn', + `Function ${functionName} could not be added to the auto schema because GraphQL names must match /^[_a-zA-Z][_a-zA-Z0-9]*$/.` + ); + return false; + } + }); + } + + /** + * Checks for changes to the parseClasses + * objects (i.e. database schema) or to + * the parseGraphQLConfig object. If no + * changes are found, return true; + */ + _hasSchemaInputChanged(params: { + parseClasses: any, + parseGraphQLConfig: ?ParseGraphQLConfig, + functionNamesString: string, + }): boolean { + const { parseClasses, parseGraphQLConfig, functionNamesString } = params; + + // First init + if (!this.graphQLSchema) { + return true; + } + + if ( + isDeepStrictEqual(this.parseGraphQLConfig, parseGraphQLConfig) && + this.functionNamesString === functionNamesString && + isDeepStrictEqual(this.parseClasses, parseClasses) + ) { + return false; + } + return true; + } +} + +export { ParseGraphQLSchema }; diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js new file mode 100644 index 0000000000..0b2c17d232 --- /dev/null +++ b/src/GraphQL/ParseGraphQLServer.js @@ -0,0 +1,266 @@ +import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.js'; +import { ApolloServer } from '@apollo/server'; +import { expressMiddleware } from '@as-integrations/express5'; +import { ApolloServerPluginCacheControlDisabled } from '@apollo/server/plugin/disabled'; +import express from 'express'; +import { GraphQLError, parse } from 'graphql'; +import { allowCrossDomain, handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares'; +import requiredParameter from '../requiredParameter'; +import defaultLogger from '../logger'; +import { ParseGraphQLSchema } from './ParseGraphQLSchema'; +import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController'; +import { createComplexityValidationPlugin } from './helpers/queryComplexity'; + + +const hasTypeIntrospection = (query) => { + try { + const ast = parse(query); + const checkSelections = (selections) => { + for (const selection of selections) { + if (selection.kind === 'Field' && selection.name.value === '__type') { + if (selection.arguments && selection.arguments.length > 0) { + return true; + } + } + if (selection.selectionSet) { + if (checkSelections(selection.selectionSet.selections)) { + return true; + } + } + } + return false; + }; + for (const definition of ast.definitions) { + if (definition.selectionSet) { + if (checkSelections(definition.selectionSet.selections)) { + return true; + } + } + } + return false; + } catch { + return false; + } +}; + +const throwIntrospectionError = () => { + throw new GraphQLError('Introspection is not allowed', { + extensions: { + http: { + status: 403, + }, + } + }); +}; + +const IntrospectionControlPlugin = (publicIntrospection) => ({ + + + requestDidStart: (requestContext) => ({ + + didResolveOperation: async () => { + // If public introspection is enabled, we allow all introspection queries + if (publicIntrospection) { + return; + } + + const isMasterOrMaintenance = requestContext.contextValue.auth?.isMaster || requestContext.contextValue.auth?.isMaintenance + if (isMasterOrMaintenance) { + return; + } + + const query = requestContext.request.query; + + + // Fast path: simple string check for __schema + // This avoids parsing the query in most cases + if (query?.includes('__schema')) { + return throwIntrospectionError(); + } + + // Smart check for __type: only parse if the string is present + // This avoids false positives (e.g., "__type" in strings or comments) + // while still being efficient for the common case + if (query?.includes('__type') && hasTypeIntrospection(query)) { + return throwIntrospectionError(); + } + }, + + }) + +}); + +class ParseGraphQLServer { + parseGraphQLController: ParseGraphQLController; + + constructor(parseServer, config) { + this.parseServer = parseServer || requiredParameter('You must provide a parseServer instance!'); + if (!config || !config.graphQLPath) { + requiredParameter('You must provide a config.graphQLPath!'); + } + this.config = config; + this.parseGraphQLController = this.parseServer.config.parseGraphQLController; + this.log = + (this.parseServer.config && this.parseServer.config.loggerController) || defaultLogger; + this.parseGraphQLSchema = new ParseGraphQLSchema({ + parseGraphQLController: this.parseGraphQLController, + databaseController: this.parseServer.config.databaseController, + log: this.log, + graphQLCustomTypeDefs: this.config.graphQLCustomTypeDefs, + appId: this.parseServer.config.appId, + }); + } + + async _getGraphQLOptions() { + try { + return { + schema: await this.parseGraphQLSchema.load(), + context: async ({ req }) => { + return { + info: req.info, + config: req.config, + auth: req.auth, + }; + }, + }; + } catch (e) { + this.log.error(e.stack || (typeof e.toString === 'function' && e.toString()) || e); + throw e; + } + } + + async _getServer() { + const schemaRef = this.parseGraphQLSchema.graphQLSchema; + const newSchemaRef = await this.parseGraphQLSchema.load(); + if (schemaRef === newSchemaRef && this._server) { + return this._server; + } + // It means a parallel _getServer call is already in progress + if (this._schemaRefMutex === newSchemaRef) { + return this._server; + } + // Update the schema ref mutex to avoid parallel _getServer calls + this._schemaRefMutex = newSchemaRef; + const createServer = async () => { + try { + const { schema, context } = await this._getGraphQLOptions(); + const apollo = new ApolloServer({ + csrfPrevention: { + // See https://www.apollographql.com/docs/router/configuration/csrf/ + // needed since we use graphql upload + requestHeaders: ['X-Parse-Application-Id'], + }, + // We need always true introspection because apollo server have changing behavior based on the NODE_ENV variable + // we delegate the introspection control to the IntrospectionControlPlugin + introspection: true, + plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection), createComplexityValidationPlugin(() => this.parseServer.config.requestComplexity)], + schema, + }); + await apollo.start(); + return expressMiddleware(apollo, { + context, + }); + } catch (e) { + // Reset all mutexes and forward the error + this._server = null; + this._schemaRefMutex = null; + throw e; + } + } + // Do not await so parallel request will wait the same promise ref + this._server = createServer(); + return this._server; + } + + _transformMaxUploadSizeToBytes(maxUploadSize) { + const unitMap = { + kb: 1, + mb: 2, + gb: 3, + }; + + return ( + Number(maxUploadSize.slice(0, -2)) * + Math.pow(1024, unitMap[maxUploadSize.slice(-2).toLowerCase()]) + ); + } + + /** + * @static + * Allow developers to customize each request with inversion of control/dependency injection + */ + applyRequestContextMiddleware(api, options) { + if (options.requestContextMiddleware) { + if (typeof options.requestContextMiddleware !== 'function') { + throw new Error('requestContextMiddleware must be a function'); + } + api.use(this.config.graphQLPath, options.requestContextMiddleware); + } + } + + applyGraphQL(app) { + if (!app || !app.use) { + requiredParameter('You must provide an Express.js app instance!'); + } + app.use(this.config.graphQLPath, allowCrossDomain(this.parseServer.config.appId)); + app.use(this.config.graphQLPath, handleParseHeaders); + app.use(this.config.graphQLPath, handleParseSession); + this.applyRequestContextMiddleware(app, this.parseServer.config); + app.use(this.config.graphQLPath, handleParseErrors); + app.use( + this.config.graphQLPath, + graphqlUploadExpress({ + maxFileSize: this._transformMaxUploadSizeToBytes( + this.parseServer.config.maxUploadSize || '20mb' + ), + }) + ); + app.use(this.config.graphQLPath, express.json(), async (req, res, next) => { + const server = await this._getServer(); + return server(req, res, next); + }); + } + + applyPlayground(app) { + if (!app || !app.get) { + requiredParameter('You must provide an Express.js app instance!'); + } + + app.get( + this.config.playgroundPath || + requiredParameter('You must provide a config.playgroundPath to applyPlayground!'), + (_req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.write( + `
+ + ` + ); + res.end(); + } + ); + } + + setGraphQLConfig(graphQLConfig: ParseGraphQLConfig): Promise { + return this.parseGraphQLController.updateGraphQLConfig(graphQLConfig); + } +} + +export { ParseGraphQLServer }; diff --git a/src/GraphQL/helpers/objectsMutations.js b/src/GraphQL/helpers/objectsMutations.js new file mode 100644 index 0000000000..72fb84bc86 --- /dev/null +++ b/src/GraphQL/helpers/objectsMutations.js @@ -0,0 +1,27 @@ +import rest from '../../rest'; + +const createObject = async (className, fields, config, auth, info) => { + if (!fields) { + fields = {}; + } + + return (await rest.create(config, auth, className, fields, info.clientSDK, info.context)) + .response; +}; + +const updateObject = async (className, objectId, fields, config, auth, info) => { + if (!fields) { + fields = {}; + } + + return ( + await rest.update(config, auth, className, { objectId }, fields, info.clientSDK, info.context) + ).response; +}; + +const deleteObject = async (className, objectId, config, auth, info) => { + await rest.del(config, auth, className, objectId, info.context); + return true; +}; + +export { createObject, updateObject, deleteObject }; diff --git a/src/GraphQL/helpers/objectsQueries.js b/src/GraphQL/helpers/objectsQueries.js new file mode 100644 index 0000000000..1aae0e7f9c --- /dev/null +++ b/src/GraphQL/helpers/objectsQueries.js @@ -0,0 +1,318 @@ +import Parse from 'parse/node'; +import { offsetToCursor, cursorToOffset } from 'graphql-relay'; +import rest from '../../rest'; +import { transformQueryInputToParse } from '../transformers/query'; + +// Eslint/Prettier conflict +/* eslint-disable*/ +const needToGetAllKeys = (fields, keys, parseClasses) => + keys + ? keys.split(',').some(keyName => { + const key = keyName.split('.'); + if (fields[key[0]]) { + if (fields[key[0]].type === 'Relation') return false; + if (fields[key[0]].type === 'Pointer') { + const subClass = parseClasses[fields[key[0]].targetClass]; + if (subClass && subClass.fields[key[1]]) { + // Current sub key is not custom + return false; + } + } else if ( + !key[1] || + fields[key[0]].type === 'Array' || + fields[key[0]].type === 'Object' + ) { + // current key is not custom + return false; + } + } + // Key not found into Parse Schema so it's custom + return true; + }) + : true; +/* eslint-enable*/ + +const getObject = async ( + className, + objectId, + keys, + include, + readPreference, + includeReadPreference, + config, + auth, + info, + parseClasses +) => { + const options = {}; + try { + if (!needToGetAllKeys(parseClasses[className].fields, keys, parseClasses)) { + options.keys = keys; + } + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } + if (include) { + options.include = include; + if (includeReadPreference) { + options.includeReadPreference = includeReadPreference; + } + } + if (readPreference) { + options.readPreference = readPreference; + } + + const response = await rest.get( + config, + auth, + className, + objectId, + options, + info.clientSDK, + info.context + ); + + if (!response.results || response.results.length == 0) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); + } + + const object = response.results[0]; + if (className === '_User') { + delete object.sessionToken; + } + return object; +}; + +const findObjects = async ( + className, + where, + order, + skipInput, + first, + after, + last, + before, + keys, + include, + includeAll, + readPreference, + includeReadPreference, + subqueryReadPreference, + config, + auth, + info, + selectedFields, + parseClasses +) => { + if (!where) { + where = {}; + } + transformQueryInputToParse(where, className, parseClasses); + const skipAndLimitCalculation = calculateSkipAndLimit( + skipInput, + first, + after, + last, + before, + config.maxLimit + ); + let { skip } = skipAndLimitCalculation; + const { limit, needToPreCount } = skipAndLimitCalculation; + let preCount = undefined; + if (needToPreCount) { + const preCountOptions = { + limit: 0, + count: true, + }; + if (readPreference) { + preCountOptions.readPreference = readPreference; + } + if (Object.keys(where).length > 0 && subqueryReadPreference) { + preCountOptions.subqueryReadPreference = subqueryReadPreference; + } + preCount = ( + await rest.find(config, auth, className, where, preCountOptions, info.clientSDK, info.context) + ).count; + if ((skip || 0) + limit < preCount) { + skip = preCount - limit; + } + } + + const options = {}; + + if (selectedFields.find(field => field.startsWith('edges.') || field.startsWith('pageInfo.'))) { + if (limit || limit === 0) { + options.limit = limit; + } else { + options.limit = 100; + } + if (options.limit !== 0) { + if (order) { + options.order = order; + } + if (skip) { + options.skip = skip; + } + if (config.maxLimit && options.limit > config.maxLimit) { + // Silently replace the limit on the query with the max configured + options.limit = config.maxLimit; + } + if (!needToGetAllKeys(parseClasses[className].fields, keys, parseClasses)) { + options.keys = keys; + } + if (includeAll === true) { + options.includeAll = includeAll; + } + if (!options.includeAll && include) { + options.include = include; + } + if ((options.includeAll || options.include) && includeReadPreference) { + options.includeReadPreference = includeReadPreference; + } + } + } else { + options.limit = 0; + } + + if ( + (selectedFields.includes('count') || + selectedFields.includes('pageInfo.hasPreviousPage') || + selectedFields.includes('pageInfo.hasNextPage')) && + !needToPreCount + ) { + options.count = true; + } + + if (readPreference) { + options.readPreference = readPreference; + } + if (Object.keys(where).length > 0 && subqueryReadPreference) { + options.subqueryReadPreference = subqueryReadPreference; + } + + let results, count; + if (options.count || !options.limit || (options.limit && options.limit > 0)) { + const findResult = await rest.find( + config, + auth, + className, + where, + options, + info.clientSDK, + info.context + ); + results = findResult.results; + count = findResult.count; + } + + let edges = null; + let pageInfo = null; + if (results) { + edges = results.map((result, index) => ({ + cursor: offsetToCursor((skip || 0) + index), + node: result, + })); + + pageInfo = { + hasPreviousPage: + ((preCount && preCount > 0) || (count && count > 0)) && skip !== undefined && skip > 0, + startCursor: offsetToCursor(skip || 0), + endCursor: offsetToCursor((skip || 0) + (results.length || 1) - 1), + hasNextPage: (preCount || count) > (skip || 0) + results.length, + }; + } + + return { + edges, + pageInfo, + count: preCount || count, + }; +}; + +const calculateSkipAndLimit = (skipInput, first, after, last, before, maxLimit) => { + let skip = undefined; + let limit = undefined; + let needToPreCount = false; + + // Validates the skip input + if (skipInput || skipInput === 0) { + if (skipInput < 0) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Skip should be a positive number'); + } + skip = skipInput; + } + + // Validates the after param + if (after) { + after = cursorToOffset(after); + if ((!after && after !== 0) || after < 0) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, 'After is not a valid cursor'); + } + + // If skip and after are passed, a new skip is calculated by adding them + skip = (skip || 0) + (after + 1); + } + + // Validates the first param + if (first || first === 0) { + if (first < 0) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, 'First should be a positive number'); + } + + // The first param is translated to the limit param of the Parse legacy API + limit = first; + } + + // Validates the before param + if (before || before === 0) { + // This method converts the cursor to the index of the object + before = cursorToOffset(before); + if ((!before && before !== 0) || before < 0) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Before is not a valid cursor'); + } + + if ((skip || 0) >= before) { + // If the before index is less than the skip, no objects will be returned + limit = 0; + } else if ((!limit && limit !== 0) || (skip || 0) + limit > before) { + // If there is no limit set, the limit is calculated. Or, if the limit (plus skip) is bigger than the before index, the new limit is set. + limit = before - (skip || 0); + } + } + + // Validates the last param + if (last || last === 0) { + if (last < 0) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Last should be a positive number'); + } + + if (last > maxLimit) { + // Last can't be bigger than Parse server maxLimit config. + last = maxLimit; + } + + if (limit || limit === 0) { + // If there is a previous limit set, it may be adjusted + if (last < limit) { + // if last is less than the current limit + skip = (skip || 0) + (limit - last); // The skip is adjusted + limit = last; // the limit is adjusted + } + } else if (last === 0) { + // No objects will be returned + limit = 0; + } else { + // No previous limit set, the limit will be equal to last and pre count is needed. + limit = last; + needToPreCount = true; + } + } + return { + skip, + limit, + needToPreCount, + }; +}; + +export { getObject, findObjects, calculateSkipAndLimit, needToGetAllKeys }; diff --git a/src/GraphQL/helpers/queryComplexity.js b/src/GraphQL/helpers/queryComplexity.js new file mode 100644 index 0000000000..cd20424b8e --- /dev/null +++ b/src/GraphQL/helpers/queryComplexity.js @@ -0,0 +1,130 @@ +import { GraphQLError } from 'graphql'; +import logger from '../../logger'; + +function calculateQueryComplexity(operation, fragments, limits = {}) { + let maxDepth = 0; + let totalFields = 0; + const fragmentCache = new Map(); + const { maxDepth: allowedMaxDepth, maxFields: allowedMaxFields } = limits; + + function visitSelectionSet(selectionSet, depth, visitedFragments) { + if (!selectionSet) { + return; + } + if ( + (allowedMaxFields !== undefined && allowedMaxFields !== -1 && totalFields > allowedMaxFields) || + (allowedMaxDepth !== undefined && allowedMaxDepth !== -1 && maxDepth > allowedMaxDepth) + ) { + return; + } + for (const selection of selectionSet.selections) { + if (selection.kind === 'Field') { + totalFields++; + const newDepth = depth + 1; + if (newDepth > maxDepth) { + maxDepth = newDepth; + } + if (selection.selectionSet) { + visitSelectionSet(selection.selectionSet, newDepth, visitedFragments); + } + } else if (selection.kind === 'InlineFragment') { + visitSelectionSet(selection.selectionSet, depth, visitedFragments); + } else if (selection.kind === 'FragmentSpread') { + const name = selection.name.value; + if (fragmentCache.has(name)) { + const cached = fragmentCache.get(name); + totalFields += cached.fields; + const adjustedDepth = depth + cached.maxDepthDelta; + if (adjustedDepth > maxDepth) { + maxDepth = adjustedDepth; + } + continue; + } + if (visitedFragments.has(name)) { + continue; + } + const fragment = fragments[name]; + if (fragment) { + if ( + (allowedMaxFields !== undefined && allowedMaxFields !== -1 && totalFields > allowedMaxFields) || + (allowedMaxDepth !== undefined && allowedMaxDepth !== -1 && maxDepth > allowedMaxDepth) + ) { + continue; + } + visitedFragments.add(name); + const savedFields = totalFields; + const savedMaxDepth = maxDepth; + maxDepth = depth; + visitSelectionSet(fragment.selectionSet, depth, visitedFragments); + const fieldsContribution = totalFields - savedFields; + const maxDepthDelta = maxDepth - depth; + fragmentCache.set(name, { fields: fieldsContribution, maxDepthDelta }); + maxDepth = Math.max(savedMaxDepth, maxDepth); + visitedFragments.delete(name); + } + } + } + } + + visitSelectionSet(operation.selectionSet, 0, new Set()); + + return { depth: maxDepth, fields: totalFields }; +} + +function createComplexityValidationPlugin(getConfig) { + return { + requestDidStart: (requestContext) => ({ + didResolveOperation: async () => { + const auth = requestContext.contextValue?.auth; + if (auth?.isMaster || auth?.isMaintenance) { + return; + } + + const config = getConfig(); + if (!config) { + return; + } + + const { graphQLDepth, graphQLFields } = config; + if (graphQLDepth === -1 && graphQLFields === -1) { + return; + } + + const fragments = {}; + for (const definition of requestContext.document.definitions) { + if (definition.kind === 'FragmentDefinition') { + fragments[definition.name.value] = definition; + } + } + + const { depth, fields } = calculateQueryComplexity( + requestContext.operation, + fragments, + { maxDepth: graphQLDepth, maxFields: graphQLFields } + ); + + if (graphQLDepth !== -1 && depth > graphQLDepth) { + const message = `GraphQL query depth of ${depth} exceeds maximum allowed depth of ${graphQLDepth}`; + logger.warn(message); + throw new GraphQLError(message, { + extensions: { + http: { status: 400 }, + }, + }); + } + + if (graphQLFields !== -1 && fields > graphQLFields) { + const message = `Number of GraphQL fields (${fields}) exceeds maximum allowed (${graphQLFields})`; + logger.warn(message); + throw new GraphQLError(message, { + extensions: { + http: { status: 400 }, + }, + }); + } + }, + }), + }; +} + +export { calculateQueryComplexity, createComplexityValidationPlugin }; diff --git a/src/GraphQL/loaders/configMutations.js b/src/GraphQL/loaders/configMutations.js new file mode 100644 index 0000000000..c7c6176d68 --- /dev/null +++ b/src/GraphQL/loaders/configMutations.js @@ -0,0 +1,76 @@ +import { GraphQLNonNull, GraphQLString, GraphQLBoolean } from 'graphql'; +import { mutationWithClientMutationId } from 'graphql-relay'; +import Parse from 'parse/node'; +import { createSanitizedError } from '../../Error'; +import GlobalConfigRouter from '../../Routers/GlobalConfigRouter'; + +const globalConfigRouter = new GlobalConfigRouter(); + +const updateCloudConfig = async (context, paramName, value, isMasterKeyOnly = false) => { + const { config, auth } = context; + + if (!auth.isMaster) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + 'Master Key is required to update GlobalConfig.' + ); + } + + await globalConfigRouter.updateGlobalConfig({ + body: { + params: { [paramName]: value }, + masterKeyOnly: { [paramName]: isMasterKeyOnly }, + }, + config, + auth, + context, + }); + + return { value, isMasterKeyOnly }; +}; + +const load = parseGraphQLSchema => { + const updateCloudConfigMutation = mutationWithClientMutationId({ + name: 'UpdateCloudConfig', + description: 'Updates the value of a specific parameter in GlobalConfig.', + inputFields: { + paramName: { + description: 'The name of the parameter to set.', + type: new GraphQLNonNull(GraphQLString), + }, + value: { + description: 'The value to set for the parameter.', + type: new GraphQLNonNull(GraphQLString), + }, + isMasterKeyOnly: { + description: 'Whether this parameter should only be accessible with master key.', + type: GraphQLBoolean, + defaultValue: false, + }, + }, + outputFields: { + cloudConfig: { + description: 'The updated config value.', + type: new GraphQLNonNull(parseGraphQLSchema.cloudConfigType), + }, + }, + mutateAndGetPayload: async (args, context) => { + try { + const { paramName, value, isMasterKeyOnly } = args; + const result = await updateCloudConfig(context, paramName, value, isMasterKeyOnly); + return { + cloudConfig: result, + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType(updateCloudConfigMutation.args.input.type.ofType, true, true); + parseGraphQLSchema.addGraphQLType(updateCloudConfigMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation('updateCloudConfig', updateCloudConfigMutation, true, true); +}; + +export { load, updateCloudConfig }; + diff --git a/src/GraphQL/loaders/configQueries.js b/src/GraphQL/loaders/configQueries.js new file mode 100644 index 0000000000..0320d68395 --- /dev/null +++ b/src/GraphQL/loaders/configQueries.js @@ -0,0 +1,61 @@ +import { GraphQLNonNull, GraphQLString, GraphQLBoolean, GraphQLObjectType } from 'graphql'; +import Parse from 'parse/node'; +import { createSanitizedError } from '../../Error'; + +const cloudConfig = async (context, paramName) => { + const { config, auth } = context; + + if (!auth.isMaster) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + 'Master Key is required to access GlobalConfig.' + ); + } + + const results = await config.database.find('_GlobalConfig', { objectId: '1' }, { limit: 1 }); + + if (results.length !== 1) { + return { value: null, isMasterKeyOnly: null }; + } + + const globalConfig = results[0]; + const params = globalConfig.params || {}; + const masterKeyOnly = globalConfig.masterKeyOnly || {}; + + if (params[paramName] !== undefined) { + return { value: params[paramName], isMasterKeyOnly: masterKeyOnly[paramName] ?? null }; + } + + return { value: null, isMasterKeyOnly: null }; +}; + +const load = (parseGraphQLSchema) => { + if (!parseGraphQLSchema.cloudConfigType) { + const cloudConfigType = new GraphQLObjectType({ + name: 'ConfigValue', + fields: { + value: { type: GraphQLString }, + isMasterKeyOnly: { type: GraphQLBoolean }, + }, + }); + parseGraphQLSchema.addGraphQLType(cloudConfigType, true, true); + parseGraphQLSchema.cloudConfigType = cloudConfigType; + } + + parseGraphQLSchema.addGraphQLQuery('cloudConfig', { + description: 'Returns the value of a specific parameter from GlobalConfig.', + args: { + paramName: { type: new GraphQLNonNull(GraphQLString) }, + }, + type: new GraphQLNonNull(parseGraphQLSchema.cloudConfigType), + async resolve(_source, args, context) { + try { + return await cloudConfig(context, args.paramName); + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }, false, true); +}; + +export { load, cloudConfig }; diff --git a/src/GraphQL/loaders/defaultGraphQLMutations.js b/src/GraphQL/loaders/defaultGraphQLMutations.js new file mode 100644 index 0000000000..46f513fb8e --- /dev/null +++ b/src/GraphQL/loaders/defaultGraphQLMutations.js @@ -0,0 +1,15 @@ +import * as filesMutations from './filesMutations'; +import * as usersMutations from './usersMutations'; +import * as functionsMutations from './functionsMutations'; +import * as schemaMutations from './schemaMutations'; +import * as configMutations from './configMutations'; + +const load = parseGraphQLSchema => { + filesMutations.load(parseGraphQLSchema); + usersMutations.load(parseGraphQLSchema); + functionsMutations.load(parseGraphQLSchema); + schemaMutations.load(parseGraphQLSchema); + configMutations.load(parseGraphQLSchema); +}; + +export { load }; diff --git a/src/GraphQL/loaders/defaultGraphQLQueries.js b/src/GraphQL/loaders/defaultGraphQLQueries.js new file mode 100644 index 0000000000..e98e02147b --- /dev/null +++ b/src/GraphQL/loaders/defaultGraphQLQueries.js @@ -0,0 +1,23 @@ +import { GraphQLNonNull, GraphQLBoolean } from 'graphql'; +import * as usersQueries from './usersQueries'; +import * as schemaQueries from './schemaQueries'; +import * as configQueries from './configQueries'; + +const load = parseGraphQLSchema => { + parseGraphQLSchema.addGraphQLQuery( + 'health', + { + description: 'The health query can be used to check if the server is up and running.', + type: new GraphQLNonNull(GraphQLBoolean), + resolve: () => true, + }, + true, + true + ); + + usersQueries.load(parseGraphQLSchema); + schemaQueries.load(parseGraphQLSchema); + configQueries.load(parseGraphQLSchema); +}; + +export { load }; diff --git a/src/GraphQL/loaders/defaultGraphQLTypes.js b/src/GraphQL/loaders/defaultGraphQLTypes.js new file mode 100644 index 0000000000..d24047329c --- /dev/null +++ b/src/GraphQL/loaders/defaultGraphQLTypes.js @@ -0,0 +1,1361 @@ +import { + Kind, + GraphQLNonNull, + GraphQLScalarType, + GraphQLID, + GraphQLString, + GraphQLObjectType, + GraphQLInterfaceType, + GraphQLEnumType, + GraphQLInt, + GraphQLFloat, + GraphQLList, + GraphQLInputObjectType, + GraphQLBoolean, + GraphQLUnionType, +} from 'graphql'; +import { toGlobalId } from 'graphql-relay'; +import GraphQLUpload from 'graphql-upload/GraphQLUpload.js'; +import Utils from '../../Utils'; + +class TypeValidationError extends Error { + constructor(value, type) { + super(`${value} is not a valid ${type}`); + } +} + +const parseStringValue = value => { + if (typeof value === 'string') { + return value; + } + + throw new TypeValidationError(value, 'String'); +}; + +const parseIntValue = value => { + if (typeof value === 'string') { + const int = Number(value); + if (Number.isInteger(int)) { + return int; + } + } + + throw new TypeValidationError(value, 'Int'); +}; + +const parseFloatValue = value => { + if (typeof value === 'string') { + const float = Number(value); + if (!isNaN(float)) { + return float; + } + } + + throw new TypeValidationError(value, 'Float'); +}; + +const parseBooleanValue = value => { + if (typeof value === 'boolean') { + return value; + } + + throw new TypeValidationError(value, 'Boolean'); +}; + +const parseValue = value => { + switch (value.kind) { + case Kind.STRING: + return parseStringValue(value.value); + + case Kind.INT: + return parseIntValue(value.value); + + case Kind.FLOAT: + return parseFloatValue(value.value); + + case Kind.BOOLEAN: + return parseBooleanValue(value.value); + + case Kind.LIST: + return parseListValues(value.values); + + case Kind.OBJECT: + return parseObjectFields(value.fields); + + default: + return value.value; + } +}; + +const parseListValues = values => { + if (Array.isArray(values)) { + return values.map(value => parseValue(value)); + } + + throw new TypeValidationError(values, 'List'); +}; + +const parseObjectFields = fields => { + if (Array.isArray(fields)) { + return fields.reduce( + (object, field) => ({ + ...object, + [field.name.value]: parseValue(field.value), + }), + {} + ); + } + + throw new TypeValidationError(fields, 'Object'); +}; + +const ANY = new GraphQLScalarType({ + name: 'Any', + description: + 'The Any scalar type is used in operations and types that involve any type of value.', + parseValue: value => value, + serialize: value => value, + parseLiteral: ast => parseValue(ast), +}); + +const OBJECT = new GraphQLScalarType({ + name: 'Object', + description: 'The Object scalar type is used in operations and types that involve objects.', + parseValue(value) { + if (typeof value === 'object') { + return value; + } + + throw new TypeValidationError(value, 'Object'); + }, + serialize(value) { + if (typeof value === 'object') { + return value; + } + + throw new TypeValidationError(value, 'Object'); + }, + parseLiteral(ast) { + if (ast.kind === Kind.OBJECT) { + return parseObjectFields(ast.fields); + } + + throw new TypeValidationError(ast.kind, 'Object'); + }, +}); + +const parseDateIsoValue = value => { + if (typeof value === 'string') { + const date = new Date(value); + if (!isNaN(date)) { + return date; + } + } else if (Utils.isDate(value)) { + return value; + } + + throw new TypeValidationError(value, 'Date'); +}; + +const serializeDateIso = value => { + if (typeof value === 'string') { + return value; + } + if (Utils.isDate(value)) { + return value.toISOString(); + } + + throw new TypeValidationError(value, 'Date'); +}; + +const parseDateIsoLiteral = ast => { + if (ast.kind === Kind.STRING) { + return parseDateIsoValue(ast.value); + } + + throw new TypeValidationError(ast.kind, 'Date'); +}; + +const DATE = new GraphQLScalarType({ + name: 'Date', + description: 'The Date scalar type is used in operations and types that involve dates.', + parseValue(value) { + if (typeof value === 'string' || Utils.isDate(value)) { + return { + __type: 'Date', + iso: parseDateIsoValue(value), + }; + } else if (typeof value === 'object' && value.__type === 'Date' && value.iso) { + return { + __type: value.__type, + iso: parseDateIsoValue(value.iso), + }; + } + + throw new TypeValidationError(value, 'Date'); + }, + serialize(value) { + if (typeof value === 'string' || Utils.isDate(value)) { + return serializeDateIso(value); + } else if (typeof value === 'object' && value.__type === 'Date' && value.iso) { + return serializeDateIso(value.iso); + } + + throw new TypeValidationError(value, 'Date'); + }, + parseLiteral(ast) { + if (ast.kind === Kind.STRING) { + return { + __type: 'Date', + iso: parseDateIsoLiteral(ast), + }; + } else if (ast.kind === Kind.OBJECT) { + const __type = ast.fields.find(field => field.name.value === '__type'); + const iso = ast.fields.find(field => field.name.value === 'iso'); + if (__type && __type.value && __type.value.value === 'Date' && iso) { + return { + __type: __type.value.value, + iso: parseDateIsoLiteral(iso.value), + }; + } + } + + throw new TypeValidationError(ast.kind, 'Date'); + }, +}); + +const BYTES = new GraphQLScalarType({ + name: 'Bytes', + description: + 'The Bytes scalar type is used in operations and types that involve base 64 binary data.', + parseValue(value) { + if (typeof value === 'string') { + return { + __type: 'Bytes', + base64: value, + }; + } else if ( + typeof value === 'object' && + value.__type === 'Bytes' && + typeof value.base64 === 'string' + ) { + return value; + } + + throw new TypeValidationError(value, 'Bytes'); + }, + serialize(value) { + if (typeof value === 'string') { + return value; + } else if ( + typeof value === 'object' && + value.__type === 'Bytes' && + typeof value.base64 === 'string' + ) { + return value.base64; + } + + throw new TypeValidationError(value, 'Bytes'); + }, + parseLiteral(ast) { + if (ast.kind === Kind.STRING) { + return { + __type: 'Bytes', + base64: ast.value, + }; + } else if (ast.kind === Kind.OBJECT) { + const __type = ast.fields.find(field => field.name.value === '__type'); + const base64 = ast.fields.find(field => field.name.value === 'base64'); + if ( + __type && + __type.value && + __type.value.value === 'Bytes' && + base64 && + base64.value && + typeof base64.value.value === 'string' + ) { + return { + __type: __type.value.value, + base64: base64.value.value, + }; + } + } + + throw new TypeValidationError(ast.kind, 'Bytes'); + }, +}); + +const parseFileValue = value => { + if (typeof value === 'string') { + return { + __type: 'File', + name: value, + }; + } else if ( + typeof value === 'object' && + value.__type === 'File' && + typeof value.name === 'string' && + (value.url === undefined || typeof value.url === 'string') + ) { + return value; + } + + throw new TypeValidationError(value, 'File'); +}; + +const FILE = new GraphQLScalarType({ + name: 'File', + description: 'The File scalar type is used in operations and types that involve files.', + parseValue: parseFileValue, + serialize: value => { + if (typeof value === 'string') { + return value; + } else if ( + typeof value === 'object' && + value.__type === 'File' && + typeof value.name === 'string' && + (value.url === undefined || typeof value.url === 'string') + ) { + return value.name; + } + + throw new TypeValidationError(value, 'File'); + }, + parseLiteral(ast) { + if (ast.kind === Kind.STRING) { + return parseFileValue(ast.value); + } else if (ast.kind === Kind.OBJECT) { + const __type = ast.fields.find(field => field.name.value === '__type'); + const name = ast.fields.find(field => field.name.value === 'name'); + const url = ast.fields.find(field => field.name.value === 'url'); + if (__type && __type.value && name && name.value) { + return parseFileValue({ + __type: __type.value.value, + name: name.value.value, + url: url && url.value ? url.value.value : undefined, + }); + } + } + + throw new TypeValidationError(ast.kind, 'File'); + }, +}); + +const FILE_INFO = new GraphQLObjectType({ + name: 'FileInfo', + description: 'The FileInfo object type is used to return the information about files.', + fields: { + name: { + description: 'This is the file name.', + type: new GraphQLNonNull(GraphQLString), + }, + url: { + description: 'This is the url in which the file can be downloaded.', + type: new GraphQLNonNull(GraphQLString), + }, + }, +}); + +const FILE_INPUT = new GraphQLInputObjectType({ + name: 'FileInput', + description: + 'If this field is set to null the file will be unlinked (the file will not be deleted on cloud storage).', + fields: { + file: { + description: 'A File Scalar can be an url or a FileInfo object.', + type: FILE, + }, + upload: { + description: 'Use this field if you want to create a new file.', + type: GraphQLUpload, + }, + }, +}); + +const GEO_POINT_FIELDS = { + latitude: { + description: 'This is the latitude.', + type: new GraphQLNonNull(GraphQLFloat), + }, + longitude: { + description: 'This is the longitude.', + type: new GraphQLNonNull(GraphQLFloat), + }, +}; + +const GEO_POINT_INPUT = new GraphQLInputObjectType({ + name: 'GeoPointInput', + description: + 'The GeoPointInput type is used in operations that involve inputting fields of type geo point.', + fields: GEO_POINT_FIELDS, +}); + +const GEO_POINT = new GraphQLObjectType({ + name: 'GeoPoint', + description: 'The GeoPoint object type is used to return the information about geo point fields.', + fields: GEO_POINT_FIELDS, +}); + +const POLYGON_INPUT = new GraphQLList(new GraphQLNonNull(GEO_POINT_INPUT)); + +const POLYGON = new GraphQLList(new GraphQLNonNull(GEO_POINT)); + +const USER_ACL_INPUT = new GraphQLInputObjectType({ + name: 'UserACLInput', + description: 'Allow to manage users in ACL.', + fields: { + userId: { + description: 'ID of the targetted User.', + type: new GraphQLNonNull(GraphQLID), + }, + read: { + description: 'Allow the user to read the current object.', + type: new GraphQLNonNull(GraphQLBoolean), + }, + write: { + description: 'Allow the user to write on the current object.', + type: new GraphQLNonNull(GraphQLBoolean), + }, + }, +}); + +const ROLE_ACL_INPUT = new GraphQLInputObjectType({ + name: 'RoleACLInput', + description: 'Allow to manage roles in ACL.', + fields: { + roleName: { + description: 'Name of the targetted Role.', + type: new GraphQLNonNull(GraphQLString), + }, + read: { + description: 'Allow users who are members of the role to read the current object.', + type: new GraphQLNonNull(GraphQLBoolean), + }, + write: { + description: 'Allow users who are members of the role to write on the current object.', + type: new GraphQLNonNull(GraphQLBoolean), + }, + }, +}); + +const PUBLIC_ACL_INPUT = new GraphQLInputObjectType({ + name: 'PublicACLInput', + description: 'Allow to manage public rights.', + fields: { + read: { + description: 'Allow anyone to read the current object.', + type: new GraphQLNonNull(GraphQLBoolean), + }, + write: { + description: 'Allow anyone to write on the current object.', + type: new GraphQLNonNull(GraphQLBoolean), + }, + }, +}); + +const ACL_INPUT = new GraphQLInputObjectType({ + name: 'ACLInput', + description: + 'Allow to manage access rights. If not provided object will be publicly readable and writable', + fields: { + users: { + description: 'Access control list for users.', + type: new GraphQLList(new GraphQLNonNull(USER_ACL_INPUT)), + }, + roles: { + description: 'Access control list for roles.', + type: new GraphQLList(new GraphQLNonNull(ROLE_ACL_INPUT)), + }, + public: { + description: 'Public access control list.', + type: PUBLIC_ACL_INPUT, + }, + }, +}); + +const USER_ACL = new GraphQLObjectType({ + name: 'UserACL', + description: + 'Allow to manage users in ACL. If read and write are null the users have read and write rights.', + fields: { + userId: { + description: 'ID of the targetted User.', + type: new GraphQLNonNull(GraphQLID), + }, + read: { + description: 'Allow the user to read the current object.', + type: new GraphQLNonNull(GraphQLBoolean), + }, + write: { + description: 'Allow the user to write on the current object.', + type: new GraphQLNonNull(GraphQLBoolean), + }, + }, +}); + +const ROLE_ACL = new GraphQLObjectType({ + name: 'RoleACL', + description: + 'Allow to manage roles in ACL. If read and write are null the role have read and write rights.', + fields: { + roleName: { + description: 'Name of the targetted Role.', + type: new GraphQLNonNull(GraphQLID), + }, + read: { + description: 'Allow users who are members of the role to read the current object.', + type: new GraphQLNonNull(GraphQLBoolean), + }, + write: { + description: 'Allow users who are members of the role to write on the current object.', + type: new GraphQLNonNull(GraphQLBoolean), + }, + }, +}); + +const PUBLIC_ACL = new GraphQLObjectType({ + name: 'PublicACL', + description: 'Allow to manage public rights.', + fields: { + read: { + description: 'Allow anyone to read the current object.', + type: GraphQLBoolean, + }, + write: { + description: 'Allow anyone to write on the current object.', + type: GraphQLBoolean, + }, + }, +}); + +const ACL = new GraphQLObjectType({ + name: 'ACL', + description: 'Current access control list of the current object.', + fields: { + users: { + description: 'Access control list for users.', + type: new GraphQLList(new GraphQLNonNull(USER_ACL)), + resolve(p) { + const users = []; + Object.keys(p).forEach(rule => { + if (rule !== '*' && rule.indexOf('role:') !== 0) { + users.push({ + userId: toGlobalId('_User', rule), + read: p[rule].read ? true : false, + write: p[rule].write ? true : false, + }); + } + }); + return users.length ? users : null; + }, + }, + roles: { + description: 'Access control list for roles.', + type: new GraphQLList(new GraphQLNonNull(ROLE_ACL)), + resolve(p) { + const roles = []; + Object.keys(p).forEach(rule => { + if (rule.indexOf('role:') === 0) { + roles.push({ + roleName: rule.replace('role:', ''), + read: p[rule].read ? true : false, + write: p[rule].write ? true : false, + }); + } + }); + return roles.length ? roles : null; + }, + }, + public: { + description: 'Public access control list.', + type: PUBLIC_ACL, + resolve(p) { + /* eslint-disable */ + return p['*'] + ? { + read: p['*'].read ? true : false, + write: p['*'].write ? true : false, + } + : null; + }, + }, + }, +}); + +const OBJECT_ID = new GraphQLNonNull(GraphQLID); + +const CLASS_NAME_ATT = { + description: 'This is the class name of the object.', + type: new GraphQLNonNull(GraphQLString), +}; + +const GLOBAL_OR_OBJECT_ID_ATT = { + description: 'This is the object id. You can use either the global or the object id.', + type: OBJECT_ID, +}; + +const OBJECT_ID_ATT = { + description: 'This is the object id.', + type: OBJECT_ID, +}; + +const CREATED_AT_ATT = { + description: 'This is the date in which the object was created.', + type: new GraphQLNonNull(DATE), +}; + +const UPDATED_AT_ATT = { + description: 'This is the date in which the object was las updated.', + type: new GraphQLNonNull(DATE), +}; + +const INPUT_FIELDS = { + ACL: { + type: ACL, + }, +}; + +const CREATE_RESULT_FIELDS = { + objectId: OBJECT_ID_ATT, + createdAt: CREATED_AT_ATT, +}; + +const UPDATE_RESULT_FIELDS = { + updatedAt: UPDATED_AT_ATT, +}; + +const PARSE_OBJECT_FIELDS = { + ...CREATE_RESULT_FIELDS, + ...UPDATE_RESULT_FIELDS, + ...INPUT_FIELDS, + ACL: { + type: new GraphQLNonNull(ACL), + resolve: ({ ACL }) => (ACL ? ACL : { '*': { read: true, write: true } }), + }, +}; + +const PARSE_OBJECT = new GraphQLInterfaceType({ + name: 'ParseObject', + description: + 'The ParseObject interface type is used as a base type for the auto generated object types.', + fields: PARSE_OBJECT_FIELDS, +}); + +const SESSION_TOKEN_ATT = { + description: 'The current user session token.', + type: new GraphQLNonNull(GraphQLString), +}; + +const READ_PREFERENCE = new GraphQLEnumType({ + name: 'ReadPreference', + description: + 'The ReadPreference enum type is used in queries in order to select in which database replica the operation must run.', + values: { + PRIMARY: { value: 'PRIMARY' }, + PRIMARY_PREFERRED: { value: 'PRIMARY_PREFERRED' }, + SECONDARY: { value: 'SECONDARY' }, + SECONDARY_PREFERRED: { value: 'SECONDARY_PREFERRED' }, + NEAREST: { value: 'NEAREST' }, + }, +}); + +const READ_PREFERENCE_ATT = { + description: 'The read preference for the main query to be executed.', + type: READ_PREFERENCE, +}; + +const INCLUDE_READ_PREFERENCE_ATT = { + description: 'The read preference for the queries to be executed to include fields.', + type: READ_PREFERENCE, +}; + +const SUBQUERY_READ_PREFERENCE_ATT = { + description: 'The read preference for the subqueries that may be required.', + type: READ_PREFERENCE, +}; + +const READ_OPTIONS_INPUT = new GraphQLInputObjectType({ + name: 'ReadOptionsInput', + description: + 'The ReadOptionsInputt type is used in queries in order to set the read preferences.', + fields: { + readPreference: READ_PREFERENCE_ATT, + includeReadPreference: INCLUDE_READ_PREFERENCE_ATT, + subqueryReadPreference: SUBQUERY_READ_PREFERENCE_ATT, + }, +}); + +const READ_OPTIONS_ATT = { + description: 'The read options for the query to be executed.', + type: READ_OPTIONS_INPUT, +}; + +const WHERE_ATT = { + description: 'These are the conditions that the objects need to match in order to be found', + type: OBJECT, +}; + +const SKIP_ATT = { + description: 'This is the number of objects that must be skipped to return.', + type: GraphQLInt, +}; + +const LIMIT_ATT = { + description: 'This is the limit number of objects that must be returned.', + type: GraphQLInt, +}; + +const COUNT_ATT = { + description: + 'This is the total matched objecs count that is returned when the count flag is set.', + type: new GraphQLNonNull(GraphQLInt), +}; + +const SEARCH_INPUT = new GraphQLInputObjectType({ + name: 'SearchInput', + description: 'The SearchInput type is used to specifiy a search operation on a full text search.', + fields: { + term: { + description: 'This is the term to be searched.', + type: new GraphQLNonNull(GraphQLString), + }, + language: { + description: + 'This is the language to tetermine the list of stop words and the rules for tokenizer.', + type: GraphQLString, + }, + caseSensitive: { + description: 'This is the flag to enable or disable case sensitive search.', + type: GraphQLBoolean, + }, + diacriticSensitive: { + description: 'This is the flag to enable or disable diacritic sensitive search.', + type: GraphQLBoolean, + }, + }, +}); + +const TEXT_INPUT = new GraphQLInputObjectType({ + name: 'TextInput', + description: 'The TextInput type is used to specify a text operation on a constraint.', + fields: { + search: { + description: 'This is the search to be executed.', + type: new GraphQLNonNull(SEARCH_INPUT), + }, + }, +}); + +const BOX_INPUT = new GraphQLInputObjectType({ + name: 'BoxInput', + description: 'The BoxInput type is used to specifiy a box operation on a within geo query.', + fields: { + bottomLeft: { + description: 'This is the bottom left coordinates of the box.', + type: new GraphQLNonNull(GEO_POINT_INPUT), + }, + upperRight: { + description: 'This is the upper right coordinates of the box.', + type: new GraphQLNonNull(GEO_POINT_INPUT), + }, + }, +}); + +const WITHIN_INPUT = new GraphQLInputObjectType({ + name: 'WithinInput', + description: 'The WithinInput type is used to specify a within operation on a constraint.', + fields: { + box: { + description: 'This is the box to be specified.', + type: new GraphQLNonNull(BOX_INPUT), + }, + }, +}); + +const CENTER_SPHERE_INPUT = new GraphQLInputObjectType({ + name: 'CenterSphereInput', + description: + 'The CenterSphereInput type is used to specifiy a centerSphere operation on a geoWithin query.', + fields: { + center: { + description: 'This is the center of the sphere.', + type: new GraphQLNonNull(GEO_POINT_INPUT), + }, + distance: { + description: 'This is the radius of the sphere.', + type: new GraphQLNonNull(GraphQLFloat), + }, + }, +}); + +const GEO_WITHIN_INPUT = new GraphQLInputObjectType({ + name: 'GeoWithinInput', + description: 'The GeoWithinInput type is used to specify a geoWithin operation on a constraint.', + fields: { + polygon: { + description: 'This is the polygon to be specified.', + type: POLYGON_INPUT, + }, + centerSphere: { + description: 'This is the sphere to be specified.', + type: CENTER_SPHERE_INPUT, + }, + }, +}); + +const GEO_INTERSECTS_INPUT = new GraphQLInputObjectType({ + name: 'GeoIntersectsInput', + description: + 'The GeoIntersectsInput type is used to specify a geoIntersects operation on a constraint.', + fields: { + point: { + description: 'This is the point to be specified.', + type: GEO_POINT_INPUT, + }, + }, +}); + +const equalTo = type => ({ + description: + 'This is the equalTo operator to specify a constraint to select the objects where the value of a field equals to a specified value.', + type, +}); + +const notEqualTo = type => ({ + description: + 'This is the notEqualTo operator to specify a constraint to select the objects where the value of a field do not equal to a specified value.', + type, +}); + +const lessThan = type => ({ + description: + 'This is the lessThan operator to specify a constraint to select the objects where the value of a field is less than a specified value.', + type, +}); + +const lessThanOrEqualTo = type => ({ + description: + 'This is the lessThanOrEqualTo operator to specify a constraint to select the objects where the value of a field is less than or equal to a specified value.', + type, +}); + +const greaterThan = type => ({ + description: + 'This is the greaterThan operator to specify a constraint to select the objects where the value of a field is greater than a specified value.', + type, +}); + +const greaterThanOrEqualTo = type => ({ + description: + 'This is the greaterThanOrEqualTo operator to specify a constraint to select the objects where the value of a field is greater than or equal to a specified value.', + type, +}); + +const inOp = type => ({ + description: + 'This is the in operator to specify a constraint to select the objects where the value of a field equals any value in the specified array.', + type: new GraphQLList(type), +}); + +const notIn = type => ({ + description: + 'This is the notIn operator to specify a constraint to select the objects where the value of a field do not equal any value in the specified array.', + type: new GraphQLList(type), +}); + +const exists = { + description: + 'This is the exists operator to specify a constraint to select the objects where a field exists (or do not exist).', + type: GraphQLBoolean, +}; + +const matchesRegex = { + description: + 'This is the matchesRegex operator to specify a constraint to select the objects where the value of a field matches a specified regular expression.', + type: GraphQLString, +}; + +const options = { + description: + 'This is the options operator to specify optional flags (such as "i" and "m") to be added to a matchesRegex operation in the same set of constraints.', + type: GraphQLString, +}; + +const SUBQUERY_INPUT = new GraphQLInputObjectType({ + name: 'SubqueryInput', + description: 'The SubqueryInput type is used to specify a sub query to another class.', + fields: { + className: CLASS_NAME_ATT, + where: Object.assign({}, WHERE_ATT, { + type: new GraphQLNonNull(WHERE_ATT.type), + }), + }, +}); + +const SELECT_INPUT = new GraphQLInputObjectType({ + name: 'SelectInput', + description: + 'The SelectInput type is used to specify an inQueryKey or a notInQueryKey operation on a constraint.', + fields: { + query: { + description: 'This is the subquery to be executed.', + type: new GraphQLNonNull(SUBQUERY_INPUT), + }, + key: { + description: + 'This is the key in the result of the subquery that must match (not match) the field.', + type: new GraphQLNonNull(GraphQLString), + }, + }, +}); + +const inQueryKey = { + description: + 'This is the inQueryKey operator to specify a constraint to select the objects where a field equals to a key in the result of a different query.', + type: SELECT_INPUT, +}; + +const notInQueryKey = { + description: + 'This is the notInQueryKey operator to specify a constraint to select the objects where a field do not equal to a key in the result of a different query.', + type: SELECT_INPUT, +}; + +const ID_WHERE_INPUT = new GraphQLInputObjectType({ + name: 'IdWhereInput', + description: + 'The IdWhereInput input type is used in operations that involve filtering objects by an id.', + fields: { + equalTo: equalTo(GraphQLID), + notEqualTo: notEqualTo(GraphQLID), + lessThan: lessThan(GraphQLID), + lessThanOrEqualTo: lessThanOrEqualTo(GraphQLID), + greaterThan: greaterThan(GraphQLID), + greaterThanOrEqualTo: greaterThanOrEqualTo(GraphQLID), + in: inOp(GraphQLID), + notIn: notIn(GraphQLID), + exists, + inQueryKey, + notInQueryKey, + }, +}); + +const STRING_WHERE_INPUT = new GraphQLInputObjectType({ + name: 'StringWhereInput', + description: + 'The StringWhereInput input type is used in operations that involve filtering objects by a field of type String.', + fields: { + equalTo: equalTo(GraphQLString), + notEqualTo: notEqualTo(GraphQLString), + lessThan: lessThan(GraphQLString), + lessThanOrEqualTo: lessThanOrEqualTo(GraphQLString), + greaterThan: greaterThan(GraphQLString), + greaterThanOrEqualTo: greaterThanOrEqualTo(GraphQLString), + in: inOp(GraphQLString), + notIn: notIn(GraphQLString), + exists, + matchesRegex, + options, + text: { + description: 'This is the $text operator to specify a full text search constraint.', + type: TEXT_INPUT, + }, + inQueryKey, + notInQueryKey, + }, +}); + +const NUMBER_WHERE_INPUT = new GraphQLInputObjectType({ + name: 'NumberWhereInput', + description: + 'The NumberWhereInput input type is used in operations that involve filtering objects by a field of type Number.', + fields: { + equalTo: equalTo(GraphQLFloat), + notEqualTo: notEqualTo(GraphQLFloat), + lessThan: lessThan(GraphQLFloat), + lessThanOrEqualTo: lessThanOrEqualTo(GraphQLFloat), + greaterThan: greaterThan(GraphQLFloat), + greaterThanOrEqualTo: greaterThanOrEqualTo(GraphQLFloat), + in: inOp(GraphQLFloat), + notIn: notIn(GraphQLFloat), + exists, + inQueryKey, + notInQueryKey, + }, +}); + +const BOOLEAN_WHERE_INPUT = new GraphQLInputObjectType({ + name: 'BooleanWhereInput', + description: + 'The BooleanWhereInput input type is used in operations that involve filtering objects by a field of type Boolean.', + fields: { + equalTo: equalTo(GraphQLBoolean), + notEqualTo: notEqualTo(GraphQLBoolean), + exists, + inQueryKey, + notInQueryKey, + }, +}); + +const ARRAY_WHERE_INPUT = new GraphQLInputObjectType({ + name: 'ArrayWhereInput', + description: + 'The ArrayWhereInput input type is used in operations that involve filtering objects by a field of type Array.', + fields: { + equalTo: equalTo(ANY), + notEqualTo: notEqualTo(ANY), + lessThan: lessThan(ANY), + lessThanOrEqualTo: lessThanOrEqualTo(ANY), + greaterThan: greaterThan(ANY), + greaterThanOrEqualTo: greaterThanOrEqualTo(ANY), + in: inOp(ANY), + notIn: notIn(ANY), + exists, + containedBy: { + description: + 'This is the containedBy operator to specify a constraint to select the objects where the values of an array field is contained by another specified array.', + type: new GraphQLList(ANY), + }, + contains: { + description: + 'This is the contains operator to specify a constraint to select the objects where the values of an array field contain all elements of another specified array.', + type: new GraphQLList(ANY), + }, + inQueryKey, + notInQueryKey, + }, +}); + +const KEY_VALUE_INPUT = new GraphQLInputObjectType({ + name: 'KeyValueInput', + description: 'An entry from an object, i.e., a pair of key and value.', + fields: { + key: { + description: 'The key used to retrieve the value of this entry.', + type: new GraphQLNonNull(GraphQLString), + }, + value: { + description: 'The value of the entry. Could be any type of scalar data.', + type: new GraphQLNonNull(ANY), + }, + }, +}); + +const OBJECT_WHERE_INPUT = new GraphQLInputObjectType({ + name: 'ObjectWhereInput', + description: + 'The ObjectWhereInput input type is used in operations that involve filtering result by a field of type Object.', + fields: { + equalTo: equalTo(KEY_VALUE_INPUT), + notEqualTo: notEqualTo(KEY_VALUE_INPUT), + in: inOp(KEY_VALUE_INPUT), + notIn: notIn(KEY_VALUE_INPUT), + lessThan: lessThan(KEY_VALUE_INPUT), + lessThanOrEqualTo: lessThanOrEqualTo(KEY_VALUE_INPUT), + greaterThan: greaterThan(KEY_VALUE_INPUT), + greaterThanOrEqualTo: greaterThanOrEqualTo(KEY_VALUE_INPUT), + exists, + inQueryKey, + notInQueryKey, + }, +}); + +const DATE_WHERE_INPUT = new GraphQLInputObjectType({ + name: 'DateWhereInput', + description: + 'The DateWhereInput input type is used in operations that involve filtering objects by a field of type Date.', + fields: { + equalTo: equalTo(DATE), + notEqualTo: notEqualTo(DATE), + lessThan: lessThan(DATE), + lessThanOrEqualTo: lessThanOrEqualTo(DATE), + greaterThan: greaterThan(DATE), + greaterThanOrEqualTo: greaterThanOrEqualTo(DATE), + in: inOp(DATE), + notIn: notIn(DATE), + exists, + inQueryKey, + notInQueryKey, + }, +}); + +const BYTES_WHERE_INPUT = new GraphQLInputObjectType({ + name: 'BytesWhereInput', + description: + 'The BytesWhereInput input type is used in operations that involve filtering objects by a field of type Bytes.', + fields: { + equalTo: equalTo(BYTES), + notEqualTo: notEqualTo(BYTES), + lessThan: lessThan(BYTES), + lessThanOrEqualTo: lessThanOrEqualTo(BYTES), + greaterThan: greaterThan(BYTES), + greaterThanOrEqualTo: greaterThanOrEqualTo(BYTES), + in: inOp(BYTES), + notIn: notIn(BYTES), + exists, + inQueryKey, + notInQueryKey, + }, +}); + +const FILE_WHERE_INPUT = new GraphQLInputObjectType({ + name: 'FileWhereInput', + description: + 'The FileWhereInput input type is used in operations that involve filtering objects by a field of type File.', + fields: { + equalTo: equalTo(FILE), + notEqualTo: notEqualTo(FILE), + lessThan: lessThan(FILE), + lessThanOrEqualTo: lessThanOrEqualTo(FILE), + greaterThan: greaterThan(FILE), + greaterThanOrEqualTo: greaterThanOrEqualTo(FILE), + in: inOp(FILE), + notIn: notIn(FILE), + exists, + matchesRegex, + options, + inQueryKey, + notInQueryKey, + }, +}); + +const GEO_POINT_WHERE_INPUT = new GraphQLInputObjectType({ + name: 'GeoPointWhereInput', + description: + 'The GeoPointWhereInput input type is used in operations that involve filtering objects by a field of type GeoPoint.', + fields: { + exists, + nearSphere: { + description: + 'This is the nearSphere operator to specify a constraint to select the objects where the values of a geo point field is near to another geo point.', + type: GEO_POINT_INPUT, + }, + maxDistance: { + description: + 'This is the maxDistance operator to specify a constraint to select the objects where the values of a geo point field is at a max distance (in radians) from the geo point specified in the $nearSphere operator.', + type: GraphQLFloat, + }, + maxDistanceInRadians: { + description: + 'This is the maxDistanceInRadians operator to specify a constraint to select the objects where the values of a geo point field is at a max distance (in radians) from the geo point specified in the $nearSphere operator.', + type: GraphQLFloat, + }, + maxDistanceInMiles: { + description: + 'This is the maxDistanceInMiles operator to specify a constraint to select the objects where the values of a geo point field is at a max distance (in miles) from the geo point specified in the $nearSphere operator.', + type: GraphQLFloat, + }, + maxDistanceInKilometers: { + description: + 'This is the maxDistanceInKilometers operator to specify a constraint to select the objects where the values of a geo point field is at a max distance (in kilometers) from the geo point specified in the $nearSphere operator.', + type: GraphQLFloat, + }, + within: { + description: + 'This is the within operator to specify a constraint to select the objects where the values of a geo point field is within a specified box.', + type: WITHIN_INPUT, + }, + geoWithin: { + description: + 'This is the geoWithin operator to specify a constraint to select the objects where the values of a geo point field is within a specified polygon or sphere.', + type: GEO_WITHIN_INPUT, + }, + }, +}); + +const POLYGON_WHERE_INPUT = new GraphQLInputObjectType({ + name: 'PolygonWhereInput', + description: + 'The PolygonWhereInput input type is used in operations that involve filtering objects by a field of type Polygon.', + fields: { + exists, + geoIntersects: { + description: + 'This is the geoIntersects operator to specify a constraint to select the objects where the values of a polygon field intersect a specified point.', + type: GEO_INTERSECTS_INPUT, + }, + }, +}); + +const ELEMENT = new GraphQLObjectType({ + name: 'Element', + description: "The Element object type is used to return array items' value.", + fields: { + value: { + description: 'Return the value of the element in the array', + type: new GraphQLNonNull(ANY), + }, + }, +}); + +// Default static union type, we update types and resolveType function later +let ARRAY_RESULT; + +const loadArrayResult = (parseGraphQLSchema, parseClassesArray) => { + const classTypes = parseClassesArray + .filter(parseClass => + parseGraphQLSchema.parseClassTypes[parseClass.className].classGraphQLOutputType ? true : false + ) + .map( + parseClass => parseGraphQLSchema.parseClassTypes[parseClass.className].classGraphQLOutputType + ); + ARRAY_RESULT = new GraphQLUnionType({ + name: 'ArrayResult', + description: + 'Use Inline Fragment on Array to get results: https://graphql.org/learn/queries/#inline-fragments', + types: () => [ELEMENT, ...classTypes], + resolveType: value => { + if (value.__type === 'Object' && value.className && value.objectId) { + if (parseGraphQLSchema.parseClassTypes[value.className]) { + return parseGraphQLSchema.parseClassTypes[value.className].classGraphQLOutputType.name; + } else { + return ELEMENT.name; + } + } else { + return ELEMENT.name; + } + }, + }); + parseGraphQLSchema.graphQLTypes.push(ARRAY_RESULT); +}; + +const load = parseGraphQLSchema => { + parseGraphQLSchema.addGraphQLType(GraphQLUpload, true); + parseGraphQLSchema.addGraphQLType(ANY, true); + parseGraphQLSchema.addGraphQLType(OBJECT, true); + parseGraphQLSchema.addGraphQLType(DATE, true); + parseGraphQLSchema.addGraphQLType(BYTES, true); + parseGraphQLSchema.addGraphQLType(FILE, true); + parseGraphQLSchema.addGraphQLType(FILE_INFO, true); + parseGraphQLSchema.addGraphQLType(FILE_INPUT, true); + parseGraphQLSchema.addGraphQLType(GEO_POINT_INPUT, true); + parseGraphQLSchema.addGraphQLType(GEO_POINT, true); + parseGraphQLSchema.addGraphQLType(PARSE_OBJECT, true); + parseGraphQLSchema.addGraphQLType(READ_PREFERENCE, true); + parseGraphQLSchema.addGraphQLType(READ_OPTIONS_INPUT, true); + parseGraphQLSchema.addGraphQLType(SEARCH_INPUT, true); + parseGraphQLSchema.addGraphQLType(TEXT_INPUT, true); + parseGraphQLSchema.addGraphQLType(BOX_INPUT, true); + parseGraphQLSchema.addGraphQLType(WITHIN_INPUT, true); + parseGraphQLSchema.addGraphQLType(CENTER_SPHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(GEO_WITHIN_INPUT, true); + parseGraphQLSchema.addGraphQLType(GEO_INTERSECTS_INPUT, true); + parseGraphQLSchema.addGraphQLType(ID_WHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(STRING_WHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(NUMBER_WHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(BOOLEAN_WHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(ARRAY_WHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(KEY_VALUE_INPUT, true); + parseGraphQLSchema.addGraphQLType(OBJECT_WHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(DATE_WHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(BYTES_WHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(FILE_WHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(GEO_POINT_WHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(POLYGON_WHERE_INPUT, true); + parseGraphQLSchema.addGraphQLType(ELEMENT, true); + parseGraphQLSchema.addGraphQLType(ACL_INPUT, true); + parseGraphQLSchema.addGraphQLType(USER_ACL_INPUT, true); + parseGraphQLSchema.addGraphQLType(ROLE_ACL_INPUT, true); + parseGraphQLSchema.addGraphQLType(PUBLIC_ACL_INPUT, true); + parseGraphQLSchema.addGraphQLType(ACL, true); + parseGraphQLSchema.addGraphQLType(USER_ACL, true); + parseGraphQLSchema.addGraphQLType(ROLE_ACL, true); + parseGraphQLSchema.addGraphQLType(PUBLIC_ACL, true); + parseGraphQLSchema.addGraphQLType(SUBQUERY_INPUT, true); + parseGraphQLSchema.addGraphQLType(SELECT_INPUT, true); +}; + +export { + GraphQLUpload, + TypeValidationError, + parseStringValue, + parseIntValue, + parseFloatValue, + parseBooleanValue, + parseValue, + parseListValues, + parseObjectFields, + ANY, + OBJECT, + parseDateIsoValue, + serializeDateIso, + DATE, + BYTES, + parseFileValue, + SUBQUERY_INPUT, + SELECT_INPUT, + FILE, + FILE_INFO, + FILE_INPUT, + GEO_POINT_FIELDS, + GEO_POINT_INPUT, + GEO_POINT, + POLYGON_INPUT, + POLYGON, + OBJECT_ID, + CLASS_NAME_ATT, + GLOBAL_OR_OBJECT_ID_ATT, + OBJECT_ID_ATT, + UPDATED_AT_ATT, + CREATED_AT_ATT, + INPUT_FIELDS, + CREATE_RESULT_FIELDS, + UPDATE_RESULT_FIELDS, + PARSE_OBJECT_FIELDS, + PARSE_OBJECT, + SESSION_TOKEN_ATT, + READ_PREFERENCE, + READ_PREFERENCE_ATT, + INCLUDE_READ_PREFERENCE_ATT, + SUBQUERY_READ_PREFERENCE_ATT, + READ_OPTIONS_INPUT, + READ_OPTIONS_ATT, + WHERE_ATT, + SKIP_ATT, + LIMIT_ATT, + COUNT_ATT, + SEARCH_INPUT, + TEXT_INPUT, + BOX_INPUT, + WITHIN_INPUT, + CENTER_SPHERE_INPUT, + GEO_WITHIN_INPUT, + GEO_INTERSECTS_INPUT, + equalTo, + notEqualTo, + lessThan, + lessThanOrEqualTo, + greaterThan, + greaterThanOrEqualTo, + inOp, + notIn, + exists, + matchesRegex, + options, + inQueryKey, + notInQueryKey, + ID_WHERE_INPUT, + STRING_WHERE_INPUT, + NUMBER_WHERE_INPUT, + BOOLEAN_WHERE_INPUT, + ARRAY_WHERE_INPUT, + KEY_VALUE_INPUT, + OBJECT_WHERE_INPUT, + DATE_WHERE_INPUT, + BYTES_WHERE_INPUT, + FILE_WHERE_INPUT, + GEO_POINT_WHERE_INPUT, + POLYGON_WHERE_INPUT, + ARRAY_RESULT, + ELEMENT, + ACL_INPUT, + USER_ACL_INPUT, + ROLE_ACL_INPUT, + PUBLIC_ACL_INPUT, + ACL, + USER_ACL, + ROLE_ACL, + PUBLIC_ACL, + load, + loadArrayResult, +}; diff --git a/src/GraphQL/loaders/defaultRelaySchema.js b/src/GraphQL/loaders/defaultRelaySchema.js new file mode 100644 index 0000000000..0a8f0f7620 --- /dev/null +++ b/src/GraphQL/loaders/defaultRelaySchema.js @@ -0,0 +1,51 @@ +import { nodeDefinitions, fromGlobalId } from 'graphql-relay'; +import getFieldNames from 'graphql-list-fields'; +import * as defaultGraphQLTypes from './defaultGraphQLTypes'; +import * as objectsQueries from '../helpers/objectsQueries'; +import { extractKeysAndInclude } from './parseClassTypes'; + +const GLOBAL_ID_ATT = { + description: 'This is the global id.', + type: defaultGraphQLTypes.OBJECT_ID, +}; + +const load = parseGraphQLSchema => { + const { nodeInterface, nodeField } = nodeDefinitions( + async (globalId, context, queryInfo) => { + try { + const { type, id } = fromGlobalId(globalId); + const { config, auth, info } = context; + const selectedFields = getFieldNames(queryInfo); + + const { keys, include } = extractKeysAndInclude(selectedFields); + + return { + className: type, + ...(await objectsQueries.getObject( + type, + id, + keys, + include, + undefined, + undefined, + config, + auth, + info, + parseGraphQLSchema.parseClasses + )), + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + obj => { + return parseGraphQLSchema.parseClassTypes[obj.className].classGraphQLOutputType.name; + } + ); + + parseGraphQLSchema.addGraphQLType(nodeInterface, true); + parseGraphQLSchema.relayNodeInterface = nodeInterface; + parseGraphQLSchema.addGraphQLQuery('node', nodeField, true); +}; + +export { GLOBAL_ID_ATT, load }; diff --git a/src/GraphQL/loaders/filesMutations.js b/src/GraphQL/loaders/filesMutations.js new file mode 100644 index 0000000000..8439dfeb4f --- /dev/null +++ b/src/GraphQL/loaders/filesMutations.js @@ -0,0 +1,94 @@ +import { GraphQLNonNull } from 'graphql'; +import { request } from 'http'; +import { mutationWithClientMutationId } from 'graphql-relay'; +import Parse from 'parse/node'; +import * as defaultGraphQLTypes from './defaultGraphQLTypes'; +import logger from '../../logger'; + +// Handle GraphQL file upload and proxy file upload to GraphQL server url specified in config; +// `createFile` is not directly called by Parse Server to leverage standard file upload mechanism +const handleUpload = async (upload, config) => { + const { createReadStream, filename, mimetype } = await upload; + const headers = { ...config.headers }; + delete headers['accept-encoding']; + delete headers['accept']; + delete headers['connection']; + delete headers['host']; + delete headers['content-length']; + const stream = createReadStream(); + const mime = (await import('mime')).default; + try { + const ext = mime.getExtension(mimetype); + const fullFileName = filename.endsWith(`.${ext}`) ? filename : `${filename}.${ext}`; + const serverUrl = new URL(config.serverURL); + const fileInfo = await new Promise((resolve, reject) => { + const req = request( + { + hostname: serverUrl.hostname, + port: serverUrl.port, + path: `${serverUrl.pathname}/files/${fullFileName}`, + method: 'POST', + headers, + }, + res => { + let data = ''; + res.on('data', chunk => { + data += chunk; + }); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch { + reject(new Parse.Error(Parse.error, data)); + } + }); + } + ); + stream.pipe(req); + stream.on('end', () => { + req.end(); + }); + }); + return { + fileInfo, + }; + } catch (e) { + stream.destroy(); + logger.error('Error creating a file: ', e); + throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `Could not store file: ${filename}.`); + } +}; + +const load = parseGraphQLSchema => { + const createMutation = mutationWithClientMutationId({ + name: 'CreateFile', + description: 'The createFile mutation can be used to create and upload a new file.', + inputFields: { + upload: { + description: 'This is the new file to be created and uploaded.', + type: new GraphQLNonNull(defaultGraphQLTypes.GraphQLUpload), + }, + }, + outputFields: { + fileInfo: { + description: 'This is the created file info.', + type: new GraphQLNonNull(defaultGraphQLTypes.FILE_INFO), + }, + }, + mutateAndGetPayload: async (args, context) => { + try { + const { upload } = args; + const { config } = context; + return handleUpload(upload, config); + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType(createMutation.args.input.type.ofType, true, true); + parseGraphQLSchema.addGraphQLType(createMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation('createFile', createMutation, true, true); +}; + +export { load, handleUpload }; diff --git a/src/GraphQL/loaders/functionsMutations.js b/src/GraphQL/loaders/functionsMutations.js new file mode 100644 index 0000000000..c6761d7966 --- /dev/null +++ b/src/GraphQL/loaders/functionsMutations.js @@ -0,0 +1,76 @@ +import { GraphQLNonNull, GraphQLEnumType } from 'graphql'; + +import { mutationWithClientMutationId } from 'graphql-relay'; +import { cloneArgs } from '../parseGraphQLUtils'; +import { FunctionsRouter } from '../../Routers/FunctionsRouter'; +import * as defaultGraphQLTypes from './defaultGraphQLTypes'; + +const load = parseGraphQLSchema => { + if (parseGraphQLSchema.functionNames.length > 0) { + const cloudCodeFunctionEnum = parseGraphQLSchema.addGraphQLType( + new GraphQLEnumType({ + name: 'CloudCodeFunction', + description: + 'The CloudCodeFunction enum type contains a list of all available cloud code functions.', + values: parseGraphQLSchema.functionNames.reduce( + (values, functionName) => ({ + ...values, + [functionName]: { value: functionName }, + }), + {} + ), + }), + true, + true + ); + + const callCloudCodeMutation = mutationWithClientMutationId({ + name: 'CallCloudCode', + description: 'The callCloudCode mutation can be used to invoke a cloud code function.', + inputFields: { + functionName: { + description: 'This is the function to be called.', + type: new GraphQLNonNull(cloudCodeFunctionEnum), + }, + params: { + description: 'These are the params to be passed to the function.', + type: defaultGraphQLTypes.OBJECT, + }, + }, + outputFields: { + result: { + description: 'This is the result value of the cloud code function execution.', + type: defaultGraphQLTypes.ANY, + }, + }, + mutateAndGetPayload: async (args, context) => { + try { + const { functionName, params } = cloneArgs(args); + const { config, auth, info } = context; + + return { + result: ( + await FunctionsRouter.handleCloudFunction({ + params: { + functionName, + }, + config, + auth, + info, + body: params, + }) + ).response.result, + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType(callCloudCodeMutation.args.input.type.ofType, true, true); + parseGraphQLSchema.addGraphQLType(callCloudCodeMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation('callCloudCode', callCloudCodeMutation, true, true); + } +}; + +export { load }; diff --git a/src/GraphQL/loaders/parseClassMutations.js b/src/GraphQL/loaders/parseClassMutations.js new file mode 100644 index 0000000000..df9a096995 --- /dev/null +++ b/src/GraphQL/loaders/parseClassMutations.js @@ -0,0 +1,337 @@ +import { GraphQLNonNull } from 'graphql'; +import { fromGlobalId, mutationWithClientMutationId } from 'graphql-relay'; +import getFieldNames from 'graphql-list-fields'; + +import * as defaultGraphQLTypes from './defaultGraphQLTypes'; +import { extractKeysAndInclude, getParseClassMutationConfig, cloneArgs } from '../parseGraphQLUtils'; +import * as objectsMutations from '../helpers/objectsMutations'; +import * as objectsQueries from '../helpers/objectsQueries'; +import { ParseGraphQLClassConfig } from '../../Controllers/ParseGraphQLController'; +import { transformClassNameToGraphQL } from '../transformers/className'; +import { transformTypes } from '../transformers/mutation'; + +const filterDeletedFields = fields => + Object.keys(fields).reduce((acc, key) => { + if (typeof fields[key] === 'object' && fields[key]?.__op === 'Delete') { + acc[key] = null; + } + return acc; + }, fields); + +const getOnlyRequiredFields = ( + updatedFields, + selectedFieldsString, + includedFieldsString, + nativeObjectFields +) => { + const includedFields = includedFieldsString ? includedFieldsString.split(',') : []; + const selectedFields = selectedFieldsString ? selectedFieldsString.split(',') : []; + const missingFields = selectedFields + .filter(field => !nativeObjectFields.includes(field) || includedFields.includes(field)) + .join(','); + if (!missingFields.length) { + return { needGet: false, keys: '' }; + } else { + return { needGet: true, keys: missingFields }; + } +}; + +const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseGraphQLClassConfig) { + const className = parseClass.className; + const graphQLClassName = transformClassNameToGraphQL(className); + const getGraphQLQueryName = graphQLClassName.charAt(0).toLowerCase() + graphQLClassName.slice(1); + + const { + create: isCreateEnabled = true, + update: isUpdateEnabled = true, + destroy: isDestroyEnabled = true, + createAlias: createAlias = '', + updateAlias: updateAlias = '', + destroyAlias: destroyAlias = '', + } = getParseClassMutationConfig(parseClassConfig); + + const { + classGraphQLCreateType, + classGraphQLUpdateType, + classGraphQLOutputType, + } = parseGraphQLSchema.parseClassTypes[className]; + + if (isCreateEnabled) { + const createGraphQLMutationName = createAlias || `create${graphQLClassName}`; + const createGraphQLMutation = mutationWithClientMutationId({ + name: `Create${graphQLClassName}`, + description: `The ${createGraphQLMutationName} mutation can be used to create a new object of the ${graphQLClassName} class.`, + inputFields: { + fields: { + description: 'These are the fields that will be used to create the new object.', + type: classGraphQLCreateType || defaultGraphQLTypes.OBJECT, + }, + }, + outputFields: { + [getGraphQLQueryName]: { + description: 'This is the created object.', + type: new GraphQLNonNull(classGraphQLOutputType || defaultGraphQLTypes.OBJECT), + }, + }, + mutateAndGetPayload: async (args, context, mutationInfo) => { + try { + let { fields } = cloneArgs(args); + if (!fields) { fields = {}; } + const { config, auth, info } = context; + + const parseFields = await transformTypes('create', fields, { + className, + parseGraphQLSchema, + originalFields: args.fields, + req: { config, auth, info }, + }); + + const createdObject = await objectsMutations.createObject( + className, + parseFields, + config, + auth, + info + ); + const selectedFields = getFieldNames(mutationInfo) + .filter(field => field.startsWith(`${getGraphQLQueryName}.`)) + .map(field => field.replace(`${getGraphQLQueryName}.`, '')); + const { keys, include } = extractKeysAndInclude(selectedFields); + const { keys: requiredKeys, needGet } = getOnlyRequiredFields(fields, keys, include, [ + 'id', + 'objectId', + 'createdAt', + 'updatedAt', + ]); + const needToGetAllKeys = objectsQueries.needToGetAllKeys( + parseClass.fields, + keys, + parseGraphQLSchema.parseClasses + ); + let optimizedObject = {}; + if (needGet && !needToGetAllKeys) { + optimizedObject = await objectsQueries.getObject( + className, + createdObject.objectId, + requiredKeys, + include, + undefined, + undefined, + config, + auth, + info, + parseGraphQLSchema.parseClasses + ); + } else if (needToGetAllKeys) { + optimizedObject = await objectsQueries.getObject( + className, + createdObject.objectId, + undefined, + include, + undefined, + undefined, + config, + auth, + info, + parseGraphQLSchema.parseClasses + ); + } + return { + [getGraphQLQueryName]: { + ...createdObject, + updatedAt: createdObject.createdAt, + ...filterDeletedFields(parseFields), + ...optimizedObject, + }, + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + if ( + parseGraphQLSchema.addGraphQLType(createGraphQLMutation.args.input.type.ofType) && + parseGraphQLSchema.addGraphQLType(createGraphQLMutation.type) + ) { + parseGraphQLSchema.addGraphQLMutation(createGraphQLMutationName, createGraphQLMutation); + } + } + + if (isUpdateEnabled) { + const updateGraphQLMutationName = updateAlias || `update${graphQLClassName}`; + const updateGraphQLMutation = mutationWithClientMutationId({ + name: `Update${graphQLClassName}`, + description: `The ${updateGraphQLMutationName} mutation can be used to update an object of the ${graphQLClassName} class.`, + inputFields: { + id: defaultGraphQLTypes.GLOBAL_OR_OBJECT_ID_ATT, + fields: { + description: 'These are the fields that will be used to update the object.', + type: classGraphQLUpdateType || defaultGraphQLTypes.OBJECT, + }, + }, + outputFields: { + [getGraphQLQueryName]: { + description: 'This is the updated object.', + type: new GraphQLNonNull(classGraphQLOutputType || defaultGraphQLTypes.OBJECT), + }, + }, + mutateAndGetPayload: async (args, context, mutationInfo) => { + try { + let { id, fields } = cloneArgs(args); + if (!fields) { fields = {}; } + const { config, auth, info } = context; + + const globalIdObject = fromGlobalId(id); + + if (globalIdObject.type === className) { + id = globalIdObject.id; + } + + const parseFields = await transformTypes('update', fields, { + className, + parseGraphQLSchema, + originalFields: args.fields, + req: { config, auth, info }, + }); + + const updatedObject = await objectsMutations.updateObject( + className, + id, + parseFields, + config, + auth, + info + ); + + const selectedFields = getFieldNames(mutationInfo) + .filter(field => field.startsWith(`${getGraphQLQueryName}.`)) + .map(field => field.replace(`${getGraphQLQueryName}.`, '')); + const { keys, include } = extractKeysAndInclude(selectedFields); + const { keys: requiredKeys, needGet } = getOnlyRequiredFields(fields, keys, include, [ + 'id', + 'objectId', + 'updatedAt', + ]); + const needToGetAllKeys = objectsQueries.needToGetAllKeys( + parseClass.fields, + keys, + parseGraphQLSchema.parseClasses + ); + let optimizedObject = {}; + if (needGet && !needToGetAllKeys) { + optimizedObject = await objectsQueries.getObject( + className, + id, + requiredKeys, + include, + undefined, + undefined, + config, + auth, + info, + parseGraphQLSchema.parseClasses + ); + } else if (needToGetAllKeys) { + optimizedObject = await objectsQueries.getObject( + className, + id, + undefined, + include, + undefined, + undefined, + config, + auth, + info, + parseGraphQLSchema.parseClasses + ); + } + return { + [getGraphQLQueryName]: { + objectId: id, + ...updatedObject, + ...filterDeletedFields(parseFields), + ...optimizedObject, + }, + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + if ( + parseGraphQLSchema.addGraphQLType(updateGraphQLMutation.args.input.type.ofType) && + parseGraphQLSchema.addGraphQLType(updateGraphQLMutation.type) + ) { + parseGraphQLSchema.addGraphQLMutation(updateGraphQLMutationName, updateGraphQLMutation); + } + } + + if (isDestroyEnabled) { + const deleteGraphQLMutationName = destroyAlias || `delete${graphQLClassName}`; + const deleteGraphQLMutation = mutationWithClientMutationId({ + name: `Delete${graphQLClassName}`, + description: `The ${deleteGraphQLMutationName} mutation can be used to delete an object of the ${graphQLClassName} class.`, + inputFields: { + id: defaultGraphQLTypes.GLOBAL_OR_OBJECT_ID_ATT, + }, + outputFields: { + [getGraphQLQueryName]: { + description: 'This is the deleted object.', + type: new GraphQLNonNull(classGraphQLOutputType || defaultGraphQLTypes.OBJECT), + }, + }, + mutateAndGetPayload: async (args, context, mutationInfo) => { + try { + let { id } = cloneArgs(args); + const { config, auth, info } = context; + + const globalIdObject = fromGlobalId(id); + + if (globalIdObject.type === className) { + id = globalIdObject.id; + } + + const selectedFields = getFieldNames(mutationInfo) + .filter(field => field.startsWith(`${getGraphQLQueryName}.`)) + .map(field => field.replace(`${getGraphQLQueryName}.`, '')); + const { keys, include } = extractKeysAndInclude(selectedFields); + let optimizedObject = {}; + if (keys && keys.split(',').filter(key => !['id', 'objectId'].includes(key)).length > 0) { + optimizedObject = await objectsQueries.getObject( + className, + id, + keys, + include, + undefined, + undefined, + config, + auth, + info, + parseGraphQLSchema.parseClasses + ); + } + await objectsMutations.deleteObject(className, id, config, auth, info); + return { + [getGraphQLQueryName]: { + objectId: id, + ...optimizedObject, + }, + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + if ( + parseGraphQLSchema.addGraphQLType(deleteGraphQLMutation.args.input.type.ofType) && + parseGraphQLSchema.addGraphQLType(deleteGraphQLMutation.type) + ) { + parseGraphQLSchema.addGraphQLMutation(deleteGraphQLMutationName, deleteGraphQLMutation); + } + } +}; + +export { load }; diff --git a/src/GraphQL/loaders/parseClassQueries.js b/src/GraphQL/loaders/parseClassQueries.js new file mode 100644 index 0000000000..6c34d320c6 --- /dev/null +++ b/src/GraphQL/loaders/parseClassQueries.js @@ -0,0 +1,144 @@ +import { GraphQLNonNull } from 'graphql'; +import { fromGlobalId } from 'graphql-relay'; +import getFieldNames from 'graphql-list-fields'; + +import pluralize from 'pluralize'; +import * as defaultGraphQLTypes from './defaultGraphQLTypes'; +import * as objectsQueries from '../helpers/objectsQueries'; +import { ParseGraphQLClassConfig } from '../../Controllers/ParseGraphQLController'; +import { transformClassNameToGraphQL } from '../transformers/className'; +import { extractKeysAndInclude, cloneArgs } from '../parseGraphQLUtils'; + +const getParseClassQueryConfig = function (parseClassConfig: ?ParseGraphQLClassConfig) { + return (parseClassConfig && parseClassConfig.query) || {}; +}; + +const getQuery = async (parseClass, _source, args, context, queryInfo, parseClasses) => { + let { id } = args; + const { options } = args; + const { readPreference, includeReadPreference } = options || {}; + const { config, auth, info } = context; + const selectedFields = getFieldNames(queryInfo); + + const globalIdObject = fromGlobalId(id); + + if (globalIdObject.type === parseClass.className) { + id = globalIdObject.id; + } + + const { keys, include } = extractKeysAndInclude(selectedFields); + + return await objectsQueries.getObject( + parseClass.className, + id, + keys, + include, + readPreference, + includeReadPreference, + config, + auth, + info, + parseClasses + ); +}; + +const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseGraphQLClassConfig) { + const className = parseClass.className; + const graphQLClassName = transformClassNameToGraphQL(className); + const { + get: isGetEnabled = true, + find: isFindEnabled = true, + getAlias: getAlias = '', + findAlias: findAlias = '', + } = getParseClassQueryConfig(parseClassConfig); + + const { + classGraphQLOutputType, + classGraphQLFindArgs, + classGraphQLFindResultType, + } = parseGraphQLSchema.parseClassTypes[className]; + + if (isGetEnabled) { + const lowerCaseClassName = graphQLClassName.charAt(0).toLowerCase() + graphQLClassName.slice(1); + + const getGraphQLQueryName = getAlias || lowerCaseClassName; + + parseGraphQLSchema.addGraphQLQuery(getGraphQLQueryName, { + description: `The ${getGraphQLQueryName} query can be used to get an object of the ${graphQLClassName} class by its id.`, + args: { + id: defaultGraphQLTypes.GLOBAL_OR_OBJECT_ID_ATT, + options: defaultGraphQLTypes.READ_OPTIONS_ATT, + }, + type: new GraphQLNonNull(classGraphQLOutputType || defaultGraphQLTypes.OBJECT), + async resolve(_source, args, context, queryInfo) { + try { + return await getQuery( + parseClass, + _source, + cloneArgs(args), + context, + queryInfo, + parseGraphQLSchema.parseClasses + ); + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + } + + if (isFindEnabled) { + const lowerCaseClassName = graphQLClassName.charAt(0).toLowerCase() + graphQLClassName.slice(1); + + const findGraphQLQueryName = findAlias || pluralize(lowerCaseClassName); + + parseGraphQLSchema.addGraphQLQuery(findGraphQLQueryName, { + description: `The ${findGraphQLQueryName} query can be used to find objects of the ${graphQLClassName} class.`, + args: classGraphQLFindArgs, + type: new GraphQLNonNull(classGraphQLFindResultType || defaultGraphQLTypes.OBJECT), + async resolve(_source, args, context, queryInfo) { + try { + // Deep copy args to avoid internal re assign issue + const { where, order, skip, first, after, last, before, options } = cloneArgs(args); + const { readPreference, includeReadPreference, subqueryReadPreference } = options || {}; + const { config, auth, info } = context; + const selectedFields = getFieldNames(queryInfo); + + const { keys, include } = extractKeysAndInclude( + selectedFields + .filter(field => field.startsWith('edges.node.')) + .map(field => field.replace('edges.node.', '')) + .filter(field => field.indexOf('edges.node') < 0) + ); + const parseOrder = order && order.join(','); + + return await objectsQueries.findObjects( + className, + where, + parseOrder, + skip, + first, + after, + last, + before, + keys, + include, + false, + readPreference, + includeReadPreference, + subqueryReadPreference, + config, + auth, + info, + selectedFields, + parseGraphQLSchema.parseClasses + ); + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + } +}; + +export { load }; diff --git a/src/GraphQL/loaders/parseClassTypes.js b/src/GraphQL/loaders/parseClassTypes.js new file mode 100644 index 0000000000..c6c08c8889 --- /dev/null +++ b/src/GraphQL/loaders/parseClassTypes.js @@ -0,0 +1,537 @@ +/* eslint-disable indent */ +import { + GraphQLID, + GraphQLObjectType, + GraphQLString, + GraphQLList, + GraphQLInputObjectType, + GraphQLNonNull, + GraphQLBoolean, + GraphQLEnumType, +} from 'graphql'; +import { globalIdField, connectionArgs, connectionDefinitions } from 'graphql-relay'; +import getFieldNames from 'graphql-list-fields'; +import * as defaultGraphQLTypes from './defaultGraphQLTypes'; +import * as objectsQueries from '../helpers/objectsQueries'; +import { ParseGraphQLClassConfig } from '../../Controllers/ParseGraphQLController'; +import { transformClassNameToGraphQL } from '../transformers/className'; +import { transformInputTypeToGraphQL } from '../transformers/inputType'; +import { transformOutputTypeToGraphQL } from '../transformers/outputType'; +import { transformConstraintTypeToGraphQL } from '../transformers/constraintType'; +import { extractKeysAndInclude, getParseClassMutationConfig } from '../parseGraphQLUtils'; + +const getParseClassTypeConfig = function (parseClassConfig: ?ParseGraphQLClassConfig) { + return (parseClassConfig && parseClassConfig.type) || {}; +}; + +const getInputFieldsAndConstraints = function ( + parseClass, + parseClassConfig: ?ParseGraphQLClassConfig +) { + const classFields = Object.keys(parseClass.fields).concat('id'); + const { + inputFields: allowedInputFields, + outputFields: allowedOutputFields, + constraintFields: allowedConstraintFields, + sortFields: allowedSortFields, + } = getParseClassTypeConfig(parseClassConfig); + + let classOutputFields; + let classCreateFields; + let classUpdateFields; + let classConstraintFields; + let classSortFields; + + // All allowed customs fields + const classCustomFields = classFields.filter(field => { + return !Object.keys(defaultGraphQLTypes.PARSE_OBJECT_FIELDS).includes(field) && field !== 'id'; + }); + + if (allowedInputFields && allowedInputFields.create) { + classCreateFields = classCustomFields.filter(field => { + return allowedInputFields.create.includes(field); + }); + } else { + classCreateFields = classCustomFields; + } + if (allowedInputFields && allowedInputFields.update) { + classUpdateFields = classCustomFields.filter(field => { + return allowedInputFields.update.includes(field); + }); + } else { + classUpdateFields = classCustomFields; + } + + if (allowedOutputFields) { + classOutputFields = classCustomFields.filter(field => { + return allowedOutputFields.includes(field); + }); + } else { + classOutputFields = classCustomFields; + } + // Filters the "password" field from class _User + if (parseClass.className === '_User') { + classOutputFields = classOutputFields.filter(outputField => outputField !== 'password'); + } + + if (allowedConstraintFields) { + classConstraintFields = classCustomFields.filter(field => { + return allowedConstraintFields.includes(field); + }); + } else { + classConstraintFields = classFields; + } + + if (allowedSortFields) { + classSortFields = allowedSortFields; + if (!classSortFields.length) { + // must have at least 1 order field + // otherwise the FindArgs Input Type will throw. + classSortFields.push({ + field: 'id', + asc: true, + desc: true, + }); + } + } else { + classSortFields = classFields.map(field => { + return { field, asc: true, desc: true }; + }); + } + + return { + classCreateFields, + classUpdateFields, + classConstraintFields, + classOutputFields, + classSortFields, + }; +}; + +const load = (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseGraphQLClassConfig) => { + const className = parseClass.className; + const graphQLClassName = transformClassNameToGraphQL(className); + const { + classCreateFields, + classUpdateFields, + classOutputFields, + classConstraintFields, + classSortFields, + } = getInputFieldsAndConstraints(parseClass, parseClassConfig); + + const { + create: isCreateEnabled = true, + update: isUpdateEnabled = true, + } = getParseClassMutationConfig(parseClassConfig); + + const classGraphQLCreateTypeName = `Create${graphQLClassName}FieldsInput`; + let classGraphQLCreateType = new GraphQLInputObjectType({ + name: classGraphQLCreateTypeName, + description: `The ${classGraphQLCreateTypeName} input type is used in operations that involve creation of objects in the ${graphQLClassName} class.`, + fields: () => + classCreateFields.reduce( + (fields, field) => { + const type = transformInputTypeToGraphQL( + parseClass.fields[field].type, + parseClass.fields[field].targetClass, + parseGraphQLSchema.parseClassTypes + ); + if (type) { + return { + ...fields, + [field]: { + description: `This is the object ${field}.`, + type: parseClass.fields[field].required ? new GraphQLNonNull(type) : type, + }, + }; + } else { + return fields; + } + }, + { + ACL: { type: defaultGraphQLTypes.ACL_INPUT }, + } + ), + }); + classGraphQLCreateType = parseGraphQLSchema.addGraphQLType(classGraphQLCreateType); + + const classGraphQLUpdateTypeName = `Update${graphQLClassName}FieldsInput`; + let classGraphQLUpdateType = new GraphQLInputObjectType({ + name: classGraphQLUpdateTypeName, + description: `The ${classGraphQLUpdateTypeName} input type is used in operations that involve creation of objects in the ${graphQLClassName} class.`, + fields: () => + classUpdateFields.reduce( + (fields, field) => { + const type = transformInputTypeToGraphQL( + parseClass.fields[field].type, + parseClass.fields[field].targetClass, + parseGraphQLSchema.parseClassTypes + ); + if (type) { + return { + ...fields, + [field]: { + description: `This is the object ${field}.`, + type, + }, + }; + } else { + return fields; + } + }, + { + ACL: { type: defaultGraphQLTypes.ACL_INPUT }, + } + ), + }); + classGraphQLUpdateType = parseGraphQLSchema.addGraphQLType(classGraphQLUpdateType); + + const classGraphQLPointerTypeName = `${graphQLClassName}PointerInput`; + let classGraphQLPointerType = new GraphQLInputObjectType({ + name: classGraphQLPointerTypeName, + description: `Allow to link OR add and link an object of the ${graphQLClassName} class.`, + fields: () => { + const fields = { + link: { + description: `Link an existing object from ${graphQLClassName} class. You can use either the global or the object id.`, + type: GraphQLID, + }, + }; + if (isCreateEnabled) { + fields['createAndLink'] = { + description: `Create and link an object from ${graphQLClassName} class.`, + type: classGraphQLCreateType, + }; + } + return fields; + }, + }); + classGraphQLPointerType = + parseGraphQLSchema.addGraphQLType(classGraphQLPointerType) || defaultGraphQLTypes.OBJECT; + + const classGraphQLRelationTypeName = `${graphQLClassName}RelationInput`; + let classGraphQLRelationType = new GraphQLInputObjectType({ + name: classGraphQLRelationTypeName, + description: `Allow to add, remove, createAndAdd objects of the ${graphQLClassName} class into a relation field.`, + fields: () => { + const fields = { + add: { + description: `Add existing objects from the ${graphQLClassName} class into the relation. You can use either the global or the object ids.`, + type: new GraphQLList(defaultGraphQLTypes.OBJECT_ID), + }, + remove: { + description: `Remove existing objects from the ${graphQLClassName} class out of the relation. You can use either the global or the object ids.`, + type: new GraphQLList(defaultGraphQLTypes.OBJECT_ID), + }, + }; + if (isCreateEnabled) { + fields['createAndAdd'] = { + description: `Create and add objects of the ${graphQLClassName} class into the relation.`, + type: new GraphQLList(new GraphQLNonNull(classGraphQLCreateType)), + }; + } + return fields; + }, + }); + classGraphQLRelationType = + parseGraphQLSchema.addGraphQLType(classGraphQLRelationType) || defaultGraphQLTypes.OBJECT; + + const classGraphQLConstraintsTypeName = `${graphQLClassName}WhereInput`; + let classGraphQLConstraintsType = new GraphQLInputObjectType({ + name: classGraphQLConstraintsTypeName, + description: `The ${classGraphQLConstraintsTypeName} input type is used in operations that involve filtering objects of ${graphQLClassName} class.`, + fields: () => ({ + ...classConstraintFields.reduce((fields, field) => { + if (['OR', 'AND', 'NOR'].includes(field)) { + parseGraphQLSchema.log.warn( + `Field ${field} could not be added to the auto schema ${classGraphQLConstraintsTypeName} because it collided with an existing one.` + ); + return fields; + } + const parseField = field === 'id' ? 'objectId' : field; + const type = transformConstraintTypeToGraphQL( + parseClass.fields[parseField].type, + parseClass.fields[parseField].targetClass, + parseGraphQLSchema.parseClassTypes, + field + ); + if (type) { + return { + ...fields, + [field]: { + description: `This is the object ${field}.`, + type, + }, + }; + } else { + return fields; + } + }, {}), + OR: { + description: 'This is the OR operator to compound constraints.', + type: new GraphQLList(new GraphQLNonNull(classGraphQLConstraintsType)), + }, + AND: { + description: 'This is the AND operator to compound constraints.', + type: new GraphQLList(new GraphQLNonNull(classGraphQLConstraintsType)), + }, + NOR: { + description: 'This is the NOR operator to compound constraints.', + type: new GraphQLList(new GraphQLNonNull(classGraphQLConstraintsType)), + }, + }), + }); + classGraphQLConstraintsType = + parseGraphQLSchema.addGraphQLType(classGraphQLConstraintsType) || defaultGraphQLTypes.OBJECT; + + const classGraphQLRelationConstraintsTypeName = `${graphQLClassName}RelationWhereInput`; + let classGraphQLRelationConstraintsType = new GraphQLInputObjectType({ + name: classGraphQLRelationConstraintsTypeName, + description: `The ${classGraphQLRelationConstraintsTypeName} input type is used in operations that involve filtering objects of ${graphQLClassName} class.`, + fields: () => ({ + have: { + description: 'Run a relational/pointer query where at least one child object can match.', + type: classGraphQLConstraintsType, + }, + haveNot: { + description: + 'Run an inverted relational/pointer query where at least one child object can match.', + type: classGraphQLConstraintsType, + }, + exists: { + description: 'Check if the relation/pointer contains objects.', + type: GraphQLBoolean, + }, + }), + }); + classGraphQLRelationConstraintsType = + parseGraphQLSchema.addGraphQLType(classGraphQLRelationConstraintsType) || + defaultGraphQLTypes.OBJECT; + + const classGraphQLOrderTypeName = `${graphQLClassName}Order`; + let classGraphQLOrderType = new GraphQLEnumType({ + name: classGraphQLOrderTypeName, + description: `The ${classGraphQLOrderTypeName} input type is used when sorting objects of the ${graphQLClassName} class.`, + values: classSortFields.reduce((sortFields, fieldConfig) => { + const { field, asc, desc } = fieldConfig; + const updatedSortFields = { + ...sortFields, + }; + const value = field === 'id' ? 'objectId' : field; + if (asc) { + updatedSortFields[`${field}_ASC`] = { value }; + } + if (desc) { + updatedSortFields[`${field}_DESC`] = { value: `-${value}` }; + } + return updatedSortFields; + }, {}), + }); + classGraphQLOrderType = parseGraphQLSchema.addGraphQLType(classGraphQLOrderType); + + const classGraphQLFindArgs = { + where: { + description: 'These are the conditions that the objects need to match in order to be found.', + type: classGraphQLConstraintsType, + }, + order: { + description: 'The fields to be used when sorting the data fetched.', + type: classGraphQLOrderType + ? new GraphQLList(new GraphQLNonNull(classGraphQLOrderType)) + : GraphQLString, + }, + skip: defaultGraphQLTypes.SKIP_ATT, + ...connectionArgs, + options: defaultGraphQLTypes.READ_OPTIONS_ATT, + }; + const classGraphQLOutputTypeName = `${graphQLClassName}`; + const interfaces = [defaultGraphQLTypes.PARSE_OBJECT, parseGraphQLSchema.relayNodeInterface]; + const parseObjectFields = { + id: globalIdField(className, obj => obj.objectId), + ...defaultGraphQLTypes.PARSE_OBJECT_FIELDS, + ...(className === '_User' + ? { + authDataResponse: { + description: `auth provider response when triggered on signUp/logIn.`, + type: defaultGraphQLTypes.OBJECT, + }, + } + : {}), + }; + const outputFields = () => { + return classOutputFields.reduce((fields, field) => { + const type = transformOutputTypeToGraphQL( + parseClass.fields[field].type, + parseClass.fields[field].targetClass, + parseGraphQLSchema.parseClassTypes + ); + if (parseClass.fields[field].type === 'Relation') { + const targetParseClassTypes = + parseGraphQLSchema.parseClassTypes[parseClass.fields[field].targetClass]; + const args = targetParseClassTypes ? targetParseClassTypes.classGraphQLFindArgs : undefined; + return { + ...fields, + [field]: { + description: `This is the object ${field}.`, + args, + type: parseClass.fields[field].required ? new GraphQLNonNull(type) : type, + async resolve(source, args, context, queryInfo) { + try { + const { where, order, skip, first, after, last, before, options } = args; + const { readPreference, includeReadPreference, subqueryReadPreference } = + options || {}; + const { config, auth, info } = context; + const selectedFields = getFieldNames(queryInfo); + + const { keys, include } = extractKeysAndInclude( + selectedFields + .filter(field => field.startsWith('edges.node.')) + .map(field => field.replace('edges.node.', '')) + .filter(field => field.indexOf('edges.node') < 0) + ); + const parseOrder = order && order.join(','); + + return objectsQueries.findObjects( + source[field].className, + { + $relatedTo: { + object: { + __type: 'Pointer', + className: className, + objectId: source.objectId, + }, + key: field, + }, + ...(where || {}), + }, + parseOrder, + skip, + first, + after, + last, + before, + keys, + include, + false, + readPreference, + includeReadPreference, + subqueryReadPreference, + config, + auth, + info, + selectedFields, + parseGraphQLSchema.parseClasses + ); + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }, + }; + } else if (parseClass.fields[field].type === 'Polygon') { + return { + ...fields, + [field]: { + description: `This is the object ${field}.`, + type: parseClass.fields[field].required ? new GraphQLNonNull(type) : type, + async resolve(source) { + if (source[field] && source[field].coordinates) { + return source[field].coordinates.map(coordinate => ({ + latitude: coordinate[0], + longitude: coordinate[1], + })); + } else { + return null; + } + }, + }, + }; + } else if (parseClass.fields[field].type === 'Array') { + return { + ...fields, + [field]: { + description: `Use Inline Fragment on Array to get results: https://graphql.org/learn/queries/#inline-fragments`, + type: parseClass.fields[field].required ? new GraphQLNonNull(type) : type, + async resolve(source) { + if (!source[field]) { return null; } + return source[field].map(async elem => { + if (elem.className && elem.objectId && elem.__type === 'Object') { + return elem; + } else { + return { value: elem }; + } + }); + }, + }, + }; + } else if (type) { + return { + ...fields, + [field]: { + description: `This is the object ${field}.`, + type: parseClass.fields[field].required ? new GraphQLNonNull(type) : type, + }, + }; + } else { + return fields; + } + }, parseObjectFields); + }; + let classGraphQLOutputType = new GraphQLObjectType({ + name: classGraphQLOutputTypeName, + description: `The ${classGraphQLOutputTypeName} object type is used in operations that involve outputting objects of ${graphQLClassName} class.`, + interfaces, + fields: outputFields, + }); + classGraphQLOutputType = parseGraphQLSchema.addGraphQLType(classGraphQLOutputType); + + const { connectionType, edgeType } = connectionDefinitions({ + name: graphQLClassName, + connectionFields: { + count: defaultGraphQLTypes.COUNT_ATT, + }, + nodeType: classGraphQLOutputType || defaultGraphQLTypes.OBJECT, + }); + let classGraphQLFindResultType = undefined; + if ( + parseGraphQLSchema.addGraphQLType(edgeType) && + parseGraphQLSchema.addGraphQLType(connectionType, false, false, true) + ) { + classGraphQLFindResultType = connectionType; + } + + parseGraphQLSchema.parseClassTypes[className] = { + classGraphQLPointerType, + classGraphQLRelationType, + classGraphQLCreateType, + classGraphQLUpdateType, + classGraphQLConstraintsType, + classGraphQLRelationConstraintsType, + classGraphQLFindArgs, + classGraphQLOutputType, + classGraphQLFindResultType, + config: { + parseClassConfig, + isCreateEnabled, + isUpdateEnabled, + }, + }; + + if (className === '_User') { + const viewerType = new GraphQLObjectType({ + name: 'Viewer', + description: `The Viewer object type is used in operations that involve outputting the current user data.`, + fields: () => ({ + sessionToken: defaultGraphQLTypes.SESSION_TOKEN_ATT, + user: { + description: 'This is the current user.', + type: new GraphQLNonNull(classGraphQLOutputType), + }, + }), + }); + parseGraphQLSchema.addGraphQLType(viewerType, true, true); + parseGraphQLSchema.viewerType = viewerType; + } +}; + +export { extractKeysAndInclude, load }; diff --git a/src/GraphQL/loaders/schemaDirectives.js b/src/GraphQL/loaders/schemaDirectives.js new file mode 100644 index 0000000000..f354167317 --- /dev/null +++ b/src/GraphQL/loaders/schemaDirectives.js @@ -0,0 +1,56 @@ +import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils'; +import { FunctionsRouter } from '../../Routers/FunctionsRouter'; + +export const definitions = ` + directive @resolve(to: String) on FIELD_DEFINITION + directive @mock(with: Any!) on FIELD_DEFINITION +`; + +const load = parseGraphQLSchema => { + parseGraphQLSchema.graphQLSchemaDirectivesDefinitions = definitions; + + const resolveDirective = schema => + mapSchema(schema, { + [MapperKind.OBJECT_FIELD]: fieldConfig => { + const directive = getDirective(schema, fieldConfig, 'resolve')?.[0]; + if (directive) { + const { to: targetCloudFunction } = directive; + fieldConfig.resolve = async (_source, args, context, gqlInfo) => { + try { + const { config, auth, info } = context; + const functionName = targetCloudFunction || gqlInfo.fieldName; + return ( + await FunctionsRouter.handleCloudFunction({ + params: { + functionName, + }, + config, + auth, + info, + body: args, + }) + ).response.result; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }; + } + return fieldConfig; + }, + }); + + const mockDirective = schema => + mapSchema(schema, { + [MapperKind.OBJECT_FIELD]: fieldConfig => { + const directive = getDirective(schema, fieldConfig, 'mock')?.[0]; + if (directive) { + const { with: mockValue } = directive; + fieldConfig.resolve = async () => mockValue; + } + return fieldConfig; + }, + }); + + parseGraphQLSchema.graphQLSchemaDirectives = schema => mockDirective(resolveDirective(schema)); +}; +export { load }; diff --git a/src/GraphQL/loaders/schemaMutations.js b/src/GraphQL/loaders/schemaMutations.js new file mode 100644 index 0000000000..69c8e1c54c --- /dev/null +++ b/src/GraphQL/loaders/schemaMutations.js @@ -0,0 +1,166 @@ +import Parse from 'parse/node'; +import { GraphQLNonNull } from 'graphql'; + +import { mutationWithClientMutationId } from 'graphql-relay'; +import * as schemaTypes from './schemaTypes'; +import { transformToParse, transformToGraphQL } from '../transformers/schemaFields'; +import { enforceMasterKeyAccess, cloneArgs } from '../parseGraphQLUtils'; +import { getClass } from './schemaQueries'; +import { createSanitizedError } from '../../Error'; + +const load = parseGraphQLSchema => { + const createClassMutation = mutationWithClientMutationId({ + name: 'CreateClass', + description: + 'The createClass mutation can be used to create the schema for a new object class.', + inputFields: { + name: schemaTypes.CLASS_NAME_ATT, + schemaFields: { + description: "These are the schema's fields of the object class.", + type: schemaTypes.SCHEMA_FIELDS_INPUT, + }, + }, + outputFields: { + class: { + description: 'This is the created class.', + type: new GraphQLNonNull(schemaTypes.CLASS), + }, + }, + mutateAndGetPayload: async (args, context) => { + try { + const { name, schemaFields } = cloneArgs(args); + const { config, auth } = context; + + enforceMasterKeyAccess(auth, config); + + if (auth.isReadOnly) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to create a schema.", + config + ); + } + + const schema = await config.database.loadSchema({ clearCache: true }); + const parseClass = await schema.addClassIfNotExists(name, transformToParse(schemaFields)); + return { + class: { + name: parseClass.className, + schemaFields: transformToGraphQL(parseClass.fields), + }, + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType(createClassMutation.args.input.type.ofType, true, true); + parseGraphQLSchema.addGraphQLType(createClassMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation('createClass', createClassMutation, true, true); + + const updateClassMutation = mutationWithClientMutationId({ + name: 'UpdateClass', + description: + 'The updateClass mutation can be used to update the schema for an existing object class.', + inputFields: { + name: schemaTypes.CLASS_NAME_ATT, + schemaFields: { + description: "These are the schema's fields of the object class.", + type: schemaTypes.SCHEMA_FIELDS_INPUT, + }, + }, + outputFields: { + class: { + description: 'This is the updated class.', + type: new GraphQLNonNull(schemaTypes.CLASS), + }, + }, + mutateAndGetPayload: async (args, context) => { + try { + const { name, schemaFields } = cloneArgs(args); + const { config, auth } = context; + + enforceMasterKeyAccess(auth, config); + + if (auth.isReadOnly) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to update a schema.", + config + ); + } + + const schema = await config.database.loadSchema({ clearCache: true }); + const existingParseClass = await getClass(name, schema); + const parseClass = await schema.updateClass( + name, + transformToParse(schemaFields, existingParseClass.fields), + undefined, + undefined, + config.database + ); + return { + class: { + name: parseClass.className, + schemaFields: transformToGraphQL(parseClass.fields), + }, + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType(updateClassMutation.args.input.type.ofType, true, true); + parseGraphQLSchema.addGraphQLType(updateClassMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation('updateClass', updateClassMutation, true, true); + + const deleteClassMutation = mutationWithClientMutationId({ + name: 'DeleteClass', + description: 'The deleteClass mutation can be used to delete an existing object class.', + inputFields: { + name: schemaTypes.CLASS_NAME_ATT, + }, + outputFields: { + class: { + description: 'This is the deleted class.', + type: new GraphQLNonNull(schemaTypes.CLASS), + }, + }, + mutateAndGetPayload: async (args, context) => { + try { + const { name } = cloneArgs(args); + const { config, auth } = context; + + enforceMasterKeyAccess(auth, config); + + if (auth.isReadOnly) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to delete a schema.", + config + ); + } + + const schema = await config.database.loadSchema({ clearCache: true }); + const existingParseClass = await getClass(name, schema); + await config.database.deleteSchema(name); + return { + class: { + name: existingParseClass.className, + schemaFields: transformToGraphQL(existingParseClass.fields), + }, + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType(deleteClassMutation.args.input.type.ofType, true, true); + parseGraphQLSchema.addGraphQLType(deleteClassMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation('deleteClass', deleteClassMutation, true, true); +}; + +export { load }; diff --git a/src/GraphQL/loaders/schemaQueries.js b/src/GraphQL/loaders/schemaQueries.js new file mode 100644 index 0000000000..6e16a54bfb --- /dev/null +++ b/src/GraphQL/loaders/schemaQueries.js @@ -0,0 +1,77 @@ +import Parse from 'parse/node'; + +import { GraphQLNonNull, GraphQLList } from 'graphql'; +import { transformToGraphQL } from '../transformers/schemaFields'; +import * as schemaTypes from './schemaTypes'; +import { enforceMasterKeyAccess, cloneArgs } from '../parseGraphQLUtils'; + +const getClass = async (name, schema) => { + try { + return await schema.getOneSchema(name, true); + } catch (e) { + if (e === undefined) { + throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${name} does not exist.`); + } else { + throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Database adapter error.'); + } + } +}; + +const load = parseGraphQLSchema => { + parseGraphQLSchema.addGraphQLQuery( + 'class', + { + description: 'The class query can be used to retrieve an existing object class.', + args: { + name: schemaTypes.CLASS_NAME_ATT, + }, + type: new GraphQLNonNull(schemaTypes.CLASS), + resolve: async (_source, args, context) => { + try { + const { name } = cloneArgs(args); + const { config, auth } = context; + + enforceMasterKeyAccess(auth, config); + + const schema = await config.database.loadSchema({ clearCache: true }); + const parseClass = await getClass(name, schema); + return { + name: parseClass.className, + schemaFields: transformToGraphQL(parseClass.fields), + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }, + true, + true + ); + + parseGraphQLSchema.addGraphQLQuery( + 'classes', + { + description: 'The classes query can be used to retrieve the existing object classes.', + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(schemaTypes.CLASS))), + resolve: async (_source, _args, context) => { + try { + const { config, auth } = context; + + enforceMasterKeyAccess(auth, config); + + const schema = await config.database.loadSchema({ clearCache: true }); + return (await schema.getAllClasses(true)).map(parseClass => ({ + name: parseClass.className, + schemaFields: transformToGraphQL(parseClass.fields), + })); + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }, + true, + true + ); +}; + +export { getClass, load }; diff --git a/src/GraphQL/loaders/schemaTypes.js b/src/GraphQL/loaders/schemaTypes.js new file mode 100644 index 0000000000..ea8a24aca5 --- /dev/null +++ b/src/GraphQL/loaders/schemaTypes.js @@ -0,0 +1,423 @@ +import { + GraphQLNonNull, + GraphQLString, + GraphQLInputObjectType, + GraphQLList, + GraphQLObjectType, + GraphQLInterfaceType, +} from 'graphql'; + +const SCHEMA_FIELD_NAME_ATT = { + description: 'This is the field name.', + type: new GraphQLNonNull(GraphQLString), +}; + +const SCHEMA_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'SchemaFieldInput', + description: 'The SchemaFieldInput is used to specify a field of an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_FIELD = new GraphQLInterfaceType({ + name: 'SchemaField', + description: + 'The SchemaField interface type is used as a base type for the different supported fields of an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, + resolveType: value => + ({ + String: SCHEMA_STRING_FIELD.name, + Number: SCHEMA_NUMBER_FIELD.name, + Boolean: SCHEMA_BOOLEAN_FIELD.name, + Array: SCHEMA_ARRAY_FIELD.name, + Object: SCHEMA_OBJECT_FIELD.name, + Date: SCHEMA_DATE_FIELD.name, + File: SCHEMA_FILE_FIELD.name, + GeoPoint: SCHEMA_GEO_POINT_FIELD.name, + Polygon: SCHEMA_POLYGON_FIELD.name, + Bytes: SCHEMA_BYTES_FIELD.name, + Pointer: SCHEMA_POINTER_FIELD.name, + Relation: SCHEMA_RELATION_FIELD.name, + ACL: SCHEMA_ACL_FIELD.name, + }[value.type]), +}); + +const SCHEMA_STRING_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'SchemaStringFieldInput', + description: + 'The SchemaStringFieldInput is used to specify a field of type string for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_STRING_FIELD = new GraphQLObjectType({ + name: 'SchemaStringField', + description: 'The SchemaStringField is used to return information of a String field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_NUMBER_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'SchemaNumberFieldInput', + description: + 'The SchemaNumberFieldInput is used to specify a field of type number for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_NUMBER_FIELD = new GraphQLObjectType({ + name: 'SchemaNumberField', + description: 'The SchemaNumberField is used to return information of a Number field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_BOOLEAN_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'SchemaBooleanFieldInput', + description: + 'The SchemaBooleanFieldInput is used to specify a field of type boolean for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_BOOLEAN_FIELD = new GraphQLObjectType({ + name: 'SchemaBooleanField', + description: 'The SchemaBooleanField is used to return information of a Boolean field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_ARRAY_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'SchemaArrayFieldInput', + description: + 'The SchemaArrayFieldInput is used to specify a field of type array for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_ARRAY_FIELD = new GraphQLObjectType({ + name: 'SchemaArrayField', + description: 'The SchemaArrayField is used to return information of an Array field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_OBJECT_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'SchemaObjectFieldInput', + description: + 'The SchemaObjectFieldInput is used to specify a field of type object for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_OBJECT_FIELD = new GraphQLObjectType({ + name: 'SchemaObjectField', + description: 'The SchemaObjectField is used to return information of an Object field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_DATE_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'SchemaDateFieldInput', + description: + 'The SchemaDateFieldInput is used to specify a field of type date for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_DATE_FIELD = new GraphQLObjectType({ + name: 'SchemaDateField', + description: 'The SchemaDateField is used to return information of a Date field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_FILE_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'SchemaFileFieldInput', + description: + 'The SchemaFileFieldInput is used to specify a field of type file for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_FILE_FIELD = new GraphQLObjectType({ + name: 'SchemaFileField', + description: 'The SchemaFileField is used to return information of a File field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_GEO_POINT_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'SchemaGeoPointFieldInput', + description: + 'The SchemaGeoPointFieldInput is used to specify a field of type geo point for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_GEO_POINT_FIELD = new GraphQLObjectType({ + name: 'SchemaGeoPointField', + description: 'The SchemaGeoPointField is used to return information of a Geo Point field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_POLYGON_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'SchemaPolygonFieldInput', + description: + 'The SchemaPolygonFieldInput is used to specify a field of type polygon for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_POLYGON_FIELD = new GraphQLObjectType({ + name: 'SchemaPolygonField', + description: 'The SchemaPolygonField is used to return information of a Polygon field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_BYTES_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'SchemaBytesFieldInput', + description: + 'The SchemaBytesFieldInput is used to specify a field of type bytes for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_BYTES_FIELD = new GraphQLObjectType({ + name: 'SchemaBytesField', + description: 'The SchemaBytesField is used to return information of a Bytes field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const TARGET_CLASS_ATT = { + description: 'This is the name of the target class for the field.', + type: new GraphQLNonNull(GraphQLString), +}; + +const SCHEMA_POINTER_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'PointerFieldInput', + description: + 'The PointerFieldInput is used to specify a field of type pointer for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + targetClassName: TARGET_CLASS_ATT, + }, +}); + +const SCHEMA_POINTER_FIELD = new GraphQLObjectType({ + name: 'SchemaPointerField', + description: 'The SchemaPointerField is used to return information of a Pointer field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + targetClassName: TARGET_CLASS_ATT, + }, +}); + +const SCHEMA_RELATION_FIELD_INPUT = new GraphQLInputObjectType({ + name: 'RelationFieldInput', + description: + 'The RelationFieldInput is used to specify a field of type relation for an object class schema.', + fields: { + name: SCHEMA_FIELD_NAME_ATT, + targetClassName: TARGET_CLASS_ATT, + }, +}); + +const SCHEMA_RELATION_FIELD = new GraphQLObjectType({ + name: 'SchemaRelationField', + description: 'The SchemaRelationField is used to return information of a Relation field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + targetClassName: TARGET_CLASS_ATT, + }, +}); + +const SCHEMA_ACL_FIELD = new GraphQLObjectType({ + name: 'SchemaACLField', + description: 'The SchemaACLField is used to return information of an ACL field.', + interfaces: [SCHEMA_FIELD], + fields: { + name: SCHEMA_FIELD_NAME_ATT, + }, +}); + +const SCHEMA_FIELDS_INPUT = new GraphQLInputObjectType({ + name: 'SchemaFieldsInput', + description: `The CreateClassSchemaInput type is used to specify the schema for a new object class to be created.`, + fields: { + addStrings: { + description: 'These are the String fields to be added to the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_STRING_FIELD_INPUT)), + }, + addNumbers: { + description: 'These are the Number fields to be added to the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_NUMBER_FIELD_INPUT)), + }, + addBooleans: { + description: 'These are the Boolean fields to be added to the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_BOOLEAN_FIELD_INPUT)), + }, + addArrays: { + description: 'These are the Array fields to be added to the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_ARRAY_FIELD_INPUT)), + }, + addObjects: { + description: 'These are the Object fields to be added to the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_OBJECT_FIELD_INPUT)), + }, + addDates: { + description: 'These are the Date fields to be added to the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_DATE_FIELD_INPUT)), + }, + addFiles: { + description: 'These are the File fields to be added to the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_FILE_FIELD_INPUT)), + }, + addGeoPoint: { + description: + 'This is the Geo Point field to be added to the class schema. Currently it is supported only one GeoPoint field per Class.', + type: SCHEMA_GEO_POINT_FIELD_INPUT, + }, + addPolygons: { + description: 'These are the Polygon fields to be added to the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_POLYGON_FIELD_INPUT)), + }, + addBytes: { + description: 'These are the Bytes fields to be added to the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_BYTES_FIELD_INPUT)), + }, + addPointers: { + description: 'These are the Pointer fields to be added to the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_POINTER_FIELD_INPUT)), + }, + addRelations: { + description: 'These are the Relation fields to be added to the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_RELATION_FIELD_INPUT)), + }, + remove: { + description: 'These are the fields to be removed from the class schema.', + type: new GraphQLList(new GraphQLNonNull(SCHEMA_FIELD_INPUT)), + }, + }, +}); + +const CLASS_NAME_ATT = { + description: 'This is the name of the object class.', + type: new GraphQLNonNull(GraphQLString), +}; + +const CLASS = new GraphQLObjectType({ + name: 'Class', + description: `The Class type is used to return the information about an object class.`, + fields: { + name: CLASS_NAME_ATT, + schemaFields: { + description: "These are the schema's fields of the object class.", + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(SCHEMA_FIELD))), + }, + }, +}); + +const load = parseGraphQLSchema => { + parseGraphQLSchema.addGraphQLType(SCHEMA_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_STRING_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_STRING_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_NUMBER_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_NUMBER_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_BOOLEAN_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_BOOLEAN_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_ARRAY_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_ARRAY_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_OBJECT_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_OBJECT_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_DATE_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_DATE_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_FILE_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_FILE_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_GEO_POINT_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_GEO_POINT_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_POLYGON_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_POLYGON_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_BYTES_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_BYTES_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_POINTER_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_POINTER_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_RELATION_FIELD_INPUT, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_RELATION_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_ACL_FIELD, true); + parseGraphQLSchema.addGraphQLType(SCHEMA_FIELDS_INPUT, true); + parseGraphQLSchema.addGraphQLType(CLASS, true); +}; + +export { + SCHEMA_FIELD_NAME_ATT, + SCHEMA_FIELD_INPUT, + SCHEMA_STRING_FIELD_INPUT, + SCHEMA_STRING_FIELD, + SCHEMA_NUMBER_FIELD_INPUT, + SCHEMA_NUMBER_FIELD, + SCHEMA_BOOLEAN_FIELD_INPUT, + SCHEMA_BOOLEAN_FIELD, + SCHEMA_ARRAY_FIELD_INPUT, + SCHEMA_ARRAY_FIELD, + SCHEMA_OBJECT_FIELD_INPUT, + SCHEMA_OBJECT_FIELD, + SCHEMA_DATE_FIELD_INPUT, + SCHEMA_DATE_FIELD, + SCHEMA_FILE_FIELD_INPUT, + SCHEMA_FILE_FIELD, + SCHEMA_GEO_POINT_FIELD_INPUT, + SCHEMA_GEO_POINT_FIELD, + SCHEMA_POLYGON_FIELD_INPUT, + SCHEMA_POLYGON_FIELD, + SCHEMA_BYTES_FIELD_INPUT, + SCHEMA_BYTES_FIELD, + TARGET_CLASS_ATT, + SCHEMA_POINTER_FIELD_INPUT, + SCHEMA_POINTER_FIELD, + SCHEMA_RELATION_FIELD_INPUT, + SCHEMA_RELATION_FIELD, + SCHEMA_ACL_FIELD, + SCHEMA_FIELDS_INPUT, + CLASS_NAME_ATT, + CLASS, + load, +}; diff --git a/src/GraphQL/loaders/usersMutations.js b/src/GraphQL/loaders/usersMutations.js new file mode 100644 index 0000000000..1067035b93 --- /dev/null +++ b/src/GraphQL/loaders/usersMutations.js @@ -0,0 +1,435 @@ +import { GraphQLNonNull, GraphQLString, GraphQLBoolean, GraphQLInputObjectType } from 'graphql'; +import { mutationWithClientMutationId } from 'graphql-relay'; + +import { cloneArgs } from '../parseGraphQLUtils'; +import UsersRouter from '../../Routers/UsersRouter'; +import * as objectsMutations from '../helpers/objectsMutations'; +import { OBJECT } from './defaultGraphQLTypes'; +import { getUserFromSessionToken } from './usersQueries'; +import { transformTypes } from '../transformers/mutation'; +import Parse from 'parse/node'; + +const usersRouter = new UsersRouter(); + +const load = parseGraphQLSchema => { + if (parseGraphQLSchema.isUsersClassDisabled) { + return; + } + + const signUpMutation = mutationWithClientMutationId({ + name: 'SignUp', + description: 'The signUp mutation can be used to create and sign up a new user.', + inputFields: { + fields: { + descriptions: 'These are the fields of the new user to be created and signed up.', + type: parseGraphQLSchema.parseClassTypes['_User'].classGraphQLCreateType, + }, + }, + outputFields: { + viewer: { + description: 'This is the new user that was created, signed up and returned as a viewer.', + type: new GraphQLNonNull(parseGraphQLSchema.viewerType), + }, + }, + mutateAndGetPayload: async (args, context, mutationInfo) => { + try { + const { fields } = cloneArgs(args); + const { config, auth, info } = context; + + const parseFields = await transformTypes('create', fields, { + className: '_User', + parseGraphQLSchema, + originalFields: args.fields, + req: { config, auth, info }, + }); + + const { sessionToken, objectId, authDataResponse } = await objectsMutations.createObject( + '_User', + parseFields, + config, + auth, + info + ); + + context.info.sessionToken = sessionToken; + const viewer = await getUserFromSessionToken( + context, + mutationInfo, + 'viewer.user.', + objectId + ); + if (authDataResponse && viewer.user) { viewer.user.authDataResponse = authDataResponse; } + return { + viewer, + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType(signUpMutation.args.input.type.ofType, true, true); + parseGraphQLSchema.addGraphQLType(signUpMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation('signUp', signUpMutation, true, true); + const logInWithMutation = mutationWithClientMutationId({ + name: 'LogInWith', + description: + 'The logInWith mutation can be used to signup, login user with 3rd party authentication system. This mutation create a user if the authData do not correspond to an existing one.', + inputFields: { + authData: { + descriptions: 'This is the auth data of your custom auth provider', + type: new GraphQLNonNull(OBJECT), + }, + fields: { + descriptions: 'These are the fields of the user to be created/updated and logged in.', + type: new GraphQLInputObjectType({ + name: 'UserLoginWithInput', + fields: () => { + const classGraphQLCreateFields = parseGraphQLSchema.parseClassTypes[ + '_User' + ].classGraphQLCreateType.getFields(); + return Object.keys(classGraphQLCreateFields).reduce((fields, fieldName) => { + if ( + fieldName !== 'password' && + fieldName !== 'username' && + fieldName !== 'authData' + ) { + fields[fieldName] = classGraphQLCreateFields[fieldName]; + } + return fields; + }, {}); + }, + }), + }, + }, + outputFields: { + viewer: { + description: 'This is the new user that was created, signed up and returned as a viewer.', + type: new GraphQLNonNull(parseGraphQLSchema.viewerType), + }, + }, + mutateAndGetPayload: async (args, context, mutationInfo) => { + try { + const { fields, authData } = cloneArgs(args); + const { config, auth, info } = context; + + const parseFields = await transformTypes('create', fields, { + className: '_User', + parseGraphQLSchema, + originalFields: args.fields, + req: { config, auth, info }, + }); + + const { sessionToken, objectId, authDataResponse } = await objectsMutations.createObject( + '_User', + { ...parseFields, authData }, + config, + auth, + info + ); + + context.info.sessionToken = sessionToken; + const viewer = await getUserFromSessionToken( + context, + mutationInfo, + 'viewer.user.', + objectId + ); + if (authDataResponse && viewer.user) { viewer.user.authDataResponse = authDataResponse; } + return { + viewer, + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType(logInWithMutation.args.input.type.ofType, true, true); + parseGraphQLSchema.addGraphQLType(logInWithMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation('logInWith', logInWithMutation, true, true); + + const logInMutation = mutationWithClientMutationId({ + name: 'LogIn', + description: 'The logIn mutation can be used to log in an existing user.', + inputFields: { + username: { + description: 'This is the username used to log in the user.', + type: new GraphQLNonNull(GraphQLString), + }, + password: { + description: 'This is the password used to log in the user.', + type: new GraphQLNonNull(GraphQLString), + }, + authData: { + description: 'Auth data payload, needed if some required auth adapters are configured.', + type: OBJECT, + }, + }, + outputFields: { + viewer: { + description: 'This is the existing user that was logged in and returned as a viewer.', + type: new GraphQLNonNull(parseGraphQLSchema.viewerType), + }, + }, + mutateAndGetPayload: async (args, context, mutationInfo) => { + try { + const { username, password, authData } = cloneArgs(args); + const { config, auth, info } = context; + + const { sessionToken, objectId, authDataResponse } = ( + await usersRouter.handleLogIn({ + body: { + username, + password, + authData, + }, + query: {}, + config, + auth, + info, + }) + ).response; + + context.info.sessionToken = sessionToken; + + const viewer = await getUserFromSessionToken( + context, + mutationInfo, + 'viewer.user.', + objectId + ); + if (authDataResponse && viewer.user) { viewer.user.authDataResponse = authDataResponse; } + return { + viewer, + }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType(logInMutation.args.input.type.ofType, true, true); + parseGraphQLSchema.addGraphQLType(logInMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation('logIn', logInMutation, true, true); + + const logOutMutation = mutationWithClientMutationId({ + name: 'LogOut', + description: 'The logOut mutation can be used to log out an existing user.', + outputFields: { + ok: { + description: "It's always true.", + type: new GraphQLNonNull(GraphQLBoolean), + }, + }, + mutateAndGetPayload: async (_args, context) => { + try { + const { config, auth, info } = context; + + await usersRouter.handleLogOut({ + config, + auth, + info, + }); + + return { ok: true }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType(logOutMutation.args.input.type.ofType, true, true); + parseGraphQLSchema.addGraphQLType(logOutMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation('logOut', logOutMutation, true, true); + + const resetPasswordMutation = mutationWithClientMutationId({ + name: 'ResetPassword', + description: + 'The resetPassword mutation can be used to reset the password of an existing user.', + inputFields: { + email: { + descriptions: 'Email of the user that should receive the reset email', + type: new GraphQLNonNull(GraphQLString), + }, + }, + outputFields: { + ok: { + description: "It's always true.", + type: new GraphQLNonNull(GraphQLBoolean), + }, + }, + mutateAndGetPayload: async ({ email }, context) => { + const { config, auth, info } = context; + + await usersRouter.handleResetRequest({ + body: { + email, + }, + config, + auth, + info, + }); + + return { ok: true }; + }, + }); + + parseGraphQLSchema.addGraphQLType(resetPasswordMutation.args.input.type.ofType, true, true); + parseGraphQLSchema.addGraphQLType(resetPasswordMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation('resetPassword', resetPasswordMutation, true, true); + + const confirmResetPasswordMutation = mutationWithClientMutationId({ + name: 'ConfirmResetPassword', + description: + 'The confirmResetPassword mutation can be used to reset the password of an existing user.', + inputFields: { + username: { + descriptions: 'Username of the user that have received the reset email', + type: new GraphQLNonNull(GraphQLString), + }, + password: { + descriptions: 'New password of the user', + type: new GraphQLNonNull(GraphQLString), + }, + token: { + descriptions: 'Reset token that was emailed to the user', + type: new GraphQLNonNull(GraphQLString), + }, + }, + outputFields: { + ok: { + description: "It's always true.", + type: new GraphQLNonNull(GraphQLBoolean), + }, + }, + mutateAndGetPayload: async ({ password, token }, context) => { + const { config } = context; + if (!password) { + throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'you must provide a password'); + } + if (!token) { + throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'you must provide a token'); + } + + const userController = config.userController; + await userController.updatePassword(token, password); + return { ok: true }; + }, + }); + + parseGraphQLSchema.addGraphQLType( + confirmResetPasswordMutation.args.input.type.ofType, + true, + true + ); + parseGraphQLSchema.addGraphQLType(confirmResetPasswordMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation( + 'confirmResetPassword', + confirmResetPasswordMutation, + true, + true + ); + + const sendVerificationEmailMutation = mutationWithClientMutationId({ + name: 'SendVerificationEmail', + description: + 'The sendVerificationEmail mutation can be used to send the verification email again.', + inputFields: { + email: { + descriptions: 'Email of the user that should receive the verification email', + type: new GraphQLNonNull(GraphQLString), + }, + }, + outputFields: { + ok: { + description: "It's always true.", + type: new GraphQLNonNull(GraphQLBoolean), + }, + }, + mutateAndGetPayload: async ({ email }, context) => { + try { + const { config, auth, info } = context; + + await usersRouter.handleVerificationEmailRequest({ + body: { + email, + }, + config, + auth, + info, + }); + + return { ok: true }; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType( + sendVerificationEmailMutation.args.input.type.ofType, + true, + true + ); + parseGraphQLSchema.addGraphQLType(sendVerificationEmailMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation( + 'sendVerificationEmail', + sendVerificationEmailMutation, + true, + true + ); + + const challengeMutation = mutationWithClientMutationId({ + name: 'Challenge', + description: + 'The challenge mutation can be used to initiate an authentication challenge when an auth adapter needs it.', + inputFields: { + username: { + description: 'This is the username used to log in the user.', + type: GraphQLString, + }, + password: { + description: 'This is the password used to log in the user.', + type: GraphQLString, + }, + authData: { + description: + 'Auth data allow to preidentify the user if the auth adapter needs preidentification.', + type: OBJECT, + }, + challengeData: { + description: + 'Challenge data payload, can be used to post data to auth providers to auth providers if they need data for the response.', + type: OBJECT, + }, + }, + outputFields: { + challengeData: { + description: 'Challenge response from configured auth adapters.', + type: OBJECT, + }, + }, + mutateAndGetPayload: async (input, context) => { + try { + const { config, auth, info } = context; + + const { response } = await usersRouter.handleChallenge({ + body: input, + config, + auth, + info, + }); + return response; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType(challengeMutation.args.input.type.ofType, true, true); + parseGraphQLSchema.addGraphQLType(challengeMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation('challenge', challengeMutation, true, true); +}; + +export { load }; diff --git a/src/GraphQL/loaders/usersQueries.js b/src/GraphQL/loaders/usersQueries.js new file mode 100644 index 0000000000..dc9f57f5ef --- /dev/null +++ b/src/GraphQL/loaders/usersQueries.js @@ -0,0 +1,99 @@ +import { GraphQLNonNull } from 'graphql'; +import getFieldNames from 'graphql-list-fields'; +import Parse from 'parse/node'; +import rest from '../../rest'; +import { extractKeysAndInclude } from './parseClassTypes'; +import { Auth } from '../../Auth'; +import { createSanitizedError } from '../../Error'; + +const getUserFromSessionToken = async (context, queryInfo, keysPrefix, userId) => { + const { info, config } = context; + if (!info || !info.sessionToken) { + throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', config); + } + const sessionToken = info.sessionToken; + const selectedFields = getFieldNames(queryInfo) + .filter(field => field.startsWith(keysPrefix)) + .map(field => field.replace(keysPrefix, '')); + + const keysAndInclude = extractKeysAndInclude(selectedFields); + const { keys } = keysAndInclude; + let { include } = keysAndInclude; + + if (userId && !keys && !include) { + return { + sessionToken, + }; + } else if (keys && !include) { + include = 'user'; + } + + if (userId) { + // We need to re create the auth context + // to avoid security breach if userId is provided + context.auth = new Auth({ + config, + isMaster: context.auth.isMaster, + user: { id: userId }, + }); + } + + const options = {}; + if (keys) { + options.keys = keys + .split(',') + .map(key => `${key}`) + .join(','); + } + if (include) { + options.include = include + .split(',') + .map(included => `${included}`) + .join(','); + } + + const response = await rest.find( + config, + context.auth, + '_User', + // Get the user it self from auth object + { objectId: context.auth.user.id }, + options, + info.clientVersion, + info.context + ); + if (!response.results || response.results.length == 0) { + throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', config); + } else { + const user = response.results[0]; + return { + sessionToken, + user, + }; + } +}; + +const load = parseGraphQLSchema => { + if (parseGraphQLSchema.isUsersClassDisabled) { + return; + } + + parseGraphQLSchema.addGraphQLQuery( + 'viewer', + { + description: 'The viewer query can be used to return the current user data.', + type: new GraphQLNonNull(parseGraphQLSchema.viewerType), + async resolve(_source, _args, context, queryInfo) { + try { + return await getUserFromSessionToken(context, queryInfo, 'user.', false); + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }, + true, + true + ); +}; + +export { load, getUserFromSessionToken }; diff --git a/src/GraphQL/parseGraphQLUtils.js b/src/GraphQL/parseGraphQLUtils.js new file mode 100644 index 0000000000..a7e92405ec --- /dev/null +++ b/src/GraphQL/parseGraphQLUtils.js @@ -0,0 +1,65 @@ +import Parse from 'parse/node'; +import { GraphQLError } from 'graphql'; +import { createSanitizedError } from '../Error'; + +export function enforceMasterKeyAccess(auth, config) { + if (!auth.isMaster) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + 'unauthorized: master key is required', + config + ); + } +} + +export function toGraphQLError(error) { + let code, message; + if (error instanceof Parse.Error) { + code = error.code; + message = error.message; + } else { + code = Parse.Error.INTERNAL_SERVER_ERROR; + message = 'Internal server error'; + } + return new GraphQLError(message, { extensions: { code } }); +} + +export const extractKeysAndInclude = selectedFields => { + selectedFields = selectedFields.filter(field => !field.includes('__typename')); + // Handles "id" field for both current and included objects + selectedFields = selectedFields.map(field => { + if (field === 'id') { return 'objectId'; } + return field.endsWith('.id') + ? `${field.substring(0, field.lastIndexOf('.id'))}.objectId` + : field; + }); + let keys = undefined; + let include = undefined; + + if (selectedFields.length > 0) { + keys = [...new Set(selectedFields)].join(','); + // We can use this shortcut since optimization is handled + // later on RestQuery, avoid overhead here. + include = keys; + } + + return { + // If authData is detected keys will not work properly + // since authData has a special storage behavior + // so we need to skip keys currently + keys: keys && keys.indexOf('authData') === -1 ? keys : undefined, + include, + }; +}; + +export const getParseClassMutationConfig = function (parseClassConfig) { + return (parseClassConfig && parseClassConfig.mutation) || {}; +}; + +export function cloneArgs(args) { + try { + return structuredClone(args); + } catch { + return JSON.parse(JSON.stringify(args)); + } +} diff --git a/src/GraphQL/transformers/className.js b/src/GraphQL/transformers/className.js new file mode 100644 index 0000000000..da1f3cbb68 --- /dev/null +++ b/src/GraphQL/transformers/className.js @@ -0,0 +1,8 @@ +const transformClassNameToGraphQL = className => { + if (className[0] === '_') { + className = className.slice(1); + } + return className[0].toUpperCase() + className.slice(1); +}; + +export { transformClassNameToGraphQL }; diff --git a/src/GraphQL/transformers/constraintType.js b/src/GraphQL/transformers/constraintType.js new file mode 100644 index 0000000000..6da986af30 --- /dev/null +++ b/src/GraphQL/transformers/constraintType.js @@ -0,0 +1,54 @@ +import * as defaultGraphQLTypes from '../loaders/defaultGraphQLTypes'; + +const transformConstraintTypeToGraphQL = (parseType, targetClass, parseClassTypes, fieldName) => { + if (fieldName === 'id' || fieldName === 'objectId') { + return defaultGraphQLTypes.ID_WHERE_INPUT; + } + + switch (parseType) { + case 'String': + return defaultGraphQLTypes.STRING_WHERE_INPUT; + case 'Number': + return defaultGraphQLTypes.NUMBER_WHERE_INPUT; + case 'Boolean': + return defaultGraphQLTypes.BOOLEAN_WHERE_INPUT; + case 'Array': + return defaultGraphQLTypes.ARRAY_WHERE_INPUT; + case 'Object': + return defaultGraphQLTypes.OBJECT_WHERE_INPUT; + case 'Date': + return defaultGraphQLTypes.DATE_WHERE_INPUT; + case 'Pointer': + if ( + parseClassTypes[targetClass] && + parseClassTypes[targetClass].classGraphQLRelationConstraintsType + ) { + return parseClassTypes[targetClass].classGraphQLRelationConstraintsType; + } else { + return defaultGraphQLTypes.OBJECT; + } + case 'File': + return defaultGraphQLTypes.FILE_WHERE_INPUT; + case 'GeoPoint': + return defaultGraphQLTypes.GEO_POINT_WHERE_INPUT; + case 'Polygon': + return defaultGraphQLTypes.POLYGON_WHERE_INPUT; + case 'Bytes': + return defaultGraphQLTypes.BYTES_WHERE_INPUT; + case 'ACL': + return defaultGraphQLTypes.OBJECT_WHERE_INPUT; + case 'Relation': + if ( + parseClassTypes[targetClass] && + parseClassTypes[targetClass].classGraphQLRelationConstraintsType + ) { + return parseClassTypes[targetClass].classGraphQLRelationConstraintsType; + } else { + return defaultGraphQLTypes.OBJECT; + } + default: + return undefined; + } +}; + +export { transformConstraintTypeToGraphQL }; diff --git a/src/GraphQL/transformers/inputType.js b/src/GraphQL/transformers/inputType.js new file mode 100644 index 0000000000..bba838bcd3 --- /dev/null +++ b/src/GraphQL/transformers/inputType.js @@ -0,0 +1,53 @@ +import { GraphQLString, GraphQLFloat, GraphQLBoolean, GraphQLList } from 'graphql'; +import * as defaultGraphQLTypes from '../loaders/defaultGraphQLTypes'; + +const transformInputTypeToGraphQL = (parseType, targetClass, parseClassTypes) => { + switch (parseType) { + case 'String': + return GraphQLString; + case 'Number': + return GraphQLFloat; + case 'Boolean': + return GraphQLBoolean; + case 'Array': + return new GraphQLList(defaultGraphQLTypes.ANY); + case 'Object': + return defaultGraphQLTypes.OBJECT; + case 'Date': + return defaultGraphQLTypes.DATE; + case 'Pointer': + if ( + parseClassTypes && + parseClassTypes[targetClass] && + parseClassTypes[targetClass].classGraphQLPointerType + ) { + return parseClassTypes[targetClass].classGraphQLPointerType; + } else { + return defaultGraphQLTypes.OBJECT; + } + case 'Relation': + if ( + parseClassTypes && + parseClassTypes[targetClass] && + parseClassTypes[targetClass].classGraphQLRelationType + ) { + return parseClassTypes[targetClass].classGraphQLRelationType; + } else { + return defaultGraphQLTypes.OBJECT; + } + case 'File': + return defaultGraphQLTypes.FILE_INPUT; + case 'GeoPoint': + return defaultGraphQLTypes.GEO_POINT_INPUT; + case 'Polygon': + return defaultGraphQLTypes.POLYGON_INPUT; + case 'Bytes': + return defaultGraphQLTypes.BYTES; + case 'ACL': + return defaultGraphQLTypes.ACL_INPUT; + default: + return undefined; + } +}; + +export { transformInputTypeToGraphQL }; diff --git a/src/GraphQL/transformers/mutation.js b/src/GraphQL/transformers/mutation.js new file mode 100644 index 0000000000..a879dcbdc2 --- /dev/null +++ b/src/GraphQL/transformers/mutation.js @@ -0,0 +1,273 @@ +import Parse from 'parse/node'; +import { fromGlobalId } from 'graphql-relay'; +import { handleUpload } from '../loaders/filesMutations'; +import * as objectsMutations from '../helpers/objectsMutations'; + +const transformTypes = async ( + inputType: 'create' | 'update', + fields, + { className, parseGraphQLSchema, req, originalFields } +) => { + const { + classGraphQLCreateType, + classGraphQLUpdateType, + config: { isCreateEnabled, isUpdateEnabled }, + } = parseGraphQLSchema.parseClassTypes[className]; + const parseClass = parseGraphQLSchema.parseClasses[className]; + if (fields) { + const classGraphQLCreateTypeFields = + isCreateEnabled && classGraphQLCreateType ? classGraphQLCreateType.getFields() : null; + const classGraphQLUpdateTypeFields = + isUpdateEnabled && classGraphQLUpdateType ? classGraphQLUpdateType.getFields() : null; + const promises = Object.keys(fields).map(async field => { + let inputTypeField; + if (inputType === 'create' && classGraphQLCreateTypeFields) { + inputTypeField = classGraphQLCreateTypeFields[field]; + } else if (classGraphQLUpdateTypeFields) { + inputTypeField = classGraphQLUpdateTypeFields[field]; + } + if (inputTypeField) { + const parseFieldType = parseClass.fields[field].type; + switch (parseFieldType) { + case 'GeoPoint': + if (fields[field] === null) { + fields[field] = { __op: 'Delete' }; + break; + } + fields[field] = transformers.geoPoint(fields[field]); + break; + case 'Polygon': + if (fields[field] === null) { + fields[field] = { __op: 'Delete' }; + break; + } + fields[field] = transformers.polygon(fields[field]); + break; + case 'File': + // We need to use the originalFields to handle the file upload + // since fields are a deepcopy and do not keep the file object + fields[field] = await transformers.file(originalFields[field], req); + break; + case 'Relation': + fields[field] = await transformers.relation( + parseClass.fields[field].targetClass, + field, + fields[field], + originalFields[field], + parseGraphQLSchema, + req + ); + break; + case 'Pointer': + if (fields[field] === null) { + fields[field] = { __op: 'Delete' }; + break; + } + fields[field] = await transformers.pointer( + parseClass.fields[field].targetClass, + field, + fields[field], + originalFields[field], + parseGraphQLSchema, + req + ); + break; + default: + if (fields[field] === null) { + fields[field] = { __op: 'Delete' }; + return; + } + break; + } + } + }); + await Promise.all(promises); + if (fields.ACL) { fields.ACL = transformers.ACL(fields.ACL); } + } + return fields; +}; + +const transformers = { + file: async (input, { config }) => { + if (input === null) { + return { __op: 'Delete' }; + } + const { file, upload } = input; + if (upload) { + const { fileInfo } = await handleUpload(upload, config); + return { ...fileInfo, __type: 'File' }; + } else if (file && file.name) { + if (file.url) { + const { validateFileUrl } = require('../../FileUrlValidator'); + validateFileUrl(file.url, config); + } + return { name: file.name, __type: 'File', url: file.url }; + } + throw new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid file upload.'); + }, + polygon: value => ({ + __type: 'Polygon', + coordinates: value.map(geoPoint => [geoPoint.latitude, geoPoint.longitude]), + }), + geoPoint: value => ({ + ...value, + __type: 'GeoPoint', + }), + ACL: value => { + const parseACL = {}; + if (value.public) { + parseACL['*'] = { + read: value.public.read, + write: value.public.write, + }; + } + if (value.users) { + value.users.forEach(rule => { + const globalIdObject = fromGlobalId(rule.userId); + if (globalIdObject.type === '_User') { + rule.userId = globalIdObject.id; + } + parseACL[rule.userId] = { + read: rule.read, + write: rule.write, + }; + }); + } + if (value.roles) { + value.roles.forEach(rule => { + parseACL[`role:${rule.roleName}`] = { + read: rule.read, + write: rule.write, + }; + }); + } + return parseACL; + }, + relation: async ( + targetClass, + field, + value, + originalValue, + parseGraphQLSchema, + { config, auth, info } + ) => { + if (Object.keys(value).length === 0) + { throw new Parse.Error( + Parse.Error.INVALID_POINTER, + `You need to provide at least one operation on the relation mutation of field ${field}` + ); } + + const op = { + __op: 'Batch', + ops: [], + }; + let nestedObjectsToAdd = []; + + if (value.createAndAdd) { + nestedObjectsToAdd = ( + await Promise.all( + value.createAndAdd.map(async (input, i) => { + const parseFields = await transformTypes('create', input, { + className: targetClass, + originalFields: originalValue.createAndAdd[i], + parseGraphQLSchema, + req: { config, auth, info }, + }); + return objectsMutations.createObject(targetClass, parseFields, config, auth, info); + }) + ) + ).map(object => ({ + __type: 'Pointer', + className: targetClass, + objectId: object.objectId, + })); + } + + if (value.add || nestedObjectsToAdd.length > 0) { + if (!value.add) { value.add = []; } + value.add = value.add.map(input => { + const globalIdObject = fromGlobalId(input); + if (globalIdObject.type === targetClass) { + input = globalIdObject.id; + } + return { + __type: 'Pointer', + className: targetClass, + objectId: input, + }; + }); + op.ops.push({ + __op: 'AddRelation', + objects: [...value.add, ...nestedObjectsToAdd], + }); + } + + if (value.remove) { + op.ops.push({ + __op: 'RemoveRelation', + objects: value.remove.map(input => { + const globalIdObject = fromGlobalId(input); + if (globalIdObject.type === targetClass) { + input = globalIdObject.id; + } + return { + __type: 'Pointer', + className: targetClass, + objectId: input, + }; + }), + }); + } + return op; + }, + pointer: async ( + targetClass, + field, + value, + originalValue, + parseGraphQLSchema, + { config, auth, info } + ) => { + if (Object.keys(value).length > 1 || Object.keys(value).length === 0) + { throw new Parse.Error( + Parse.Error.INVALID_POINTER, + `You need to provide link OR createLink on the pointer mutation of field ${field}` + ); } + + let nestedObjectToAdd; + if (value.createAndLink) { + const parseFields = await transformTypes('create', value.createAndLink, { + className: targetClass, + parseGraphQLSchema, + originalFields: originalValue.createAndLink, + req: { config, auth, info }, + }); + nestedObjectToAdd = await objectsMutations.createObject( + targetClass, + parseFields, + config, + auth, + info + ); + return { + __type: 'Pointer', + className: targetClass, + objectId: nestedObjectToAdd.objectId, + }; + } + if (value.link) { + let objectId = value.link; + const globalIdObject = fromGlobalId(objectId); + if (globalIdObject.type === targetClass) { + objectId = globalIdObject.id; + } + return { + __type: 'Pointer', + className: targetClass, + objectId, + }; + } + }, +}; + +export { transformTypes }; diff --git a/src/GraphQL/transformers/outputType.js b/src/GraphQL/transformers/outputType.js new file mode 100644 index 0000000000..81afd421d1 --- /dev/null +++ b/src/GraphQL/transformers/outputType.js @@ -0,0 +1,53 @@ +import * as defaultGraphQLTypes from '../loaders/defaultGraphQLTypes'; +import { GraphQLString, GraphQLFloat, GraphQLBoolean, GraphQLList, GraphQLNonNull } from 'graphql'; + +const transformOutputTypeToGraphQL = (parseType, targetClass, parseClassTypes) => { + switch (parseType) { + case 'String': + return GraphQLString; + case 'Number': + return GraphQLFloat; + case 'Boolean': + return GraphQLBoolean; + case 'Array': + return new GraphQLList(defaultGraphQLTypes.ARRAY_RESULT); + case 'Object': + return defaultGraphQLTypes.OBJECT; + case 'Date': + return defaultGraphQLTypes.DATE; + case 'Pointer': + if ( + parseClassTypes && + parseClassTypes[targetClass] && + parseClassTypes[targetClass].classGraphQLOutputType + ) { + return parseClassTypes[targetClass].classGraphQLOutputType; + } else { + return defaultGraphQLTypes.OBJECT; + } + case 'Relation': + if ( + parseClassTypes && + parseClassTypes[targetClass] && + parseClassTypes[targetClass].classGraphQLFindResultType + ) { + return new GraphQLNonNull(parseClassTypes[targetClass].classGraphQLFindResultType); + } else { + return new GraphQLNonNull(defaultGraphQLTypes.OBJECT); + } + case 'File': + return defaultGraphQLTypes.FILE_INFO; + case 'GeoPoint': + return defaultGraphQLTypes.GEO_POINT; + case 'Polygon': + return defaultGraphQLTypes.POLYGON; + case 'Bytes': + return defaultGraphQLTypes.BYTES; + case 'ACL': + return new GraphQLNonNull(defaultGraphQLTypes.ACL); + default: + return undefined; + } +}; + +export { transformOutputTypeToGraphQL }; diff --git a/src/GraphQL/transformers/query.js b/src/GraphQL/transformers/query.js new file mode 100644 index 0000000000..cff565491a --- /dev/null +++ b/src/GraphQL/transformers/query.js @@ -0,0 +1,268 @@ +import { fromGlobalId } from 'graphql-relay'; + +const parseQueryMap = { + OR: '$or', + AND: '$and', + NOR: '$nor', +}; + +const parseConstraintMap = { + equalTo: '$eq', + notEqualTo: '$ne', + lessThan: '$lt', + lessThanOrEqualTo: '$lte', + greaterThan: '$gt', + greaterThanOrEqualTo: '$gte', + in: '$in', + notIn: '$nin', + exists: '$exists', + inQueryKey: '$select', + notInQueryKey: '$dontSelect', + inQuery: '$inQuery', + notInQuery: '$notInQuery', + containedBy: '$containedBy', + contains: '$all', + matchesRegex: '$regex', + options: '$options', + text: '$text', + search: '$search', + term: '$term', + language: '$language', + caseSensitive: '$caseSensitive', + diacriticSensitive: '$diacriticSensitive', + nearSphere: '$nearSphere', + maxDistance: '$maxDistance', + maxDistanceInRadians: '$maxDistanceInRadians', + maxDistanceInMiles: '$maxDistanceInMiles', + maxDistanceInKilometers: '$maxDistanceInKilometers', + within: '$within', + box: '$box', + geoWithin: '$geoWithin', + polygon: '$polygon', + centerSphere: '$centerSphere', + geoIntersects: '$geoIntersects', + point: '$point', +}; + +const transformQueryConstraintInputToParse = ( + constraints, + parentFieldName, + className, + parentConstraints, + parseClasses +) => { + const fields = parseClasses[className].fields; + if (parentFieldName === 'id' && className) { + Object.keys(constraints).forEach(constraintName => { + const constraintValue = constraints[constraintName]; + if (typeof constraintValue === 'string') { + const globalIdObject = fromGlobalId(constraintValue); + + if (globalIdObject.type === className) { + constraints[constraintName] = globalIdObject.id; + } + } else if (Array.isArray(constraintValue)) { + constraints[constraintName] = constraintValue.map(value => { + const globalIdObject = fromGlobalId(value); + + if (globalIdObject.type === className) { + return globalIdObject.id; + } + + return value; + }); + } + }); + parentConstraints.objectId = constraints; + delete parentConstraints.id; + } + Object.keys(constraints).forEach(fieldName => { + let fieldValue = constraints[fieldName]; + if (parseConstraintMap[fieldName]) { + constraints[parseConstraintMap[fieldName]] = constraints[fieldName]; + delete constraints[fieldName]; + } + /** + * If we have a key-value pair, we need to change the way the constraint is structured. + * + * Example: + * From: + * { + * "someField": { + * "lessThan": { + * "key":"foo.bar", + * "value": 100 + * }, + * "greaterThan": { + * "key":"foo.bar", + * "value": 10 + * } + * } + * } + * + * To: + * { + * "someField.foo.bar": { + * "$lt": 100, + * "$gt": 10 + * } + * } + */ + if (fieldValue.key && fieldValue.value !== undefined && parentConstraints && parentFieldName) { + delete parentConstraints[parentFieldName]; + parentConstraints[`${parentFieldName}.${fieldValue.key}`] = { + ...parentConstraints[`${parentFieldName}.${fieldValue.key}`], + [parseConstraintMap[fieldName]]: fieldValue.value, + }; + } else if ( + fields[parentFieldName] && + (fields[parentFieldName].type === 'Pointer' || fields[parentFieldName].type === 'Relation') + ) { + const { targetClass } = fields[parentFieldName]; + if (fieldName === 'exists') { + if (fields[parentFieldName].type === 'Relation') { + const whereTarget = fieldValue ? 'where' : 'notWhere'; + if (constraints[whereTarget]) { + if (constraints[whereTarget].objectId) { + constraints[whereTarget].objectId = { + ...constraints[whereTarget].objectId, + $exists: fieldValue, + }; + } else { + constraints[whereTarget].objectId = { + $exists: fieldValue, + }; + } + } else { + const parseWhereTarget = fieldValue ? '$inQuery' : '$notInQuery'; + parentConstraints[parentFieldName][parseWhereTarget] = { + where: { objectId: { $exists: true } }, + className: targetClass, + }; + } + delete constraints.$exists; + } else { + parentConstraints[parentFieldName].$exists = fieldValue; + } + return; + } + switch (fieldName) { + case 'have': + parentConstraints[parentFieldName].$inQuery = { + where: fieldValue, + className: targetClass, + }; + transformQueryInputToParse( + parentConstraints[parentFieldName].$inQuery.where, + targetClass, + parseClasses + ); + break; + case 'haveNot': + parentConstraints[parentFieldName].$notInQuery = { + where: fieldValue, + className: targetClass, + }; + transformQueryInputToParse( + parentConstraints[parentFieldName].$notInQuery.where, + targetClass, + parseClasses + ); + break; + } + delete constraints[fieldName]; + return; + } + switch (fieldName) { + case 'point': + if (typeof fieldValue === 'object' && !fieldValue.__type) { + fieldValue.__type = 'GeoPoint'; + } + break; + case 'nearSphere': + if (typeof fieldValue === 'object' && !fieldValue.__type) { + fieldValue.__type = 'GeoPoint'; + } + break; + case 'box': + if (typeof fieldValue === 'object' && fieldValue.bottomLeft && fieldValue.upperRight) { + fieldValue = [ + { + __type: 'GeoPoint', + ...fieldValue.bottomLeft, + }, + { + __type: 'GeoPoint', + ...fieldValue.upperRight, + }, + ]; + constraints[parseConstraintMap[fieldName]] = fieldValue; + } + break; + case 'polygon': + if (Array.isArray(fieldValue)) { + fieldValue.forEach(geoPoint => { + if (typeof geoPoint === 'object' && !geoPoint.__type) { + geoPoint.__type = 'GeoPoint'; + } + }); + } + break; + case 'centerSphere': + if (typeof fieldValue === 'object' && fieldValue.center && fieldValue.distance) { + fieldValue = [ + { + __type: 'GeoPoint', + ...fieldValue.center, + }, + fieldValue.distance, + ]; + constraints[parseConstraintMap[fieldName]] = fieldValue; + } + break; + } + if (typeof fieldValue === 'object') { + if (fieldName === 'where') { + transformQueryInputToParse(fieldValue, className, parseClasses); + } else { + transformQueryConstraintInputToParse( + fieldValue, + fieldName, + className, + constraints, + parseClasses + ); + } + } + }); +}; + +const transformQueryInputToParse = (constraints, className, parseClasses) => { + if (!constraints || typeof constraints !== 'object') { + return; + } + + Object.keys(constraints).forEach(fieldName => { + const fieldValue = constraints[fieldName]; + + if (parseQueryMap[fieldName]) { + delete constraints[fieldName]; + fieldName = parseQueryMap[fieldName]; + constraints[fieldName] = fieldValue; + fieldValue.forEach(fieldValueItem => { + transformQueryInputToParse(fieldValueItem, className, parseClasses); + }); + return; + } else { + transformQueryConstraintInputToParse( + fieldValue, + fieldName, + className, + constraints, + parseClasses + ); + } + }); +}; + +export { transformQueryConstraintInputToParse, transformQueryInputToParse }; diff --git a/src/GraphQL/transformers/schemaFields.js b/src/GraphQL/transformers/schemaFields.js new file mode 100644 index 0000000000..4e3898737e --- /dev/null +++ b/src/GraphQL/transformers/schemaFields.js @@ -0,0 +1,139 @@ +import Parse from 'parse/node'; + +const transformToParse = (graphQLSchemaFields, existingFields) => { + if (!graphQLSchemaFields) { + return {}; + } + + let parseSchemaFields = {}; + + const reducerGenerator = type => (parseSchemaFields, field) => { + if (type === 'Remove') { + if (existingFields[field.name]) { + return { + ...parseSchemaFields, + [field.name]: { + __op: 'Delete', + }, + }; + } else { + return parseSchemaFields; + } + } + if ( + graphQLSchemaFields.remove && + graphQLSchemaFields.remove.find(removeField => removeField.name === field.name) + ) { + return parseSchemaFields; + } + if (parseSchemaFields[field.name] || (existingFields && existingFields[field.name])) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Duplicated field name: ${field.name}`); + } + if (type === 'Relation' || type === 'Pointer') { + return { + ...parseSchemaFields, + [field.name]: { + type, + targetClass: field.targetClassName, + }, + }; + } + return { + ...parseSchemaFields, + [field.name]: { + type, + }, + }; + }; + + if (graphQLSchemaFields.addStrings) { + parseSchemaFields = graphQLSchemaFields.addStrings.reduce( + reducerGenerator('String'), + parseSchemaFields + ); + } + if (graphQLSchemaFields.addNumbers) { + parseSchemaFields = graphQLSchemaFields.addNumbers.reduce( + reducerGenerator('Number'), + parseSchemaFields + ); + } + if (graphQLSchemaFields.addBooleans) { + parseSchemaFields = graphQLSchemaFields.addBooleans.reduce( + reducerGenerator('Boolean'), + parseSchemaFields + ); + } + if (graphQLSchemaFields.addArrays) { + parseSchemaFields = graphQLSchemaFields.addArrays.reduce( + reducerGenerator('Array'), + parseSchemaFields + ); + } + if (graphQLSchemaFields.addObjects) { + parseSchemaFields = graphQLSchemaFields.addObjects.reduce( + reducerGenerator('Object'), + parseSchemaFields + ); + } + if (graphQLSchemaFields.addDates) { + parseSchemaFields = graphQLSchemaFields.addDates.reduce( + reducerGenerator('Date'), + parseSchemaFields + ); + } + if (graphQLSchemaFields.addFiles) { + parseSchemaFields = graphQLSchemaFields.addFiles.reduce( + reducerGenerator('File'), + parseSchemaFields + ); + } + if (graphQLSchemaFields.addGeoPoint) { + parseSchemaFields = [graphQLSchemaFields.addGeoPoint].reduce( + reducerGenerator('GeoPoint'), + parseSchemaFields + ); + } + if (graphQLSchemaFields.addPolygons) { + parseSchemaFields = graphQLSchemaFields.addPolygons.reduce( + reducerGenerator('Polygon'), + parseSchemaFields + ); + } + if (graphQLSchemaFields.addBytes) { + parseSchemaFields = graphQLSchemaFields.addBytes.reduce( + reducerGenerator('Bytes'), + parseSchemaFields + ); + } + if (graphQLSchemaFields.addPointers) { + parseSchemaFields = graphQLSchemaFields.addPointers.reduce( + reducerGenerator('Pointer'), + parseSchemaFields + ); + } + if (graphQLSchemaFields.addRelations) { + parseSchemaFields = graphQLSchemaFields.addRelations.reduce( + reducerGenerator('Relation'), + parseSchemaFields + ); + } + if (existingFields && graphQLSchemaFields.remove) { + parseSchemaFields = graphQLSchemaFields.remove.reduce( + reducerGenerator('Remove'), + parseSchemaFields + ); + } + + return parseSchemaFields; +}; + +const transformToGraphQL = parseSchemaFields => { + return Object.keys(parseSchemaFields).map(name => ({ + name, + type: parseSchemaFields[name].type, + targetClassName: parseSchemaFields[name].targetClass, + })); +}; + +export { transformToParse, transformToGraphQL }; diff --git a/src/InstallationDedup.js b/src/InstallationDedup.js new file mode 100644 index 0000000000..391b29def4 --- /dev/null +++ b/src/InstallationDedup.js @@ -0,0 +1,176 @@ +import Parse from 'parse/node'; +import logger from './logger'; + +const CLASS_NAME = '_Installation'; + +function logResult(action, count, err) { + if (err && err.code === Parse.Error.OBJECT_NOT_FOUND) { + logger.verbose(`Installation dedup ${action} matched no rows; nothing to do.`); + return; + } + if (err && err.code === Parse.Error.OPERATION_FORBIDDEN) { + logger.warn( + `Installation dedup ${action} skipped: caller has no permission to ${action} the conflicting row(s). The conflicting row remains.` + ); + return; + } + if (err) { + logger.error(`Installation dedup ${action} failed: ${err.message || err}`); + return; + } + logger.verbose( + `Installation dedup ${action} applied to ${count == null ? 'matching' : count} conflicting row(s).` + ); +} + +async function performAction({ + database, + query, + action, + fieldToClear, + runOptions, + many, + validSchemaController, +}) { + if (action === 'delete') { + return database.destroy(CLASS_NAME, query, runOptions, validSchemaController); + } + if (action === 'update') { + return database.update( + CLASS_NAME, + query, + { [fieldToClear]: { __op: 'Delete' } }, + { ...runOptions, many }, + false, + false, + validSchemaController + ); + } + throw new Error(`Unknown installation dedup action: ${action}`); +} + +/** + * Removes or updates `_Installation` rows that hold a `deviceToken` matching the query, + * allowing the caller to claim that `deviceToken` exclusively. Used when a new or updated + * install collides with one or more existing rows on `deviceToken`. + * + * @param {Object} options + * @param {DatabaseController} options.database + * @param {Object} options.query e.g. { deviceToken: 'X', installationId: { $ne: 'I' } } + * @param {'delete'|'update'} options.action + * @param {boolean} options.enforceAuth + * @param {Object} options.runOptions RestWrite.runOptions + * @param {SchemaController} options.validSchemaController + */ +export async function removeConflictingDeviceToken({ + database, + query, + action, + enforceAuth, + runOptions, + validSchemaController, +}) { + const opts = enforceAuth ? runOptions : {}; + try { + await performAction({ + database, + query, + action, + fieldToClear: 'deviceToken', + runOptions: opts, + many: true, + validSchemaController, + }); + logResult(action, null, null); + } catch (err) { + if (err && err.code === Parse.Error.OBJECT_NOT_FOUND) { + logResult(action, 0, err); + return; + } + if (err && err.code === Parse.Error.OPERATION_FORBIDDEN) { + logResult(action, null, err); + return; + } + logResult(action, null, err); + throw err; + } +} + +/** + * Resolves a merge conflict between two `_Installation` rows that together represent the + * same install: one matched by `installationId`/`objectId` (`idMatch`), and another holding + * the same `deviceToken` but no `installationId` (`deviceTokenMatch`). The `mergePriority` + * determines which row survives; the loser receives the configured `action`. Returns the + * survivor's `objectId` so the save flow can target it. + * + * @param {Object} options + * @param {DatabaseController} options.database + * @param {{ objectId: string, installationId?: string, deviceToken?: string }} options.idMatch + * @param {{ objectId: string, deviceToken?: string }} options.deviceTokenMatch + * @param {'delete'|'update'} options.action + * @param {'deviceToken'|'installationId'} options.mergePriority + * @param {boolean} options.enforceAuth + * @param {Object} options.runOptions + * @param {SchemaController} options.validSchemaController + * @returns {Promise} survivor's objectId + */ +export async function applyDuplicateDeviceTokenMerge({ + database, + idMatch, + deviceTokenMatch, + action, + mergePriority, + enforceAuth, + runOptions, + validSchemaController, +}) { + // Self-merge guard: when both matches resolve to the same row, there's + // nothing to clean up. Skip the action so we don't destroy/update the row + // we're about to return as the survivor. + if (idMatch.objectId === deviceTokenMatch.objectId) { + return idMatch.objectId; + } + const opts = enforceAuth ? runOptions : {}; + let loser; + let survivorId; + let fieldToClear; + if (mergePriority === 'deviceToken') { + loser = idMatch; + survivorId = deviceTokenMatch.objectId; + fieldToClear = 'installationId'; + } else if (mergePriority === 'installationId') { + loser = deviceTokenMatch; + survivorId = idMatch.objectId; + fieldToClear = 'deviceToken'; + } else { + throw new Error(`Unknown installation dedup mergePriority: ${mergePriority}`); + } + + try { + await performAction({ + database, + query: { objectId: loser.objectId }, + action, + fieldToClear, + runOptions: opts, + many: false, + validSchemaController, + }); + logResult(action, 1, null); + } catch (err) { + if (err && err.code === Parse.Error.OBJECT_NOT_FOUND) { + logResult(action, 0, err); + } else if (err && err.code === Parse.Error.OPERATION_FORBIDDEN) { + logResult(action, null, err); + } else { + logResult(action, null, err); + throw err; + } + } + return survivorId; +} + +export default { + removeConflictingDeviceToken, + applyDuplicateDeviceTokenMerge, +}; diff --git a/src/KeyPromiseQueue.js b/src/KeyPromiseQueue.js new file mode 100644 index 0000000000..64458f346e --- /dev/null +++ b/src/KeyPromiseQueue.js @@ -0,0 +1,43 @@ +// KeyPromiseQueue is a simple promise queue +// used to queue operations per key basis. +// Once the tail promise in the key-queue fulfills, +// the chain on that key will be cleared. +export class KeyPromiseQueue { + constructor() { + this.queue = {}; + } + + enqueue(key, operation) { + const tuple = this.beforeOp(key); + const toAwait = tuple[1]; + const nextOperation = toAwait.then(operation); + const wrappedOperation = nextOperation.then(result => { + this.afterOp(key); + return result; + }); + tuple[1] = wrappedOperation; + return wrappedOperation; + } + + beforeOp(key) { + let tuple = this.queue[key]; + if (!tuple) { + tuple = [0, Promise.resolve()]; + this.queue[key] = tuple; + } + tuple[0]++; + return tuple; + } + + afterOp(key) { + const tuple = this.queue[key]; + if (!tuple) { + return; + } + tuple[0]--; + if (tuple[0] <= 0) { + delete this.queue[key]; + return; + } + } +} diff --git a/src/LiveQuery/Client.js b/src/LiveQuery/Client.js index 72e4a9d393..0ce629bd4e 100644 --- a/src/LiveQuery/Client.js +++ b/src/LiveQuery/Client.js @@ -1,14 +1,16 @@ -import PLog from './PLog'; -import Parse from 'parse/node'; +import logger from '../logger'; import type { FlattenedObjectData } from './Subscription'; export type Message = { [attr: string]: any }; -let dafaultFields = ['className', 'objectId', 'updatedAt', 'createdAt', 'ACL']; +const dafaultFields = ['className', 'objectId', 'updatedAt', 'createdAt', 'ACL']; class Client { id: number; parseWebSocket: any; + hasMasterKey: boolean; + sessionToken: string; + installationId: string; userId: string; roles: Array; subscriptionInfos: Object; @@ -21,9 +23,18 @@ class Client { pushDelete: Function; pushLeave: Function; - constructor(id: number, parseWebSocket: any) { + constructor( + id: number, + parseWebSocket: any, + hasMasterKey: boolean = false, + sessionToken: string, + installationId: string + ) { this.id = id; this.parseWebSocket = parseWebSocket; + this.hasMasterKey = hasMasterKey; + this.sessionToken = sessionToken; + this.installationId = installationId; this.roles = []; this.subscriptionInfos = new Map(); this.pushConnect = this._pushEvent('connected'); @@ -37,24 +48,34 @@ class Client { } static pushResponse(parseWebSocket: any, message: Message): void { - PLog.verbose('Push Response : %j', message); + logger.verbose('Push Response : %j', message); parseWebSocket.send(message); } - static pushError(parseWebSocket: any, code: number, error: string, reconnect: boolean = true): void { - Client.pushResponse(parseWebSocket, JSON.stringify({ - 'op': 'error', - 'error': error, - 'code': code, - 'reconnect': reconnect - })); + static pushError( + parseWebSocket: any, + code: number, + error: string, + reconnect: boolean = true, + requestId: number | void = null + ): void { + Client.pushResponse( + parseWebSocket, + JSON.stringify({ + op: 'error', + error, + code, + reconnect, + requestId, + }) + ); } addSubscriptionInfo(requestId: number, subscriptionInfo: any): void { this.subscriptionInfos.set(requestId, subscriptionInfo); } - getSubscriptionInfo(requestId: numner): any { + getSubscriptionInfo(requestId: number): any { return this.subscriptionInfos.get(requestId); } @@ -63,34 +84,42 @@ class Client { } _pushEvent(type: string): Function { - return function(subscriptionId: number, parseObjectJSON: any): void { - let response: Message = { - 'op' : type, - 'clientId' : this.id + return function ( + subscriptionId: number, + parseObjectJSON: any, + parseOriginalObjectJSON: any + ): void { + const response: Message = { + op: type, + clientId: this.id, + installationId: this.installationId, }; if (typeof subscriptionId !== 'undefined') { response['requestId'] = subscriptionId; } if (typeof parseObjectJSON !== 'undefined') { - let fields; + let keys; if (this.subscriptionInfos.has(subscriptionId)) { - fields = this.subscriptionInfos.get(subscriptionId).fields; + keys = this.subscriptionInfos.get(subscriptionId).keys; + } + response['object'] = this._toJSONWithFields(parseObjectJSON, keys); + if (parseOriginalObjectJSON) { + response['original'] = this._toJSONWithFields(parseOriginalObjectJSON, keys); } - response['object'] = this._toJSONWithFields(parseObjectJSON, fields); } Client.pushResponse(this.parseWebSocket, JSON.stringify(response)); - } + }; } _toJSONWithFields(parseObjectJSON: any, fields: any): FlattenedObjectData { if (!fields) { return parseObjectJSON; } - let limitedParseObject = {}; - for (let field of dafaultFields) { + const limitedParseObject = {}; + for (const field of dafaultFields) { limitedParseObject[field] = parseObjectJSON[field]; } - for (let field of fields) { + for (const field of fields) { if (field in parseObjectJSON) { limitedParseObject[field] = parseObjectJSON[field]; } @@ -99,6 +128,4 @@ class Client { } } -export { - Client -} +export { Client }; diff --git a/src/LiveQuery/PLog.js b/src/LiveQuery/PLog.js deleted file mode 100644 index 8ae8f69145..0000000000 --- a/src/LiveQuery/PLog.js +++ /dev/null @@ -1,5 +0,0 @@ -import { addGroup } from '../logger'; - -let PLog = addGroup('parse-live-query-server'); - -module.exports = PLog; diff --git a/src/LiveQuery/ParseCloudCodePublisher.js b/src/LiveQuery/ParseCloudCodePublisher.js index ac5e9d3483..0e0dce1417 100644 --- a/src/LiveQuery/ParseCloudCodePublisher.js +++ b/src/LiveQuery/ParseCloudCodePublisher.js @@ -1,5 +1,6 @@ import { ParsePubSub } from './ParsePubSub'; -import PLog from './PLog'; +import Parse from 'parse/node'; +import logger from '../logger'; class ParseCloudCodePublisher { parsePublisher: Object; @@ -10,28 +11,49 @@ class ParseCloudCodePublisher { this.parsePublisher = ParsePubSub.createPublisher(config); } + async connect() { + if (typeof this.parsePublisher.connect === 'function') { + if (this.parsePublisher.isOpen) { + return; + } + return Promise.resolve(this.parsePublisher.connect()); + } + } + onCloudCodeAfterSave(request: any): void { - this._onCloudCodeMessage('afterSave', request); + this._onCloudCodeMessage(Parse.applicationId + 'afterSave', request); } onCloudCodeAfterDelete(request: any): void { - this._onCloudCodeMessage('afterDelete', request); + this._onCloudCodeMessage(Parse.applicationId + 'afterDelete', request); + } + + onClearCachedRoles(user: Parse.Object) { + this.parsePublisher.publish( + Parse.applicationId + 'clearCache', + JSON.stringify({ userId: user.id }) + ); } // Request is the request object from cloud code functions. request.object is a ParseObject. _onCloudCodeMessage(type: string, request: any): void { - PLog.verbose('Raw request from cloud code current : %j | original : %j', request.object, request.original); + logger.verbose( + 'Raw request from cloud code current : %j | original : %j', + request.object, + request.original + ); // We need the full JSON which includes className - let message = { - currentParseObject: request.object._toFullJSON() - } + const message = { + currentParseObject: request.object._toFullJSON(), + }; if (request.original) { message.originalParseObject = request.original._toFullJSON(); } + if (request.classLevelPermissions) { + message.classLevelPermissions = request.classLevelPermissions; + } this.parsePublisher.publish(type, JSON.stringify(message)); } } -export { - ParseCloudCodePublisher -} +export { ParseCloudCodePublisher }; diff --git a/src/LiveQuery/ParseLiveQueryServer.js b/src/LiveQuery/ParseLiveQueryServer.js deleted file mode 100644 index 0d59211f4b..0000000000 --- a/src/LiveQuery/ParseLiveQueryServer.js +++ /dev/null @@ -1,459 +0,0 @@ -import tv4 from 'tv4'; -import Parse from 'parse/node'; -import { Subscription } from './Subscription'; -import { Client } from './Client'; -import { ParseWebSocketServer } from './ParseWebSocketServer'; -import PLog from './PLog'; -import RequestSchema from './RequestSchema'; -import { matchesQuery, queryHash } from './QueryTools'; -import { ParsePubSub } from './ParsePubSub'; -import { SessionTokenCache } from './SessionTokenCache'; - -class ParseLiveQueryServer { - clientId: number; - clients: Object; - // className -> (queryHash -> subscription) - subscriptions: Object; - parseWebSocketServer: Object; - keyPairs : any; - // The subscriber we use to get object update from publisher - subscriber: Object; - - constructor(server: any, config: any) { - this.clientId = 0; - this.clients = new Map(); - this.subscriptions = new Map(); - - config = config || {}; - // Set LogLevel - PLog.level = config.logLevel || 'INFO'; - // Store keys, convert obj to map - let keyPairs = config.keyPairs || {}; - this.keyPairs = new Map(); - for (let key of Object.keys(keyPairs)) { - this.keyPairs.set(key, keyPairs[key]); - } - PLog.verbose('Support key pairs', this.keyPairs); - - // Initialize Parse - Parse.Object.disableSingleInstance(); - Parse.User.enableUnsafeCurrentUser(); - - let serverURL = config.serverURL || Parse.serverURL; - Parse.serverURL = serverURL; - let appId = config.appId || Parse.applicationId; - let javascriptKey = Parse.javaScriptKey; - let masterKey = config.masterKey || Parse.masterKey; - Parse.initialize(appId, javascriptKey, masterKey); - - // Initialize websocket server - this.parseWebSocketServer = new ParseWebSocketServer( - server, - (parseWebsocket) => this._onConnect(parseWebsocket), - config.websocketTimeout - ); - - // Initialize subscriber - this.subscriber = ParsePubSub.createSubscriber({ - redisURL: config.redisURL - }); - this.subscriber.subscribe('afterSave'); - this.subscriber.subscribe('afterDelete'); - // Register message handler for subscriber. When publisher get messages, it will publish message - // to the subscribers and the handler will be called. - this.subscriber.on('message', (channel, messageStr) => { - PLog.verbose('Subscribe messsage %j', messageStr); - let message = JSON.parse(messageStr); - this._inflateParseObject(message); - if (channel === 'afterSave') { - this._onAfterSave(message); - } else if (channel === 'afterDelete') { - this._onAfterDelete(message); - } else { - PLog.error('Get message %s from unknown channel %j', message, channel); - } - }); - - // Initialize sessionToken cache - this.sessionTokenCache = new SessionTokenCache(config.cacheTimeout); - } - - // Message is the JSON object from publisher. Message.currentParseObject is the ParseObject JSON after changes. - // Message.originalParseObject is the original ParseObject JSON. - _inflateParseObject(message: any): void { - // Inflate merged object - let currentParseObject = message.currentParseObject; - let className = currentParseObject.className; - let parseObject = new Parse.Object(className); - parseObject._finishFetch(currentParseObject); - message.currentParseObject = parseObject; - // Inflate original object - let originalParseObject = message.originalParseObject; - if (originalParseObject) { - className = originalParseObject.className; - parseObject = new Parse.Object(className); - parseObject._finishFetch(originalParseObject); - message.originalParseObject = parseObject; - } - } - - // Message is the JSON object from publisher after inflated. Message.currentParseObject is the ParseObject after changes. - // Message.originalParseObject is the original ParseObject. - _onAfterDelete(message: any): void { - PLog.verbose('afterDelete is triggered'); - - let deletedParseObject = message.currentParseObject.toJSON(); - let className = deletedParseObject.className; - PLog.verbose('ClassName: %j | ObjectId: %s', className, deletedParseObject.id); - PLog.verbose('Current client number : %d', this.clients.size); - - let classSubscriptions = this.subscriptions.get(className); - if (typeof classSubscriptions === 'undefined') { - PLog.error('Can not find subscriptions under this class ' + className); - return; - } - for (let subscription of classSubscriptions.values()) { - let isSubscriptionMatched = this._matchesSubscription(deletedParseObject, subscription); - if (!isSubscriptionMatched) { - continue; - } - for (let [clientId, requestIds] of subscription.clientRequestIds.entries()) { - let client = this.clients.get(clientId); - if (typeof client === 'undefined') { - continue; - } - for (let requestId of requestIds) { - let acl = message.currentParseObject.getACL(); - // Check ACL - this._matchesACL(acl, client, requestId).then((isMatched) => { - if (!isMatched) { - return null; - } - client.pushDelete(requestId, deletedParseObject); - }, (error) => { - PLog.error('Matching ACL error : ', error); - }); - } - } - } - } - - // Message is the JSON object from publisher after inflated. Message.currentParseObject is the ParseObject after changes. - // Message.originalParseObject is the original ParseObject. - _onAfterSave(message: any): void { - PLog.verbose('afterSave is triggered'); - - let originalParseObject = null; - if (message.originalParseObject) { - originalParseObject = message.originalParseObject.toJSON(); - } - let currentParseObject = message.currentParseObject.toJSON(); - let className = currentParseObject.className; - PLog.verbose('ClassName: %s | ObjectId: %s', className, currentParseObject.id); - PLog.verbose('Current client number : %d', this.clients.size); - - let classSubscriptions = this.subscriptions.get(className); - if (typeof classSubscriptions === 'undefined') { - PLog.error('Can not find subscriptions under this class ' + className); - return; - } - for (let subscription of classSubscriptions.values()) { - let isOriginalSubscriptionMatched = this._matchesSubscription(originalParseObject, subscription); - let isCurrentSubscriptionMatched = this._matchesSubscription(currentParseObject, subscription); - for (let [clientId, requestIds] of subscription.clientRequestIds.entries()) { - let client = this.clients.get(clientId); - if (typeof client === 'undefined') { - continue; - } - for (let requestId of requestIds) { - // Set orignal ParseObject ACL checking promise, if the object does not match - // subscription, we do not need to check ACL - let originalACLCheckingPromise; - if (!isOriginalSubscriptionMatched) { - originalACLCheckingPromise = Parse.Promise.as(false); - } else { - let originalACL; - if (message.originalParseObject) { - originalACL = message.originalParseObject.getACL(); - } - originalACLCheckingPromise = this._matchesACL(originalACL, client, requestId); - } - // Set current ParseObject ACL checking promise, if the object does not match - // subscription, we do not need to check ACL - let currentACLCheckingPromise; - if (!isCurrentSubscriptionMatched) { - currentACLCheckingPromise = Parse.Promise.as(false); - } else { - let currentACL = message.currentParseObject.getACL(); - currentACLCheckingPromise = this._matchesACL(currentACL, client, requestId); - } - - Parse.Promise.when( - originalACLCheckingPromise, - currentACLCheckingPromise - ).then((isOriginalMatched, isCurrentMatched) => { - PLog.verbose('Original %j | Current %j | Match: %s, %s, %s, %s | Query: %s', - originalParseObject, - currentParseObject, - isOriginalSubscriptionMatched, - isCurrentSubscriptionMatched, - isOriginalMatched, - isCurrentMatched, - subscription.hash - ); - - // Decide event type - let type; - if (isOriginalMatched && isCurrentMatched) { - type = 'Update'; - } else if (isOriginalMatched && !isCurrentMatched) { - type = 'Leave'; - } else if (!isOriginalMatched && isCurrentMatched) { - if (originalParseObject) { - type = 'Enter'; - } else { - type = 'Create'; - } - } else { - return null; - } - let functionName = 'push' + type; - client[functionName](requestId, currentParseObject); - }, (error) => { - PLog.error('Matching ACL error : ', error); - }); - } - } - } - } - - _onConnect(parseWebsocket: any): void { - parseWebsocket.on('message', (request) => { - if (typeof request === 'string') { - request = JSON.parse(request); - } - PLog.verbose('Request: %j', request); - - // Check whether this request is a valid request, return error directly if not - if (!tv4.validate(request, RequestSchema['general']) || !tv4.validate(request, RequestSchema[request.op])) { - Client.pushError(parseWebsocket, 1, tv4.error.message); - PLog.error('Connect message error %s', tv4.error.message); - return; - } - - switch(request.op) { - case 'connect': - this._handleConnect(parseWebsocket, request); - break; - case 'subscribe': - this._handleSubscribe(parseWebsocket, request); - break; - case 'unsubscribe': - this._handleUnsubscribe(parseWebsocket, request); - break; - default: - Client.pushError(parseWebsocket, 3, 'Get unknown operation'); - PLog.error('Get unknown operation', request.op); - } - }); - - parseWebsocket.on('disconnect', () => { - PLog.log('Client disconnect: %d', parseWebsocket.clientId); - let clientId = parseWebsocket.clientId; - if (!this.clients.has(clientId)) { - PLog.error('Can not find client %d on disconnect', clientId); - return; - } - - // Delete client - let client = this.clients.get(clientId); - this.clients.delete(clientId); - - // Delete client from subscriptions - for (let [requestId, subscriptionInfo] of client.subscriptionInfos.entries()) { - let subscription = subscriptionInfo.subscription; - subscription.deleteClientSubscription(clientId, requestId); - - // If there is no client which is subscribing this subscription, remove it from subscriptions - let classSubscriptions = this.subscriptions.get(subscription.className); - if (!subscription.hasSubscribingClient()) { - classSubscriptions.delete(subscription.hash); - } - // If there is no subscriptions under this class, remove it from subscriptions - if (classSubscriptions.size === 0) { - this.subscriptions.delete(subscription.className); - } - } - - PLog.verbose('Current clients %d', this.clients.size); - PLog.verbose('Current subscriptions %d', this.subscriptions.size); - }); - } - - _matchesSubscription(parseObject: any, subscription: any): boolean { - // Object is undefined or null, not match - if (!parseObject) { - return false; - } - return matchesQuery(parseObject, subscription.query); - } - - _matchesACL(acl: any, client: any, requestId: number): any { - // If ACL is undefined or null, or ACL has public read access, return true directly - if (!acl || acl.getPublicReadAccess()) { - return Parse.Promise.as(true); - } - // Check subscription sessionToken matches ACL first - let subscriptionInfo = client.getSubscriptionInfo(requestId); - if (typeof subscriptionInfo === 'undefined') { - return Parse.Promise.as(false); - } - - let subscriptionSessionToken = subscriptionInfo.sessionToken; - return this.sessionTokenCache.getUserId(subscriptionSessionToken).then((userId) => { - return acl.getReadAccess(userId); - }).then((isSubscriptionSessionTokenMatched) => { - if (isSubscriptionSessionTokenMatched) { - return Parse.Promise.as(true); - } - // Check client sessionToken matches ACL - let clientSessionToken = client.sessionToken; - return this.sessionTokenCache.getUserId(clientSessionToken).then((userId) => { - return acl.getReadAccess(userId); - }); - }).then((isMatched) => { - return Parse.Promise.as(isMatched); - }, (error) => { - return Parse.Promise.as(false); - }); - } - - _handleConnect(parseWebsocket: any, request: any): any { - if (!this._validateKeys(request, this.keyPairs)) { - Client.pushError(parseWebsocket, 4, 'Key in request is not valid'); - PLog.error('Key in request is not valid'); - return; - } - let client = new Client(this.clientId, parseWebsocket); - parseWebsocket.clientId = this.clientId; - this.clientId += 1; - this.clients.set(parseWebsocket.clientId, client); - PLog.log('Create new client: %d', parseWebsocket.clientId); - client.pushConnect(); - } - - _validateKeys(request: any, validKeyPairs: any): boolean { - if (!validKeyPairs || validKeyPairs.size == 0) { - return true; - } - let isValid = false; - for (let [key, secret] of validKeyPairs) { - if (!request[key] || request[key] !== secret) { - continue; - } - isValid = true; - break; - } - return isValid; - } - - _handleSubscribe(parseWebsocket: any, request: any): any { - // If we can not find this client, return error to client - if (!parseWebsocket.hasOwnProperty('clientId')) { - Client.pushError(parseWebsocket, 2, 'Can not find this client, make sure you connect to server before subscribing'); - PLog.error('Can not find this client, make sure you connect to server before subscribing'); - return; - } - let client = this.clients.get(parseWebsocket.clientId); - - // Get subscription from subscriptions, create one if necessary - let subscriptionHash = queryHash(request.query); - // Add className to subscriptions if necessary - let className = request.query.className; - if (!this.subscriptions.has(className)) { - this.subscriptions.set(className, new Map()); - } - let classSubscriptions = this.subscriptions.get(className); - let subscription; - if (classSubscriptions.has(subscriptionHash)) { - subscription = classSubscriptions.get(subscriptionHash); - } else { - subscription = new Subscription(className, request.query.where, subscriptionHash); - classSubscriptions.set(subscriptionHash, subscription); - } - - // Add subscriptionInfo to client - let subscriptionInfo = { - subscription: subscription - }; - // Add selected fields and sessionToken for this subscription if necessary - if (request.query.fields) { - subscriptionInfo.fields = request.query.fields; - } - if (request.sessionToken) { - subscriptionInfo.sessionToken = request.sessionToken; - } - client.addSubscriptionInfo(request.requestId, subscriptionInfo); - - // Add clientId to subscription - subscription.addClientSubscription(parseWebsocket.clientId, request.requestId); - - client.pushSubscribe(request.requestId); - - PLog.verbose('Create client %d new subscription: %d', parseWebsocket.clientId, request.requestId); - PLog.verbose('Current client number: %d', this.clients.size); - } - - _handleUnsubscribe(parseWebsocket: any, request: any): any { - // If we can not find this client, return error to client - if (!parseWebsocket.hasOwnProperty('clientId')) { - Client.pushError(parseWebsocket, 2, 'Can not find this client, make sure you connect to server before unsubscribing'); - PLog.error('Can not find this client, make sure you connect to server before unsubscribing'); - return; - } - let requestId = request.requestId; - let client = this.clients.get(parseWebsocket.clientId); - if (typeof client === 'undefined') { - Client.pushError(parseWebsocket, 2, 'Cannot find client with clientId ' + parseWebsocket.clientId + - '. Make sure you connect to live query server before unsubscribing.'); - PLog.error('Can not find this client ' + parseWebsocket.clientId); - return; - } - - let subscriptionInfo = client.getSubscriptionInfo(requestId); - if (typeof subscriptionInfo === 'undefined') { - Client.pushError(parseWebsocket, 2, 'Cannot find subscription with clientId ' + parseWebsocket.clientId + - ' subscriptionId ' + requestId + '. Make sure you subscribe to live query server before unsubscribing.'); - PLog.error('Can not find subscription with clientId ' + parseWebsocket.clientId + ' subscriptionId ' + requestId); - return; - } - - // Remove subscription from client - client.deleteSubscriptionInfo(requestId); - // Remove client from subscription - let subscription = subscriptionInfo.subscription; - let className = subscription.className; - subscription.deleteClientSubscription(parseWebsocket.clientId, requestId); - // If there is no client which is subscribing this subscription, remove it from subscriptions - let classSubscriptions = this.subscriptions.get(className); - if (!subscription.hasSubscribingClient()) { - classSubscriptions.delete(subscription.hash); - } - // If there is no subscriptions under this class, remove it from subscriptions - if (classSubscriptions.size === 0) { - this.subscriptions.delete(className); - } - - client.pushUnsubscribe(request.requestId); - - PLog.verbose('Delete client: %d | subscription: %d', parseWebsocket.clientId, request.requestId); - } -} - -ParseLiveQueryServer.setLogLevel = function(logLevel) { - PLog.logLevel = logLevel; -} - -export { - ParseLiveQueryServer -} diff --git a/src/LiveQuery/ParseLiveQueryServer.ts b/src/LiveQuery/ParseLiveQueryServer.ts new file mode 100644 index 0000000000..f835fe2140 --- /dev/null +++ b/src/LiveQuery/ParseLiveQueryServer.ts @@ -0,0 +1,1318 @@ +import tv4 from 'tv4'; +import Parse from 'parse/node'; +import { Subscription } from './Subscription'; +import { Client } from './Client'; +import { ParseWebSocketServer } from './ParseWebSocketServer'; +// @ts-ignore +import logger from '../logger'; +import RequestSchema from './RequestSchema'; +import { matchesQuery, queryHash } from './QueryTools'; +import { ParsePubSub } from './ParsePubSub'; +import SchemaController from '../Controllers/SchemaController'; +import _ from 'lodash'; +import { randomUUID } from 'crypto'; +import { + runLiveQueryEventHandlers, + getTrigger, + runTrigger, + resolveError, + toJSONwithObjects, +} from '../triggers'; +import { getAuthForSessionToken, Auth } from '../Auth'; +import { getCacheController, getDatabaseController } from '../Controllers'; +import Config from '../Config'; +import { LRUCache as LRU } from 'lru-cache'; +import UserRouter from '../Routers/UsersRouter'; +import DatabaseController from '../Controllers/DatabaseController'; +import { isDeepStrictEqual } from 'util'; + + +class ParseLiveQueryServer { + server: any; + config: any; + clients: Map; + // className -> (queryHash -> subscription) + subscriptions: Map; + parseWebSocketServer: any; + keyPairs: any; + // The subscriber we use to get object update from publisher + subscriber: any; + authCache: any; + cacheController: any; + + constructor(server: any, config: any = {}, parseServerConfig: any = {}) { + this.server = server; + this.clients = new Map(); + this.subscriptions = new Map(); + this.config = config; + + config.appId = config.appId || Parse.applicationId; + config.masterKey = config.masterKey || Parse.masterKey; + + // Store keys, convert obj to map + const keyPairs = config.keyPairs || {}; + this.keyPairs = new Map(); + for (const key of Object.keys(keyPairs)) { + this.keyPairs.set(key, keyPairs[key]); + } + logger.verbose('Support key pairs', this.keyPairs); + + // Initialize Parse + Parse.Object.disableSingleInstance(); + const serverURL = config.serverURL || Parse.serverURL; + Parse.serverURL = serverURL; + Parse.initialize(config.appId, Parse.javaScriptKey, config.masterKey); + + // The cache controller is a proper cache controller + // with access to User and Roles + this.cacheController = getCacheController(parseServerConfig); + + config.cacheTimeout = config.cacheTimeout || 5 * 1000; // 5s + + // This auth cache stores the promises for each auth resolution. + // The main benefit is to be able to reuse the same user / session token resolution. + this.authCache = new LRU({ + max: 500, // 500 concurrent + ttl: config.cacheTimeout, + }); + // Initialize websocket server + this.parseWebSocketServer = new ParseWebSocketServer( + server, + parseWebsocket => this._onConnect(parseWebsocket), + config + ); + this.subscriber = ParsePubSub.createSubscriber(config); + if (!this.subscriber.connect) { + this.connect(); + } + } + + async connect() { + if (this.subscriber.isOpen) { + return; + } + if (typeof this.subscriber.connect === 'function') { + await Promise.resolve(this.subscriber.connect()); + } else { + this.subscriber.isOpen = true; + } + this._createSubscribers(); + } + + async shutdown() { + if (this.subscriber.isOpen) { + await Promise.all([ + ...[...this.clients.values()].map(client => client.parseWebSocket.ws.close()), + this.parseWebSocketServer.close?.(), + ...Array.from(this.subscriber.subscriptions?.keys() || []).map(key => + this.subscriber.unsubscribe(key) + ), + this.subscriber.close?.(), + ]); + } + if (typeof this.subscriber.close === 'function') { + try { + await this.subscriber.close(); + } catch (err) { + logger.error('PubSubAdapter error on shutdown', { error: err }); + } + } else { + this.subscriber.isOpen = false; + } + } + + _createSubscribers() { + const messageRecieved = (channel, messageStr) => { + logger.verbose('Subscribe message %j', messageStr); + let message; + try { + message = JSON.parse(messageStr); + } catch (e) { + logger.error('unable to parse message', messageStr, e); + return; + } + if (channel === Parse.applicationId + 'clearCache') { + this._clearCachedRoles(message.userId); + return; + } + this._inflateParseObject(message); + if (channel === Parse.applicationId + 'afterSave') { + this._onAfterSave(message); + } else if (channel === Parse.applicationId + 'afterDelete') { + this._onAfterDelete(message); + } else { + logger.error('Get message %s from unknown channel %j', message, channel); + } + }; + this.subscriber.on('message', (channel, messageStr) => messageRecieved(channel, messageStr)); + for (const field of ['afterSave', 'afterDelete', 'clearCache']) { + const channel = `${Parse.applicationId}${field}`; + this.subscriber.subscribe(channel, messageStr => messageRecieved(channel, messageStr)); + } + } + + // Message is the JSON object from publisher. Message.currentParseObject is the ParseObject JSON after changes. + // Message.originalParseObject is the original ParseObject JSON. + _inflateParseObject(message: any): void { + // Inflate merged object + const currentParseObject = message.currentParseObject; + UserRouter.removeHiddenProperties(currentParseObject); + let className = currentParseObject.className; + let parseObject = new Parse.Object(className); + parseObject._finishFetch(currentParseObject); + message.currentParseObject = parseObject; + // Inflate original object + const originalParseObject = message.originalParseObject; + if (originalParseObject) { + UserRouter.removeHiddenProperties(originalParseObject); + className = originalParseObject.className; + parseObject = new Parse.Object(className); + parseObject._finishFetch(originalParseObject); + message.originalParseObject = parseObject; + } + } + + // Message is the JSON object from publisher after inflated. Message.currentParseObject is the ParseObject after changes. + // Message.originalParseObject is the original ParseObject. + async _onAfterDelete(message: any): Promise { + logger.verbose(Parse.applicationId + 'afterDelete is triggered'); + + let deletedParseObject = message.currentParseObject.toJSON(); + const classLevelPermissions = message.classLevelPermissions; + const className = deletedParseObject.className; + logger.verbose('ClassName: %j | ObjectId: %s', className, deletedParseObject.id); + logger.verbose('Current client number : %d', this.clients.size); + + const classSubscriptions = this.subscriptions.get(className); + if (typeof classSubscriptions === 'undefined') { + logger.debug('Can not find subscriptions under this class ' + className); + return; + } + + for (const subscription of classSubscriptions.values()) { + let isSubscriptionMatched; + try { + isSubscriptionMatched = this._matchesSubscription(deletedParseObject, subscription); + } catch (e) { + logger.error(`Failed matching subscription for class ${className}: ${e.message}`); + continue; + } + if (!isSubscriptionMatched) { + continue; + } + for (const [clientId, requestIds] of _.entries(subscription.clientRequestIds)) { + const client = this.clients.get(clientId); + if (typeof client === 'undefined') { + continue; + } + requestIds.forEach(async requestId => { + // Deep-clone shared object so each concurrent callback works on its own copy + let localDeletedParseObject = JSON.parse(JSON.stringify(deletedParseObject)); + const acl = message.currentParseObject.getACL(); + // Check CLP + const op = this._getCLPOperation(subscription.query); + let res: any = {}; + try { + const matchesCLP = await this._matchesCLP( + classLevelPermissions, + message.currentParseObject, + client, + requestId, + op + ); + if (matchesCLP === false) { + return null; + } + const isMatched = await this._matchesACL(acl, client, requestId); + if (!isMatched) { + return null; + } + res = { + event: 'delete', + sessionToken: client.sessionToken, + object: localDeletedParseObject, + clients: this.clients.size, + subscriptions: this.subscriptions.size, + useMasterKey: client.hasMasterKey, + installationId: client.installationId, + sendEvent: true, + }; + const trigger = getTrigger(className, 'afterEvent', Parse.applicationId); + if (trigger) { + const auth = await this.getAuthFromClient(client, requestId); + if (auth && auth.user) { + res.user = auth.user; + } + if (res.object) { + res.object = Parse.Object.fromJSON(res.object); + } + await runTrigger(trigger, `afterEvent.${className}`, res, auth); + } + if (!res.sendEvent) { + return; + } + if (res.object && typeof res.object.toJSON === 'function') { + localDeletedParseObject = toJSONwithObjects(res.object, res.object.className || className); + } + res.object = localDeletedParseObject; + await this._filterSensitiveData( + classLevelPermissions, + res, + client, + requestId, + op, + subscription.query + ); + client.pushDelete(requestId, res.object); + } catch (e) { + const error = resolveError(e); + Client.pushError(client.parseWebSocket, error.code, error.message, false, requestId); + logger.error( + `Failed running afterLiveQueryEvent on class ${className} for event ${res.event} with session ${res.sessionToken} with:\n Error: ` + + JSON.stringify(error) + ); + } + }); + } + } + } + + // Message is the JSON object from publisher after inflated. Message.currentParseObject is the ParseObject after changes. + // Message.originalParseObject is the original ParseObject. + async _onAfterSave(message: any): Promise { + logger.verbose(Parse.applicationId + 'afterSave is triggered'); + + let originalParseObject = null; + if (message.originalParseObject) { + originalParseObject = message.originalParseObject.toJSON(); + } + const classLevelPermissions = message.classLevelPermissions; + let currentParseObject = message.currentParseObject.toJSON(); + const className = currentParseObject.className; + logger.verbose('ClassName: %s | ObjectId: %s', className, currentParseObject.id); + logger.verbose('Current client number : %d', this.clients.size); + + const classSubscriptions = this.subscriptions.get(className); + if (typeof classSubscriptions === 'undefined') { + logger.debug('Can not find subscriptions under this class ' + className); + return; + } + for (const subscription of classSubscriptions.values()) { + let isOriginalSubscriptionMatched; + let isCurrentSubscriptionMatched; + try { + isOriginalSubscriptionMatched = this._matchesSubscription( + originalParseObject, + subscription + ); + isCurrentSubscriptionMatched = this._matchesSubscription( + currentParseObject, + subscription + ); + } catch (e) { + logger.error(`Failed matching subscription for class ${className}: ${e.message}`); + continue; + } + for (const [clientId, requestIds] of _.entries(subscription.clientRequestIds)) { + const client = this.clients.get(clientId); + if (typeof client === 'undefined') { + continue; + } + requestIds.forEach(async requestId => { + // Deep-clone shared objects so each concurrent callback works on its own copy. + // Without cloning, _filterSensitiveData's in-place field deletion and afterEvent + // trigger modifications corrupt the shared state across concurrent subscribers. + let localCurrentParseObject = JSON.parse(JSON.stringify(currentParseObject)); + let localOriginalParseObject = originalParseObject + ? JSON.parse(JSON.stringify(originalParseObject)) + : null; + // Set orignal ParseObject ACL checking promise, if the object does not match + // subscription, we do not need to check ACL + let originalACLCheckingPromise; + if (!isOriginalSubscriptionMatched) { + originalACLCheckingPromise = Promise.resolve(false); + } else { + let originalACL; + if (message.originalParseObject) { + originalACL = message.originalParseObject.getACL(); + } + originalACLCheckingPromise = this._matchesACL(originalACL, client, requestId); + } + // Set current ParseObject ACL checking promise, if the object does not match + // subscription, we do not need to check ACL + let currentACLCheckingPromise; + let res: any = {}; + if (!isCurrentSubscriptionMatched) { + currentACLCheckingPromise = Promise.resolve(false); + } else { + const currentACL = message.currentParseObject.getACL(); + currentACLCheckingPromise = this._matchesACL(currentACL, client, requestId); + } + try { + const op = this._getCLPOperation(subscription.query); + const matchesCLP = await this._matchesCLP( + classLevelPermissions, + message.currentParseObject, + client, + requestId, + op + ); + if (matchesCLP === false) { + return; + } + const [isOriginalMatched, isCurrentMatched] = await Promise.all([ + originalACLCheckingPromise, + currentACLCheckingPromise, + ]); + logger.verbose( + 'Original %j | Current %j | Match: %s, %s, %s, %s | Query: %s', + localOriginalParseObject, + localCurrentParseObject, + isOriginalSubscriptionMatched, + isCurrentSubscriptionMatched, + isOriginalMatched, + isCurrentMatched, + subscription.hash + ); + // Decide event type + let type; + if (isOriginalMatched && isCurrentMatched) { + type = 'update'; + } else if (isOriginalMatched && !isCurrentMatched) { + type = 'leave'; + } else if (!isOriginalMatched && isCurrentMatched) { + if (localOriginalParseObject) { + type = 'enter'; + } else { + type = 'create'; + } + } else { + return null; + } + const watchFieldsChanged = this._checkWatchFields(client, requestId, message); + if (!watchFieldsChanged && (type === 'update' || type === 'create')) { + return; + } + res = { + event: type, + sessionToken: client.sessionToken, + object: localCurrentParseObject, + original: localOriginalParseObject, + clients: this.clients.size, + subscriptions: this.subscriptions.size, + useMasterKey: client.hasMasterKey, + installationId: client.installationId, + sendEvent: true, + }; + const trigger = getTrigger(className, 'afterEvent', Parse.applicationId); + if (trigger) { + if (res.object) { + res.object = Parse.Object.fromJSON(res.object); + } + if (res.original) { + res.original = Parse.Object.fromJSON(res.original); + } + const auth = await this.getAuthFromClient(client, requestId); + if (auth && auth.user) { + res.user = auth.user; + } + await runTrigger(trigger, `afterEvent.${className}`, res, auth); + } + if (!res.sendEvent) { + return; + } + if (res.object && typeof res.object.toJSON === 'function') { + localCurrentParseObject = toJSONwithObjects(res.object, res.object.className || className); + } + if (res.original && typeof res.original.toJSON === 'function') { + localOriginalParseObject = toJSONwithObjects( + res.original, + res.original.className || className + ); + } + res.object = localCurrentParseObject; + res.original = localOriginalParseObject; + await this._filterSensitiveData( + classLevelPermissions, + res, + client, + requestId, + op, + subscription.query + ); + const functionName = 'push' + res.event.charAt(0).toUpperCase() + res.event.slice(1); + if (client[functionName]) { + client[functionName](requestId, res.object, res.original ?? null); + } + } catch (e) { + const error = resolveError(e); + Client.pushError(client.parseWebSocket, error.code, error.message, false, requestId); + logger.error( + `Failed running afterLiveQueryEvent on class ${className} for event ${res.event} with session ${res.sessionToken} with:\n Error: ` + + JSON.stringify(error) + ); + } + }); + } + } + } + + _onConnect(parseWebsocket: any): void { + parseWebsocket.on('message', request => { + if (typeof request === 'string') { + try { + request = JSON.parse(request); + } catch (e) { + logger.error('unable to parse request', request, e); + return; + } + } + logger.verbose('Request: %j', request); + + // Check whether this request is a valid request, return error directly if not + if ( + !tv4.validate(request, RequestSchema['general']) || + !tv4.validate(request, RequestSchema[request.op]) + ) { + Client.pushError(parseWebsocket, 1, tv4.error.message); + logger.error('Connect message error %s', tv4.error.message); + return; + } + + switch (request.op) { + case 'connect': + this._handleConnect(parseWebsocket, request); + break; + case 'subscribe': + this._handleSubscribe(parseWebsocket, request); + break; + case 'update': + this._handleUpdateSubscription(parseWebsocket, request); + break; + case 'unsubscribe': + this._handleUnsubscribe(parseWebsocket, request); + break; + default: + Client.pushError(parseWebsocket, 3, 'Get unknown operation'); + logger.error('Get unknown operation', request.op); + } + }); + + parseWebsocket.on('disconnect', () => { + logger.info(`Client disconnect: ${parseWebsocket.clientId}`); + const clientId = parseWebsocket.clientId; + if (!this.clients.has(clientId)) { + runLiveQueryEventHandlers({ + event: 'ws_disconnect_error', + clients: this.clients.size, + subscriptions: this.subscriptions.size, + error: `Unable to find client ${clientId}`, + }); + logger.error(`Can not find client ${clientId} on disconnect`); + return; + } + + // Delete client + const client = this.clients.get(clientId); + this.clients.delete(clientId); + + // Delete client from subscriptions + for (const [requestId, subscriptionInfo] of _.entries(client.subscriptionInfos)) { + const subscription = subscriptionInfo.subscription; + subscription.deleteClientSubscription(clientId, requestId); + + // If there is no client which is subscribing this subscription, remove it from subscriptions + const classSubscriptions = this.subscriptions.get(subscription.className); + if (!subscription.hasSubscribingClient()) { + classSubscriptions.delete(subscription.hash); + } + // If there is no subscriptions under this class, remove it from subscriptions + if (classSubscriptions.size === 0) { + this.subscriptions.delete(subscription.className); + } + } + + logger.verbose('Current clients %d', this.clients.size); + logger.verbose('Current subscriptions %d', this.subscriptions.size); + runLiveQueryEventHandlers({ + event: 'ws_disconnect', + clients: this.clients.size, + subscriptions: this.subscriptions.size, + useMasterKey: client.hasMasterKey, + installationId: client.installationId, + sessionToken: client.sessionToken, + }); + }); + + runLiveQueryEventHandlers({ + event: 'ws_connect', + clients: this.clients.size, + subscriptions: this.subscriptions.size, + }); + } + + _validateQueryConstraints(where: any): void { + if (typeof where !== 'object' || where === null) { + return; + } + for (const op of ['$or', '$and', '$nor']) { + if (where[op] !== undefined && !Array.isArray(where[op])) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, `${op} must be an array`); + } + if (Array.isArray(where[op])) { + where[op].forEach((subQuery: any) => { + this._validateQueryConstraints(subQuery); + }); + } + } + for (const key of Object.keys(where)) { + const constraint = where[key]; + if (typeof constraint === 'object' && constraint !== null) { + if (constraint.$regex !== undefined) { + const regex = constraint.$regex; + const isRegExpLike = + regex !== null && + typeof regex === 'object' && + typeof regex.source === 'string' && + typeof regex.flags === 'string'; + if (typeof regex !== 'string' && !isRegExpLike) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + 'Invalid regular expression: $regex must be a string or RegExp' + ); + } + const pattern = isRegExpLike ? regex.source : regex; + const flags = isRegExpLike ? regex.flags : constraint.$options || ''; + try { + new RegExp(pattern, flags); + } catch (e) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Invalid regular expression: ${e.message}` + ); + } + } + } + } + } + + _matchesSubscription(parseObject: any, subscription: any): boolean { + // Object is undefined or null, not match + if (!parseObject) { + return false; + } + return matchesQuery(structuredClone(parseObject), subscription.query); + } + + async _clearCachedRoles(userId: string) { + try { + const validTokens = await new Parse.Query(Parse.Session) + .equalTo('user', Parse.User.createWithoutData(userId)) + .find({ useMasterKey: true }); + await Promise.all( + validTokens.map(async token => { + const sessionToken = token.get('sessionToken'); + const authPromise = this.authCache.get(sessionToken); + if (!authPromise) { + return; + } + const [auth1, auth2] = await Promise.all([ + authPromise, + getAuthForSessionToken({ cacheController: this.cacheController, sessionToken }), + ]); + auth1.auth?.clearRoleCache(sessionToken); + auth2.auth?.clearRoleCache(sessionToken); + this.authCache.delete(sessionToken); + }) + ); + } catch (e) { + logger.verbose(`Could not clear role cache. ${e}`); + } + } + + getAuthForSessionToken(sessionToken?: string): Promise<{ auth?: Auth, userId?: string }> { + if (!sessionToken) { + return Promise.resolve({}); + } + const fromCache = this.authCache.get(sessionToken); + if (fromCache) { + return fromCache; + } + const authPromise = getAuthForSessionToken({ + cacheController: this.cacheController, + sessionToken: sessionToken, + }) + .then(auth => { + return { auth, userId: auth && auth.user && auth.user.id }; + }) + .catch(error => { + // There was an error with the session token + const result: any = {}; + if (error && error.code === Parse.Error.INVALID_SESSION_TOKEN) { + result.error = error; + this.authCache.set(sessionToken, Promise.resolve(result), this.config.cacheTimeout); + } else { + this.authCache.delete(sessionToken); + } + return result; + }); + this.authCache.set(sessionToken, authPromise); + return authPromise; + } + + async _matchesCLP( + classLevelPermissions?: any, + object?: any, + client?: any, + requestId?: number, + op?: string + ): Promise { + const subscriptionInfo = client.getSubscriptionInfo(requestId); + const aclGroup = ['*']; + let userId; + if (typeof subscriptionInfo !== 'undefined') { + const result = await this.getAuthForSessionToken(subscriptionInfo.sessionToken); + userId = result.userId; + if (userId) { + aclGroup.push(userId); + } + } + await SchemaController.validatePermission( + classLevelPermissions, + object.className, + aclGroup, + op + ); + // Enforce pointer permissions that validatePermission defers. + // Returns false to silently skip the event (like ACL), rather than + // throwing which would push errors to the client and log noise. + if (!client.hasMasterKey && classLevelPermissions) { + const permissionField = + ['get', 'find', 'count'].indexOf(op) > -1 ? 'readUserFields' : 'writeUserFields'; + const pointerFields = []; + if (classLevelPermissions[op]?.pointerFields) { + pointerFields.push(...classLevelPermissions[op].pointerFields); + } + if (Array.isArray(classLevelPermissions[permissionField])) { + for (const field of classLevelPermissions[permissionField]) { + if (!pointerFields.includes(field)) { + pointerFields.push(field); + } + } + } + if (pointerFields.length > 0) { + // If public or user-specific permission already grants access, skip pointer check + if ( + !SchemaController.testPermissions(classLevelPermissions, aclGroup, op) + ) { + if (!userId) { + return false; + } + // Check if any pointer field points to the current user + const hasAccess = pointerFields.some(field => { + const value = + typeof object.get === 'function' ? object.get(field) : object[field]; + if (!value) { + return false; + } + // Handle Parse.Object pointer (has .id) + if (value.id) { + return value.id === userId; + } + // Handle raw pointer JSON (has .objectId) + if (value.objectId) { + return value.objectId === userId; + } + // Handle array of pointers + if (Array.isArray(value)) { + return value.some(item => { + if (item.id) { + return item.id === userId; + } + if (item.objectId) { + return item.objectId === userId; + } + return false; + }); + } + return false; + }); + if (!hasAccess) { + return false; + } + } + } + } + } + + async _filterSensitiveData( + classLevelPermissions?: any, + res?: any, + client?: any, + requestId?: number, + op?: string, + query?: any + ) { + const subscriptionInfo = client.getSubscriptionInfo(requestId); + const aclGroup = ['*']; + let clientAuth; + if (typeof subscriptionInfo !== 'undefined') { + const { userId, auth } = await this.getAuthForSessionToken(subscriptionInfo.sessionToken); + if (userId) { + aclGroup.push(userId); + } + clientAuth = auth; + } + const filter = obj => { + if (!obj) { + return; + } + let protectedFields = classLevelPermissions?.protectedFields || []; + if (client.hasMasterKey) { + protectedFields = []; + } else if (!Array.isArray(protectedFields)) { + protectedFields = getDatabaseController(this.config).addProtectedFields( + classLevelPermissions, + res.object.className, + query, + aclGroup, + clientAuth + ); + } + return DatabaseController.filterSensitiveData( + client.hasMasterKey, + false, + aclGroup, + clientAuth, + op, + classLevelPermissions, + res.object.className, + protectedFields, + obj, + this.config.protectedFieldsOwnerExempt + ); + }; + res.object = filter(res.object); + res.original = filter(res.original); + } + + _getCLPOperation(query: any) { + return typeof query === 'object' && + Object.keys(query).length == 1 && + typeof query.objectId === 'string' + ? 'get' + : 'find'; + } + + async _verifyACL(acl: any, token: string) { + if (!token) { + return false; + } + + const { auth, userId } = await this.getAuthForSessionToken(token); + + // Getting the session token failed + // This means that no additional auth is available + // At this point, just bail out as no additional visibility can be inferred. + if (!auth || !userId) { + return false; + } + const isSubscriptionSessionTokenMatched = acl.getReadAccess(userId); + if (isSubscriptionSessionTokenMatched) { + return true; + } + + // Check if the user has any roles that match the ACL + return Promise.resolve() + .then(async () => { + // Resolve false right away if the acl doesn't have any roles + const acl_has_roles = Object.keys(acl.permissionsById).some(key => key.startsWith('role:')); + if (!acl_has_roles) { + return false; + } + const roleNames = await auth.getUserRoles(); + // Finally, see if any of the user's roles allow them read access + for (const role of roleNames) { + // We use getReadAccess as `role` is in the form `role:roleName` + if (acl.getReadAccess(role)) { + return true; + } + } + return false; + }) + .catch(() => { + return false; + }); + } + + async getAuthFromClient(client: any, requestId: number, sessionToken?: string) { + const getSessionFromClient = () => { + const subscriptionInfo = client.getSubscriptionInfo(requestId); + if (typeof subscriptionInfo === 'undefined') { + return client.sessionToken; + } + return subscriptionInfo.sessionToken || client.sessionToken; + }; + if (!sessionToken) { + sessionToken = getSessionFromClient(); + } + if (!sessionToken) { + return; + } + const { auth } = await this.getAuthForSessionToken(sessionToken); + return auth; + } + + _checkWatchFields(client: any, requestId: any, message: any) { + const subscriptionInfo = client.getSubscriptionInfo(requestId); + const watch = subscriptionInfo?.watch; + if (!watch) { + return true; + } + const object = message.currentParseObject; + const original = message.originalParseObject; + return watch.some(field => !isDeepStrictEqual(object.get(field), original?.get(field))); + } + + async _matchesACL(acl: any, client: any, requestId: number): Promise { + // Return true directly if ACL isn't present, ACL is public read, or client has master key + if (!acl || acl.getPublicReadAccess() || client.hasMasterKey) { + return true; + } + // Check subscription sessionToken matches ACL first + const subscriptionInfo = client.getSubscriptionInfo(requestId); + if (typeof subscriptionInfo === 'undefined') { + return false; + } + + const subscriptionToken = subscriptionInfo.sessionToken; + const clientSessionToken = client.sessionToken; + + if (await this._verifyACL(acl, subscriptionToken)) { + return true; + } + + if (await this._verifyACL(acl, clientSessionToken)) { + return true; + } + + return false; + } + + async _handleConnect(parseWebsocket: any, request: any): Promise { + if (!this._validateKeys(request, this.keyPairs)) { + Client.pushError(parseWebsocket, 4, 'Key in request is not valid'); + logger.error('Key in request is not valid'); + return; + } + const hasMasterKey = this._hasMasterKey(request, this.keyPairs); + const clientId = randomUUID(); + const client = new Client( + clientId, + parseWebsocket, + hasMasterKey, + request.sessionToken, + request.installationId + ); + try { + const req = { + client, + event: 'connect', + clients: this.clients.size, + subscriptions: this.subscriptions.size, + sessionToken: request.sessionToken, + useMasterKey: client.hasMasterKey, + installationId: request.installationId, + user: undefined, + }; + const trigger = getTrigger('@Connect', 'beforeConnect', Parse.applicationId); + if (trigger) { + const auth = await this.getAuthFromClient(client, request.requestId, req.sessionToken); + if (auth && auth.user) { + req.user = auth.user; + } + await runTrigger(trigger, `beforeConnect.@Connect`, req, auth); + } + parseWebsocket.clientId = clientId; + this.clients.set(parseWebsocket.clientId, client); + logger.info(`Create new client: ${parseWebsocket.clientId}`); + client.pushConnect(); + runLiveQueryEventHandlers(req); + } catch (e) { + const error = resolveError(e); + Client.pushError(parseWebsocket, error.code, error.message, false); + logger.error( + `Failed running beforeConnect for session ${request.sessionToken} with:\n Error: ` + + JSON.stringify(error) + ); + } + } + + _hasMasterKey(request: any, validKeyPairs: any): boolean { + if (!validKeyPairs || validKeyPairs.size == 0 || !validKeyPairs.has('masterKey')) { + return false; + } + if (!request || !Object.prototype.hasOwnProperty.call(request, 'masterKey')) { + return false; + } + return request.masterKey === validKeyPairs.get('masterKey'); + } + + _validateKeys(request: any, validKeyPairs: any): boolean { + if (!validKeyPairs || validKeyPairs.size == 0) { + return true; + } + let isValid = false; + for (const [key, secret] of validKeyPairs) { + if (!request[key] || request[key] !== secret) { + continue; + } + isValid = true; + break; + } + return isValid; + } + + async _handleSubscribe(parseWebsocket: any, request: any): Promise { + // If we can not find this client, return error to client + if (!Object.prototype.hasOwnProperty.call(parseWebsocket, 'clientId')) { + Client.pushError( + parseWebsocket, + 2, + 'Can not find this client, make sure you connect to server before subscribing' + ); + logger.error('Can not find this client, make sure you connect to server before subscribing'); + return; + } + const client = this.clients.get(parseWebsocket.clientId); + const className = request.query.className; + let authCalled = false; + try { + const trigger = getTrigger(className, 'beforeSubscribe', Parse.applicationId); + if (trigger) { + const auth = await this.getAuthFromClient(client, request.requestId, request.sessionToken); + authCalled = true; + if (auth && auth.user) { + request.user = auth.user; + } + + const parseQuery = new Parse.Query(className); + parseQuery.withJSON(request.query); + request.query = parseQuery; + await runTrigger(trigger, `beforeSubscribe.${className}`, request, auth); + + const query = request.query.toJSON(); + request.query = query; + } + + if (className === '_Session') { + if (!authCalled) { + const auth = await this.getAuthFromClient( + client, + request.requestId, + request.sessionToken + ); + if (auth && auth.user) { + request.user = auth.user; + } + } + if (request.user) { + request.query.where.user = request.user.toPointer(); + } else if (!request.master) { + Client.pushError( + parseWebsocket, + Parse.Error.INVALID_SESSION_TOKEN, + 'Invalid session token', + false, + request.requestId + ); + return; + } + } + // Validate query condition depth + const appConfig = Config.get(this.config.appId); + if (!client.hasMasterKey) { + const rc = appConfig.requestComplexity; + if (rc && rc.queryDepth !== -1) { + const maxDepth = rc.queryDepth; + const checkDepth = (where: any, depth: number) => { + if (depth > maxDepth) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Query condition nesting depth exceeds maximum allowed depth of ${maxDepth}` + ); + } + if (typeof where !== 'object' || where === null) { + return; + } + for (const op of ['$or', '$and', '$nor']) { + if (where[op] !== undefined && !Array.isArray(where[op])) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, `${op} must be an array`); + } + if (Array.isArray(where[op])) { + for (const subQuery of where[op]) { + checkDepth(subQuery, depth + 1); + } + } + } + }; + checkDepth(request.query.where, 0); + } + } + + // Validate allowRegex + if (!client.hasMasterKey) { + const rc = appConfig.requestComplexity; + if (rc && rc.allowRegex === false) { + const checkRegex = (where: any) => { + if (typeof where !== 'object' || where === null) { + return; + } + for (const key of Object.keys(where)) { + const constraint = where[key]; + if (typeof constraint === 'object' && constraint !== null && constraint.$regex !== undefined) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, '$regex operator is not allowed'); + } + } + for (const op of ['$or', '$and', '$nor']) { + if (Array.isArray(where[op])) { + for (const subQuery of where[op]) { + checkRegex(subQuery); + } + } + } + }; + checkRegex(request.query.where); + } + } + + // Check CLP for subscribe operation + const schemaController = await appConfig.database.loadSchema(); + const classLevelPermissions = schemaController.getClassLevelPermissions(className); + const op = this._getCLPOperation(request.query); + const aclGroup = ['*']; + if (!authCalled) { + const auth = await this.getAuthFromClient( + client, + request.requestId, + request.sessionToken + ); + authCalled = true; + if (auth && auth.user) { + request.user = auth.user; + aclGroup.push(auth.user.id); + } + } else if (request.user) { + aclGroup.push(request.user.id); + } + await SchemaController.validatePermission( + classLevelPermissions, + className, + aclGroup, + op + ); + + // Check protected fields in WHERE clause and WATCH parameter + if (!client.hasMasterKey) { + const auth = request.user ? { user: request.user, userRoles: [] } : {}; + const protectedFields = + appConfig.database.addProtectedFields( + classLevelPermissions, + className, + request.query.where, + aclGroup, + auth + ) || []; + if (protectedFields.length > 0 && request.query.where) { + const checkWhere = (where: any) => { + if (typeof where !== 'object' || where === null) { + return; + } + for (const whereKey of Object.keys(where)) { + const rootField = whereKey.split('.')[0]; + if (protectedFields.includes(whereKey) || protectedFields.includes(rootField)) { + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + 'Permission denied' + ); + } + } + for (const op of ['$or', '$and', '$nor']) { + if (where[op] !== undefined && !Array.isArray(where[op])) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, `${op} must be an array`); + } + if (Array.isArray(where[op])) { + where[op].forEach((subQuery: any) => checkWhere(subQuery)); + } + } + }; + checkWhere(request.query.where); + } + if (protectedFields.length > 0 && Array.isArray(request.query.watch)) { + for (const watchField of request.query.watch) { + const rootField = watchField.split('.')[0]; + if (protectedFields.includes(watchField) || protectedFields.includes(rootField)) { + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + 'Permission denied' + ); + } + } + } + } + + // Validate regex patterns in the subscription query + this._validateQueryConstraints(request.query.where); + + // Get subscription from subscriptions, create one if necessary + const subscriptionHash = queryHash(request.query); + // Add className to subscriptions if necessary + + if (!this.subscriptions.has(className)) { + this.subscriptions.set(className, new Map()); + } + const classSubscriptions = this.subscriptions.get(className); + let subscription; + if (classSubscriptions.has(subscriptionHash)) { + subscription = classSubscriptions.get(subscriptionHash); + } else { + subscription = new Subscription(className, request.query.where, subscriptionHash); + classSubscriptions.set(subscriptionHash, subscription); + } + + // Add subscriptionInfo to client + const subscriptionInfo: any = { + subscription: subscription, + }; + // Add selected fields, sessionToken and installationId for this subscription if necessary + if (request.query.keys) { + subscriptionInfo.keys = Array.isArray(request.query.keys) + ? request.query.keys + : request.query.keys.split(','); + } + if (request.query.watch) { + subscriptionInfo.watch = request.query.watch; + } + if (request.sessionToken) { + subscriptionInfo.sessionToken = request.sessionToken; + } + client.addSubscriptionInfo(request.requestId, subscriptionInfo); + + // Add clientId to subscription + subscription.addClientSubscription(parseWebsocket.clientId, request.requestId); + + client.pushSubscribe(request.requestId); + + logger.verbose( + `Create client ${parseWebsocket.clientId} new subscription: ${request.requestId}` + ); + logger.verbose('Current client number: %d', this.clients.size); + runLiveQueryEventHandlers({ + client, + event: 'subscribe', + clients: this.clients.size, + subscriptions: this.subscriptions.size, + sessionToken: request.sessionToken, + useMasterKey: client.hasMasterKey, + installationId: client.installationId, + }); + } catch (e) { + const error = resolveError(e); + Client.pushError(parseWebsocket, error.code, error.message, false, request.requestId); + logger.error( + `Failed running beforeSubscribe on ${className} for session ${request.sessionToken} with:\n Error: ` + + JSON.stringify(error) + ); + } + } + + _handleUpdateSubscription(parseWebsocket: any, request: any): any { + this._handleUnsubscribe(parseWebsocket, request, false); + this._handleSubscribe(parseWebsocket, request); + } + + _handleUnsubscribe(parseWebsocket: any, request: any, notifyClient: boolean = true): any { + // If we can not find this client, return error to client + if (!Object.prototype.hasOwnProperty.call(parseWebsocket, 'clientId')) { + Client.pushError( + parseWebsocket, + 2, + 'Can not find this client, make sure you connect to server before unsubscribing' + ); + logger.error( + 'Can not find this client, make sure you connect to server before unsubscribing' + ); + return; + } + const requestId = request.requestId; + const client = this.clients.get(parseWebsocket.clientId); + if (typeof client === 'undefined') { + Client.pushError( + parseWebsocket, + 2, + 'Cannot find client with clientId ' + + parseWebsocket.clientId + + '. Make sure you connect to live query server before unsubscribing.' + ); + logger.error('Can not find this client ' + parseWebsocket.clientId); + return; + } + + const subscriptionInfo = client.getSubscriptionInfo(requestId); + if (typeof subscriptionInfo === 'undefined') { + Client.pushError( + parseWebsocket, + 2, + 'Cannot find subscription with clientId ' + + parseWebsocket.clientId + + ' subscriptionId ' + + requestId + + '. Make sure you subscribe to live query server before unsubscribing.' + ); + logger.error( + 'Can not find subscription with clientId ' + + parseWebsocket.clientId + + ' subscriptionId ' + + requestId + ); + return; + } + + // Remove subscription from client + client.deleteSubscriptionInfo(requestId); + // Remove client from subscription + const subscription = subscriptionInfo.subscription; + const className = subscription.className; + subscription.deleteClientSubscription(parseWebsocket.clientId, requestId); + // If there is no client which is subscribing this subscription, remove it from subscriptions + const classSubscriptions = this.subscriptions.get(className); + if (!subscription.hasSubscribingClient()) { + classSubscriptions.delete(subscription.hash); + } + // If there is no subscriptions under this class, remove it from subscriptions + if (classSubscriptions.size === 0) { + this.subscriptions.delete(className); + } + runLiveQueryEventHandlers({ + client, + event: 'unsubscribe', + clients: this.clients.size, + subscriptions: this.subscriptions.size, + sessionToken: subscriptionInfo.sessionToken, + useMasterKey: client.hasMasterKey, + installationId: client.installationId, + }); + + if (!notifyClient) { + return; + } + + client.pushUnsubscribe(request.requestId); + + logger.verbose( + `Delete client: ${parseWebsocket.clientId} | subscription: ${request.requestId}` + ); + } +} + +export { ParseLiveQueryServer }; diff --git a/src/LiveQuery/ParsePubSub.js b/src/LiveQuery/ParsePubSub.js index d49d8566db..34b1d0c255 100644 --- a/src/LiveQuery/ParsePubSub.js +++ b/src/LiveQuery/ParsePubSub.js @@ -1,29 +1,37 @@ -import { RedisPubSub } from './RedisPubSub'; -import { EventEmitterPubSub } from './EventEmitterPubSub'; +import { loadAdapter } from '../Adapters/AdapterLoader'; +import { EventEmitterPubSub } from '../Adapters/PubSub/EventEmitterPubSub'; -let ParsePubSub = {}; +import { RedisPubSub } from '../Adapters/PubSub/RedisPubSub'; + +const ParsePubSub = {}; function useRedis(config: any): boolean { - let redisURL = config.redisURL; + const redisURL = config.redisURL; return typeof redisURL !== 'undefined' && redisURL !== ''; } -ParsePubSub.createPublisher = function(config: any): any { +ParsePubSub.createPublisher = function (config: any): any { if (useRedis(config)) { - return RedisPubSub.createPublisher(config.redisURL); + return RedisPubSub.createPublisher(config); } else { - return EventEmitterPubSub.createPublisher(); + const adapter = loadAdapter(config.pubSubAdapter, EventEmitterPubSub, config); + if (typeof adapter.createPublisher !== 'function') { + throw 'pubSubAdapter should have createPublisher()'; + } + return adapter.createPublisher(config); } -} +}; -ParsePubSub.createSubscriber = function(config: any): void { +ParsePubSub.createSubscriber = function (config: any): void { if (useRedis(config)) { - return RedisPubSub.createSubscriber(config.redisURL); + return RedisPubSub.createSubscriber(config); } else { - return EventEmitterPubSub.createSubscriber(); + const adapter = loadAdapter(config.pubSubAdapter, EventEmitterPubSub, config); + if (typeof adapter.createSubscriber !== 'function') { + throw 'pubSubAdapter should have createSubscriber()'; + } + return adapter.createSubscriber(config); } -} +}; -export { - ParsePubSub -} +export { ParsePubSub }; diff --git a/src/LiveQuery/ParseWebSocketServer.js b/src/LiveQuery/ParseWebSocketServer.js index e97223063b..927ee4b275 100644 --- a/src/LiveQuery/ParseWebSocketServer.js +++ b/src/LiveQuery/ParseWebSocketServer.js @@ -1,44 +1,65 @@ -import PLog from './PLog'; - -let typeMap = new Map([['disconnect', 'close']]); +import { loadAdapter } from '../Adapters/AdapterLoader'; +import { WSAdapter } from '../Adapters/WebSocketServer/WSAdapter'; +import logger from '../logger'; +import events from 'events'; +import { inspect } from 'util'; export class ParseWebSocketServer { server: Object; - constructor(server: any, onConnect: Function, websocketTimeout: number = 10 * 1000) { - let WebSocketServer = require('ws').Server; - let wss = new WebSocketServer({ server: server }); - wss.on('listening', () => { - PLog.log('Parse LiveQuery Server starts running'); - }); - wss.on('connection', (ws) => { + constructor(server: any, onConnect: Function, config) { + config.server = server; + const wss = loadAdapter(config.wssAdapter, WSAdapter, config); + wss.onListen = () => { + logger.info('Parse LiveQuery Server started running'); + }; + wss.onConnection = ws => { + ws.waitingForPong = false; + ws.on('pong', () => { + ws.waitingForPong = false; + }); + ws.on('error', error => { + logger.error(error.message); + logger.error(inspect(ws, false)); + }); onConnect(new ParseWebSocket(ws)); // Send ping to client periodically - let pingIntervalId = setInterval(() => { - if (ws.readyState == ws.OPEN) { + const pingIntervalId = setInterval(() => { + if (!ws.waitingForPong) { ws.ping(); + ws.waitingForPong = true; } else { clearInterval(pingIntervalId); + ws.terminate(); } - }, websocketTimeout); - }); + }, config.websocketTimeout || 10 * 1000); + }; + wss.onError = error => { + logger.error(error); + }; + wss.start(); this.server = wss; } + + close() { + if (this.server && this.server.close) { + this.server.close(); + } + } } -export class ParseWebSocket { +export class ParseWebSocket extends events.EventEmitter { ws: any; constructor(ws: any) { + super(); + ws.onmessage = request => + this.emit('message', request && request.data ? request.data : request); + ws.onclose = () => this.emit('disconnect'); this.ws = ws; } - on(type: string, callback): void { - let wsType = typeMap.has(type) ? typeMap.get(type) : type; - this.ws.on(wsType, callback); - } - - send(message: any, channel: string): void { + send(message: any): void { this.ws.send(message); } } diff --git a/src/LiveQuery/QueryTools.js b/src/LiveQuery/QueryTools.js index f2cb5edcbe..37cfbfa47f 100644 --- a/src/LiveQuery/QueryTools.js +++ b/src/LiveQuery/QueryTools.js @@ -1,6 +1,51 @@ var equalObjects = require('./equalObjects'); var Id = require('./Id'); var Parse = require('parse/node'); +var vm = require('vm'); +var logger = require('../logger').default; + +var regexTimeout = 0; +// IMPORTANT: vmContext is shared across all calls for performance (vm.createContext() is expensive). +// This is safe because safeRegexTest is synchronous — setting the context properties and calling +// runInContext happen in the same event loop tick with no interruption possible. Do NOT add any +// asynchronous operations (await, callbacks, promises) between setting vmContext properties and +// calling script.runInContext, as this would allow other calls to overwrite the context values +// and cause cross-contamination between regex evaluations. +var vmContext = vm.createContext(Object.create(null)); +var scriptCache = new Map(); +var SCRIPT_CACHE_MAX = 1000; + +function setRegexTimeout(ms) { + regexTimeout = ms; +} + +// IMPORTANT: This function must remain synchronous. See vmContext comment above. +function safeRegexTest(pattern, flags, input) { + try { + if (!regexTimeout) { + var re = new RegExp(pattern, flags); + return re.test(input); + } + var cacheKey = flags + ':' + pattern; + var script = scriptCache.get(cacheKey); + if (!script) { + if (scriptCache.size >= SCRIPT_CACHE_MAX) { scriptCache.clear(); } + script = new vm.Script('new RegExp(pattern, flags).test(input)'); + scriptCache.set(cacheKey, script); + } + vmContext.pattern = pattern; + vmContext.flags = flags; + vmContext.input = input; + return script.runInContext(vmContext, { timeout: regexTimeout }); + } catch (e) { + if (e.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT') { + logger.warn(`Regex timeout: pattern "${pattern}" with flags "${flags}" exceeded ${regexTimeout}ms limit`); + } else { + logger.warn(`Invalid regex: pattern "${pattern}" with flags "${flags}": ${e.message}`); + } + return false; + } +} /** * Query Hashes are deterministic hashes for Parse Queries. @@ -13,7 +58,7 @@ var Parse = require('parse/node'); * Convert $or queries into an array of where conditions */ function flattenOrQueries(where) { - if (!where.hasOwnProperty('$or')) { + if (!Object.prototype.hasOwnProperty.call(where, '$or')) { return where; } var accum = []; @@ -55,8 +100,8 @@ function queryHash(query) { if (query instanceof Parse.Query) { query = { className: query.className, - where: query._where - } + where: query._where, + }; } var where = flattenOrQueries(query.where || {}); var columns = []; @@ -89,6 +134,34 @@ function queryHash(query) { return query.className + ':' + sections.join('|'); } +/** + * contains -- Determines if an object is contained in a list with special handling for Parse pointers. + */ +function contains(haystack: Array, needle: any): boolean { + if (needle && needle.__type && needle.__type === 'Pointer') { + for (const i in haystack) { + const ptr = haystack[i]; + if (typeof ptr === 'string' && ptr === needle.objectId) { + return true; + } + if (ptr.className === needle.className && ptr.objectId === needle.objectId) { + return true; + } + } + + return false; + } + + if (Array.isArray(needle)) { + for (const need of needle) { + if (contains(haystack, need)) { + return true; + } + } + } + + return haystack.indexOf(needle) > -1; +} /** * matchesQuery -- Determines if an object would be returned by a Parse Query * It's a lightweight, where-clause only implementation of a full query engine. @@ -97,8 +170,7 @@ function queryHash(query) { */ function matchesQuery(object: any, query: any): boolean { if (query instanceof Parse.Query) { - var className = - (object.id instanceof Id) ? object.id.className : object.className; + var className = object.id instanceof Id ? object.id.className : object.className; if (className !== query.className) { return false; } @@ -112,6 +184,18 @@ function matchesQuery(object: any, query: any): boolean { return true; } +function equalObjectsGeneric(obj, compareTo, eqlFn) { + if (Array.isArray(obj)) { + for (var i = 0; i < obj.length; i++) { + if (eqlFn(obj[i], compareTo)) { + return true; + } + } + return false; + } + + return eqlFn(obj, compareTo); +} /** * Determines whether an object matches a single key's constraints @@ -120,8 +204,18 @@ function matchesKeyConstraints(object, key, constraints) { if (constraints === null) { return false; } + if (key.indexOf('.') >= 0) { + // Key references a subobject + var keyComponents = key.split('.'); + var subObjectKey = keyComponents[0]; + var keyRemainder = keyComponents.slice(1).join('.'); + return matchesKeyConstraints(object[subObjectKey] || {}, keyRemainder, constraints); + } var i; if (key === '$or') { + if (!Array.isArray(constraints)) { + return false; + } for (i = 0; i < constraints.length; i++) { if (matchesQuery(object, constraints[i])) { return true; @@ -129,10 +223,36 @@ function matchesKeyConstraints(object, key, constraints) { } return false; } + if (key === '$and') { + if (!Array.isArray(constraints)) { + return false; + } + for (i = 0; i < constraints.length; i++) { + if (!matchesQuery(object, constraints[i])) { + return false; + } + } + return true; + } + if (key === '$nor') { + if (!Array.isArray(constraints)) { + return false; + } + for (i = 0; i < constraints.length; i++) { + if (matchesQuery(object, constraints[i])) { + return false; + } + } + return true; + } if (key === '$relatedTo') { // Bail! We can't handle relational queries locally return false; } + // Decode Date JSON value + if (object[key] && object[key].__type == 'Date') { + object[key] = new Date(object[key].iso); + } // Equality (or Array contains) cases if (typeof constraints !== 'object') { if (Array.isArray(object[key])) { @@ -143,27 +263,21 @@ function matchesKeyConstraints(object, key, constraints) { var compareTo; if (constraints.__type) { if (constraints.__type === 'Pointer') { - return ( - typeof object[key] !== 'undefined' && - constraints.className === object[key].className && - constraints.objectId === object[key].objectId - ); - } - compareTo = Parse._decode(key, constraints); - if (Array.isArray(object[key])) { - for (i = 0; i < object[key].length; i++) { - if (equalObjects(object[key][i], compareTo)) { - return true; - } - } - return false; + return equalObjectsGeneric(object[key], constraints, function (obj, ptr) { + return ( + typeof obj !== 'undefined' && + ptr.className === obj.className && + ptr.objectId === obj.objectId + ); + }); } - return equalObjects(object[key], compareTo); + + return equalObjectsGeneric(object[key], Parse._decode(key, constraints), equalObjects); } // More complex cases for (var condition in constraints) { compareTo = constraints[condition]; - if (compareTo.__type) { + if (compareTo?.__type) { compareTo = Parse._decode(key, compareTo); } switch (condition) { @@ -187,31 +301,39 @@ function matchesKeyConstraints(object, key, constraints) { return false; } break; + case '$eq': + if (!equalObjects(object[key], compareTo)) { + return false; + } + break; case '$ne': if (equalObjects(object[key], compareTo)) { return false; } break; case '$in': - if (compareTo.indexOf(object[key]) < 0) { + if (!contains(compareTo, object[key])) { return false; } break; case '$nin': - if (compareTo.indexOf(object[key]) > -1) { + if (contains(compareTo, object[key])) { return false; } break; case '$all': + if (!object[key]) { + return false; + } for (i = 0; i < compareTo.length; i++) { if (object[key].indexOf(compareTo[i]) < 0) { return false; } } break; - case '$exists': - let propertyExists = typeof object[key] !== 'undefined'; - let existenceIsRequired = constraints['$exists']; + case '$exists': { + const propertyExists = typeof object[key] !== 'undefined'; + const existenceIsRequired = constraints['$exists']; if (typeof constraints['$exists'] !== 'boolean') { // The SDK will never submit a non-boolean for $exists, but if someone // tries to submit a non-boolean for $exits outside the SDKs, just ignore it. @@ -221,9 +343,13 @@ function matchesKeyConstraints(object, key, constraints) { return false; } break; - case '$regex': + } + case '$regex': { if (typeof compareTo === 'object') { - return compareTo.test(object[key]); + if (!safeRegexTest(compareTo.source, compareTo.flags, object[key])) { + return false; + } + break; } // JS doesn't support perl-style escaping var expString = ''; @@ -234,27 +360,34 @@ function matchesKeyConstraints(object, key, constraints) { expString += compareTo.substring(escapeEnd + 2, escapeStart); escapeEnd = compareTo.indexOf('\\E', escapeStart); if (escapeEnd > -1) { - expString += compareTo.substring(escapeStart + 2, escapeEnd) - .replace(/\\\\\\\\E/g, '\\E').replace(/\W/g, '\\$&'); + expString += compareTo + .substring(escapeStart + 2, escapeEnd) + .replace(/\\\\\\\\E/g, '\\E') + .replace(/\W/g, '\\$&'); } escapeStart = compareTo.indexOf('\\Q', escapeEnd); } expString += compareTo.substring(Math.max(escapeStart, escapeEnd + 2)); - var exp = new RegExp(expString, constraints.$options || ''); - if (!exp.test(object[key])) { + if (!safeRegexTest(expString, constraints.$options || '', object[key])) { return false; } break; + } case '$nearSphere': + if (!compareTo || !object[key]) { + return false; + } var distance = compareTo.radiansTo(object[key]); var max = constraints.$maxDistance || Infinity; return distance <= max; case '$within': + if (!compareTo || !object[key]) { + return false; + } var southWest = compareTo.$box[0]; var northEast = compareTo.$box[1]; - if (southWest.latitude > northEast.latitude || - southWest.longitude > northEast.longitude) { + if (southWest.latitude > northEast.latitude || southWest.longitude > northEast.longitude) { // Invalid box, crosses the date line return false; } @@ -264,6 +397,40 @@ function matchesKeyConstraints(object, key, constraints) { object[key].longitude > southWest.longitude && object[key].longitude < northEast.longitude ); + case '$containedBy': { + for (const value of object[key]) { + if (!contains(compareTo, value)) { + return false; + } + } + return true; + } + case '$geoWithin': { + if (compareTo.$polygon) { + const points = compareTo.$polygon.map(geoPoint => [ + geoPoint.latitude, + geoPoint.longitude, + ]); + const polygon = new Parse.Polygon(points); + return polygon.containsPoint(object[key]); + } + if (compareTo.$centerSphere) { + const [WGS84Point, maxDistance] = compareTo.$centerSphere; + const centerPoint = new Parse.GeoPoint({ + latitude: WGS84Point[1], + longitude: WGS84Point[0], + }); + const point = new Parse.GeoPoint(object[key]); + const distance = point.radiansTo(centerPoint); + return distance <= maxDistance; + } + break; + } + case '$geoIntersects': { + const polygon = new Parse.Polygon(object[key].coordinates); + const point = new Parse.GeoPoint(compareTo.$point); + return polygon.containsPoint(point); + } case '$options': // Not a query type, but a way to add options to $regex. Ignore and // avoid the default @@ -285,7 +452,8 @@ function matchesKeyConstraints(object, key, constraints) { var QueryTools = { queryHash: queryHash, - matchesQuery: matchesQuery + matchesQuery: matchesQuery, + setRegexTimeout: setRegexTimeout, }; module.exports = QueryTools; diff --git a/src/LiveQuery/RedisPubSub.js b/src/LiveQuery/RedisPubSub.js deleted file mode 100644 index 92e3d86e66..0000000000 --- a/src/LiveQuery/RedisPubSub.js +++ /dev/null @@ -1,18 +0,0 @@ -import redis from 'redis'; - -function createPublisher(redisURL: string): any { - return redis.createClient(redisURL, { no_ready_check: true }); -} - -function createSubscriber(redisURL: string): any { - return redis.createClient(redisURL, { no_ready_check: true }); -} - -let RedisPubSub = { - createPublisher, - createSubscriber -} - -export { - RedisPubSub -} diff --git a/src/LiveQuery/RequestSchema.js b/src/LiveQuery/RequestSchema.js index 9811df5738..6e0a0566b2 100644 --- a/src/LiveQuery/RequestSchema.js +++ b/src/LiveQuery/RequestSchema.js @@ -1,101 +1,160 @@ -let general = { - 'title': 'General request schema', - 'type': 'object', - 'properties': { - 'op': { - 'type': 'string', - 'enum': ['connect', 'subscribe', 'unsubscribe'] +const general = { + title: 'General request schema', + type: 'object', + properties: { + op: { + type: 'string', + enum: ['connect', 'subscribe', 'unsubscribe', 'update'], }, }, + required: ['op'], }; -let connect = { - 'title': 'Connect operation schema', - 'type': 'object', - 'properties': { - 'op': 'connect', - 'applicationId': { - 'type': 'string' +const connect = { + title: 'Connect operation schema', + type: 'object', + properties: { + op: 'connect', + applicationId: { + type: 'string', }, - 'javascriptKey': { - type: 'string' + javascriptKey: { + type: 'string', }, - 'masterKey': { - type: 'string' + masterKey: { + type: 'string', }, - 'clientKey': { - type: 'string' + clientKey: { + type: 'string', }, - 'windowsKey': { - type: 'string' + windowsKey: { + type: 'string', }, - 'restAPIKey': { - 'type': 'string' + restAPIKey: { + type: 'string', + }, + sessionToken: { + type: 'string', + }, + installationId: { + type: 'string', }, - 'sessionToken': { - 'type': 'string' - } }, - 'required': ['op', 'applicationId'], - "additionalProperties": false + required: ['op', 'applicationId'], + additionalProperties: false, }; -let subscribe = { - 'title': 'Subscribe operation schema', - 'type': 'object', - 'properties': { - 'op': 'subscribe', - 'requestId': { - 'type': 'number' - }, - 'query': { - 'title': 'Query field schema', - 'type': 'object', - 'properties': { - 'className': { - 'type': 'string' +const subscribe = { + title: 'Subscribe operation schema', + type: 'object', + properties: { + op: 'subscribe', + requestId: { + type: 'number', + }, + query: { + title: 'Query field schema', + type: 'object', + properties: { + className: { + type: 'string', }, - 'where': { - 'type': 'object' + where: { + type: 'object', }, - 'fields': { - "type": "array", - "items": { - "type": "string" + keys: { + type: 'array', + items: { + type: 'string', }, - "minItems": 1, - "uniqueItems": true - } + minItems: 1, + uniqueItems: true, + }, + watch: { + type: 'array', + items: { + type: 'string', + }, + minItems: 1, + uniqueItems: true, + }, }, - 'required': ['where', 'className'], - 'additionalProperties': false + required: ['where', 'className'], + additionalProperties: false, + }, + sessionToken: { + type: 'string', }, - 'sessionToken': { - 'type': 'string' - } }, - 'required': ['op', 'requestId', 'query'], - 'additionalProperties': false + required: ['op', 'requestId', 'query'], + additionalProperties: false, }; -let unsubscribe = { - 'title': 'Unsubscribe operation schema', - 'type': 'object', - 'properties': { - 'op': 'unsubscribe', - 'requestId': { - 'type': 'number' - } +const update = { + title: 'Update operation schema', + type: 'object', + properties: { + op: 'update', + requestId: { + type: 'number', + }, + query: { + title: 'Query field schema', + type: 'object', + properties: { + className: { + type: 'string', + }, + where: { + type: 'object', + }, + keys: { + type: 'array', + items: { + type: 'string', + }, + minItems: 1, + uniqueItems: true, + }, + watch: { + type: 'array', + items: { + type: 'string', + }, + minItems: 1, + uniqueItems: true, + }, + }, + required: ['where', 'className'], + additionalProperties: false, + }, + sessionToken: { + type: 'string', + }, }, - 'required': ['op', 'requestId'], - "additionalProperties": false -} + required: ['op', 'requestId', 'query'], + additionalProperties: false, +}; -let RequestSchema = { - 'general': general, - 'connect': connect, - 'subscribe': subscribe, - 'unsubscribe': unsubscribe -} +const unsubscribe = { + title: 'Unsubscribe operation schema', + type: 'object', + properties: { + op: 'unsubscribe', + requestId: { + type: 'number', + }, + }, + required: ['op', 'requestId'], + additionalProperties: false, +}; + +const RequestSchema = { + general: general, + connect: connect, + subscribe: subscribe, + update: update, + unsubscribe: unsubscribe, +}; export default RequestSchema; diff --git a/src/LiveQuery/SessionTokenCache.js b/src/LiveQuery/SessionTokenCache.js index 07d9d62744..a7f52b65a0 100644 --- a/src/LiveQuery/SessionTokenCache.js +++ b/src/LiveQuery/SessionTokenCache.js @@ -1,38 +1,50 @@ import Parse from 'parse/node'; -import LRU from 'lru-cache'; -import PLog from './PLog'; +import { LRUCache as LRU } from 'lru-cache'; +import logger from '../logger'; + +function userForSessionToken(sessionToken) { + var q = new Parse.Query('_Session'); + q.equalTo('sessionToken', sessionToken); + return q.first({ useMasterKey: true }).then(function (session) { + if (!session) { + return Promise.reject('No session found for session token'); + } + return session.get('user'); + }); +} class SessionTokenCache { cache: Object; - constructor(timeout: number = 30 * 24 * 60 *60 * 1000, maxSize: number = 10000) { + constructor(timeout: number = 30 * 24 * 60 * 60 * 1000, maxSize: number = 10000) { this.cache = new LRU({ max: maxSize, - maxAge: timeout + ttl: timeout, }); } getUserId(sessionToken: string): any { if (!sessionToken) { - return Parse.Promise.error('Empty sessionToken'); + return Promise.reject('Empty sessionToken'); } - let userId = this.cache.get(sessionToken); + const userId = this.cache.get(sessionToken); if (userId) { - PLog.verbose('Fetch userId %s of sessionToken %s from Cache', userId, sessionToken); - return Parse.Promise.as(userId); + logger.verbose('Fetch userId %s of sessionToken %s from Cache', userId, sessionToken); + return Promise.resolve(userId); } - return Parse.User.become(sessionToken).then((user) => { - PLog.verbose('Fetch userId %s of sessionToken %s from Parse', user.id, sessionToken); - let userId = user.id; - this.cache.set(sessionToken, userId); - return Parse.Promise.as(userId); - }, (error) => { - PLog.error('Can not fetch userId for sessionToken %j, error %j', sessionToken, error); - return Parse.Promise.error(error); - }); + return userForSessionToken(sessionToken).then( + user => { + logger.verbose('Fetch userId %s of sessionToken %s from Parse', user.id, sessionToken); + const userId = user.id; + this.cache.set(sessionToken, userId); + return Promise.resolve(userId); + }, + error => { + logger.error('Can not fetch userId for sessionToken %j, error %j', sessionToken, error); + return Promise.reject(error); + } + ); } } -export { - SessionTokenCache -} +export { SessionTokenCache }; diff --git a/src/LiveQuery/Subscription.js b/src/LiveQuery/Subscription.js index e3b63dafd3..83df0b831f 100644 --- a/src/LiveQuery/Subscription.js +++ b/src/LiveQuery/Subscription.js @@ -1,5 +1,4 @@ -import {matchesQuery, queryHash} from './QueryTools'; -import PLog from './PLog'; +import logger from '../logger'; export type FlattenedObjectData = { [attr: string]: any }; export type QueryData = { [attr: string]: any }; @@ -22,20 +21,20 @@ class Subscription { if (!this.clientRequestIds.has(clientId)) { this.clientRequestIds.set(clientId, []); } - let requestIds = this.clientRequestIds.get(clientId); + const requestIds = this.clientRequestIds.get(clientId); requestIds.push(requestId); } deleteClientSubscription(clientId: number, requestId: number): void { - let requestIds = this.clientRequestIds.get(clientId); + const requestIds = this.clientRequestIds.get(clientId); if (typeof requestIds === 'undefined') { - PLog.error('Can not find client %d to delete', clientId); + logger.error('Can not find client %d to delete', clientId); return; } - let index = requestIds.indexOf(requestId); + const index = requestIds.indexOf(requestId); if (index < 0) { - PLog.error('Can not find client %d subscription %d to delete', clientId, requestId); + logger.error('Can not find client %d subscription %d to delete', clientId, requestId); return; } requestIds.splice(index, 1); @@ -50,6 +49,4 @@ class Subscription { } } -export { - Subscription -} +export { Subscription }; diff --git a/src/LiveQuery/equalObjects.js b/src/LiveQuery/equalObjects.js index 931d392fd8..5bc3f5e957 100644 --- a/src/LiveQuery/equalObjects.js +++ b/src/LiveQuery/equalObjects.js @@ -9,14 +9,14 @@ function equalObjects(a, b) { return false; } if (typeof a !== 'object') { - return (a === b); + return a === b; } if (a === b) { return true; } if (toString.call(a) === '[object Date]') { if (toString.call(b) === '[object Date]') { - return (+a === +b); + return +a === +b; } return false; } diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js new file mode 100644 index 0000000000..277ba9a477 --- /dev/null +++ b/src/Options/Definitions.js @@ -0,0 +1,1596 @@ +/* +**** GENERATED CODE **** +This code has been generated by resources/buildConfigDefinitions.js +Do not edit manually, but update Options/index.js +*/ +var parsers = require('./parsers'); +module.exports.SchemaOptions = { + afterMigration: { + env: 'PARSE_SERVER_SCHEMA_AFTER_MIGRATION', + help: 'Execute a callback after running schema migrations.', + }, + beforeMigration: { + env: 'PARSE_SERVER_SCHEMA_BEFORE_MIGRATION', + help: 'Execute a callback before running schema migrations.', + }, + definitions: { + env: 'PARSE_SERVER_SCHEMA_DEFINITIONS', + help: 'Rest representation on Parse.Schema https://docs.parseplatform.org/rest/guide/#adding-a-schema', + required: true, + action: parsers.objectParser, + default: [], + }, + deleteExtraFields: { + env: 'PARSE_SERVER_SCHEMA_DELETE_EXTRA_FIELDS', + help: 'Is true if Parse Server should delete any fields not defined in a schema definition. This should only be used during development.', + action: parsers.booleanParser, + default: false, + }, + keepUnknownIndexes: { + env: 'PARSE_SERVER_SCHEMA_KEEP_UNKNOWN_INDEXES', + help: "(Optional) Keep indexes that are present in the database but not defined in the schema. Set this to `true` if you are adding indexes manually, so that they won't be removed when running schema migration. Default is `false`.", + action: parsers.booleanParser, + default: false, + }, + lockSchemas: { + env: 'PARSE_SERVER_SCHEMA_LOCK_SCHEMAS', + help: 'Is true if Parse Server will reject any attempts to modify the schema while the server is running.', + action: parsers.booleanParser, + default: false, + }, + recreateModifiedFields: { + env: 'PARSE_SERVER_SCHEMA_RECREATE_MODIFIED_FIELDS', + help: 'Is true if Parse Server should recreate any fields that are different between the current database schema and theschema definition. This should only be used during development.', + action: parsers.booleanParser, + default: false, + }, + strict: { + env: 'PARSE_SERVER_SCHEMA_STRICT', + help: 'Is true if Parse Server should exit if schema update fail.', + action: parsers.booleanParser, + default: false, + }, +}; +module.exports.ParseServerOptions = { + accountLockout: { + env: 'PARSE_SERVER_ACCOUNT_LOCKOUT', + help: "The account lockout policy for failed login attempts.

Note: Setting a user's ACL to an empty object `{}` via master key is a separate mechanism that only prevents new logins; it does not invalidate existing session tokens. To immediately revoke a user's access, destroy their sessions via master key in addition to setting the ACL.", + action: parsers.objectParser, + type: 'AccountLockoutOptions', + }, + allowClientClassCreation: { + env: 'PARSE_SERVER_ALLOW_CLIENT_CLASS_CREATION', + help: 'Enable (or disable) client class creation, defaults to false', + action: parsers.booleanParser, + default: false, + }, + allowCustomObjectId: { + env: 'PARSE_SERVER_ALLOW_CUSTOM_OBJECT_ID', + help: 'Enable (or disable) custom objectId', + action: parsers.booleanParser, + default: false, + }, + allowExpiredAuthDataToken: { + env: 'PARSE_SERVER_ALLOW_EXPIRED_AUTH_DATA_TOKEN', + help: 'Deprecated. This option will be removed in a future version. Auth providers are always validated on login. On update, if this is set to `true`, auth providers are only re-validated when the auth data has changed. If this is set to `false`, auth providers are re-validated on every update. Defaults to `false`.', + 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 origins for Access-Control-Allow-Origin. This can be a string for a single origin or an array of strings for multiple origins.', + action: parsers.arrayParser, + }, + 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

Provider names must start with a letter and contain only letters, digits, and underscores (`/^[A-Za-z][A-Za-z0-9_]*$/`). This is because each provider name is used to construct a database field (`_auth_data_`), which must comply with Parse Server's field naming rules.", + 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: '', + }, + convertEmailToLowercase: { + env: 'PARSE_SERVER_CONVERT_EMAIL_TO_LOWERCASE', + help: 'Optional. If set to `true`, the `email` property of a user is automatically converted to lowercase before being stored in the database. Consequently, queries must match the case as stored in the database, which would be lowercase in this scenario. If `false`, the `email` property is stored as set, without any case modifications. Default is `false`.', + action: parsers.booleanParser, + default: false, + }, + convertUsernameToLowercase: { + env: 'PARSE_SERVER_CONVERT_USERNAME_TO_LOWERCASE', + help: 'Optional. If set to `true`, the `username` property of a user is automatically converted to lowercase before being stored in the database. Consequently, queries must match the case as stored in the database, which would be lowercase in this scenario. If `false`, the `username` property is stored as set, without any case modifications. Default is `false`.', + action: parsers.booleanParser, + default: false, + }, + customPages: { + env: 'PARSE_SERVER_CUSTOM_PAGES', + help: 'custom pages for password validation and reset', + action: parsers.objectParser, + type: 'CustomPagesOptions', + default: {}, + }, + databaseAdapter: { + env: 'PARSE_SERVER_DATABASE_ADAPTER', + help: 'Adapter module for the database; any options that are not explicitly described here are passed directly to the database client.', + action: parsers.moduleOrObjectParser, + }, + databaseOptions: { + env: 'PARSE_SERVER_DATABASE_OPTIONS', + help: 'Options to pass to the database client', + action: parsers.objectParser, + type: 'DatabaseOptions', + }, + 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', + }, + defaultLimit: { + env: 'PARSE_SERVER_DEFAULT_LIMIT', + help: 'Default value for limit option on queries, defaults to `100`.', + action: parsers.numberParser('defaultLimit'), + default: 100, + }, + directAccess: { + env: 'PARSE_SERVER_DIRECT_ACCESS', + help: 'Set to `true` if Parse requests within the same Node.js environment as Parse Server should be routed to Parse Server directly instead of via the HTTP interface. Default is `false`.

If set to `false` then Parse requests within the same Node.js environment as Parse Server are executed as HTTP requests sent to Parse Server via the `serverURL`. For example, a `Parse.Query` in Cloud Code is calling Parse Server via a HTTP request. The server is essentially making a HTTP request to itself, unnecessarily using network resources such as network ports.

\u26A0\uFE0F In environments where multiple Parse Server instances run behind a load balancer and Parse requests within the current Node.js environment should be routed via the load balancer and distributed as HTTP requests among all instances via the `serverURL`, this should be set to `false`.', + action: parsers.booleanParser, + default: true, + }, + 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, + }, + emailVerifySuccessOnInvalidEmail: { + env: 'PARSE_SERVER_EMAIL_VERIFY_SUCCESS_ON_INVALID_EMAIL', + help: 'Set to `true` if a request to verify the email should return a success response even if the provided email address does not belong to a verifiable account, for example because it is unknown or already verified, or `false` if the request should return an error response in those cases.

Default is `true`.
Requires option `verifyUserEmails: true`.', + action: parsers.booleanParser, + default: true, + }, + emailVerifyTokenReuseIfValid: { + env: 'PARSE_SERVER_EMAIL_VERIFY_TOKEN_REUSE_IF_VALID', + help: 'Set to `true` if a email verification token should be reused in case another token is requested but there is a token that is still valid, i.e. has not expired. This avoids the often observed issue that a user requests multiple emails and does not know which link contains a valid token because each newly generated token would invalidate the previous token.

Default is `false`.
Requires option `verifyUserEmails: true`.', + action: parsers.booleanParser, + default: false, + }, + emailVerifyTokenValidityDuration: { + env: 'PARSE_SERVER_EMAIL_VERIFY_TOKEN_VALIDITY_DURATION', + help: 'Set the validity duration of the email verification token in seconds after which the token expires. The token is used in the link that is set in the email. After the token expires, the link becomes invalid and a new link has to be sent. If the option is not set or set to `undefined`, then the token never expires.

For example, to expire the token after 2 hours, set a value of 7200 seconds (= 60 seconds * 60 minutes * 2 hours).

Default is `undefined`.
Requires option `verifyUserEmails: true`.', + 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, + }, + enableCollationCaseComparison: { + env: 'PARSE_SERVER_ENABLE_COLLATION_CASE_COMPARISON', + help: 'Optional. If set to `true`, the collation rule of case comparison for queries and indexes is enabled. Enable this option to run Parse Server with MongoDB Atlas Serverless or AWS Amazon DocumentDB. If `false`, the collation rule of case comparison is disabled. Default is `false`.', + action: parsers.booleanParser, + default: false, + }, + enableExpressErrorHandler: { + env: 'PARSE_SERVER_ENABLE_EXPRESS_ERROR_HANDLER', + help: 'Enables the default express error handler for all errors', + action: parsers.booleanParser, + default: false, + }, + enableInsecureAuthAdapters: { + env: 'PARSE_SERVER_ENABLE_INSECURE_AUTH_ADAPTERS', + help: 'Optional. Enables insecure authentication adapters. Insecure auth adapters are deprecated and will be removed in a future version. Defaults to `false`.', + action: parsers.booleanParser, + default: false, + }, + enableProductPurchaseLegacyApi: { + env: 'PARSE_SERVER_ENABLE_PRODUCT_PURCHASE_LEGACY_API', + help: 'Deprecated. Enables the legacy product purchase API including the `_Product` class and the `/validate_purchase` endpoint. This is an undocumented, unmaintained legacy feature inherited from the original Parse platform that may not function as expected. We strongly advise against using it. It will be removed in a future major version.', + action: parsers.booleanParser, + default: true, + }, + enableSanitizedErrorResponse: { + env: 'PARSE_SERVER_ENABLE_SANITIZED_ERROR_RESPONSE', + help: 'If set to `true`, error details are removed from error messages in responses to client requests, and instead a generic error message is sent. Default is `true`.', + action: parsers.booleanParser, + default: true, + }, + encryptionKey: { + env: 'PARSE_SERVER_ENCRYPTION_KEY', + help: 'Key for encrypting your files', + }, + enforcePrivateUsers: { + env: 'PARSE_SERVER_ENFORCE_PRIVATE_USERS', + help: 'Set to true if new users should be created without public read and write access.', + action: parsers.booleanParser, + default: true, + }, + expireInactiveSessions: { + env: 'PARSE_SERVER_EXPIRE_INACTIVE_SESSIONS', + help: 'Sets whether we should expire the inactive sessions, defaults to true. If false, all new sessions are created with no expiration date.', + action: parsers.booleanParser, + default: true, + }, + extendSessionOnUse: { + env: 'PARSE_SERVER_EXTEND_SESSION_ON_USE', + help: "Whether Parse Server should automatically extend a valid session by the sessionLength. In order to reduce the number of session updates in the database, a session will only be extended when a request is received after at least half of the current session's lifetime has passed.", + action: parsers.booleanParser, + default: false, + }, + fileDownload: { + env: 'PARSE_SERVER_FILE_DOWNLOAD_OPTIONS', + help: 'Options for file downloads', + action: parsers.objectParser, + type: 'FileDownloadOptions', + default: {}, + }, + 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, + }, + fileUpload: { + env: 'PARSE_SERVER_FILE_UPLOAD_OPTIONS', + help: 'Options for file uploads', + action: parsers.objectParser, + type: 'FileUploadOptions', + default: {}, + }, + graphQLPath: { + env: 'PARSE_SERVER_GRAPHQL_PATH', + help: 'The mount path for the GraphQL endpoint

\u26A0\uFE0F File upload inside the GraphQL mutation system requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.

Defaults is `/graphql`.', + default: '/graphql', + }, + graphQLPublicIntrospection: { + env: 'PARSE_SERVER_GRAPHQL_PUBLIC_INTROSPECTION', + help: 'Enable public introspection for the GraphQL endpoint, defaults to false', + action: parsers.booleanParser, + default: false, + }, + 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, + type: 'IdempotencyOptions', + default: {}, + }, + installation: { + env: 'PARSE_SERVER_INSTALLATION', + help: 'Options controlling how Parse Server deduplicates `_Installation` records that share the same `deviceToken`.', + action: parsers.objectParser, + type: 'InstallationOptions', + 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, + type: 'LiveQueryOptions', + }, + liveQueryServerOptions: { + env: 'PARSE_SERVER_LIVE_QUERY_SERVER_OPTIONS', + help: 'Live query server configuration options (will start the liveQuery server)', + action: parsers.objectParser, + type: 'LiveQueryServerOptions', + }, + 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', + }, + logLevels: { + env: 'PARSE_SERVER_LOG_LEVELS', + help: '(Optional) Overrides the log levels used internally by Parse Server to log events.', + action: parsers.objectParser, + type: 'LogLevels', + default: {}, + }, + logsFolder: { + env: 'PARSE_SERVER_LOGS_FOLDER', + help: "Folder for the logs (defaults to './logs'); set to null to disable file based logging", + default: './logs', + }, + maintenanceKey: { + env: 'PARSE_SERVER_MAINTENANCE_KEY', + help: '(Optional) The maintenance key is used for modifying internal and read-only fields of Parse Server.

\u26A0\uFE0F This key is not intended to be used as part of a regular operation of Parse Server. This key is intended to conduct out-of-band changes such as one-time migrations or data correction tasks. Internal fields are not officially documented and may change at any time without publication in release changelogs. We strongly advice not to rely on internal fields as part of your regular operation and to investigate the implications of any planned changes *directly in the source code* of your current version of Parse Server.', + required: true, + }, + maintenanceKeyIps: { + env: 'PARSE_SERVER_MAINTENANCE_KEY_IPS', + help: "(Optional) Restricts the use of maintenance key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the maintenance key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the maintenance key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `\"0.0.0.0/0,::0\"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['127.0.0.1', '::1']` which means that only `localhost`, the server instance on which Parse Server runs, is allowed to use the maintenance key.", + action: parsers.arrayParser, + default: ['127.0.0.1', '::1'], + }, + masterKey: { + env: 'PARSE_SERVER_MASTER_KEY', + help: 'Your Parse Master Key', + required: true, + }, + masterKeyIps: { + env: 'PARSE_SERVER_MASTER_KEY_IPS', + help: "(Optional) Restricts the use of master key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the master key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the master key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `\"0.0.0.0/0,::0\"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['127.0.0.1', '::1']` which means that only `localhost`, the server instance on which Parse Server runs, is allowed to use the master key.", + action: parsers.arrayParser, + default: ['127.0.0.1', '::1'], + }, + masterKeyTtl: { + env: 'PARSE_SERVER_MASTER_KEY_TTL', + help: '(Optional) The duration in seconds for which the current `masterKey` is being used before it is requested again if `masterKey` is set to a function. If `masterKey` is not set to a function, this option has no effect. Default is `0`, which means the master key is requested by invoking the `masterKey` function every time the master key is used internally by Parse Server.', + action: parsers.numberParser('masterKeyTtl'), + }, + 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.numberOrStringParser('maxLogFiles'), + }, + 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: 'Deprecated. Mounts the GraphQL Playground which is deprecated and will be removed in a future version. The playground exposes the master key in the browser. Use Parse Dashboard as GraphQL IDE or configure a third-party GraphQL client with custom request headers.', + 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, + }, + pages: { + env: 'PARSE_SERVER_PAGES', + help: 'The options for pages such as password reset and email verification.', + action: parsers.objectParser, + type: 'PagesOptions', + default: {}, + }, + passwordPolicy: { + env: 'PARSE_SERVER_PASSWORD_POLICY', + help: 'The password policy for enforcing password related rules.', + action: parsers.objectParser, + type: 'PasswordPolicyOptions', + }, + playgroundPath: { + env: 'PARSE_SERVER_PLAYGROUND_PATH', + help: 'Deprecated. Mount path for the GraphQL Playground. The playground is deprecated and will be removed in a future version.', + 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: "Set to `true` to prevent a user from logging in if the email has not yet been verified and email verification is required. Supports a function with a return value of `true` or `false` for conditional prevention. The function receives a request object that includes `createdWith` to indicate whether the invocation is for `signup` or `login` and the used auth provider.

The `createdWith` values per scenario:
  • Password signup: `{ action: 'signup', authProvider: 'password' }`
  • Auth provider signup: `{ action: 'signup', authProvider: '' }`
  • Password login: `{ action: 'login', authProvider: 'password' }`
  • Auth provider login: function not invoked; auth provider login bypasses email verification
Default is `false`.
Requires option `verifyUserEmails: true`.", + action: parsers.booleanOrFunctionParser, + default: false, + }, + preventSignupWithUnverifiedEmail: { + env: 'PARSE_SERVER_PREVENT_SIGNUP_WITH_UNVERIFIED_EMAIL', + help: "If set to `true` it prevents a user from signing up if the email has not yet been verified and email verification is required. In that case the server responds to the sign-up with HTTP status 400 and a Parse Error 205 `EMAIL_NOT_FOUND`. If set to `false` the server responds with HTTP status 200, and client SDKs return an unauthenticated Parse User without session token. In that case subsequent requests fail until the user's email address is verified.

Default is `false`.
Requires option `verifyUserEmails: true`.", + action: parsers.booleanParser, + default: false, + }, + protectedFields: { + env: 'PARSE_SERVER_PROTECTED_FIELDS', + help: "Fields per class that are hidden from query results for specific user groups. Protected fields are stripped from the server response, but can still be used internally (e.g. in Cloud Code triggers). Configure as `{ 'ClassName': { 'UserGroup': ['field1', 'field2'] } }` where `UserGroup` is one of: `'*'` (all users), `'authenticated'` (authenticated users), `'role:RoleName'` (users with a specific role), `'userField:FieldName'` (users referenced by a pointer field), or a user `objectId` to target a specific user. When multiple groups apply, the intersection of their protected fields is used. Any field can be protected, including system fields like `createdAt` and `updatedAt`. By default, `email` is protected on the `_User` class for all users. On the `_User` class, the object owner is exempt from protected fields by default; see `protectedFieldsOwnerExempt` to change this.", + action: parsers.objectParser, + default: { + _User: { + '*': ['email'], + }, + }, + }, + protectedFieldsOwnerExempt: { + env: 'PARSE_SERVER_PROTECTED_FIELDS_OWNER_EXEMPT', + help: "Whether the `_User` class is exempt from `protectedFields` when the logged-in user queries their own user object. If `true` (default), a user can see all their own fields regardless of `protectedFields` configuration; default protected fields (e.g. `email`) are merged into any custom `protectedFields` configuration. If `false`, `protectedFields` applies equally to the user's own object, consistent with all other classes; only explicitly configured protected fields apply, defaults are not merged. Defaults to `true`.", + action: parsers.booleanParser, + default: true, + }, + protectedFieldsSaveResponseExempt: { + env: 'PARSE_SERVER_PROTECTED_FIELDS_SAVE_RESPONSE_EXEMPT', + help: 'Whether save operation responses (create, update) are exempt from `protectedFields`. If `true` (default), protected fields modified during a save are included in the response to the client. If `false`, protected fields are stripped from save responses, consistent with how they are stripped from query results. Defaults to `true`.', + action: parsers.booleanParser, + default: true, + }, + protectedFieldsTriggerExempt: { + env: 'PARSE_SERVER_PROTECTED_FIELDS_TRIGGER_EXEMPT', + help: "Whether Cloud Code triggers (e.g. `beforeSave`, `afterSave`) are exempt from `protectedFields`. If `true`, triggers receive the full object including protected fields in `request.object` and `request.original`, regardless of the caller's auth context. If `false`, protected fields are stripped from the original object fetch used to build trigger objects. Defaults to `false`.", + action: parsers.booleanParser, + default: false, + }, + publicServerURL: { + env: 'PARSE_PUBLIC_SERVER_URL', + help: 'Optional. The public URL to Parse Server. This URL will be used to reach Parse Server publicly for features like password reset and email verification links. The option can be set to a string or a function that can be asynchronously resolved. The returned URL string must start 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, + }, + query: { + env: 'PARSE_SERVER_QUERY', + help: 'Query-related server defaults.', + action: parsers.objectParser, + type: 'QueryServerOptions', + default: {}, + }, + rateLimit: { + env: 'PARSE_SERVER_RATE_LIMIT', + help: "Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

\u2139\uFE0F Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case.", + action: parsers.arrayParser, + type: 'RateLimitOptions[]', + default: [], + }, + readOnlyMasterKey: { + env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY', + help: 'Read-only key, which has the same capabilities as MasterKey without writes', + }, + readOnlyMasterKeyIps: { + env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY_IPS', + help: "(Optional) Restricts the use of read-only master key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the read-only master key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the read-only master key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `\"0.0.0.0/0,::0\"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['0.0.0.0/0', '::0']` which means that any IP address is allowed to use the read-only master key. It is recommended to set this option to `['127.0.0.1', '::1']` to restrict access to `localhost`.", + action: parsers.arrayParser, + default: ['0.0.0.0/0', '::0'], + }, + requestComplexity: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY', + help: 'Options to limit the complexity of requests to prevent denial-of-service attacks. Limits are enforced for all requests except those using the master or maintenance key. Each property can be set to `-1` to disable that specific limit.', + action: parsers.objectParser, + type: 'RequestComplexityOptions', + default: {}, + }, + requestContextMiddleware: { + env: 'PARSE_SERVER_REQUEST_CONTEXT_MIDDLEWARE', + help: 'Options to customize the request context using inversion of control/dependency injection.', + }, + requestKeywordDenylist: { + env: 'PARSE_SERVER_REQUEST_KEYWORD_DENYLIST', + help: 'An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns.', + action: parsers.arrayParser, + default: [ + { + key: '_bsontype', + value: 'Code', + }, + { + key: 'constructor', + }, + { + key: '__proto__', + }, + ], + }, + 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, + }, + routeAllowList: { + env: 'PARSE_SERVER_ROUTE_ALLOW_LIST', + help: '(Optional) Restricts external client access to a list of allowed API routes.

When this option is set, all external non-master-key requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.

Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.

Examples of normalized route identifiers:
  • `classes/GameScore` (class CRUD)
  • `classes/GameScore/abc123` (object by ID)
  • `users` (user operations)
  • `login` (login endpoint)
  • `functions/sendEmail` (Cloud Function)
  • `jobs/cleanup` (Cloud Job)
  • `push` (push notifications)
  • `config` (client config)
  • `installations` (installations)
  • `files/picture.jpg` (file operations)
Example patterns:
  • `classes/ChatMessage` matches only `classes/ChatMessage`
  • `classes/Chat.*` matches `classes/ChatMessage`, `classes/ChatRoom`, etc.
  • `functions/.*` matches all Cloud Functions
Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.

Defaults to `undefined` which means the feature is inactive and all routes are accessible.', + action: parsers.arrayParser, + }, + scheduledPush: { + env: 'PARSE_SERVER_SCHEDULED_PUSH', + help: 'Configuration for push scheduling, defaults to false.', + action: parsers.booleanParser, + default: false, + }, + schema: { + env: 'PARSE_SERVER_SCHEMA', + help: 'Defined schema', + action: parsers.objectParser, + type: 'SchemaOptions', + }, + security: { + env: 'PARSE_SERVER_SECURITY', + help: 'The security options to identify and report weak security settings.', + action: parsers.objectParser, + type: 'SecurityOptions', + default: {}, + }, + sendUserEmailVerification: { + env: 'PARSE_SERVER_SEND_USER_EMAIL_VERIFICATION', + help: 'Set to `false` to prevent sending of verification email. Supports a function with a return value of `true` or `false` for conditional email sending.

Default is `true`.
', + action: parsers.booleanOrFunctionParser, + default: true, + }, + serverCloseComplete: { + env: 'PARSE_SERVER_SERVER_CLOSE_COMPLETE', + help: 'Callback when server has closed', + }, + serverURL: { + env: 'PARSE_SERVER_URL', + help: 'The URL to Parse Server.

\u26A0\uFE0F Certain server features or adapters may require Parse Server to be able to call itself by making requests to the URL set in `serverURL`. If a feature requires this, it is mentioned in the documentation. In that case ensure that the URL is accessible from the server itself.', + 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, + }, + trustProxy: { + env: 'PARSE_SERVER_TRUST_PROXY', + help: 'The trust proxy settings. It is important to understand the exact setup of the reverse proxy, since this setting will trust values provided in the Parse Server API request. See the express trust proxy settings documentation. Defaults to `false`.', + action: parsers.objectParser, + default: [], + }, + 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, + }, + verifyServerUrl: { + env: 'PARSE_SERVER_VERIFY_SERVER_URL', + help: 'Parse Server makes a HTTP request to the URL set in `serverURL` at the end of its launch routine to verify that the launch succeeded. If this option is set to `false`, the verification will be skipped. This can be useful in environments where the server URL is not accessible from the server itself, such as when running behind a firewall or in certain containerized environments.

\u26A0\uFE0F Server URL verification requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.

Default is `true`.', + action: parsers.booleanParser, + default: true, + }, + verifyUserEmails: { + env: 'PARSE_SERVER_VERIFY_USER_EMAILS', + help: "Set to `true` to require users to verify their email address to complete the sign-up process. Supports a function with a return value of `true` or `false` for conditional verification. The function receives a request object that includes `createdWith` to indicate whether the invocation is for `signup` or `login` and the used auth provider.

The `createdWith` values per scenario:
  • Password signup: `{ action: 'signup', authProvider: 'password' }`
  • Auth provider signup: `{ action: 'signup', authProvider: '' }`
  • Password login: `{ action: 'login', authProvider: 'password' }`
  • Auth provider login: function not invoked; auth provider login bypasses email verification
  • Resend verification email: `createdWith` is `undefined`; use the `resendRequest` property to identify those
Default is `false`.", + action: parsers.booleanOrFunctionParser, + default: false, + }, + webhookKey: { + env: 'PARSE_SERVER_WEBHOOK_KEY', + help: 'Key sent with outgoing webhook calls', + }, +}; +module.exports.RateLimitOptions = { + errorResponseMessage: { + env: 'PARSE_SERVER_RATE_LIMIT_ERROR_RESPONSE_MESSAGE', + help: 'The error message that should be returned in the body of the HTTP 429 response when the rate limit is hit. Default is `Too many requests.`.', + default: 'Too many requests.', + }, + includeInternalRequests: { + env: 'PARSE_SERVER_RATE_LIMIT_INCLUDE_INTERNAL_REQUESTS', + help: 'Optional, if `true` the rate limit will also apply to requests that are made in by Cloud Code, default is `false`. Note that a public Cloud Code function that triggers internal requests may circumvent rate limiting and be vulnerable to attacks.', + action: parsers.booleanParser, + default: false, + }, + includeMasterKey: { + env: 'PARSE_SERVER_RATE_LIMIT_INCLUDE_MASTER_KEY', + help: 'Optional, if `true` the rate limit will also apply to requests using the `masterKey`, default is `false`. Note that a public Cloud Code function that triggers internal requests using the `masterKey` may circumvent rate limiting and be vulnerable to attacks.', + action: parsers.booleanParser, + default: false, + }, + redisUrl: { + env: 'PARSE_SERVER_RATE_LIMIT_REDIS_URL', + help: 'Optional, the URL of the Redis server to store rate limit data. This allows to rate limit requests for multiple servers by calculating the sum of all requests across all servers. This is useful if multiple servers are processing requests behind a load balancer. For example, the limit of 10 requests is reached if each of 2 servers processed 5 requests.', + }, + requestCount: { + env: 'PARSE_SERVER_RATE_LIMIT_REQUEST_COUNT', + help: 'The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied. For batch requests, this also limits the number of sub-requests in a single batch that target this path; however, requests already consumed in the current time window are not counted against the batch, so the effective limit may be higher when combining individual and batch requests. Note that this is a basic server-level rate limit; for comprehensive protection, use a reverse proxy or WAF for rate limiting.', + action: parsers.numberParser('requestCount'), + }, + requestMethods: { + env: 'PARSE_SERVER_RATE_LIMIT_REQUEST_METHODS', + help: 'Optional, the HTTP request methods to which the rate limit should be applied, default is all methods.', + action: parsers.arrayParser, + }, + requestPath: { + env: 'PARSE_SERVER_RATE_LIMIT_REQUEST_PATH', + help: 'The path of the API route to be rate limited. Route paths, in combination with a request method, define the endpoints at which requests can be made. Route paths can be strings or string patterns following path-to-regexp v8 syntax.', + required: true, + }, + requestTimeWindow: { + env: 'PARSE_SERVER_RATE_LIMIT_REQUEST_TIME_WINDOW', + help: 'The window of time in milliseconds within which the number of requests set in `requestCount` can be made before the rate limit is applied.', + action: parsers.numberParser('requestTimeWindow'), + }, + zone: { + env: 'PARSE_SERVER_RATE_LIMIT_ZONE', + help: 'The type of rate limit to apply. The following types are supported:
  • `global`: rate limit based on the number of requests made by all users
  • `ip`: rate limit based on the IP address of the request
  • `user`: rate limit based on the user ID of the request
  • `session`: rate limit based on the session token of the request
Default is `ip`.', + default: 'ip', + }, +}; +module.exports.RequestComplexityOptions = { + allowRegex: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY_ALLOW_REGEX', + help: 'Whether to allow the `$regex` query operator. Set to `false` to reject `$regex` in queries for non-master-key users. Default is `true`.', + action: parsers.booleanParser, + default: true, + }, + batchRequestLimit: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY_BATCH_REQUEST_LIMIT', + help: 'Maximum number of sub-requests in a single batch request. Set to `-1` to disable. Default is `-1`.', + action: parsers.numberParser('batchRequestLimit'), + default: -1, + }, + graphQLDepth: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_DEPTH', + help: 'Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `-1`.', + action: parsers.numberParser('graphQLDepth'), + default: -1, + }, + graphQLFields: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_FIELDS', + help: 'Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `-1`.', + action: parsers.numberParser('graphQLFields'), + default: -1, + }, + includeCount: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY_INCLUDE_COUNT', + help: 'Maximum number of include paths in a single query. Set to `-1` to disable. Default is `-1`.', + action: parsers.numberParser('includeCount'), + default: -1, + }, + includeDepth: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY_INCLUDE_DEPTH', + help: 'Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `-1`.', + action: parsers.numberParser('includeDepth'), + default: -1, + }, + queryDepth: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY_QUERY_DEPTH', + help: 'Maximum nesting depth of `$or`, `$and`, `$nor` query operators. Set to `-1` to disable. Default is `-1`.', + action: parsers.numberParser('queryDepth'), + default: -1, + }, + subqueryDepth: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY_SUBQUERY_DEPTH', + help: 'Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `-1`.', + action: parsers.numberParser('subqueryDepth'), + default: -1, + }, + subqueryLimit: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY_SUBQUERY_LIMIT', + help: 'Maximum number of results returned by a `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subquery. Set to `-1` to disable. Default is `-1`.', + action: parsers.numberParser('subqueryLimit'), + default: -1, + }, +}; +module.exports.InstallationOptions = { + duplicateDeviceTokenAction: { + env: 'PARSE_SERVER_INSTALLATION_DUPLICATE_DEVICE_TOKEN_ACTION', + help: "What Parse Server does to the conflicting `_Installation` row(s) when a new install's `deviceToken` collides with an existing row. `'delete'` destroys the conflicting row. `'update'` clears the now-conflicting ID field on the conflicting row, preserving custom fields, channels, and history. Default is `'delete'`.", + default: 'delete', + }, + duplicateDeviceTokenActionEnforceAuth: { + env: 'PARSE_SERVER_INSTALLATION_DUPLICATE_DEVICE_TOKEN_ACTION_ENFORCE_AUTH', + help: "Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Default is `false`.", + action: parsers.booleanParser, + default: false, + }, + duplicateDeviceTokenMergePriority: { + env: 'PARSE_SERVER_INSTALLATION_DUPLICATE_DEVICE_TOKEN_MERGE_PRIORITY', + help: "At the merge case (when an existing row holds the new `deviceToken` but has no `installationId` of its own), which side wins. `'deviceToken'` \u2014 the deviceToken-only row survives, the request's `idMatch` row is the loser. `'installationId'` \u2014 the request's `idMatch` (active install) survives, the deviceToken-only orphan is the loser. Default is `'deviceToken'`.", + default: 'deviceToken', + }, +}; +module.exports.SecurityOptions = { + checkGroups: { + env: 'PARSE_SERVER_SECURITY_CHECK_GROUPS', + help: 'The security check groups to run. This allows to add custom security checks or override existing ones. Default are the groups defined in `CheckGroups.js`.', + action: parsers.arrayParser, + }, + enableCheck: { + env: 'PARSE_SERVER_SECURITY_ENABLE_CHECK', + help: 'Is true if Parse Server should check for weak security settings.', + action: parsers.booleanParser, + default: false, + }, + enableCheckLog: { + env: 'PARSE_SERVER_SECURITY_ENABLE_CHECK_LOG', + help: 'Is true if the security check report should be written to logs. This should only be enabled temporarily to not expose weak security settings in logs.', + action: parsers.booleanParser, + default: false, + }, +}; +module.exports.QueryServerOptions = { + aggregationRawFieldNames: { + env: 'PARSE_SERVER_QUERY_AGGREGATION_RAW_FIELD_NAMES', + help: 'When `true`, all aggregation queries default to using native MongoDB field names (no automatic `createdAt` \u2192 `_created_at` rewriting). Individual queries can still override this via the `rawFieldNames` option. Default is `false`.', + action: parsers.booleanParser, + default: false, + }, + aggregationRawValues: { + env: 'PARSE_SERVER_QUERY_AGGREGATION_RAW_VALUES', + help: 'When `true`, all aggregation queries default to using MongoDB Extended JSON (EJSON) for explicit value typing and skip schema-based value coercion. Individual queries can still override this via the `rawValues` option. Default is `false`.', + action: parsers.booleanParser, + default: false, + }, +}; +module.exports.PagesOptions = { + customRoutes: { + env: 'PARSE_SERVER_PAGES_CUSTOM_ROUTES', + help: 'The custom routes.', + action: parsers.arrayParser, + type: 'PagesRoute[]', + default: [], + }, + customUrls: { + env: 'PARSE_SERVER_PAGES_CUSTOM_URLS', + help: 'The URLs to the custom pages.', + action: parsers.objectParser, + type: 'PagesCustomUrlsOptions', + default: {}, + }, + enableLocalization: { + env: 'PARSE_SERVER_PAGES_ENABLE_LOCALIZATION', + help: 'Is true if pages should be localized; this has no effect on custom page redirects.', + action: parsers.booleanParser, + default: false, + }, + encodePageParamHeaders: { + env: 'PARSE_SERVER_PAGES_ENCODE_PAGE_PARAM_HEADERS', + help: 'Is `true` if the page parameter headers should be URI-encoded. This is required if any page parameter value contains non-ASCII characters, such as the app name.', + action: parsers.booleanParser, + default: false, + }, + forceRedirect: { + env: 'PARSE_SERVER_PAGES_FORCE_REDIRECT', + help: 'Is true if responses should always be redirects and never content, false if the response type should depend on the request type (GET request -> content response; POST request -> redirect response).', + action: parsers.booleanParser, + default: false, + }, + localizationFallbackLocale: { + env: 'PARSE_SERVER_PAGES_LOCALIZATION_FALLBACK_LOCALE', + help: 'The fallback locale for localization if no matching translation is provided for the given locale. This is only relevant when providing translation resources via JSON file.', + default: 'en', + }, + localizationJsonPath: { + env: 'PARSE_SERVER_PAGES_LOCALIZATION_JSON_PATH', + help: 'The path to the JSON file for localization; the translations will be used to fill template placeholders according to the locale.', + }, + pagesEndpoint: { + env: 'PARSE_SERVER_PAGES_PAGES_ENDPOINT', + help: "The API endpoint for the pages. Default is 'apps'.", + default: 'apps', + }, + pagesPath: { + env: 'PARSE_SERVER_PAGES_PAGES_PATH', + help: "The path to the pages directory; this also defines where the static endpoint '/apps' points to. Default is the './public/' directory of the parse-server module.", + }, + placeholders: { + env: 'PARSE_SERVER_PAGES_PLACEHOLDERS', + help: 'The placeholder keys and values which will be filled in pages; this can be a simple object or a callback function.', + action: parsers.objectParser, + default: {}, + }, +}; +module.exports.PagesRoute = { + handler: { + env: 'PARSE_SERVER_PAGES_ROUTE_HANDLER', + help: 'The route handler that is an async function.', + required: true, + }, + method: { + env: 'PARSE_SERVER_PAGES_ROUTE_METHOD', + help: "The route method, e.g. 'GET' or 'POST'.", + required: true, + }, + path: { + env: 'PARSE_SERVER_PAGES_ROUTE_PATH', + help: 'The route path.', + required: true, + }, +}; +module.exports.PagesCustomUrlsOptions = { + emailVerificationLinkExpired: { + env: 'PARSE_SERVER_PAGES_CUSTOM_URL_EMAIL_VERIFICATION_LINK_EXPIRED', + help: 'The URL to the custom page for email verification -> link expired.', + }, + emailVerificationLinkInvalid: { + env: 'PARSE_SERVER_PAGES_CUSTOM_URL_EMAIL_VERIFICATION_LINK_INVALID', + help: 'The URL to the custom page for email verification -> link invalid.', + }, + emailVerificationSendFail: { + env: 'PARSE_SERVER_PAGES_CUSTOM_URL_EMAIL_VERIFICATION_SEND_FAIL', + help: 'The URL to the custom page for email verification -> link send fail.', + }, + emailVerificationSendSuccess: { + env: 'PARSE_SERVER_PAGES_CUSTOM_URL_EMAIL_VERIFICATION_SEND_SUCCESS', + help: 'The URL to the custom page for email verification -> resend link -> success.', + }, + emailVerificationSuccess: { + env: 'PARSE_SERVER_PAGES_CUSTOM_URL_EMAIL_VERIFICATION_SUCCESS', + help: 'The URL to the custom page for email verification -> success.', + }, + passwordReset: { + env: 'PARSE_SERVER_PAGES_CUSTOM_URL_PASSWORD_RESET', + help: 'The URL to the custom page for password reset.', + }, + passwordResetLinkInvalid: { + env: 'PARSE_SERVER_PAGES_CUSTOM_URL_PASSWORD_RESET_LINK_INVALID', + help: 'The URL to the custom page for password reset -> link invalid.', + }, + passwordResetSuccess: { + env: 'PARSE_SERVER_PAGES_CUSTOM_URL_PASSWORD_RESET_SUCCESS', + help: 'The URL to the custom page for password reset -> success.', + }, +}; +module.exports.CustomPagesOptions = { + choosePassword: { + env: 'PARSE_SERVER_CUSTOM_PAGES_CHOOSE_PASSWORD', + help: 'choose password page path', + }, + expiredVerificationLink: { + env: 'PARSE_SERVER_CUSTOM_PAGES_EXPIRED_VERIFICATION_LINK', + help: 'expired verification link page path', + }, + invalidLink: { + env: 'PARSE_SERVER_CUSTOM_PAGES_INVALID_LINK', + help: 'invalid link page path', + }, + invalidPasswordResetLink: { + env: 'PARSE_SERVER_CUSTOM_PAGES_INVALID_PASSWORD_RESET_LINK', + help: 'invalid password reset 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", + }, + regexTimeout: { + env: 'PARSE_SERVER_LIVEQUERY_REGEX_TIMEOUT', + help: 'Sets the maximum execution time in milliseconds for regular expression pattern matching in LiveQuery. This protects against Regular Expression Denial of Service (ReDoS) attacks where a malicious regex pattern could block the event loop. A regex that exceeds the timeout will be treated as non-matching.

The protection runs each regex evaluation in an isolated VM context with a timeout. This adds approximately 50 microseconds of overhead per regex evaluation. For most applications this is negligible, but it can add up if you have a very large number of LiveQuery subscriptions that use `$regex` on the same class. For example, 10,000 concurrent regex subscriptions would add approximately 500ms of processing time per object save event on that class.

Set to `0` to disable the timeout and use native regex evaluation without protection. Defaults to `100`.', + action: parsers.numberParser('regexTimeout'), + default: 100, + }, + 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 5 * 1000 ms (5 seconds).", + 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, + }, +}; +module.exports.AccountLockoutOptions = { + duration: { + env: 'PARSE_SERVER_ACCOUNT_LOCKOUT_DURATION', + help: 'Set the duration in minutes that a locked-out account remains locked out before automatically becoming unlocked.

Valid values are greater than `0` and less than `100000`.', + action: parsers.numberParser('duration'), + }, + threshold: { + env: 'PARSE_SERVER_ACCOUNT_LOCKOUT_THRESHOLD', + help: 'Set the number of failed sign-in attempts that will cause a user account to be locked. If the account is locked. The account will unlock after the duration set in the `duration` option has passed and no further login attempts have been made.

Valid values are greater than `0` and less than `1000`.', + action: parsers.numberParser('threshold'), + }, + unlockOnPasswordReset: { + env: 'PARSE_SERVER_ACCOUNT_LOCKOUT_UNLOCK_ON_PASSWORD_RESET', + help: 'Set to `true` if the account should be unlocked after a successful password reset.

Default is `false`.
Requires options `duration` and `threshold` to be set.', + action: parsers.booleanParser, + default: false, + }, +}; +module.exports.PasswordPolicyOptions = { + doNotAllowUsername: { + env: 'PARSE_SERVER_PASSWORD_POLICY_DO_NOT_ALLOW_USERNAME', + help: 'Set to `true` to disallow the username as part of the password.

Default is `false`.', + action: parsers.booleanParser, + default: false, + }, + maxPasswordAge: { + env: 'PARSE_SERVER_PASSWORD_POLICY_MAX_PASSWORD_AGE', + help: 'Set the number of days after which a password expires. Login attempts fail if the user does not reset the password before expiration.', + action: parsers.numberParser('maxPasswordAge'), + }, + maxPasswordHistory: { + env: 'PARSE_SERVER_PASSWORD_POLICY_MAX_PASSWORD_HISTORY', + help: 'Set the number of previous password that will not be allowed to be set as new password. If the option is not set or set to `0`, no previous passwords will be considered.

Valid values are >= `0` and <= `20`.
Default is `0`.', + action: parsers.numberParser('maxPasswordHistory'), + }, + resetPasswordSuccessOnInvalidEmail: { + env: 'PARSE_SERVER_PASSWORD_POLICY_RESET_PASSWORD_SUCCESS_ON_INVALID_EMAIL', + help: 'Set to `true` if a request to reset the password should return a success response even if the provided email address is invalid, or `false` if the request should return an error response if the email address is invalid.

Default is `true`.', + action: parsers.booleanParser, + default: true, + }, + resetTokenReuseIfValid: { + env: 'PARSE_SERVER_PASSWORD_POLICY_RESET_TOKEN_REUSE_IF_VALID', + help: 'Set to `true` if a password reset token should be reused in case another token is requested but there is a token that is still valid, i.e. has not expired. This avoids the often observed issue that a user requests multiple emails and does not know which link contains a valid token because each newly generated token would invalidate the previous token.

Default is `false`.', + action: parsers.booleanParser, + default: false, + }, + resetTokenValidityDuration: { + env: 'PARSE_SERVER_PASSWORD_POLICY_RESET_TOKEN_VALIDITY_DURATION', + help: 'Set the validity duration of the password reset token in seconds after which the token expires. The token is used in the link that is set in the email. After the token expires, the link becomes invalid and a new link has to be sent. If the option is not set or set to `undefined`, then the token never expires.

For example, to expire the token after 2 hours, set a value of 7200 seconds (= 60 seconds * 60 minutes * 2 hours).

Default is `undefined`.', + action: parsers.numberParser('resetTokenValidityDuration'), + }, + validationError: { + env: 'PARSE_SERVER_PASSWORD_POLICY_VALIDATION_ERROR', + help: 'Set the error message to be sent.

Default is `Password does not meet the Password Policy requirements.`', + }, + validatorCallback: { + env: 'PARSE_SERVER_PASSWORD_POLICY_VALIDATOR_CALLBACK', + help: 'Set a callback function to validate a password to be accepted.

If used in combination with `validatorPattern`, the password must pass both to be accepted.', + }, + validatorPattern: { + env: 'PARSE_SERVER_PASSWORD_POLICY_VALIDATOR_PATTERN', + help: 'Set the regular expression validation pattern a password must match to be accepted.

If used in combination with `validatorCallback`, the password must pass both to be accepted.', + }, +}; +module.exports.FileUploadOptions = { + allowedFileUrlDomains: { + env: 'PARSE_SERVER_FILE_UPLOAD_ALLOWED_FILE_URL_DOMAINS', + help: "Sets the allowed hostnames for file URLs referenced in Parse objects. When a File object includes a URL, its hostname must match one of these entries to be accepted. Supports exact hostnames (e.g., `'cdn.example.com'`) and wildcard subdomains (e.g., `'*.example.com'`). Use `['*']` to allow any domain. Use `[]` to block all file URLs (only name-based files allowed).", + action: parsers.arrayParser, + default: ['*'], + }, + enableForAnonymousUser: { + env: 'PARSE_SERVER_FILE_UPLOAD_ENABLE_FOR_ANONYMOUS_USER', + help: 'Is true if file upload should be allowed for anonymous users.', + action: parsers.booleanParser, + default: false, + }, + enableForAuthenticatedUser: { + env: 'PARSE_SERVER_FILE_UPLOAD_ENABLE_FOR_AUTHENTICATED_USER', + help: 'Is true if file upload should be allowed for authenticated users.', + action: parsers.booleanParser, + default: true, + }, + enableForPublic: { + env: 'PARSE_SERVER_FILE_UPLOAD_ENABLE_FOR_PUBLIC', + help: 'Is true if file upload should be allowed for anyone, regardless of user authentication.', + action: parsers.booleanParser, + default: false, + }, + fileExtensions: { + env: 'PARSE_SERVER_FILE_UPLOAD_FILE_EXTENSIONS', + help: 'Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.

It is recommended to only allow the file extensions that your app actually needs, rather than relying on blocking dangerous extensions. This allowlist approach is more secure because new dangerous file extensions may emerge that are not covered by the default blocklist.

The default blocks the most common file extensions that are known to be rendered as active content by web browsers, such as HTML, SVG, and XML files, which may be used by an attacker to compromise the session token of another user via accessing the browser\'s local storage. The blocked extensions are: `html`, `htm`, `shtml`, `xhtml`, `xhtml+xml`, `xht`, `svg`, `svgz`, `svg+xml`, `xml`, `xsl`, `xslt`, `xslt+xml`, `xsd`, `rng`, `rdf`, `rdf+xml`, `owl`, `mathml`, `mathml+xml`.

Defaults to `["^(?!([xXsS]?[hH][tT][mM][lL]?(\\\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\\\+[xX][mM][lL])?|[xX][sS][dD]|[rR][nN][gG]|[rR][dD][fF](\\\\+[xX][mM][lL])?|[oO][wW][lL]|[mM][aA][tT][hH][mM][lL](\\\\+[xX][mM][lL])?)$)"]`.', + action: parsers.arrayParser, + default: [ + '^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?|[xX][sS][dD]|[rR][nN][gG]|[rR][dD][fF](\\+[xX][mM][lL])?|[oO][wW][lL]|[mM][aA][tT][hH][mM][lL](\\+[xX][mM][lL])?)$)', + ], + }, +}; +module.exports.FileDownloadOptions = { + enableForAnonymousUser: { + env: 'PARSE_SERVER_FILE_DOWNLOAD_ENABLE_FOR_ANONYMOUS_USER', + help: 'Is true if file download should be allowed for anonymous users.', + action: parsers.booleanParser, + default: true, + }, + enableForAuthenticatedUser: { + env: 'PARSE_SERVER_FILE_DOWNLOAD_ENABLE_FOR_AUTHENTICATED_USER', + help: 'Is true if file download should be allowed for authenticated users.', + action: parsers.booleanParser, + default: true, + }, + enableForPublic: { + env: 'PARSE_SERVER_FILE_DOWNLOAD_ENABLE_FOR_PUBLIC', + help: 'Is true if file download should be allowed for anyone, regardless of user authentication.', + action: parsers.booleanParser, + default: true, + }, +}; +/* The available log levels for Parse Server logging. Valid values are:
- `'error'` - Error level (highest priority)
- `'warn'` - Warning level
- `'info'` - Info level (default)
- `'verbose'` - Verbose level
- `'debug'` - Debug level
- `'silly'` - Silly level (lowest priority) */ +module.exports.LogLevel = { + debug: { + env: 'PARSE_SERVER_LOG_LEVEL_DEBUG', + help: 'Debug level', + required: true, + }, + error: { + env: 'PARSE_SERVER_LOG_LEVEL_ERROR', + help: 'Error level - highest priority', + required: true, + }, + info: { + env: 'PARSE_SERVER_LOG_LEVEL_INFO', + help: 'Info level - default', + required: true, + }, + silly: { + env: 'PARSE_SERVER_LOG_LEVEL_SILLY', + help: 'Silly level - lowest priority', + required: true, + }, + verbose: { + env: 'PARSE_SERVER_LOG_LEVEL_VERBOSE', + help: 'Verbose level', + required: true, + }, + warn: { + env: 'PARSE_SERVER_LOG_LEVEL_WARN', + help: 'Warning level', + required: true, + }, +}; +module.exports.LogClientEvent = { + keys: { + env: 'PARSE_SERVER_DATABASE_LOG_CLIENT_EVENTS_KEYS', + help: 'Optional array of dot-notation paths to extract specific data from the event object. If not provided or empty, the entire event object will be logged.', + action: parsers.arrayParser, + }, + logLevel: { + env: 'PARSE_SERVER_DATABASE_LOG_CLIENT_EVENTS_LOG_LEVEL', + help: "The log level to use for this event. See [LogLevel](LogLevel.html) for available values. Defaults to `'info'`.", + default: 'info', + }, + name: { + env: 'PARSE_SERVER_DATABASE_LOG_CLIENT_EVENTS_NAME', + help: 'The MongoDB driver event name to listen for. See the [MongoDB driver events documentation](https://www.mongodb.com/docs/drivers/node/current/fundamentals/monitoring/) for available events.', + required: true, + }, +}; +module.exports.DatabaseOptions = { + allowPublicExplain: { + env: 'PARSE_SERVER_DATABASE_ALLOW_PUBLIC_EXPLAIN', + help: 'Set to `true` to allow `Parse.Query.explain` without master key.

\u26A0\uFE0F Enabling this option may expose sensitive query performance data to unauthorized users and could potentially be exploited for malicious purposes.', + action: parsers.booleanParser, + default: false, + }, + appName: { + env: 'PARSE_SERVER_DATABASE_APP_NAME', + help: 'The MongoDB driver option to specify the name of the application that created this MongoClient instance.', + }, + authMechanism: { + env: 'PARSE_SERVER_DATABASE_AUTH_MECHANISM', + help: 'The MongoDB driver option to specify the authentication mechanism that MongoDB will use to authenticate the connection.', + }, + authMechanismProperties: { + env: 'PARSE_SERVER_DATABASE_AUTH_MECHANISM_PROPERTIES', + help: 'The MongoDB driver option to specify properties for the specified authMechanism as a comma-separated list of colon-separated key-value pairs.', + action: parsers.objectParser, + }, + authSource: { + env: 'PARSE_SERVER_DATABASE_AUTH_SOURCE', + help: "The MongoDB driver option to specify the database name associated with the user's credentials.", + }, + autoSelectFamily: { + env: 'PARSE_SERVER_DATABASE_AUTO_SELECT_FAMILY', + help: 'The MongoDB driver option to set whether the socket attempts to connect to IPv6 and IPv4 addresses until a connection is established. If available, the driver will select the first IPv6 address.', + action: parsers.booleanParser, + }, + autoSelectFamilyAttemptTimeout: { + env: 'PARSE_SERVER_DATABASE_AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT', + help: 'The MongoDB driver option to specify the amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the autoSelectFamily option. If set to a positive integer less than 10, the value 10 is used instead.', + action: parsers.numberParser('autoSelectFamilyAttemptTimeout'), + }, + batchSize: { + env: 'PARSE_SERVER_DATABASE_BATCH_SIZE', + help: 'The number of documents per batch for MongoDB cursor `getMore` operations. A lower value reduces memory usage per batch; a higher value reduces the number of network round-trips.', + action: parsers.numberParser('batchSize'), + default: 1000, + }, + clientMetadata: { + env: 'PARSE_SERVER_DATABASE_CLIENT_METADATA', + help: "Custom metadata to append to database client connections for identifying Parse Server instances in database logs. If set, this metadata will be visible in database logs during connection handshakes. This can help with debugging and monitoring in deployments with multiple database clients. Set `name` to identify your application (e.g., 'MyApp') and `version` to your application's version. Leave undefined (default) to disable this feature and avoid the additional data transfer overhead.", + action: parsers.objectParser, + type: 'DatabaseOptionsClientMetadata', + }, + compressors: { + env: 'PARSE_SERVER_DATABASE_COMPRESSORS', + help: 'The MongoDB driver option to specify an array or comma-delimited string of compressors to enable network compression for communication between this client and a mongod/mongos instance.', + }, + connectTimeoutMS: { + env: 'PARSE_SERVER_DATABASE_CONNECT_TIMEOUT_MS', + help: 'The MongoDB driver option to specify the amount of time, in milliseconds, to wait to establish a single TCP socket connection to the server before raising an error. Specifying 0 disables the connection timeout.', + action: parsers.numberParser('connectTimeoutMS'), + }, + createIndexAuthDataUniqueness: { + env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_AUTH_DATA_UNIQUENESS', + help: 'Set to `true` to automatically create unique indexes on the authData fields of the _User collection for each configured auth provider on server start, including `anonymous` when anonymous users are enabled. These indexes prevent race conditions during concurrent signups with the same authData. Set to `false` to skip index creation. Default is `true`.

\u26A0\uFE0F When setting this option to `false` to manually create the indexes, keep in mind that the otherwise automatically created indexes may change in the future to be optimized for the internal usage by Parse Server.', + action: parsers.booleanParser, + default: true, + }, + createIndexRoleName: { + env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_ROLE_NAME', + help: 'Set to `true` to automatically create a unique index on the name field of the _Role collection on server start. Set to `false` to skip index creation. Default is `true`.

\u26A0\uFE0F When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.', + action: parsers.booleanParser, + default: true, + }, + createIndexUserEmail: { + env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_USER_EMAIL', + help: 'Set to `true` to automatically create indexes on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

\u26A0\uFE0F When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.', + action: parsers.booleanParser, + default: true, + }, + createIndexUserEmailCaseInsensitive: { + env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_USER_EMAIL_CASE_INSENSITIVE', + help: 'Set to `true` to automatically create a case-insensitive index on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

\u26A0\uFE0F When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.', + action: parsers.booleanParser, + default: true, + }, + createIndexUserEmailVerifyToken: { + env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_USER_EMAIL_VERIFY_TOKEN', + help: 'Set to `true` to automatically create an index on the _email_verify_token field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

\u26A0\uFE0F When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.', + action: parsers.booleanParser, + default: true, + }, + createIndexUserPasswordResetToken: { + env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_USER_PASSWORD_RESET_TOKEN', + help: 'Set to `true` to automatically create an index on the _perishable_token field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

\u26A0\uFE0F When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.', + action: parsers.booleanParser, + default: true, + }, + createIndexUserUsername: { + env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_USER_USERNAME', + help: 'Set to `true` to automatically create indexes on the username field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

\u26A0\uFE0F When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.', + action: parsers.booleanParser, + default: true, + }, + createIndexUserUsernameCaseInsensitive: { + env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_USER_USERNAME_CASE_INSENSITIVE', + help: 'Set to `true` to automatically create a case-insensitive index on the username field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

\u26A0\uFE0F When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.', + action: parsers.booleanParser, + default: true, + }, + directConnection: { + env: 'PARSE_SERVER_DATABASE_DIRECT_CONNECTION', + help: 'The MongoDB driver option to force a Single topology type with a connection string containing one host.', + action: parsers.booleanParser, + }, + disableIndexFieldValidation: { + env: 'PARSE_SERVER_DATABASE_DISABLE_INDEX_FIELD_VALIDATION', + help: 'Set to `true` to disable validation of index fields. When disabled, indexes can be created even if the fields do not exist in the schema. This can be useful when creating indexes on fields that will be added later.', + action: parsers.booleanParser, + }, + enableSchemaHooks: { + env: 'PARSE_SERVER_DATABASE_ENABLE_SCHEMA_HOOKS', + help: 'Enables database real-time hooks to update single schema cache. Set to `true` if using multiple Parse Servers instances connected to the same database. Failing to do so will cause a schema change to not propagate to all instances and re-syncing will only happen when the instances restart. To use this feature with MongoDB, a replica set cluster with [change stream](https://docs.mongodb.com/manual/changeStreams/#availability) support is required.', + action: parsers.booleanParser, + default: false, + }, + forceServerObjectId: { + env: 'PARSE_SERVER_DATABASE_FORCE_SERVER_OBJECT_ID', + help: 'The MongoDB driver option to force server to assign _id values instead of driver.', + action: parsers.booleanParser, + }, + heartbeatFrequencyMS: { + env: 'PARSE_SERVER_DATABASE_HEARTBEAT_FREQUENCY_MS', + help: 'The MongoDB driver option to specify the frequency in milliseconds at which the driver checks the state of the MongoDB deployment.', + action: parsers.numberParser('heartbeatFrequencyMS'), + }, + loadBalanced: { + env: 'PARSE_SERVER_DATABASE_LOAD_BALANCED', + help: 'The MongoDB driver option to instruct the driver it is connecting to a load balancer fronting a mongos like service.', + action: parsers.booleanParser, + }, + localThresholdMS: { + env: 'PARSE_SERVER_DATABASE_LOCAL_THRESHOLD_MS', + help: 'The MongoDB driver option to specify the size (in milliseconds) of the latency window for selecting among multiple suitable MongoDB instances.', + action: parsers.numberParser('localThresholdMS'), + }, + logClientEvents: { + env: 'PARSE_SERVER_DATABASE_LOG_CLIENT_EVENTS', + help: 'An array of MongoDB client event configurations to enable logging of specific events.', + action: parsers.arrayParser, + type: 'LogClientEvent[]', + }, + maxConnecting: { + env: 'PARSE_SERVER_DATABASE_MAX_CONNECTING', + help: 'The MongoDB driver option to specify the maximum number of connections that may be in the process of being established concurrently by the connection pool.', + action: parsers.numberParser('maxConnecting'), + }, + maxIdleTimeMS: { + env: 'PARSE_SERVER_DATABASE_MAX_IDLE_TIME_MS', + help: 'The MongoDB driver option to specify the amount of time in milliseconds that a connection can remain idle in the connection pool before being removed and closed.', + action: parsers.numberParser('maxIdleTimeMS'), + }, + maxPoolSize: { + env: 'PARSE_SERVER_DATABASE_MAX_POOL_SIZE', + help: 'The MongoDB driver option to set the maximum number of opened, cached, ready-to-use database connections maintained by the driver.', + action: parsers.numberParser('maxPoolSize'), + }, + maxStalenessSeconds: { + env: 'PARSE_SERVER_DATABASE_MAX_STALENESS_SECONDS', + help: 'The MongoDB driver option to set the maximum replication lag for reads from secondary nodes.', + action: parsers.numberParser('maxStalenessSeconds'), + }, + maxTimeMS: { + env: 'PARSE_SERVER_DATABASE_MAX_TIME_MS', + help: 'The MongoDB driver option to set a cumulative time limit in milliseconds for processing operations on a cursor.', + action: parsers.numberParser('maxTimeMS'), + }, + minPoolSize: { + env: 'PARSE_SERVER_DATABASE_MIN_POOL_SIZE', + help: 'The MongoDB driver option to set the minimum number of opened, cached, ready-to-use database connections maintained by the driver.', + action: parsers.numberParser('minPoolSize'), + }, + proxyHost: { + env: 'PARSE_SERVER_DATABASE_PROXY_HOST', + help: 'The MongoDB driver option to configure a Socks5 proxy host used for creating TCP connections.', + }, + proxyPassword: { + env: 'PARSE_SERVER_DATABASE_PROXY_PASSWORD', + help: 'The MongoDB driver option to configure a Socks5 proxy password when the proxy requires username/password authentication.', + }, + proxyPort: { + env: 'PARSE_SERVER_DATABASE_PROXY_PORT', + help: 'The MongoDB driver option to configure a Socks5 proxy port used for creating TCP connections.', + action: parsers.numberParser('proxyPort'), + }, + proxyUsername: { + env: 'PARSE_SERVER_DATABASE_PROXY_USERNAME', + help: 'The MongoDB driver option to configure a Socks5 proxy username when the proxy requires username/password authentication.', + }, + readConcernLevel: { + env: 'PARSE_SERVER_DATABASE_READ_CONCERN_LEVEL', + help: 'The MongoDB driver option to specify the level of isolation.', + }, + readPreference: { + env: 'PARSE_SERVER_DATABASE_READ_PREFERENCE', + help: 'The MongoDB driver option to specify the read preferences for this connection.', + }, + readPreferenceTags: { + env: 'PARSE_SERVER_DATABASE_READ_PREFERENCE_TAGS', + help: 'The MongoDB driver option to specify the tags document as a comma-separated list of colon-separated key-value pairs.', + action: parsers.arrayParser, + }, + replicaSet: { + env: 'PARSE_SERVER_DATABASE_REPLICA_SET', + help: 'The MongoDB driver option to specify the name of the replica set, if the mongod is a member of a replica set.', + }, + retryReads: { + env: 'PARSE_SERVER_DATABASE_RETRY_READS', + help: 'The MongoDB driver option to enable retryable reads.', + action: parsers.booleanParser, + }, + retryWrites: { + env: 'PARSE_SERVER_DATABASE_RETRY_WRITES', + help: 'The MongoDB driver option to set whether to retry failed writes.', + action: parsers.booleanParser, + }, + schemaCacheTtl: { + env: 'PARSE_SERVER_DATABASE_SCHEMA_CACHE_TTL', + help: 'The duration in seconds after which the schema cache expires and will be refetched from the database. Use this option if using multiple Parse Servers instances connected to the same database. A low duration will cause the schema cache to be updated too often, causing unnecessary database reads. A high duration will cause the schema to be updated too rarely, increasing the time required until schema changes propagate to all server instances. This feature can be used as an alternative or in conjunction with the option `enableSchemaHooks`. Default is infinite which means the schema cache never expires.', + action: parsers.numberParser('schemaCacheTtl'), + }, + serverMonitoringMode: { + env: 'PARSE_SERVER_DATABASE_SERVER_MONITORING_MODE', + help: 'The MongoDB driver option to instruct the driver monitors to use a specific monitoring mode.', + }, + serverSelectionTimeoutMS: { + env: 'PARSE_SERVER_DATABASE_SERVER_SELECTION_TIMEOUT_MS', + help: 'The MongoDB driver option to specify the amount of time in milliseconds for a server to be considered suitable for selection.', + action: parsers.numberParser('serverSelectionTimeoutMS'), + }, + socketTimeoutMS: { + env: 'PARSE_SERVER_DATABASE_SOCKET_TIMEOUT_MS', + help: 'The MongoDB driver option to specify the amount of time, in milliseconds, spent attempting to send or receive on a socket before timing out. Specifying 0 means no timeout.', + action: parsers.numberParser('socketTimeoutMS'), + }, + srvMaxHosts: { + env: 'PARSE_SERVER_DATABASE_SRV_MAX_HOSTS', + help: 'The MongoDB driver option to specify the maximum number of hosts to connect to when using an srv connection string, a setting of 0 means unlimited hosts.', + action: parsers.numberParser('srvMaxHosts'), + }, + srvServiceName: { + env: 'PARSE_SERVER_DATABASE_SRV_SERVICE_NAME', + help: 'The MongoDB driver option to modify the srv URI service name.', + }, + ssl: { + env: 'PARSE_SERVER_DATABASE_SSL', + help: 'The MongoDB driver option to enable or disable TLS/SSL for the connection (equivalent to tls option).', + action: parsers.booleanParser, + }, + tls: { + env: 'PARSE_SERVER_DATABASE_TLS', + help: 'The MongoDB driver option to enable or disable TLS/SSL for the connection.', + action: parsers.booleanParser, + }, + tlsAllowInvalidCertificates: { + env: 'PARSE_SERVER_DATABASE_TLS_ALLOW_INVALID_CERTIFICATES', + help: 'The MongoDB driver option to bypass validation of the certificates presented by the mongod/mongos instance.', + action: parsers.booleanParser, + }, + tlsAllowInvalidHostnames: { + env: 'PARSE_SERVER_DATABASE_TLS_ALLOW_INVALID_HOSTNAMES', + help: 'The MongoDB driver option to disable hostname validation of the certificate presented by the mongod/mongos instance.', + action: parsers.booleanParser, + }, + tlsCAFile: { + env: 'PARSE_SERVER_DATABASE_TLS_CAFILE', + help: 'The MongoDB driver option to specify the location of a local .pem file that contains the root certificate chain from the Certificate Authority.', + }, + tlsCertificateKeyFile: { + env: 'PARSE_SERVER_DATABASE_TLS_CERTIFICATE_KEY_FILE', + help: "The MongoDB driver option to specify the location of a local .pem file that contains the client's TLS/SSL certificate and key.", + }, + tlsCertificateKeyFilePassword: { + env: 'PARSE_SERVER_DATABASE_TLS_CERTIFICATE_KEY_FILE_PASSWORD', + help: 'The MongoDB driver option to specify the password to decrypt the tlsCertificateKeyFile.', + }, + tlsInsecure: { + env: 'PARSE_SERVER_DATABASE_TLS_INSECURE', + help: 'The MongoDB driver option to disable various certificate validations.', + action: parsers.booleanParser, + }, + waitQueueTimeoutMS: { + env: 'PARSE_SERVER_DATABASE_WAIT_QUEUE_TIMEOUT_MS', + help: 'The MongoDB driver option to specify the maximum time in milliseconds that a thread can wait for a connection to become available.', + action: parsers.numberParser('waitQueueTimeoutMS'), + }, + zlibCompressionLevel: { + env: 'PARSE_SERVER_DATABASE_ZLIB_COMPRESSION_LEVEL', + help: 'The MongoDB driver option to specify the compression level if using zlib for network compression (0-9).', + action: parsers.numberParser('zlibCompressionLevel'), + }, +}; +module.exports.DatabaseOptionsClientMetadata = { + name: { + env: 'PARSE_SERVER_DATABASE_CLIENT_METADATA_NAME', + help: "The name to identify your application in database logs (e.g., 'MyApp').", + required: true, + }, + version: { + env: 'PARSE_SERVER_DATABASE_CLIENT_METADATA_VERSION', + help: "The version of your application (e.g., '1.0.0').", + required: true, + }, +}; +module.exports.AuthAdapter = { + enabled: { + help: 'Is `true` if the auth adapter is enabled, `false` otherwise.', + action: parsers.booleanParser, + default: false, + }, +}; +module.exports.LogLevels = { + cloudFunctionError: { + env: 'PARSE_SERVER_LOG_LEVELS_CLOUD_FUNCTION_ERROR', + help: 'Log level used by the Cloud Code Functions on error. Default is `error`. See [LogLevel](LogLevel.html) for available values.', + default: 'error', + }, + cloudFunctionSuccess: { + env: 'PARSE_SERVER_LOG_LEVELS_CLOUD_FUNCTION_SUCCESS', + help: 'Log level used by the Cloud Code Functions on success. Default is `info`. See [LogLevel](LogLevel.html) for available values.', + default: 'info', + }, + signupUsernameTaken: { + env: 'PARSE_SERVER_LOG_LEVELS_SIGNUP_USERNAME_TAKEN', + help: 'Log level used when a sign-up fails because the username already exists. Default is `info`. See [LogLevel](LogLevel.html) for available values.', + default: 'info', + }, + triggerAfter: { + env: 'PARSE_SERVER_LOG_LEVELS_TRIGGER_AFTER', + help: 'Log level used by the Cloud Code Triggers `afterSave`, `afterDelete`, `afterFind`, `afterLogout`. Default is `info`. See [LogLevel](LogLevel.html) for available values.', + default: 'info', + }, + triggerBeforeError: { + env: 'PARSE_SERVER_LOG_LEVELS_TRIGGER_BEFORE_ERROR', + help: 'Log level used by the Cloud Code Triggers `beforeSave`, `beforeDelete`, `beforeFind`, `beforeLogin` on error. Default is `error`. See [LogLevel](LogLevel.html) for available values.', + default: 'error', + }, + triggerBeforeSuccess: { + env: 'PARSE_SERVER_LOG_LEVELS_TRIGGER_BEFORE_SUCCESS', + help: 'Log level used by the Cloud Code Triggers `beforeSave`, `beforeDelete`, `beforeFind`, `beforeLogin` on success. Default is `info`. See [LogLevel](LogLevel.html) for available values.', + default: 'info', + }, +}; diff --git a/src/Options/docs.js b/src/Options/docs.js new file mode 100644 index 0000000000..f3c454e763 --- /dev/null +++ b/src/Options/docs.js @@ -0,0 +1,386 @@ +/** + * @interface SchemaOptions + * @property {Function} afterMigration Execute a callback after running schema migrations. + * @property {Function} beforeMigration Execute a callback before running schema migrations. + * @property {Any} definitions Rest representation on Parse.Schema https://docs.parseplatform.org/rest/guide/#adding-a-schema + * @property {Boolean} deleteExtraFields Is true if Parse Server should delete any fields not defined in a schema definition. This should only be used during development. + * @property {Boolean} keepUnknownIndexes (Optional) Keep indexes that are present in the database but not defined in the schema. Set this to `true` if you are adding indexes manually, so that they won't be removed when running schema migration. Default is `false`. + * @property {Boolean} lockSchemas Is true if Parse Server will reject any attempts to modify the schema while the server is running. + * @property {Boolean} recreateModifiedFields Is true if Parse Server should recreate any fields that are different between the current database schema and theschema definition. This should only be used during development. + * @property {Boolean} strict Is true if Parse Server should exit if schema update fail. + */ + +/** + * @interface ParseServerOptions + * @property {AccountLockoutOptions} accountLockout The account lockout policy for failed login attempts.

Note: Setting a user's ACL to an empty object `{}` via master key is a separate mechanism that only prevents new logins; it does not invalidate existing session tokens. To immediately revoke a user's access, destroy their sessions via master key in addition to setting the ACL. + * @property {Boolean} allowClientClassCreation Enable (or disable) client class creation, defaults to false + * @property {Boolean} allowCustomObjectId Enable (or disable) custom objectId + * @property {Boolean} allowExpiredAuthDataToken Deprecated. This option will be removed in a future version. Auth providers are always validated on login. On update, if this is set to `true`, auth providers are only re-validated when the auth data has changed. If this is set to `false`, auth providers are re-validated on every update. Defaults to `false`. + * @property {String[]} allowHeaders Add headers to Access-Control-Allow-Headers + * @property {String|String[]} allowOrigin Sets origins for Access-Control-Allow-Origin. This can be a string for a single origin or an array of strings for multiple origins. + * @property {Adapter} analyticsAdapter Adapter module for the analytics + * @property {String} appId Your Parse Application ID + * @property {String} appName Sets the app name + * @property {Object} auth Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication

Provider names must start with a letter and contain only letters, digits, and underscores (`/^[A-Za-z][A-Za-z0-9_]*$/`). This is because each provider name is used to construct a database field (`_auth_data_`), which must comply with Parse Server's field naming rules. + * @property {Adapter} cacheAdapter Adapter module for the cache + * @property {Number} cacheMaxSize Sets the maximum size for the in memory cache, defaults to 10000 + * @property {Number} cacheTTL Sets the TTL for the in memory cache (in ms), defaults to 5000 (5 seconds) + * @property {String} clientKey Key for iOS, MacOS, tvOS clients + * @property {String} cloud Full path to your cloud code main.js + * @property {Number|Boolean} cluster Run with cluster, optionally set the number of processes default to os.cpus().length + * @property {String} collectionPrefix A collection prefix for the classes + * @property {Boolean} convertEmailToLowercase Optional. If set to `true`, the `email` property of a user is automatically converted to lowercase before being stored in the database. Consequently, queries must match the case as stored in the database, which would be lowercase in this scenario. If `false`, the `email` property is stored as set, without any case modifications. Default is `false`. + * @property {Boolean} convertUsernameToLowercase Optional. If set to `true`, the `username` property of a user is automatically converted to lowercase before being stored in the database. Consequently, queries must match the case as stored in the database, which would be lowercase in this scenario. If `false`, the `username` property is stored as set, without any case modifications. Default is `false`. + * @property {CustomPagesOptions} customPages custom pages for password validation and reset + * @property {Adapter} databaseAdapter Adapter module for the database; any options that are not explicitly described here are passed directly to the database client. + * @property {DatabaseOptions} databaseOptions Options to pass to the database client + * @property {String} databaseURI The full URI to your database. Supported databases are mongodb or postgres. + * @property {Number} defaultLimit Default value for limit option on queries, defaults to `100`. + * @property {Boolean} directAccess Set to `true` if Parse requests within the same Node.js environment as Parse Server should be routed to Parse Server directly instead of via the HTTP interface. Default is `false`.

If set to `false` then Parse requests within the same Node.js environment as Parse Server are executed as HTTP requests sent to Parse Server via the `serverURL`. For example, a `Parse.Query` in Cloud Code is calling Parse Server via a HTTP request. The server is essentially making a HTTP request to itself, unnecessarily using network resources such as network ports.

âš ī¸ In environments where multiple Parse Server instances run behind a load balancer and Parse requests within the current Node.js environment should be routed via the load balancer and distributed as HTTP requests among all instances via the `serverURL`, this should be set to `false`. + * @property {String} dotNetKey Key for Unity and .Net SDK + * @property {Adapter} emailAdapter Adapter module for email sending + * @property {Boolean} emailVerifySuccessOnInvalidEmail Set to `true` if a request to verify the email should return a success response even if the provided email address does not belong to a verifiable account, for example because it is unknown or already verified, or `false` if the request should return an error response in those cases.

Default is `true`.
Requires option `verifyUserEmails: true`. + * @property {Boolean} emailVerifyTokenReuseIfValid Set to `true` if a email verification token should be reused in case another token is requested but there is a token that is still valid, i.e. has not expired. This avoids the often observed issue that a user requests multiple emails and does not know which link contains a valid token because each newly generated token would invalidate the previous token.

Default is `false`.
Requires option `verifyUserEmails: true`. + * @property {Number} emailVerifyTokenValidityDuration Set the validity duration of the email verification token in seconds after which the token expires. The token is used in the link that is set in the email. After the token expires, the link becomes invalid and a new link has to be sent. If the option is not set or set to `undefined`, then the token never expires.

For example, to expire the token after 2 hours, set a value of 7200 seconds (= 60 seconds * 60 minutes * 2 hours).

Default is `undefined`.
Requires option `verifyUserEmails: true`. + * @property {Boolean} enableAnonymousUsers Enable (or disable) anonymous users, defaults to true + * @property {Boolean} enableCollationCaseComparison Optional. If set to `true`, the collation rule of case comparison for queries and indexes is enabled. Enable this option to run Parse Server with MongoDB Atlas Serverless or AWS Amazon DocumentDB. If `false`, the collation rule of case comparison is disabled. Default is `false`. + * @property {Boolean} enableExpressErrorHandler Enables the default express error handler for all errors + * @property {Boolean} enableInsecureAuthAdapters Optional. Enables insecure authentication adapters. Insecure auth adapters are deprecated and will be removed in a future version. Defaults to `false`. + * @property {Boolean} enableProductPurchaseLegacyApi Deprecated. Enables the legacy product purchase API including the `_Product` class and the `/validate_purchase` endpoint. This is an undocumented, unmaintained legacy feature inherited from the original Parse platform that may not function as expected. We strongly advise against using it. It will be removed in a future major version. + * @property {Boolean} enableSanitizedErrorResponse If set to `true`, error details are removed from error messages in responses to client requests, and instead a generic error message is sent. Default is `true`. + * @property {String} encryptionKey Key for encrypting your files + * @property {Boolean} enforcePrivateUsers Set to true if new users should be created without public read and write access. + * @property {Boolean} expireInactiveSessions Sets whether we should expire the inactive sessions, defaults to true. If false, all new sessions are created with no expiration date. + * @property {Boolean} extendSessionOnUse Whether Parse Server should automatically extend a valid session by the sessionLength. In order to reduce the number of session updates in the database, a session will only be extended when a request is received after at least half of the current session's lifetime has passed. + * @property {FileDownloadOptions} fileDownload Options for file downloads + * @property {String} fileKey Key for your files + * @property {Adapter} filesAdapter Adapter module for the files sub-system + * @property {FileUploadOptions} fileUpload Options for file uploads + * @property {String} graphQLPath The mount path for the GraphQL endpoint

âš ī¸ File upload inside the GraphQL mutation system requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.

Defaults is `/graphql`. + * @property {Boolean} graphQLPublicIntrospection Enable public introspection for the GraphQL endpoint, defaults to false + * @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 {InstallationOptions} installation Options controlling how Parse Server deduplicates `_Installation` records that share the same `deviceToken`. + * @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 + * @property {LiveQueryServerOptions} liveQueryServerOptions Live query server configuration options (will start the liveQuery server) + * @property {Adapter} loggerAdapter Adapter module for the logging sub-system + * @property {String} logLevel Sets the level for logs + * @property {LogLevels} logLevels (Optional) Overrides the log levels used internally by Parse Server to log events. + * @property {String} logsFolder Folder for the logs (defaults to './logs'); set to null to disable file based logging + * @property {String} maintenanceKey (Optional) The maintenance key is used for modifying internal and read-only fields of Parse Server.

âš ī¸ This key is not intended to be used as part of a regular operation of Parse Server. This key is intended to conduct out-of-band changes such as one-time migrations or data correction tasks. Internal fields are not officially documented and may change at any time without publication in release changelogs. We strongly advice not to rely on internal fields as part of your regular operation and to investigate the implications of any planned changes *directly in the source code* of your current version of Parse Server. + * @property {String[]} maintenanceKeyIps (Optional) Restricts the use of maintenance key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the maintenance key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the maintenance key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['127.0.0.1', '::1']` which means that only `localhost`, the server instance on which Parse Server runs, is allowed to use the maintenance key. + * @property {Union} masterKey Your Parse Master Key + * @property {String[]} masterKeyIps (Optional) Restricts the use of master key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the master key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the master key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['127.0.0.1', '::1']` which means that only `localhost`, the server instance on which Parse Server runs, is allowed to use the master key. + * @property {Number} masterKeyTtl (Optional) The duration in seconds for which the current `masterKey` is being used before it is requested again if `masterKey` is set to a function. If `masterKey` is not set to a function, this option has no effect. Default is `0`, which means the master key is requested by invoking the `masterKey` function every time the master key is used internally by Parse Server. + * @property {Number} maxLimit Max value for limit option on queries, defaults to unlimited + * @property {Number|String} maxLogFiles 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) + * @property {String} maxUploadSize Max file size for uploads, defaults to 20mb + * @property {Union} middleware middleware for express server, can be string or function + * @property {Boolean} mountGraphQL Mounts the GraphQL endpoint + * @property {String} mountPath Mount path for the server, defaults to /parse + * @property {Boolean} mountPlayground Deprecated. Mounts the GraphQL Playground which is deprecated and will be removed in a future version. The playground exposes the master key in the browser. Use Parse Dashboard as GraphQL IDE or configure a third-party GraphQL client with custom request headers. + * @property {Number} objectIdSize Sets the number of characters in generated object id's, default 10 + * @property {PagesOptions} pages The options for pages such as password reset and email verification. + * @property {PasswordPolicyOptions} passwordPolicy The password policy for enforcing password related rules. + * @property {String} playgroundPath Deprecated. Mount path for the GraphQL Playground. The playground is deprecated and will be removed in a future version. + * @property {Number} port The port to run the ParseServer, defaults to 1337. + * @property {Boolean} preserveFileName Enable (or disable) the addition of a unique hash to the file names + * @property {Boolean} preventLoginWithUnverifiedEmail Set to `true` to prevent a user from logging in if the email has not yet been verified and email verification is required. Supports a function with a return value of `true` or `false` for conditional prevention. The function receives a request object that includes `createdWith` to indicate whether the invocation is for `signup` or `login` and the used auth provider.

The `createdWith` values per scenario:
  • Password signup: `{ action: 'signup', authProvider: 'password' }`
  • Auth provider signup: `{ action: 'signup', authProvider: '' }`
  • Password login: `{ action: 'login', authProvider: 'password' }`
  • Auth provider login: function not invoked; auth provider login bypasses email verification
Default is `false`.
Requires option `verifyUserEmails: true`. + * @property {Boolean} preventSignupWithUnverifiedEmail If set to `true` it prevents a user from signing up if the email has not yet been verified and email verification is required. In that case the server responds to the sign-up with HTTP status 400 and a Parse Error 205 `EMAIL_NOT_FOUND`. If set to `false` the server responds with HTTP status 200, and client SDKs return an unauthenticated Parse User without session token. In that case subsequent requests fail until the user's email address is verified.

Default is `false`.
Requires option `verifyUserEmails: true`. + * @property {ProtectedFields} protectedFields Fields per class that are hidden from query results for specific user groups. Protected fields are stripped from the server response, but can still be used internally (e.g. in Cloud Code triggers). Configure as `{ 'ClassName': { 'UserGroup': ['field1', 'field2'] } }` where `UserGroup` is one of: `'*'` (all users), `'authenticated'` (authenticated users), `'role:RoleName'` (users with a specific role), `'userField:FieldName'` (users referenced by a pointer field), or a user `objectId` to target a specific user. When multiple groups apply, the intersection of their protected fields is used. Any field can be protected, including system fields like `createdAt` and `updatedAt`. By default, `email` is protected on the `_User` class for all users. On the `_User` class, the object owner is exempt from protected fields by default; see `protectedFieldsOwnerExempt` to change this. + * @property {Boolean} protectedFieldsOwnerExempt Whether the `_User` class is exempt from `protectedFields` when the logged-in user queries their own user object. If `true` (default), a user can see all their own fields regardless of `protectedFields` configuration; default protected fields (e.g. `email`) are merged into any custom `protectedFields` configuration. If `false`, `protectedFields` applies equally to the user's own object, consistent with all other classes; only explicitly configured protected fields apply, defaults are not merged. Defaults to `true`. + * @property {Boolean} protectedFieldsSaveResponseExempt Whether save operation responses (create, update) are exempt from `protectedFields`. If `true` (default), protected fields modified during a save are included in the response to the client. If `false`, protected fields are stripped from save responses, consistent with how they are stripped from query results. Defaults to `true`. + * @property {Boolean} protectedFieldsTriggerExempt Whether Cloud Code triggers (e.g. `beforeSave`, `afterSave`) are exempt from `protectedFields`. If `true`, triggers receive the full object including protected fields in `request.object` and `request.original`, regardless of the caller's auth context. If `false`, protected fields are stripped from the original object fetch used to build trigger objects. Defaults to `false`. + * @property {Union} publicServerURL Optional. The public URL to Parse Server. This URL will be used to reach Parse Server publicly for features like password reset and email verification links. The option can be set to a string or a function that can be asynchronously resolved. The returned URL string must start with `http://` or `https://`. + * @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications + * @property {QueryServerOptions} query Query-related server defaults. + * @property {RateLimitOptions[]} rateLimit Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

â„šī¸ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case. + * @property {String} readOnlyMasterKey Read-only key, which has the same capabilities as MasterKey without writes + * @property {String[]} readOnlyMasterKeyIps (Optional) Restricts the use of read-only master key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the read-only master key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the read-only master key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['0.0.0.0/0', '::0']` which means that any IP address is allowed to use the read-only master key. It is recommended to set this option to `['127.0.0.1', '::1']` to restrict access to `localhost`. + * @property {RequestComplexityOptions} requestComplexity Options to limit the complexity of requests to prevent denial-of-service attacks. Limits are enforced for all requests except those using the master or maintenance key. Each property can be set to `-1` to disable that specific limit. + * @property {Function} requestContextMiddleware Options to customize the request context using inversion of control/dependency injection. + * @property {RequestKeywordDenylist[]} requestKeywordDenylist An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns. + * @property {String} restAPIKey Key for REST calls + * @property {Boolean} revokeSessionOnPasswordReset 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. + * @property {String[]} routeAllowList (Optional) Restricts external client access to a list of allowed API routes.

When this option is set, all external non-master-key requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.

Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.

Examples of normalized route identifiers:
  • `classes/GameScore` (class CRUD)
  • `classes/GameScore/abc123` (object by ID)
  • `users` (user operations)
  • `login` (login endpoint)
  • `functions/sendEmail` (Cloud Function)
  • `jobs/cleanup` (Cloud Job)
  • `push` (push notifications)
  • `config` (client config)
  • `installations` (installations)
  • `files/picture.jpg` (file operations)
Example patterns:
  • `classes/ChatMessage` matches only `classes/ChatMessage`
  • `classes/Chat.*` matches `classes/ChatMessage`, `classes/ChatRoom`, etc.
  • `functions/.*` matches all Cloud Functions
Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.

Defaults to `undefined` which means the feature is inactive and all routes are accessible. + * @property {Boolean} scheduledPush Configuration for push scheduling, defaults to false. + * @property {SchemaOptions} schema Defined schema + * @property {SecurityOptions} security The security options to identify and report weak security settings. + * @property {Boolean} sendUserEmailVerification Set to `false` to prevent sending of verification email. Supports a function with a return value of `true` or `false` for conditional email sending.

Default is `true`.
+ * @property {Function} serverCloseComplete Callback when server has closed + * @property {String} serverURL The URL to Parse Server.

âš ī¸ Certain server features or adapters may require Parse Server to be able to call itself by making requests to the URL set in `serverURL`. If a feature requires this, it is mentioned in the documentation. In that case ensure that the URL is accessible from the server itself. + * @property {Number} sessionLength Session duration, in seconds, defaults to 1 year + * @property {Boolean} silent Disables console output + * @property {Boolean} startLiveQueryServer Starts the liveQuery server + * @property {Any} trustProxy The trust proxy settings. It is important to understand the exact setup of the reverse proxy, since this setting will trust values provided in the Parse Server API request. See the express trust proxy settings documentation. Defaults to `false`. + * @property {String[]} userSensitiveFields Personally identifiable information fields in the user table the should be removed for non-authorized users. Deprecated @see protectedFields + * @property {Boolean} verbose Set the logging to verbose + * @property {Boolean} verifyServerUrl Parse Server makes a HTTP request to the URL set in `serverURL` at the end of its launch routine to verify that the launch succeeded. If this option is set to `false`, the verification will be skipped. This can be useful in environments where the server URL is not accessible from the server itself, such as when running behind a firewall or in certain containerized environments.

âš ī¸ Server URL verification requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.

Default is `true`. + * @property {Boolean} verifyUserEmails Set to `true` to require users to verify their email address to complete the sign-up process. Supports a function with a return value of `true` or `false` for conditional verification. The function receives a request object that includes `createdWith` to indicate whether the invocation is for `signup` or `login` and the used auth provider.

The `createdWith` values per scenario:
  • Password signup: `{ action: 'signup', authProvider: 'password' }`
  • Auth provider signup: `{ action: 'signup', authProvider: '' }`
  • Password login: `{ action: 'login', authProvider: 'password' }`
  • Auth provider login: function not invoked; auth provider login bypasses email verification
  • Resend verification email: `createdWith` is `undefined`; use the `resendRequest` property to identify those
Default is `false`. + * @property {String} webhookKey Key sent with outgoing webhook calls + */ + +/** + * @interface RateLimitOptions + * @property {String} errorResponseMessage The error message that should be returned in the body of the HTTP 429 response when the rate limit is hit. Default is `Too many requests.`. + * @property {Boolean} includeInternalRequests Optional, if `true` the rate limit will also apply to requests that are made in by Cloud Code, default is `false`. Note that a public Cloud Code function that triggers internal requests may circumvent rate limiting and be vulnerable to attacks. + * @property {Boolean} includeMasterKey Optional, if `true` the rate limit will also apply to requests using the `masterKey`, default is `false`. Note that a public Cloud Code function that triggers internal requests using the `masterKey` may circumvent rate limiting and be vulnerable to attacks. + * @property {String} redisUrl Optional, the URL of the Redis server to store rate limit data. This allows to rate limit requests for multiple servers by calculating the sum of all requests across all servers. This is useful if multiple servers are processing requests behind a load balancer. For example, the limit of 10 requests is reached if each of 2 servers processed 5 requests. + * @property {Number} requestCount The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied. For batch requests, this also limits the number of sub-requests in a single batch that target this path; however, requests already consumed in the current time window are not counted against the batch, so the effective limit may be higher when combining individual and batch requests. Note that this is a basic server-level rate limit; for comprehensive protection, use a reverse proxy or WAF for rate limiting. + * @property {String[]} requestMethods Optional, the HTTP request methods to which the rate limit should be applied, default is all methods. + * @property {String} requestPath The path of the API route to be rate limited. Route paths, in combination with a request method, define the endpoints at which requests can be made. Route paths can be strings or string patterns following path-to-regexp v8 syntax. + * @property {Number} requestTimeWindow The window of time in milliseconds within which the number of requests set in `requestCount` can be made before the rate limit is applied. + * @property {String} zone The type of rate limit to apply. The following types are supported:
  • `global`: rate limit based on the number of requests made by all users
  • `ip`: rate limit based on the IP address of the request
  • `user`: rate limit based on the user ID of the request
  • `session`: rate limit based on the session token of the request
Default is `ip`. + */ + +/** + * @interface RequestComplexityOptions + * @property {Boolean} allowRegex Whether to allow the `$regex` query operator. Set to `false` to reject `$regex` in queries for non-master-key users. Default is `true`. + * @property {Number} batchRequestLimit Maximum number of sub-requests in a single batch request. Set to `-1` to disable. Default is `-1`. + * @property {Number} graphQLDepth Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `-1`. + * @property {Number} graphQLFields Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `-1`. + * @property {Number} includeCount Maximum number of include paths in a single query. Set to `-1` to disable. Default is `-1`. + * @property {Number} includeDepth Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `-1`. + * @property {Number} queryDepth Maximum nesting depth of `$or`, `$and`, `$nor` query operators. Set to `-1` to disable. Default is `-1`. + * @property {Number} subqueryDepth Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `-1`. + * @property {Number} subqueryLimit Maximum number of results returned by a `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subquery. Set to `-1` to disable. Default is `-1`. + */ + +/** + * @interface InstallationOptions + * @property {String} duplicateDeviceTokenAction What Parse Server does to the conflicting `_Installation` row(s) when a new install's `deviceToken` collides with an existing row. `'delete'` destroys the conflicting row. `'update'` clears the now-conflicting ID field on the conflicting row, preserving custom fields, channels, and history. Default is `'delete'`. + * @property {Boolean} duplicateDeviceTokenActionEnforceAuth Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Default is `false`. + * @property {String} duplicateDeviceTokenMergePriority At the merge case (when an existing row holds the new `deviceToken` but has no `installationId` of its own), which side wins. `'deviceToken'` — the deviceToken-only row survives, the request's `idMatch` row is the loser. `'installationId'` — the request's `idMatch` (active install) survives, the deviceToken-only orphan is the loser. Default is `'deviceToken'`. + */ + +/** + * @interface SecurityOptions + * @property {CheckGroup[]} checkGroups The security check groups to run. This allows to add custom security checks or override existing ones. Default are the groups defined in `CheckGroups.js`. + * @property {Boolean} enableCheck Is true if Parse Server should check for weak security settings. + * @property {Boolean} enableCheckLog Is true if the security check report should be written to logs. This should only be enabled temporarily to not expose weak security settings in logs. + */ + +/** + * @interface QueryServerOptions + * @property {Boolean} aggregationRawFieldNames When `true`, all aggregation queries default to using native MongoDB field names (no automatic `createdAt` → `_created_at` rewriting). Individual queries can still override this via the `rawFieldNames` option. Default is `false`. + * @property {Boolean} aggregationRawValues When `true`, all aggregation queries default to using MongoDB Extended JSON (EJSON) for explicit value typing and skip schema-based value coercion. Individual queries can still override this via the `rawValues` option. Default is `false`. + */ + +/** + * @interface PagesOptions + * @property {PagesRoute[]} customRoutes The custom routes. + * @property {PagesCustomUrlsOptions} customUrls The URLs to the custom pages. + * @property {Boolean} enableLocalization Is true if pages should be localized; this has no effect on custom page redirects. + * @property {Boolean} encodePageParamHeaders Is `true` if the page parameter headers should be URI-encoded. This is required if any page parameter value contains non-ASCII characters, such as the app name. + * @property {Boolean} forceRedirect Is true if responses should always be redirects and never content, false if the response type should depend on the request type (GET request -> content response; POST request -> redirect response). + * @property {String} localizationFallbackLocale The fallback locale for localization if no matching translation is provided for the given locale. This is only relevant when providing translation resources via JSON file. + * @property {String} localizationJsonPath The path to the JSON file for localization; the translations will be used to fill template placeholders according to the locale. + * @property {String} pagesEndpoint The API endpoint for the pages. Default is 'apps'. + * @property {String} pagesPath The path to the pages directory; this also defines where the static endpoint '/apps' points to. Default is the './public/' directory of the parse-server module. + * @property {Object} placeholders The placeholder keys and values which will be filled in pages; this can be a simple object or a callback function. + */ + +/** + * @interface PagesRoute + * @property {Function} handler The route handler that is an async function. + * @property {String} method The route method, e.g. 'GET' or 'POST'. + * @property {String} path The route path. + */ + +/** + * @interface PagesCustomUrlsOptions + * @property {String} emailVerificationLinkExpired The URL to the custom page for email verification -> link expired. + * @property {String} emailVerificationLinkInvalid The URL to the custom page for email verification -> link invalid. + * @property {String} emailVerificationSendFail The URL to the custom page for email verification -> link send fail. + * @property {String} emailVerificationSendSuccess The URL to the custom page for email verification -> resend link -> success. + * @property {String} emailVerificationSuccess The URL to the custom page for email verification -> success. + * @property {String} passwordReset The URL to the custom page for password reset. + * @property {String} passwordResetLinkInvalid The URL to the custom page for password reset -> link invalid. + * @property {String} passwordResetSuccess The URL to the custom page for password reset -> success. + */ + +/** + * @interface CustomPagesOptions + * @property {String} choosePassword choose password page path + * @property {String} expiredVerificationLink expired verification link page path + * @property {String} invalidLink invalid link page path + * @property {String} invalidPasswordResetLink invalid password reset link page path + * @property {String} invalidVerificationLink invalid verification link page path + * @property {String} linkSendFail verification link send fail page path + * @property {String} linkSendSuccess verification link send success page path + * @property {String} parseFrameURL for masking user-facing pages + * @property {String} passwordResetSuccess password reset success page path + * @property {String} verifyEmailSuccess verify email success page path + */ + +/** + * @interface LiveQueryOptions + * @property {String[]} classNames parse-server's LiveQuery classNames + * @property {Adapter} pubSubAdapter LiveQuery pubsub adapter + * @property {Any} redisOptions parse-server's LiveQuery redisOptions + * @property {String} redisURL parse-server's LiveQuery redisURL + * @property {Number} regexTimeout Sets the maximum execution time in milliseconds for regular expression pattern matching in LiveQuery. This protects against Regular Expression Denial of Service (ReDoS) attacks where a malicious regex pattern could block the event loop. A regex that exceeds the timeout will be treated as non-matching.

The protection runs each regex evaluation in an isolated VM context with a timeout. This adds approximately 50 microseconds of overhead per regex evaluation. For most applications this is negligible, but it can add up if you have a very large number of LiveQuery subscriptions that use `$regex` on the same class. For example, 10,000 concurrent regex subscriptions would add approximately 500ms of processing time per object save event on that class.

Set to `0` to disable the timeout and use native regex evaluation without protection. Defaults to `100`. + * @property {Adapter} wssAdapter Adapter module for the WebSocketServer + */ + +/** + * @interface LiveQueryServerOptions + * @property {String} appId 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. + * @property {Number} cacheTimeout 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 5 * 1000 ms (5 seconds). + * @property {Any} keyPairs 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. + * @property {String} logLevel This string defines the log level of the LiveQuery server. We support VERBOSE, INFO, ERROR, NONE, defaults to INFO. + * @property {String} masterKey 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. + * @property {Number} port The port to run the LiveQuery server, defaults to 1337. + * @property {Adapter} pubSubAdapter LiveQuery pubsub adapter + * @property {Any} redisOptions parse-server's LiveQuery redisOptions + * @property {String} redisURL parse-server's LiveQuery redisURL + * @property {String} serverURL 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. + * @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. + */ + +/** + * @interface AccountLockoutOptions + * @property {Number} duration Set the duration in minutes that a locked-out account remains locked out before automatically becoming unlocked.

Valid values are greater than `0` and less than `100000`. + * @property {Number} threshold Set the number of failed sign-in attempts that will cause a user account to be locked. If the account is locked. The account will unlock after the duration set in the `duration` option has passed and no further login attempts have been made.

Valid values are greater than `0` and less than `1000`. + * @property {Boolean} unlockOnPasswordReset Set to `true` if the account should be unlocked after a successful password reset.

Default is `false`.
Requires options `duration` and `threshold` to be set. + */ + +/** + * @interface PasswordPolicyOptions + * @property {Boolean} doNotAllowUsername Set to `true` to disallow the username as part of the password.

Default is `false`. + * @property {Number} maxPasswordAge Set the number of days after which a password expires. Login attempts fail if the user does not reset the password before expiration. + * @property {Number} maxPasswordHistory Set the number of previous password that will not be allowed to be set as new password. If the option is not set or set to `0`, no previous passwords will be considered.

Valid values are >= `0` and <= `20`.
Default is `0`. + * @property {Boolean} resetPasswordSuccessOnInvalidEmail Set to `true` if a request to reset the password should return a success response even if the provided email address is invalid, or `false` if the request should return an error response if the email address is invalid.

Default is `true`. + * @property {Boolean} resetTokenReuseIfValid Set to `true` if a password reset token should be reused in case another token is requested but there is a token that is still valid, i.e. has not expired. This avoids the often observed issue that a user requests multiple emails and does not know which link contains a valid token because each newly generated token would invalidate the previous token.

Default is `false`. + * @property {Number} resetTokenValidityDuration Set the validity duration of the password reset token in seconds after which the token expires. The token is used in the link that is set in the email. After the token expires, the link becomes invalid and a new link has to be sent. If the option is not set or set to `undefined`, then the token never expires.

For example, to expire the token after 2 hours, set a value of 7200 seconds (= 60 seconds * 60 minutes * 2 hours).

Default is `undefined`. + * @property {String} validationError Set the error message to be sent.

Default is `Password does not meet the Password Policy requirements.` + * @property {Function} validatorCallback Set a callback function to validate a password to be accepted.

If used in combination with `validatorPattern`, the password must pass both to be accepted. + * @property {String} validatorPattern Set the regular expression validation pattern a password must match to be accepted.

If used in combination with `validatorCallback`, the password must pass both to be accepted. + */ + +/** + * @interface FileUploadOptions + * @property {String[]} allowedFileUrlDomains Sets the allowed hostnames for file URLs referenced in Parse objects. When a File object includes a URL, its hostname must match one of these entries to be accepted. Supports exact hostnames (e.g., `'cdn.example.com'`) and wildcard subdomains (e.g., `'*.example.com'`). Use `['*']` to allow any domain. Use `[]` to block all file URLs (only name-based files allowed). + * @property {Boolean} enableForAnonymousUser Is true if file upload should be allowed for anonymous users. + * @property {Boolean} enableForAuthenticatedUser Is true if file upload should be allowed for authenticated users. + * @property {Boolean} enableForPublic Is true if file upload should be allowed for anyone, regardless of user authentication. + * @property {String[]} fileExtensions Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.

It is recommended to only allow the file extensions that your app actually needs, rather than relying on blocking dangerous extensions. This allowlist approach is more secure because new dangerous file extensions may emerge that are not covered by the default blocklist.

The default blocks the most common file extensions that are known to be rendered as active content by web browsers, such as HTML, SVG, and XML files, which may be used by an attacker to compromise the session token of another user via accessing the browser's local storage. The blocked extensions are: `html`, `htm`, `shtml`, `xhtml`, `xhtml+xml`, `xht`, `svg`, `svgz`, `svg+xml`, `xml`, `xsl`, `xslt`, `xslt+xml`, `xsd`, `rng`, `rdf`, `rdf+xml`, `owl`, `mathml`, `mathml+xml`.

Defaults to `["^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?|[xX][sS][dD]|[rR][nN][gG]|[rR][dD][fF](\\+[xX][mM][lL])?|[oO][wW][lL]|[mM][aA][tT][hH][mM][lL](\\+[xX][mM][lL])?)$)"]`. + */ + +/** + * @interface FileDownloadOptions + * @property {Boolean} enableForAnonymousUser Is true if file download should be allowed for anonymous users. + * @property {Boolean} enableForAuthenticatedUser Is true if file download should be allowed for authenticated users. + * @property {Boolean} enableForPublic Is true if file download should be allowed for anyone, regardless of user authentication. + */ + +/** + * @interface LogLevel + * @property {StringLiteral} debug Debug level + * @property {StringLiteral} error Error level - highest priority + * @property {StringLiteral} info Info level - default + * @property {StringLiteral} silly Silly level - lowest priority + * @property {StringLiteral} verbose Verbose level + * @property {StringLiteral} warn Warning level + */ + +/** + * @interface LogClientEvent + * @property {String[]} keys Optional array of dot-notation paths to extract specific data from the event object. If not provided or empty, the entire event object will be logged. + * @property {String} logLevel The log level to use for this event. See [LogLevel](LogLevel.html) for available values. Defaults to `'info'`. + * @property {String} name The MongoDB driver event name to listen for. See the [MongoDB driver events documentation](https://www.mongodb.com/docs/drivers/node/current/fundamentals/monitoring/) for available events. + */ + +/** + * @interface DatabaseOptions + * @property {Boolean} allowPublicExplain Set to `true` to allow `Parse.Query.explain` without master key.

âš ī¸ Enabling this option may expose sensitive query performance data to unauthorized users and could potentially be exploited for malicious purposes. + * @property {String} appName The MongoDB driver option to specify the name of the application that created this MongoClient instance. + * @property {String} authMechanism The MongoDB driver option to specify the authentication mechanism that MongoDB will use to authenticate the connection. + * @property {Any} authMechanismProperties The MongoDB driver option to specify properties for the specified authMechanism as a comma-separated list of colon-separated key-value pairs. + * @property {String} authSource The MongoDB driver option to specify the database name associated with the user's credentials. + * @property {Boolean} autoSelectFamily The MongoDB driver option to set whether the socket attempts to connect to IPv6 and IPv4 addresses until a connection is established. If available, the driver will select the first IPv6 address. + * @property {Number} autoSelectFamilyAttemptTimeout The MongoDB driver option to specify the amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the autoSelectFamily option. If set to a positive integer less than 10, the value 10 is used instead. + * @property {Number} batchSize The number of documents per batch for MongoDB cursor `getMore` operations. A lower value reduces memory usage per batch; a higher value reduces the number of network round-trips. + * @property {DatabaseOptionsClientMetadata} clientMetadata Custom metadata to append to database client connections for identifying Parse Server instances in database logs. If set, this metadata will be visible in database logs during connection handshakes. This can help with debugging and monitoring in deployments with multiple database clients. Set `name` to identify your application (e.g., 'MyApp') and `version` to your application's version. Leave undefined (default) to disable this feature and avoid the additional data transfer overhead. + * @property {Union} compressors The MongoDB driver option to specify an array or comma-delimited string of compressors to enable network compression for communication between this client and a mongod/mongos instance. + * @property {Number} connectTimeoutMS The MongoDB driver option to specify the amount of time, in milliseconds, to wait to establish a single TCP socket connection to the server before raising an error. Specifying 0 disables the connection timeout. + * @property {Boolean} createIndexAuthDataUniqueness Set to `true` to automatically create unique indexes on the authData fields of the _User collection for each configured auth provider on server start, including `anonymous` when anonymous users are enabled. These indexes prevent race conditions during concurrent signups with the same authData. Set to `false` to skip index creation. Default is `true`.

âš ī¸ When setting this option to `false` to manually create the indexes, keep in mind that the otherwise automatically created indexes may change in the future to be optimized for the internal usage by Parse Server. + * @property {Boolean} createIndexRoleName Set to `true` to automatically create a unique index on the name field of the _Role collection on server start. Set to `false` to skip index creation. Default is `true`.

âš ī¸ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + * @property {Boolean} createIndexUserEmail Set to `true` to automatically create indexes on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

âš ī¸ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + * @property {Boolean} createIndexUserEmailCaseInsensitive Set to `true` to automatically create a case-insensitive index on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

âš ī¸ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + * @property {Boolean} createIndexUserEmailVerifyToken Set to `true` to automatically create an index on the _email_verify_token field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

âš ī¸ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + * @property {Boolean} createIndexUserPasswordResetToken Set to `true` to automatically create an index on the _perishable_token field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

âš ī¸ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + * @property {Boolean} createIndexUserUsername Set to `true` to automatically create indexes on the username field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

âš ī¸ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + * @property {Boolean} createIndexUserUsernameCaseInsensitive Set to `true` to automatically create a case-insensitive index on the username field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

âš ī¸ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + * @property {Boolean} directConnection The MongoDB driver option to force a Single topology type with a connection string containing one host. + * @property {Boolean} disableIndexFieldValidation Set to `true` to disable validation of index fields. When disabled, indexes can be created even if the fields do not exist in the schema. This can be useful when creating indexes on fields that will be added later. + * @property {Boolean} enableSchemaHooks Enables database real-time hooks to update single schema cache. Set to `true` if using multiple Parse Servers instances connected to the same database. Failing to do so will cause a schema change to not propagate to all instances and re-syncing will only happen when the instances restart. To use this feature with MongoDB, a replica set cluster with [change stream](https://docs.mongodb.com/manual/changeStreams/#availability) support is required. + * @property {Boolean} forceServerObjectId The MongoDB driver option to force server to assign _id values instead of driver. + * @property {Number} heartbeatFrequencyMS The MongoDB driver option to specify the frequency in milliseconds at which the driver checks the state of the MongoDB deployment. + * @property {Boolean} loadBalanced The MongoDB driver option to instruct the driver it is connecting to a load balancer fronting a mongos like service. + * @property {Number} localThresholdMS The MongoDB driver option to specify the size (in milliseconds) of the latency window for selecting among multiple suitable MongoDB instances. + * @property {LogClientEvent[]} logClientEvents An array of MongoDB client event configurations to enable logging of specific events. + * @property {Number} maxConnecting The MongoDB driver option to specify the maximum number of connections that may be in the process of being established concurrently by the connection pool. + * @property {Number} maxIdleTimeMS The MongoDB driver option to specify the amount of time in milliseconds that a connection can remain idle in the connection pool before being removed and closed. + * @property {Number} maxPoolSize The MongoDB driver option to set the maximum number of opened, cached, ready-to-use database connections maintained by the driver. + * @property {Number} maxStalenessSeconds The MongoDB driver option to set the maximum replication lag for reads from secondary nodes. + * @property {Number} maxTimeMS The MongoDB driver option to set a cumulative time limit in milliseconds for processing operations on a cursor. + * @property {Number} minPoolSize The MongoDB driver option to set the minimum number of opened, cached, ready-to-use database connections maintained by the driver. + * @property {String} proxyHost The MongoDB driver option to configure a Socks5 proxy host used for creating TCP connections. + * @property {String} proxyPassword The MongoDB driver option to configure a Socks5 proxy password when the proxy requires username/password authentication. + * @property {Number} proxyPort The MongoDB driver option to configure a Socks5 proxy port used for creating TCP connections. + * @property {String} proxyUsername The MongoDB driver option to configure a Socks5 proxy username when the proxy requires username/password authentication. + * @property {String} readConcernLevel The MongoDB driver option to specify the level of isolation. + * @property {String} readPreference The MongoDB driver option to specify the read preferences for this connection. + * @property {Any[]} readPreferenceTags The MongoDB driver option to specify the tags document as a comma-separated list of colon-separated key-value pairs. + * @property {String} replicaSet The MongoDB driver option to specify the name of the replica set, if the mongod is a member of a replica set. + * @property {Boolean} retryReads The MongoDB driver option to enable retryable reads. + * @property {Boolean} retryWrites The MongoDB driver option to set whether to retry failed writes. + * @property {Number} schemaCacheTtl The duration in seconds after which the schema cache expires and will be refetched from the database. Use this option if using multiple Parse Servers instances connected to the same database. A low duration will cause the schema cache to be updated too often, causing unnecessary database reads. A high duration will cause the schema to be updated too rarely, increasing the time required until schema changes propagate to all server instances. This feature can be used as an alternative or in conjunction with the option `enableSchemaHooks`. Default is infinite which means the schema cache never expires. + * @property {String} serverMonitoringMode The MongoDB driver option to instruct the driver monitors to use a specific monitoring mode. + * @property {Number} serverSelectionTimeoutMS The MongoDB driver option to specify the amount of time in milliseconds for a server to be considered suitable for selection. + * @property {Number} socketTimeoutMS The MongoDB driver option to specify the amount of time, in milliseconds, spent attempting to send or receive on a socket before timing out. Specifying 0 means no timeout. + * @property {Number} srvMaxHosts The MongoDB driver option to specify the maximum number of hosts to connect to when using an srv connection string, a setting of 0 means unlimited hosts. + * @property {String} srvServiceName The MongoDB driver option to modify the srv URI service name. + * @property {Boolean} ssl The MongoDB driver option to enable or disable TLS/SSL for the connection (equivalent to tls option). + * @property {Boolean} tls The MongoDB driver option to enable or disable TLS/SSL for the connection. + * @property {Boolean} tlsAllowInvalidCertificates The MongoDB driver option to bypass validation of the certificates presented by the mongod/mongos instance. + * @property {Boolean} tlsAllowInvalidHostnames The MongoDB driver option to disable hostname validation of the certificate presented by the mongod/mongos instance. + * @property {String} tlsCAFile The MongoDB driver option to specify the location of a local .pem file that contains the root certificate chain from the Certificate Authority. + * @property {String} tlsCertificateKeyFile The MongoDB driver option to specify the location of a local .pem file that contains the client's TLS/SSL certificate and key. + * @property {String} tlsCertificateKeyFilePassword The MongoDB driver option to specify the password to decrypt the tlsCertificateKeyFile. + * @property {Boolean} tlsInsecure The MongoDB driver option to disable various certificate validations. + * @property {Number} waitQueueTimeoutMS The MongoDB driver option to specify the maximum time in milliseconds that a thread can wait for a connection to become available. + * @property {Number} zlibCompressionLevel The MongoDB driver option to specify the compression level if using zlib for network compression (0-9). + */ + +/** + * @interface DatabaseOptionsClientMetadata + * @property {String} name The name to identify your application in database logs (e.g., 'MyApp'). + * @property {String} version The version of your application (e.g., '1.0.0'). + */ + +/** + * @interface AuthAdapter + * @property {Boolean} enabled Is `true` if the auth adapter is enabled, `false` otherwise. + */ + +/** + * @interface LogLevels + * @property {String} cloudFunctionError Log level used by the Cloud Code Functions on error. Default is `error`. See [LogLevel](LogLevel.html) for available values. + * @property {String} cloudFunctionSuccess Log level used by the Cloud Code Functions on success. Default is `info`. See [LogLevel](LogLevel.html) for available values. + * @property {String} signupUsernameTaken Log level used when a sign-up fails because the username already exists. Default is `info`. See [LogLevel](LogLevel.html) for available values. + * @property {String} triggerAfter Log level used by the Cloud Code Triggers `afterSave`, `afterDelete`, `afterFind`, `afterLogout`. Default is `info`. See [LogLevel](LogLevel.html) for available values. + * @property {String} triggerBeforeError Log level used by the Cloud Code Triggers `beforeSave`, `beforeDelete`, `beforeFind`, `beforeLogin` on error. Default is `error`. See [LogLevel](LogLevel.html) for available values. + * @property {String} triggerBeforeSuccess Log level used by the Cloud Code Triggers `beforeSave`, `beforeDelete`, `beforeFind`, `beforeLogin` on success. Default is `info`. See [LogLevel](LogLevel.html) for available values. + */ diff --git a/src/Options/index.js b/src/Options/index.js new file mode 100644 index 0000000000..e1266d239a --- /dev/null +++ b/src/Options/index.js @@ -0,0 +1,953 @@ +// @flow +import { AnalyticsAdapter } from '../Adapters/Analytics/AnalyticsAdapter'; +import { CacheAdapter } from '../Adapters/Cache/CacheAdapter'; +import { MailAdapter } from '../Adapters/Email/MailAdapter'; +import { FilesAdapter } from '../Adapters/Files/FilesAdapter'; +import { LoggerAdapter } from '../Adapters/Logger/LoggerAdapter'; +import { PubSubAdapter } from '../Adapters/PubSub/PubSubAdapter'; +import { StorageAdapter } from '../Adapters/Storage/StorageAdapter'; +import { WSSAdapter } from '../Adapters/WebSocketServer/WSSAdapter'; +import { CheckGroup } from '../Security/CheckGroup'; + +export interface SchemaOptions { + /* Rest representation on Parse.Schema https://docs.parseplatform.org/rest/guide/#adding-a-schema + :DEFAULT: [] */ + definitions: any; + /* Is true if Parse Server should exit if schema update fail. + :DEFAULT: false */ + strict: ?boolean; + /* Is true if Parse Server should delete any fields not defined in a schema definition. This should only be used during development. + :DEFAULT: false */ + deleteExtraFields: ?boolean; + /* Is true if Parse Server should recreate any fields that are different between the current database schema and theschema definition. This should only be used during development. + :DEFAULT: false */ + recreateModifiedFields: ?boolean; + /* Is true if Parse Server will reject any attempts to modify the schema while the server is running. + :DEFAULT: false */ + lockSchemas: ?boolean; + /* (Optional) Keep indexes that are present in the database but not defined in the schema. Set this to `true` if you are adding indexes manually, so that they won't be removed when running schema migration. Default is `false`. + :DEFAULT: false */ + keepUnknownIndexes: ?boolean; + /* Execute a callback before running schema migrations. */ + beforeMigration: ?() => void | Promise; + /* Execute a callback after running schema migrations. */ + afterMigration: ?() => void | Promise; +} + +type Adapter = string | any | T; +type NumberOrBoolean = number | boolean; +type NumberOrString = number | string; +type ProtectedFields = any; +type StringOrStringArray = string | string[]; +type RequestKeywordDenylist = { + key: string | any, + value: any, +}; +type EmailVerificationRequest = { + original?: any, + object: any, + master?: boolean, + ip?: string, + installationId?: string, + createdWith?: { + action: 'login' | 'signup', + authProvider: string, + }, + resendRequest?: boolean, +}; +type SendEmailVerificationRequest = { + user: any, + master?: boolean, +}; + +export interface ParseServerOptions { + /* Your Parse Application ID + :ENV: PARSE_SERVER_APPLICATION_ID */ + appId: string; + /* Your Parse Master Key */ + masterKey: (() => void) | string; + /* (Optional) The duration in seconds for which the current `masterKey` is being used before it is requested again if `masterKey` is set to a function. If `masterKey` is not set to a function, this option has no effect. Default is `0`, which means the master key is requested by invoking the `masterKey` function every time the master key is used internally by Parse Server. */ + masterKeyTtl: ?number; + /* (Optional) The maintenance key is used for modifying internal and read-only fields of Parse Server.

âš ī¸ This key is not intended to be used as part of a regular operation of Parse Server. This key is intended to conduct out-of-band changes such as one-time migrations or data correction tasks. Internal fields are not officially documented and may change at any time without publication in release changelogs. We strongly advice not to rely on internal fields as part of your regular operation and to investigate the implications of any planned changes *directly in the source code* of your current version of Parse Server. */ + maintenanceKey: string; + /* The URL to Parse Server.

âš ī¸ Certain server features or adapters may require Parse Server to be able to call itself by making requests to the URL set in `serverURL`. If a feature requires this, it is mentioned in the documentation. In that case ensure that the URL is accessible from the server itself. + :ENV: PARSE_SERVER_URL */ + serverURL: string; + /* Parse Server makes a HTTP request to the URL set in `serverURL` at the end of its launch routine to verify that the launch succeeded. If this option is set to `false`, the verification will be skipped. This can be useful in environments where the server URL is not accessible from the server itself, such as when running behind a firewall or in certain containerized environments.

âš ī¸ Server URL verification requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.

Default is `true`. + :DEFAULT: true */ + verifyServerUrl: ?boolean; + /* (Optional) Restricts the use of master key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the master key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the master key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['127.0.0.1', '::1']` which means that only `localhost`, the server instance on which Parse Server runs, is allowed to use the master key. + :DEFAULT: ["127.0.0.1","::1"] */ + masterKeyIps: ?(string[]); + /* (Optional) Restricts external client access to a list of allowed API routes.

When this option is set, all external non-master-key requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.

Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.

Examples of normalized route identifiers:
  • `classes/GameScore` (class CRUD)
  • `classes/GameScore/abc123` (object by ID)
  • `users` (user operations)
  • `login` (login endpoint)
  • `functions/sendEmail` (Cloud Function)
  • `jobs/cleanup` (Cloud Job)
  • `push` (push notifications)
  • `config` (client config)
  • `installations` (installations)
  • `files/picture.jpg` (file operations)
Example patterns:
  • `classes/ChatMessage` matches only `classes/ChatMessage`
  • `classes/Chat.*` matches `classes/ChatMessage`, `classes/ChatRoom`, etc.
  • `functions/.*` matches all Cloud Functions
Setting an empty array `[]` blocks all external non-master-key requests (full lockdown).

When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.

Defaults to `undefined` which means the feature is inactive and all routes are accessible. */ + routeAllowList: ?(string[]); + /* (Optional) Restricts the use of maintenance key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the maintenance key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the maintenance key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['127.0.0.1', '::1']` which means that only `localhost`, the server instance on which Parse Server runs, is allowed to use the maintenance key. + :DEFAULT: ["127.0.0.1","::1"] */ + maintenanceKeyIps: ?(string[]); + /* (Optional) Restricts the use of read-only master key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the read-only master key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the read-only master key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['0.0.0.0/0', '::0']` which means that any IP address is allowed to use the read-only master key. It is recommended to set this option to `['127.0.0.1', '::1']` to restrict access to `localhost`. + :DEFAULT: ["0.0.0.0/0","::0"] */ + readOnlyMasterKeyIps: ?(string[]); + /* Sets the app name */ + appName: ?string; + /* Add headers to Access-Control-Allow-Headers */ + allowHeaders: ?(string[]); + /* Sets origins for Access-Control-Allow-Origin. This can be a string for a single origin or an array of strings for multiple origins. */ + allowOrigin: ?StringOrStringArray; + /* Adapter module for the analytics */ + analyticsAdapter: ?Adapter; + /* Adapter module for the files sub-system */ + filesAdapter: ?Adapter; + /* Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications */ + push: ?any; + /* Configuration for push scheduling, defaults to false. + :DEFAULT: false */ + scheduledPush: ?boolean; + /* Adapter module for the logging sub-system */ + loggerAdapter: ?Adapter; + /* Log as structured JSON objects + :ENV: JSON_LOGS */ + jsonLogs: ?boolean; + /* Folder for the logs (defaults to './logs'); set to null to disable file based logging + :ENV: PARSE_SERVER_LOGS_FOLDER + :DEFAULT: ./logs */ + logsFolder: ?string; + /* Set the logging to verbose + :ENV: VERBOSE */ + verbose: ?boolean; + /* Sets the level for logs */ + logLevel: ?string; + /* (Optional) Overrides the log levels used internally by Parse Server to log events. + :DEFAULT: {} */ + logLevels: ?LogLevels; + /* 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) */ + maxLogFiles: ?NumberOrString; + /* Disables console output + :ENV: SILENT */ + silent: ?boolean; + /* The full URI to your database. Supported databases are mongodb or postgres. + :DEFAULT: mongodb://localhost:27017/parse */ + databaseURI: string; + /* Options to pass to the database client + :ENV: PARSE_SERVER_DATABASE_OPTIONS */ + databaseOptions: ?DatabaseOptions; + /* Adapter module for the database; any options that are not explicitly described here are passed directly to the database client. */ + databaseAdapter: ?Adapter; + /* Optional. If set to `true`, the collation rule of case comparison for queries and indexes is enabled. Enable this option to run Parse Server with MongoDB Atlas Serverless or AWS Amazon DocumentDB. If `false`, the collation rule of case comparison is disabled. Default is `false`. + :DEFAULT: false */ + enableCollationCaseComparison: ?boolean; + /* Optional. If set to `true`, the `email` property of a user is automatically converted to lowercase before being stored in the database. Consequently, queries must match the case as stored in the database, which would be lowercase in this scenario. If `false`, the `email` property is stored as set, without any case modifications. Default is `false`. + :DEFAULT: false */ + convertEmailToLowercase: ?boolean; + /* Optional. If set to `true`, the `username` property of a user is automatically converted to lowercase before being stored in the database. Consequently, queries must match the case as stored in the database, which would be lowercase in this scenario. If `false`, the `username` property is stored as set, without any case modifications. Default is `false`. + :DEFAULT: false */ + convertUsernameToLowercase: ?boolean; + /* Full path to your cloud code main.js */ + cloud: ?string; + /* A collection prefix for the classes + :DEFAULT: '' */ + collectionPrefix: ?string; + /* Key for iOS, MacOS, tvOS clients */ + clientKey: ?string; + /* Key for the Javascript SDK */ + javascriptKey: ?string; + /* Key for Unity and .Net SDK */ + dotNetKey: ?string; + /* Key for encrypting your files + :ENV: PARSE_SERVER_ENCRYPTION_KEY */ + encryptionKey: ?string; + /* Key for REST calls + :ENV: PARSE_SERVER_REST_API_KEY */ + restAPIKey: ?string; + /* Read-only key, which has the same capabilities as MasterKey without writes */ + readOnlyMasterKey: ?string; + /* Key sent with outgoing webhook calls */ + webhookKey: ?string; + /* Key for your files */ + fileKey: ?string; + /* Enable (or disable) the addition of a unique hash to the file names + :ENV: PARSE_SERVER_PRESERVE_FILE_NAME + :DEFAULT: false */ + preserveFileName: ?boolean; + /* Personally identifiable information fields in the user table the should be removed for non-authorized users. Deprecated @see protectedFields */ + userSensitiveFields: ?(string[]); + /* Fields per class that are hidden from query results for specific user groups. Protected fields are stripped from the server response, but can still be used internally (e.g. in Cloud Code triggers). Configure as `{ 'ClassName': { 'UserGroup': ['field1', 'field2'] } }` where `UserGroup` is one of: `'*'` (all users), `'authenticated'` (authenticated users), `'role:RoleName'` (users with a specific role), `'userField:FieldName'` (users referenced by a pointer field), or a user `objectId` to target a specific user. When multiple groups apply, the intersection of their protected fields is used. Any field can be protected, including system fields like `createdAt` and `updatedAt`. By default, `email` is protected on the `_User` class for all users. On the `_User` class, the object owner is exempt from protected fields by default; see `protectedFieldsOwnerExempt` to change this. + :DEFAULT: {"_User": {"*": ["email"]}} */ + protectedFields: ?ProtectedFields; + /* Whether the `_User` class is exempt from `protectedFields` when the logged-in user queries their own user object. If `true` (default), a user can see all their own fields regardless of `protectedFields` configuration; default protected fields (e.g. `email`) are merged into any custom `protectedFields` configuration. If `false`, `protectedFields` applies equally to the user's own object, consistent with all other classes; only explicitly configured protected fields apply, defaults are not merged. Defaults to `true`. + :ENV: PARSE_SERVER_PROTECTED_FIELDS_OWNER_EXEMPT + :DEFAULT: true */ + protectedFieldsOwnerExempt: ?boolean; + /* Whether Cloud Code triggers (e.g. `beforeSave`, `afterSave`) are exempt from `protectedFields`. If `true`, triggers receive the full object including protected fields in `request.object` and `request.original`, regardless of the caller's auth context. If `false`, protected fields are stripped from the original object fetch used to build trigger objects. Defaults to `false`. + :ENV: PARSE_SERVER_PROTECTED_FIELDS_TRIGGER_EXEMPT + :DEFAULT: false */ + protectedFieldsTriggerExempt: ?boolean; + /* Whether save operation responses (create, update) are exempt from `protectedFields`. If `true` (default), protected fields modified during a save are included in the response to the client. If `false`, protected fields are stripped from save responses, consistent with how they are stripped from query results. Defaults to `true`. + :ENV: PARSE_SERVER_PROTECTED_FIELDS_SAVE_RESPONSE_EXEMPT + :DEFAULT: true */ + protectedFieldsSaveResponseExempt: ?boolean; + /* Enable (or disable) anonymous users, defaults to true + :ENV: PARSE_SERVER_ENABLE_ANON_USERS + :DEFAULT: true */ + enableAnonymousUsers: ?boolean; + /* Enable (or disable) client class creation, defaults to false + :ENV: PARSE_SERVER_ALLOW_CLIENT_CLASS_CREATION + :DEFAULT: false */ + allowClientClassCreation: ?boolean; + /* Enable (or disable) custom objectId + :ENV: PARSE_SERVER_ALLOW_CUSTOM_OBJECT_ID + :DEFAULT: false */ + allowCustomObjectId: ?boolean; + /* Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication

Provider names must start with a letter and contain only letters, digits, and underscores (`/^[A-Za-z][A-Za-z0-9_]*$/`). This is because each provider name is used to construct a database field (`_auth_data_`), which must comply with Parse Server's field naming rules. + :ENV: PARSE_SERVER_AUTH_PROVIDERS */ + auth: ?{ [string]: AuthAdapter }; + /* Optional. Enables insecure authentication adapters. Insecure auth adapters are deprecated and will be removed in a future version. Defaults to `false`. + :ENV: PARSE_SERVER_ENABLE_INSECURE_AUTH_ADAPTERS + :DEFAULT: false */ + enableInsecureAuthAdapters: ?boolean; + /* Max file size for uploads, defaults to 20mb + :DEFAULT: 20mb */ + maxUploadSize: ?string; + /* Set to `true` to require users to verify their email address to complete the sign-up process. Supports a function with a return value of `true` or `false` for conditional verification. The function receives a request object that includes `createdWith` to indicate whether the invocation is for `signup` or `login` and the used auth provider. +

+ The `createdWith` values per scenario: +
  • Password signup: `{ action: 'signup', authProvider: 'password' }`
  • Auth provider signup: `{ action: 'signup', authProvider: '' }`
  • Password login: `{ action: 'login', authProvider: 'password' }`
  • Auth provider login: function not invoked; auth provider login bypasses email verification
  • Resend verification email: `createdWith` is `undefined`; use the `resendRequest` property to identify those
+ Default is `false`. + :DEFAULT: false */ + verifyUserEmails: ?(boolean | (EmailVerificationRequest => boolean | Promise)); + /* Set to `true` to prevent a user from logging in if the email has not yet been verified and email verification is required. Supports a function with a return value of `true` or `false` for conditional prevention. The function receives a request object that includes `createdWith` to indicate whether the invocation is for `signup` or `login` and the used auth provider. +

+ The `createdWith` values per scenario: +
  • Password signup: `{ action: 'signup', authProvider: 'password' }`
  • Auth provider signup: `{ action: 'signup', authProvider: '' }`
  • Password login: `{ action: 'login', authProvider: 'password' }`
  • Auth provider login: function not invoked; auth provider login bypasses email verification
+ Default is `false`. +
+ Requires option `verifyUserEmails: true`. + :DEFAULT: false */ + preventLoginWithUnverifiedEmail: ?( + | boolean + | (EmailVerificationRequest => boolean | Promise) + ); + /* If set to `true` it prevents a user from signing up if the email has not yet been verified and email verification is required. In that case the server responds to the sign-up with HTTP status 400 and a Parse Error 205 `EMAIL_NOT_FOUND`. If set to `false` the server responds with HTTP status 200, and client SDKs return an unauthenticated Parse User without session token. In that case subsequent requests fail until the user's email address is verified. +

+ Default is `false`. +
+ Requires option `verifyUserEmails: true`. + :DEFAULT: false */ + preventSignupWithUnverifiedEmail: ?boolean; + /* Set the validity duration of the email verification token in seconds after which the token expires. The token is used in the link that is set in the email. After the token expires, the link becomes invalid and a new link has to be sent. If the option is not set or set to `undefined`, then the token never expires. +

+ For example, to expire the token after 2 hours, set a value of 7200 seconds (= 60 seconds * 60 minutes * 2 hours). +

+ Default is `undefined`. +
+ Requires option `verifyUserEmails: true`. + */ + emailVerifyTokenValidityDuration: ?number; + /* Set to `true` if a email verification token should be reused in case another token is requested but there is a token that is still valid, i.e. has not expired. This avoids the often observed issue that a user requests multiple emails and does not know which link contains a valid token because each newly generated token would invalidate the previous token. +

+ Default is `false`. +
+ Requires option `verifyUserEmails: true`. + :DEFAULT: false */ + emailVerifyTokenReuseIfValid: ?boolean; + /* Set to `true` if a request to verify the email should return a success response even if the provided email address does not belong to a verifiable account, for example because it is unknown or already verified, or `false` if the request should return an error response in those cases. +

+ Default is `true`. +
+ Requires option `verifyUserEmails: true`. + :DEFAULT: true */ + emailVerifySuccessOnInvalidEmail: ?boolean; + /* Set to `false` to prevent sending of verification email. Supports a function with a return value of `true` or `false` for conditional email sending. +

+ Default is `true`. +
+ :DEFAULT: true */ + sendUserEmailVerification: ?( + | boolean + | (SendEmailVerificationRequest => boolean | Promise) + ); + /* The account lockout policy for failed login attempts. +

+ Note: Setting a user's ACL to an empty object `{}` via master key is a separate mechanism that only prevents new logins; it does not invalidate existing session tokens. To immediately revoke a user's access, destroy their sessions via master key in addition to setting the ACL. */ + accountLockout: ?AccountLockoutOptions; + /* The password policy for enforcing password related rules. */ + passwordPolicy: ?PasswordPolicyOptions; + /* Adapter module for the cache */ + cacheAdapter: ?Adapter; + /* Adapter module for email sending */ + emailAdapter: ?Adapter; + /* Optional. The public URL to Parse Server. This URL will be used to reach Parse Server publicly for features like password reset and email verification links. The option can be set to a string or a function that can be asynchronously resolved. The returned URL string must start with `http://` or `https://`. + :ENV: PARSE_PUBLIC_SERVER_URL */ + publicServerURL: ?(string | (() => string) | (() => Promise)); + /* The options for pages such as password reset and email verification. + :DEFAULT: {} */ + pages: ?PagesOptions; + /* custom pages for password validation and reset + :DEFAULT: {} */ + customPages: ?CustomPagesOptions; + /* parse-server's LiveQuery configuration object */ + liveQuery: ?LiveQueryOptions; + /* Session duration, in seconds, defaults to 1 year + :DEFAULT: 31536000 */ + sessionLength: ?number; + /* Whether Parse Server should automatically extend a valid session by the sessionLength. In order to reduce the number of session updates in the database, a session will only be extended when a request is received after at least half of the current session's lifetime has passed. + :DEFAULT: false */ + extendSessionOnUse: ?boolean; + /* Default value for limit option on queries, defaults to `100`. + :DEFAULT: 100 */ + defaultLimit: ?number; + /* Max value for limit option on queries, defaults to unlimited */ + maxLimit: ?number; + /* Sets whether we should expire the inactive sessions, defaults to true. If false, all new sessions are created with no expiration date. + :DEFAULT: true */ + expireInactiveSessions: ?boolean; + /* 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. + :DEFAULT: true */ + revokeSessionOnPasswordReset: ?boolean; + /* Sets the TTL for the in memory cache (in ms), defaults to 5000 (5 seconds) + :DEFAULT: 5000 */ + cacheTTL: ?number; + /* Sets the maximum size for the in memory cache, defaults to 10000 + :DEFAULT: 10000 */ + cacheMaxSize: ?number; + /* Set to `true` if Parse requests within the same Node.js environment as Parse Server should be routed to Parse Server directly instead of via the HTTP interface. Default is `false`. +

+ If set to `false` then Parse requests within the same Node.js environment as Parse Server are executed as HTTP requests sent to Parse Server via the `serverURL`. For example, a `Parse.Query` in Cloud Code is calling Parse Server via a HTTP request. The server is essentially making a HTTP request to itself, unnecessarily using network resources such as network ports. +

+ âš ī¸ In environments where multiple Parse Server instances run behind a load balancer and Parse requests within the current Node.js environment should be routed via the load balancer and distributed as HTTP requests among all instances via the `serverURL`, this should be set to `false`. + :DEFAULT: true */ + directAccess: ?boolean; + /* Enables the default express error handler for all errors + :DEFAULT: false */ + enableExpressErrorHandler: ?boolean; + /* Deprecated. Enables the legacy product purchase API including the `_Product` class and the `/validate_purchase` endpoint. This is an undocumented, unmaintained legacy feature inherited from the original Parse platform that may not function as expected. We strongly advise against using it. It will be removed in a future major version. + :ENV: PARSE_SERVER_ENABLE_PRODUCT_PURCHASE_LEGACY_API + :DEFAULT: true */ + enableProductPurchaseLegacyApi: ?boolean; + /* Sets the number of characters in generated object id's, default 10 + :DEFAULT: 10 */ + objectIdSize: ?number; + /* The port to run the ParseServer, defaults to 1337. + :ENV: PORT + :DEFAULT: 1337 */ + port: ?number; + /* The host to serve ParseServer on, defaults to 0.0.0.0 + :DEFAULT: 0.0.0.0 */ + host: ?string; + /* Mount path for the server, defaults to /parse + :DEFAULT: /parse */ + mountPath: ?string; + /* Run with cluster, optionally set the number of processes default to os.cpus().length */ + cluster: ?NumberOrBoolean; + /* middleware for express server, can be string or function */ + middleware: ?((() => void) | string); + /* The trust proxy settings. It is important to understand the exact setup of the reverse proxy, since this setting will trust values provided in the Parse Server API request. See the express trust proxy settings documentation. Defaults to `false`. + :DEFAULT: false */ + trustProxy: ?any; + /* Starts the liveQuery server */ + 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; + /* Options for file uploads + :ENV: PARSE_SERVER_FILE_UPLOAD_OPTIONS + :DEFAULT: {} */ + fileUpload: ?FileUploadOptions; + /* Options for file downloads + :ENV: PARSE_SERVER_FILE_DOWNLOAD_OPTIONS + :DEFAULT: {} */ + fileDownload: ?FileDownloadOptions; + /* Full path to your GraphQL custom schema.graphql file */ + graphQLSchema: ?string; + /* Mounts the GraphQL endpoint + :ENV: PARSE_SERVER_MOUNT_GRAPHQL + :DEFAULT: false */ + mountGraphQL: ?boolean; + /* The mount path for the GraphQL endpoint

âš ī¸ File upload inside the GraphQL mutation system requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.

Defaults is `/graphql`. + :ENV: PARSE_SERVER_GRAPHQL_PATH + :DEFAULT: /graphql */ + graphQLPath: ?string; + /* Enable public introspection for the GraphQL endpoint, defaults to false + :ENV: PARSE_SERVER_GRAPHQL_PUBLIC_INTROSPECTION + :DEFAULT: false */ + graphQLPublicIntrospection: ?boolean; + /* Deprecated. Mounts the GraphQL Playground which is deprecated and will be removed in a future version. The playground exposes the master key in the browser. Use Parse Dashboard as GraphQL IDE or configure a third-party GraphQL client with custom request headers. + :ENV: PARSE_SERVER_MOUNT_PLAYGROUND + :DEFAULT: false */ + mountPlayground: ?boolean; + /* Deprecated. Mount path for the GraphQL Playground. The playground is deprecated and will be removed in a future version. + :ENV: PARSE_SERVER_PLAYGROUND_PATH + :DEFAULT: /playground */ + playgroundPath: ?string; + /* Defined schema + :ENV: PARSE_SERVER_SCHEMA + */ + schema: ?SchemaOptions; + /* Callback when server has closed */ + serverCloseComplete: ?() => void; + /* Options to limit the complexity of requests to prevent denial-of-service attacks. Limits are enforced for all requests except those using the master or maintenance key. Each property can be set to `-1` to disable that specific limit. + :ENV: PARSE_SERVER_REQUEST_COMPLEXITY + :DEFAULT: {} */ + requestComplexity: ?RequestComplexityOptions; + /* Options controlling how Parse Server deduplicates `_Installation` records that share the same `deviceToken`. + :ENV: PARSE_SERVER_INSTALLATION + :DEFAULT: {} */ + installation: ?InstallationOptions; + /* Query-related server defaults. + :ENV: PARSE_SERVER_QUERY + :DEFAULT: {} */ + query: ?QueryServerOptions; + /* The security options to identify and report weak security settings. + :DEFAULT: {} */ + security: ?SecurityOptions; + /* Set to true if new users should be created without public read and write access. + :DEFAULT: true */ + enforcePrivateUsers: ?boolean; + /* Deprecated. This option will be removed in a future version. Auth providers are always validated on login. On update, if this is set to `true`, auth providers are only re-validated when the auth data has changed. If this is set to `false`, auth providers are re-validated on every update. Defaults to `false`. + :DEFAULT: false */ + allowExpiredAuthDataToken: ?boolean; + /* An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns. + :DEFAULT: [{"key":"_bsontype","value":"Code"},{"key":"constructor"},{"key":"__proto__"}] */ + requestKeywordDenylist: ?(RequestKeywordDenylist[]); + /* Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

â„šī¸ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case. + :DEFAULT: [] */ + rateLimit: ?(RateLimitOptions[]); + /* Options to customize the request context using inversion of control/dependency injection.*/ + requestContextMiddleware: ?(req: any, res: any, next: any) => void; + /* If set to `true`, error details are removed from error messages in responses to client requests, and instead a generic error message is sent. Default is `true`. + :DEFAULT: true */ + enableSanitizedErrorResponse: ?boolean; +} + +export interface RateLimitOptions { + /* The path of the API route to be rate limited. Route paths, in combination with a request method, define the endpoints at which requests can be made. Route paths can be strings or string patterns following path-to-regexp v8 syntax. */ + requestPath: string; + /* The window of time in milliseconds within which the number of requests set in `requestCount` can be made before the rate limit is applied. */ + requestTimeWindow: ?number; + /* The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied. For batch requests, this also limits the number of sub-requests in a single batch that target this path; however, requests already consumed in the current time window are not counted against the batch, so the effective limit may be higher when combining individual and batch requests. Note that this is a basic server-level rate limit; for comprehensive protection, use a reverse proxy or WAF for rate limiting. */ + requestCount: ?number; + /* The error message that should be returned in the body of the HTTP 429 response when the rate limit is hit. Default is `Too many requests.`. + :DEFAULT: Too many requests. */ + errorResponseMessage: ?string; + /* Optional, the HTTP request methods to which the rate limit should be applied, default is all methods. */ + requestMethods: ?(string[]); + /* Optional, if `true` the rate limit will also apply to requests using the `masterKey`, default is `false`. Note that a public Cloud Code function that triggers internal requests using the `masterKey` may circumvent rate limiting and be vulnerable to attacks. + :DEFAULT: false */ + includeMasterKey: ?boolean; + /* Optional, if `true` the rate limit will also apply to requests that are made in by Cloud Code, default is `false`. Note that a public Cloud Code function that triggers internal requests may circumvent rate limiting and be vulnerable to attacks. + :DEFAULT: false */ + includeInternalRequests: ?boolean; + /* Optional, the URL of the Redis server to store rate limit data. This allows to rate limit requests for multiple servers by calculating the sum of all requests across all servers. This is useful if multiple servers are processing requests behind a load balancer. For example, the limit of 10 requests is reached if each of 2 servers processed 5 requests. + */ + redisUrl: ?string; + /* The type of rate limit to apply. The following types are supported: +
    +
  • `global`: rate limit based on the number of requests made by all users
  • +
  • `ip`: rate limit based on the IP address of the request
  • +
  • `user`: rate limit based on the user ID of the request
  • +
  • `session`: rate limit based on the session token of the request
  • +
+ Default is `ip`. + :DEFAULT: ip */ + zone: ?string; +} + +export interface RequestComplexityOptions { + /* Whether to allow the `$regex` query operator. Set to `false` to reject `$regex` in queries for non-master-key users. Default is `true`. + :ENV: PARSE_SERVER_REQUEST_COMPLEXITY_ALLOW_REGEX + :DEFAULT: true */ + allowRegex: ?boolean; + /* Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `-1`. + :DEFAULT: -1 */ + includeDepth: ?number; + /* Maximum number of include paths in a single query. Set to `-1` to disable. Default is `-1`. + :DEFAULT: -1 */ + includeCount: ?number; + /* Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `-1`. + :DEFAULT: -1 */ + subqueryDepth: ?number; + /* Maximum number of results returned by a `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subquery. Set to `-1` to disable. Default is `-1`. + :DEFAULT: -1 */ + subqueryLimit: ?number; + /* Maximum nesting depth of `$or`, `$and`, `$nor` query operators. Set to `-1` to disable. Default is `-1`. + :DEFAULT: -1 */ + queryDepth: ?number; + /* Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `-1`. + :ENV: PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_DEPTH + :DEFAULT: -1 */ + graphQLDepth: ?number; + /* Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `-1`. + :ENV: PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_FIELDS + :DEFAULT: -1 */ + graphQLFields: ?number; + /* Maximum number of sub-requests in a single batch request. Set to `-1` to disable. Default is `-1`. + :DEFAULT: -1 */ + batchRequestLimit: ?number; +} + +export interface InstallationOptions { + /* Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Default is `false`. + :DEFAULT: false */ + duplicateDeviceTokenActionEnforceAuth: ?boolean; + /* What Parse Server does to the conflicting `_Installation` row(s) when a new install's `deviceToken` collides with an existing row. `'delete'` destroys the conflicting row. `'update'` clears the now-conflicting ID field on the conflicting row, preserving custom fields, channels, and history. Default is `'delete'`. + :DEFAULT: delete */ + duplicateDeviceTokenAction: ?string; + /* At the merge case (when an existing row holds the new `deviceToken` but has no `installationId` of its own), which side wins. `'deviceToken'` — the deviceToken-only row survives, the request's `idMatch` row is the loser. `'installationId'` — the request's `idMatch` (active install) survives, the deviceToken-only orphan is the loser. Default is `'deviceToken'`. + :DEFAULT: deviceToken */ + duplicateDeviceTokenMergePriority: ?string; +} + +export interface SecurityOptions { + /* Is true if Parse Server should check for weak security settings. + :DEFAULT: false */ + enableCheck: ?boolean; + /* Is true if the security check report should be written to logs. This should only be enabled temporarily to not expose weak security settings in logs. + :DEFAULT: false */ + enableCheckLog: ?boolean; + /* The security check groups to run. This allows to add custom security checks or override existing ones. Default are the groups defined in `CheckGroups.js`. */ + checkGroups: ?(CheckGroup[]); +} + +export interface QueryServerOptions { + /* When `true`, all aggregation queries default to using MongoDB Extended JSON (EJSON) for explicit value typing and skip schema-based value coercion. Individual queries can still override this via the `rawValues` option. Default is `false`. + :ENV: PARSE_SERVER_QUERY_AGGREGATION_RAW_VALUES + :DEFAULT: false */ + aggregationRawValues: ?boolean; + /* When `true`, all aggregation queries default to using native MongoDB field names (no automatic `createdAt` → `_created_at` rewriting). Individual queries can still override this via the `rawFieldNames` option. Default is `false`. + :ENV: PARSE_SERVER_QUERY_AGGREGATION_RAW_FIELD_NAMES + :DEFAULT: false */ + aggregationRawFieldNames: ?boolean; +} + +export interface PagesOptions { + /* Is true if pages should be localized; this has no effect on custom page redirects. + :DEFAULT: false */ + enableLocalization: ?boolean; + /* The path to the JSON file for localization; the translations will be used to fill template placeholders according to the locale. */ + localizationJsonPath: ?string; + /* The fallback locale for localization if no matching translation is provided for the given locale. This is only relevant when providing translation resources via JSON file. + :DEFAULT: en */ + localizationFallbackLocale: ?string; + /* The placeholder keys and values which will be filled in pages; this can be a simple object or a callback function. + :DEFAULT: {} */ + placeholders: ?Object; + /* Is true if responses should always be redirects and never content, false if the response type should depend on the request type (GET request -> content response; POST request -> redirect response). + :DEFAULT: false */ + forceRedirect: ?boolean; + /* The path to the pages directory; this also defines where the static endpoint '/apps' points to. Default is the './public/' directory of the parse-server module. */ + pagesPath: ?string; + /* The API endpoint for the pages. Default is 'apps'. + :DEFAULT: apps */ + pagesEndpoint: ?string; + /* The URLs to the custom pages. + :DEFAULT: {} */ + customUrls: ?PagesCustomUrlsOptions; + /* The custom routes. + :DEFAULT: [] */ + customRoutes: ?(PagesRoute[]); + /* Is `true` if the page parameter headers should be URI-encoded. This is required if any page parameter value contains non-ASCII characters, such as the app name. + :DEFAULT: false */ + encodePageParamHeaders: ?boolean; +} + +export interface PagesRoute { + /* The route path. */ + path: string; + /* The route method, e.g. 'GET' or 'POST'. */ + method: string; + /* The route handler that is an async function. */ + handler: () => void; +} + +export interface PagesCustomUrlsOptions { + /* The URL to the custom page for password reset. */ + passwordReset: ?string; + /* The URL to the custom page for password reset -> link invalid. */ + passwordResetLinkInvalid: ?string; + /* The URL to the custom page for password reset -> success. */ + passwordResetSuccess: ?string; + /* The URL to the custom page for email verification -> success. */ + emailVerificationSuccess: ?string; + /* The URL to the custom page for email verification -> link send fail. */ + emailVerificationSendFail: ?string; + /* The URL to the custom page for email verification -> resend link -> success. */ + emailVerificationSendSuccess: ?string; + /* The URL to the custom page for email verification -> link invalid. */ + emailVerificationLinkInvalid: ?string; + /* The URL to the custom page for email verification -> link expired. */ + emailVerificationLinkExpired: ?string; +} + +export interface CustomPagesOptions { + /* invalid link page path */ + invalidLink: ?string; + /* verification link send fail page path */ + linkSendFail: ?string; + /* choose password page path */ + choosePassword: ?string; + /* verification link send success page path */ + linkSendSuccess: ?string; + /* verify email success page path */ + verifyEmailSuccess: ?string; + /* password reset success page path */ + passwordResetSuccess: ?string; + /* invalid verification link page path */ + invalidVerificationLink: ?string; + /* expired verification link page path */ + expiredVerificationLink: ?string; + /* invalid password reset link page path */ + invalidPasswordResetLink: ?string; + /* for masking user-facing pages */ + parseFrameURL: ?string; +} + +export interface LiveQueryOptions { + /* parse-server's LiveQuery classNames + :ENV: PARSE_SERVER_LIVEQUERY_CLASSNAMES */ + classNames: ?(string[]); + /* parse-server's LiveQuery redisOptions */ + redisOptions: ?any; + /* parse-server's LiveQuery redisURL */ + redisURL: ?string; + /* LiveQuery pubsub adapter */ + pubSubAdapter: ?Adapter; + /* Sets the maximum execution time in milliseconds for regular expression pattern matching in LiveQuery. This protects against Regular Expression Denial of Service (ReDoS) attacks where a malicious regex pattern could block the event loop. A regex that exceeds the timeout will be treated as non-matching.

The protection runs each regex evaluation in an isolated VM context with a timeout. This adds approximately 50 microseconds of overhead per regex evaluation. For most applications this is negligible, but it can add up if you have a very large number of LiveQuery subscriptions that use `$regex` on the same class. For example, 10,000 concurrent regex subscriptions would add approximately 500ms of processing time per object save event on that class.

Set to `0` to disable the timeout and use native regex evaluation without protection. Defaults to `100`. + :DEFAULT: 100 */ + regexTimeout: ?number; + /* Adapter module for the WebSocketServer */ + wssAdapter: ?Adapter; +} + +export interface LiveQueryServerOptions { + /* 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.*/ + appId: ?string; + /* 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.*/ + masterKey: ?string; + /* 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.*/ + serverURL: ?string; + /* 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.*/ + keyPairs: ?any; + /* 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).*/ + websocketTimeout: ?number; + /* 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 5 * 1000 ms (5 seconds).*/ + cacheTimeout: ?number; + /* This string defines the log level of the LiveQuery server. We support VERBOSE, INFO, ERROR, NONE, defaults to INFO.*/ + logLevel: ?string; + /* The port to run the LiveQuery server, defaults to 1337. + :DEFAULT: 1337 */ + port: ?number; + /* parse-server's LiveQuery redisOptions */ + redisOptions: ?any; + /* parse-server's LiveQuery redisURL */ + redisURL: ?string; + /* LiveQuery pubsub adapter */ + pubSubAdapter: ?Adapter; + /* 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; +} + +export interface AccountLockoutOptions { + /* Set the duration in minutes that a locked-out account remains locked out before automatically becoming unlocked. +

+ Valid values are greater than `0` and less than `100000`. */ + duration: ?number; + /* Set the number of failed sign-in attempts that will cause a user account to be locked. If the account is locked. The account will unlock after the duration set in the `duration` option has passed and no further login attempts have been made. +

+ Valid values are greater than `0` and less than `1000`. */ + threshold: ?number; + /* Set to `true` if the account should be unlocked after a successful password reset. +

+ Default is `false`. +
+ Requires options `duration` and `threshold` to be set. + :DEFAULT: false */ + unlockOnPasswordReset: ?boolean; +} + +export interface PasswordPolicyOptions { + /* Set the regular expression validation pattern a password must match to be accepted. +

+ If used in combination with `validatorCallback`, the password must pass both to be accepted. */ + validatorPattern: ?string; + /* */ + /* Set a callback function to validate a password to be accepted. +

+ If used in combination with `validatorPattern`, the password must pass both to be accepted. */ + validatorCallback: ?() => void; + /* Set the error message to be sent. +

+ Default is `Password does not meet the Password Policy requirements.` */ + validationError: ?string; + /* Set to `true` to disallow the username as part of the password. +

+ Default is `false`. + :DEFAULT: false */ + doNotAllowUsername: ?boolean; + /* Set the number of days after which a password expires. Login attempts fail if the user does not reset the password before expiration. */ + maxPasswordAge: ?number; + /* Set the number of previous password that will not be allowed to be set as new password. If the option is not set or set to `0`, no previous passwords will be considered. +

+ Valid values are >= `0` and <= `20`. +
+ Default is `0`. + */ + maxPasswordHistory: ?number; + /* Set the validity duration of the password reset token in seconds after which the token expires. The token is used in the link that is set in the email. After the token expires, the link becomes invalid and a new link has to be sent. If the option is not set or set to `undefined`, then the token never expires. +

+ For example, to expire the token after 2 hours, set a value of 7200 seconds (= 60 seconds * 60 minutes * 2 hours). +

+ Default is `undefined`. + */ + resetTokenValidityDuration: ?number; + /* Set to `true` if a password reset token should be reused in case another token is requested but there is a token that is still valid, i.e. has not expired. This avoids the often observed issue that a user requests multiple emails and does not know which link contains a valid token because each newly generated token would invalidate the previous token. +

+ Default is `false`. + :DEFAULT: false */ + resetTokenReuseIfValid: ?boolean; + /* Set to `true` if a request to reset the password should return a success response even if the provided email address is invalid, or `false` if the request should return an error response if the email address is invalid. +

+ Default is `true`. + :DEFAULT: true */ + resetPasswordSuccessOnInvalidEmail: ?boolean; +} + +export interface FileUploadOptions { + /* Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.

It is recommended to only allow the file extensions that your app actually needs, rather than relying on blocking dangerous extensions. This allowlist approach is more secure because new dangerous file extensions may emerge that are not covered by the default blocklist.

The default blocks the most common file extensions that are known to be rendered as active content by web browsers, such as HTML, SVG, and XML files, which may be used by an attacker to compromise the session token of another user via accessing the browser's local storage. The blocked extensions are: `html`, `htm`, `shtml`, `xhtml`, `xhtml+xml`, `xht`, `svg`, `svgz`, `svg+xml`, `xml`, `xsl`, `xslt`, `xslt+xml`, `xsd`, `rng`, `rdf`, `rdf+xml`, `owl`, `mathml`, `mathml+xml`.

Defaults to `["^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?|[xX][sS][dD]|[rR][nN][gG]|[rR][dD][fF](\\+[xX][mM][lL])?|[oO][wW][lL]|[mM][aA][tT][hH][mM][lL](\\+[xX][mM][lL])?)$)"]`. + :DEFAULT: ["^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?|[xX][sS][dD]|[rR][nN][gG]|[rR][dD][fF](\\+[xX][mM][lL])?|[oO][wW][lL]|[mM][aA][tT][hH][mM][lL](\\+[xX][mM][lL])?)$)"] */ + fileExtensions: ?(string[]); + /* Is true if file upload should be allowed for anonymous users. + :DEFAULT: false */ + enableForAnonymousUser: ?boolean; + /* Is true if file upload should be allowed for authenticated users. + :DEFAULT: true */ + enableForAuthenticatedUser: ?boolean; + /* Is true if file upload should be allowed for anyone, regardless of user authentication. + :DEFAULT: false */ + enableForPublic: ?boolean; + /* Sets the allowed hostnames for file URLs referenced in Parse objects. When a File object includes a URL, its hostname must match one of these entries to be accepted. Supports exact hostnames (e.g., `'cdn.example.com'`) and wildcard subdomains (e.g., `'*.example.com'`). Use `['*']` to allow any domain. Use `[]` to block all file URLs (only name-based files allowed). + :DEFAULT: ["*"] */ + allowedFileUrlDomains: ?(string[]); +} + +export interface FileDownloadOptions { + /* Is true if file download should be allowed for anonymous users. + :DEFAULT: true */ + enableForAnonymousUser: ?boolean; + /* Is true if file download should be allowed for authenticated users. + :DEFAULT: true */ + enableForAuthenticatedUser: ?boolean; + /* Is true if file download should be allowed for anyone, regardless of user authentication. + :DEFAULT: true */ + enableForPublic: ?boolean; +} + +/* The available log levels for Parse Server logging. Valid values are:
- `'error'` - Error level (highest priority)
- `'warn'` - Warning level
- `'info'` - Info level (default)
- `'verbose'` - Verbose level
- `'debug'` - Debug level
- `'silly'` - Silly level (lowest priority) */ +export interface LogLevel { + /* Error level - highest priority */ + error: 'error'; + /* Warning level */ + warn: 'warn'; + /* Info level - default */ + info: 'info'; + /* Verbose level */ + verbose: 'verbose'; + /* Debug level */ + debug: 'debug'; + /* Silly level - lowest priority */ + silly: 'silly'; +} + +export interface LogClientEvent { + /* The MongoDB driver event name to listen for. See the [MongoDB driver events documentation](https://www.mongodb.com/docs/drivers/node/current/fundamentals/monitoring/) for available events. */ + name: string; + /* Optional array of dot-notation paths to extract specific data from the event object. If not provided or empty, the entire event object will be logged. */ + keys: ?(string[]); + /* The log level to use for this event. See [LogLevel](LogLevel.html) for available values. Defaults to `'info'`. + :DEFAULT: info */ + logLevel: ?string; +} + +export interface DatabaseOptions { + /* Enables database real-time hooks to update single schema cache. Set to `true` if using multiple Parse Servers instances connected to the same database. Failing to do so will cause a schema change to not propagate to all instances and re-syncing will only happen when the instances restart. To use this feature with MongoDB, a replica set cluster with [change stream](https://docs.mongodb.com/manual/changeStreams/#availability) support is required. + :DEFAULT: false */ + enableSchemaHooks: ?boolean; + /* The duration in seconds after which the schema cache expires and will be refetched from the database. Use this option if using multiple Parse Servers instances connected to the same database. A low duration will cause the schema cache to be updated too often, causing unnecessary database reads. A high duration will cause the schema to be updated too rarely, increasing the time required until schema changes propagate to all server instances. This feature can be used as an alternative or in conjunction with the option `enableSchemaHooks`. Default is infinite which means the schema cache never expires. */ + schemaCacheTtl: ?number; + /* The MongoDB driver option to set whether to retry failed writes. */ + retryWrites: ?boolean; + /* The number of documents per batch for MongoDB cursor `getMore` operations. A lower value reduces memory usage per batch; a higher value reduces the number of network round-trips. + :DEFAULT: 1000 */ + batchSize: ?number; + /* The MongoDB driver option to set a cumulative time limit in milliseconds for processing operations on a cursor. */ + maxTimeMS: ?number; + /* The MongoDB driver option to set the maximum replication lag for reads from secondary nodes.*/ + maxStalenessSeconds: ?number; + /* The MongoDB driver option to set the minimum number of opened, cached, ready-to-use database connections maintained by the driver. */ + minPoolSize: ?number; + /* The MongoDB driver option to set the maximum number of opened, cached, ready-to-use database connections maintained by the driver. */ + maxPoolSize: ?number; + /* The MongoDB driver option to specify the amount of time in milliseconds for a server to be considered suitable for selection. */ + serverSelectionTimeoutMS: ?number; + /* The MongoDB driver option to specify the amount of time in milliseconds that a connection can remain idle in the connection pool before being removed and closed. */ + maxIdleTimeMS: ?number; + /* The MongoDB driver option to specify the frequency in milliseconds at which the driver checks the state of the MongoDB deployment. */ + heartbeatFrequencyMS: ?number; + /* The MongoDB driver option to specify the amount of time, in milliseconds, to wait to establish a single TCP socket connection to the server before raising an error. Specifying 0 disables the connection timeout. */ + connectTimeoutMS: ?number; + /* The MongoDB driver option to specify the amount of time, in milliseconds, spent attempting to send or receive on a socket before timing out. Specifying 0 means no timeout. */ + socketTimeoutMS: ?number; + /* The MongoDB driver option to set whether the socket attempts to connect to IPv6 and IPv4 addresses until a connection is established. If available, the driver will select the first IPv6 address. */ + autoSelectFamily: ?boolean; + /* The MongoDB driver option to specify the amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the autoSelectFamily option. If set to a positive integer less than 10, the value 10 is used instead. */ + autoSelectFamilyAttemptTimeout: ?number; + /* The MongoDB driver option to specify the maximum number of connections that may be in the process of being established concurrently by the connection pool. */ + maxConnecting: ?number; + /* The MongoDB driver option to specify the maximum time in milliseconds that a thread can wait for a connection to become available. */ + waitQueueTimeoutMS: ?number; + /* The MongoDB driver option to specify the name of the replica set, if the mongod is a member of a replica set. */ + replicaSet: ?string; + /* The MongoDB driver option to force a Single topology type with a connection string containing one host. */ + directConnection: ?boolean; + /* The MongoDB driver option to instruct the driver it is connecting to a load balancer fronting a mongos like service. */ + loadBalanced: ?boolean; + /* The MongoDB driver option to specify the size (in milliseconds) of the latency window for selecting among multiple suitable MongoDB instances. */ + localThresholdMS: ?number; + /* The MongoDB driver option to specify the maximum number of hosts to connect to when using an srv connection string, a setting of 0 means unlimited hosts. */ + srvMaxHosts: ?number; + /* The MongoDB driver option to modify the srv URI service name. */ + srvServiceName: ?string; + /* The MongoDB driver option to enable or disable TLS/SSL for the connection. */ + tls: ?boolean; + /* The MongoDB driver option to enable or disable TLS/SSL for the connection (equivalent to tls option). */ + ssl: ?boolean; + /* The MongoDB driver option to specify the location of a local .pem file that contains the client's TLS/SSL certificate and key. */ + tlsCertificateKeyFile: ?string; + /* The MongoDB driver option to specify the password to decrypt the tlsCertificateKeyFile. */ + tlsCertificateKeyFilePassword: ?string; + /* The MongoDB driver option to specify the location of a local .pem file that contains the root certificate chain from the Certificate Authority. */ + tlsCAFile: ?string; + /* The MongoDB driver option to bypass validation of the certificates presented by the mongod/mongos instance. */ + tlsAllowInvalidCertificates: ?boolean; + /* The MongoDB driver option to disable hostname validation of the certificate presented by the mongod/mongos instance. */ + tlsAllowInvalidHostnames: ?boolean; + /* The MongoDB driver option to disable various certificate validations. */ + tlsInsecure: ?boolean; + /* The MongoDB driver option to specify an array or comma-delimited string of compressors to enable network compression for communication between this client and a mongod/mongos instance. */ + compressors: ?(string[] | string); + /* The MongoDB driver option to specify the compression level if using zlib for network compression (0-9). */ + zlibCompressionLevel: ?number; + /* The MongoDB driver option to specify the read preferences for this connection. */ + readPreference: ?string; + /* The MongoDB driver option to specify the tags document as a comma-separated list of colon-separated key-value pairs. */ + readPreferenceTags: ?(any[]); + /* The MongoDB driver option to specify the level of isolation. */ + readConcernLevel: ?string; + /* The MongoDB driver option to specify the database name associated with the user's credentials. */ + authSource: ?string; + /* The MongoDB driver option to specify the authentication mechanism that MongoDB will use to authenticate the connection. */ + authMechanism: ?string; + /* The MongoDB driver option to specify properties for the specified authMechanism as a comma-separated list of colon-separated key-value pairs. */ + authMechanismProperties: ?any; + /* The MongoDB driver option to specify the name of the application that created this MongoClient instance. */ + appName: ?string; + /* The MongoDB driver option to enable retryable reads. */ + retryReads: ?boolean; + /* The MongoDB driver option to force server to assign _id values instead of driver. */ + forceServerObjectId: ?boolean; + /* The MongoDB driver option to instruct the driver monitors to use a specific monitoring mode. */ + serverMonitoringMode: ?string; + /* The MongoDB driver option to configure a Socks5 proxy host used for creating TCP connections. */ + proxyHost: ?string; + /* The MongoDB driver option to configure a Socks5 proxy port used for creating TCP connections. */ + proxyPort: ?number; + /* The MongoDB driver option to configure a Socks5 proxy username when the proxy requires username/password authentication. */ + proxyUsername: ?string; + /* The MongoDB driver option to configure a Socks5 proxy password when the proxy requires username/password authentication. */ + proxyPassword: ?string; + /* Set to `true` to automatically create indexes on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

âš ī¸ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + :DEFAULT: true */ + createIndexUserEmail: ?boolean; + /* Set to `true` to automatically create a case-insensitive index on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

âš ī¸ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + :DEFAULT: true */ + createIndexUserEmailCaseInsensitive: ?boolean; + /* Set to `true` to automatically create an index on the _email_verify_token field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

âš ī¸ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + :DEFAULT: true */ + createIndexUserEmailVerifyToken: ?boolean; + /* Set to `true` to automatically create an index on the _perishable_token field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

âš ī¸ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + :DEFAULT: true */ + createIndexUserPasswordResetToken: ?boolean; + /* Set to `true` to automatically create indexes on the username field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

âš ī¸ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + :DEFAULT: true */ + createIndexUserUsername: ?boolean; + /* Set to `true` to automatically create a case-insensitive index on the username field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

âš ī¸ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + :DEFAULT: true */ + createIndexUserUsernameCaseInsensitive: ?boolean; + /* Set to `true` to automatically create unique indexes on the authData fields of the _User collection for each configured auth provider on server start, including `anonymous` when anonymous users are enabled. These indexes prevent race conditions during concurrent signups with the same authData. Set to `false` to skip index creation. Default is `true`.

âš ī¸ When setting this option to `false` to manually create the indexes, keep in mind that the otherwise automatically created indexes may change in the future to be optimized for the internal usage by Parse Server. + :DEFAULT: true */ + createIndexAuthDataUniqueness: ?boolean; + /* Set to `true` to automatically create a unique index on the name field of the _Role collection on server start. Set to `false` to skip index creation. Default is `true`.

âš ī¸ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. + :DEFAULT: true */ + createIndexRoleName: ?boolean; + /* Set to `true` to disable validation of index fields. When disabled, indexes can be created even if the fields do not exist in the schema. This can be useful when creating indexes on fields that will be added later. */ + disableIndexFieldValidation: ?boolean; + /* Set to `true` to allow `Parse.Query.explain` without master key.

âš ī¸ Enabling this option may expose sensitive query performance data to unauthorized users and could potentially be exploited for malicious purposes. + :DEFAULT: false */ + allowPublicExplain: ?boolean; + /* An array of MongoDB client event configurations to enable logging of specific events. */ + logClientEvents: ?(LogClientEvent[]); + /* Custom metadata to append to database client connections for identifying Parse Server instances in database logs. If set, this metadata will be visible in database logs during connection handshakes. This can help with debugging and monitoring in deployments with multiple database clients. Set `name` to identify your application (e.g., 'MyApp') and `version` to your application's version. Leave undefined (default) to disable this feature and avoid the additional data transfer overhead. */ + clientMetadata: ?DatabaseOptionsClientMetadata; +} + +export interface DatabaseOptionsClientMetadata { + /* The name to identify your application in database logs (e.g., 'MyApp'). */ + name: string; + /* The version of your application (e.g., '1.0.0'). */ + version: string; +} + +export interface AuthAdapter { + /* Is `true` if the auth adapter is enabled, `false` otherwise. + :DEFAULT: false + :ENV: + */ + enabled: ?boolean; +} + +export interface LogLevels { + /* Log level used by the Cloud Code Triggers `afterSave`, `afterDelete`, `afterFind`, `afterLogout`. Default is `info`. See [LogLevel](LogLevel.html) for available values. + :DEFAULT: info + */ + triggerAfter: ?string; + /* Log level used by the Cloud Code Triggers `beforeSave`, `beforeDelete`, `beforeFind`, `beforeLogin` on success. Default is `info`. See [LogLevel](LogLevel.html) for available values. + :DEFAULT: info + */ + triggerBeforeSuccess: ?string; + /* Log level used by the Cloud Code Triggers `beforeSave`, `beforeDelete`, `beforeFind`, `beforeLogin` on error. Default is `error`. See [LogLevel](LogLevel.html) for available values. + :DEFAULT: error + */ + triggerBeforeError: ?string; + /* Log level used by the Cloud Code Functions on success. Default is `info`. See [LogLevel](LogLevel.html) for available values. + :DEFAULT: info + */ + cloudFunctionSuccess: ?string; + /* Log level used by the Cloud Code Functions on error. Default is `error`. See [LogLevel](LogLevel.html) for available values. + :DEFAULT: error + */ + cloudFunctionError: ?string; + /* Log level used when a sign-up fails because the username already exists. Default is `info`. See [LogLevel](LogLevel.html) for available values. + :DEFAULT: info + */ + signupUsernameTaken: ?string; +} diff --git a/src/Options/parsers.js b/src/Options/parsers.js new file mode 100644 index 0000000000..d2f0b7b6fb --- /dev/null +++ b/src/Options/parsers.js @@ -0,0 +1,95 @@ +function numberParser(key) { + return function (opt) { + const intOpt = parseInt(opt); + if (!Number.isInteger(intOpt)) { + throw new Error(`Key ${key} has invalid value ${opt}`); + } + return intOpt; + }; +} + +function numberOrBoolParser(key) { + return function (opt) { + if (typeof opt === 'boolean') { + return opt; + } + if (opt === 'true') { + return true; + } + if (opt === 'false') { + return false; + } + return numberParser(key)(opt); + }; +} + +function numberOrStringParser(key) { + return function (opt) { + if (typeof opt === 'string') { + return opt; + } + return numberParser(key)(opt); + }; +} + +function objectParser(opt) { + if (typeof opt == 'object') { + return opt; + } + return JSON.parse(opt); +} + +function arrayParser(opt) { + if (Array.isArray(opt)) { + return opt; + } else if (typeof opt === 'string') { + return opt.split(','); + } else { + throw new Error(`${opt} should be a comma separated string or an array`); + } +} + +function moduleOrObjectParser(opt) { + if (typeof opt == 'object') { + return opt; + } + try { + return JSON.parse(opt); + } catch { + /* */ + } + return opt; +} + +function booleanParser(opt) { + if (opt == true || opt == 'true' || opt == '1') { + return true; + } + return false; +} + +function booleanOrFunctionParser(opt) { + if (typeof opt === 'function') { + return opt; + } + return booleanParser(opt); +} + +function nullParser(opt) { + if (opt == 'null') { + return null; + } + return opt; +} + +module.exports = { + numberParser, + numberOrBoolParser, + numberOrStringParser, + nullParser, + booleanParser, + booleanOrFunctionParser, + moduleOrObjectParser, + arrayParser, + objectParser, +}; diff --git a/src/Page.js b/src/Page.js new file mode 100644 index 0000000000..27a5237197 --- /dev/null +++ b/src/Page.js @@ -0,0 +1,36 @@ +/*eslint no-unused-vars: "off"*/ +/** + * @interface Page + * Page + * Page content that is returned by PageRouter. + */ +export class Page { + /** + * @description Creates a page. + * @param {Object} params The page parameters. + * @param {String} params.id The page identifier. + * @param {String} params.defaultFile The page file name. + * @returns {Page} The page. + */ + constructor(params = {}) { + const { id, defaultFile } = params; + + this._id = id; + this._defaultFile = defaultFile; + } + + get id() { + return this._id; + } + get defaultFile() { + return this._defaultFile; + } + set id(v) { + this._id = v; + } + set defaultFile(v) { + this._defaultFile = v; + } +} + +export default Page; diff --git a/src/ParseMessageQueue.js b/src/ParseMessageQueue.js new file mode 100644 index 0000000000..b2ff53f59f --- /dev/null +++ b/src/ParseMessageQueue.js @@ -0,0 +1,22 @@ +import { loadAdapter } from './Adapters/AdapterLoader'; +import { EventEmitterMQ } from './Adapters/MessageQueue/EventEmitterMQ'; + +const ParseMessageQueue = {}; + +ParseMessageQueue.createPublisher = function (config: any): any { + const adapter = loadAdapter(config.messageQueueAdapter, EventEmitterMQ, config); + if (typeof adapter.createPublisher !== 'function') { + throw 'pubSubAdapter should have createPublisher()'; + } + return adapter.createPublisher(config); +}; + +ParseMessageQueue.createSubscriber = function (config: any): void { + const adapter = loadAdapter(config.messageQueueAdapter, EventEmitterMQ, config); + if (typeof adapter.createSubscriber !== 'function') { + throw 'messageQueueAdapter should have createSubscriber()'; + } + return adapter.createSubscriber(config); +}; + +export { ParseMessageQueue }; diff --git a/src/ParseServer.js b/src/ParseServer.js deleted file mode 100644 index d8db7d3684..0000000000 --- a/src/ParseServer.js +++ /dev/null @@ -1,361 +0,0 @@ -// ParseServer - open-source compatible API Server for Parse apps - -var batch = require('./batch'), - bodyParser = require('body-parser'), - DatabaseAdapter = require('./DatabaseAdapter'), - express = require('express'), - middlewares = require('./middlewares'), - multer = require('multer'), - Parse = require('parse/node').Parse, - path = require('path'), - authDataManager = require('./authDataManager'); - -if (!global._babelPolyfill) { - require('babel-polyfill'); -} - -import { logger, - configureLogger } from './logger'; -import AppCache from './cache'; -import Config from './Config'; -import parseServerPackage from '../package.json'; -import PromiseRouter from './PromiseRouter'; -import requiredParameter from './requiredParameter'; -import { AnalyticsRouter } from './Routers/AnalyticsRouter'; -import { ClassesRouter } from './Routers/ClassesRouter'; -import { FeaturesRouter } from './Routers/FeaturesRouter'; -import { InMemoryCacheAdapter } from './Adapters/Cache/InMemoryCacheAdapter'; -import { AnalyticsController } from './Controllers/AnalyticsController'; -import { CacheController } from './Controllers/CacheController'; -import { AnalyticsAdapter } from './Adapters/Analytics/AnalyticsAdapter'; -import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; -import { FilesController } from './Controllers/FilesController'; -import { FilesRouter } from './Routers/FilesRouter'; -import { FunctionsRouter } from './Routers/FunctionsRouter'; -import { GlobalConfigRouter } from './Routers/GlobalConfigRouter'; -import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; -import { HooksController } from './Controllers/HooksController'; -import { HooksRouter } from './Routers/HooksRouter'; -import { IAPValidationRouter } from './Routers/IAPValidationRouter'; -import { InstallationsRouter } from './Routers/InstallationsRouter'; -import { loadAdapter } from './Adapters/AdapterLoader'; -import { LiveQueryController } from './Controllers/LiveQueryController'; -import { LoggerController } from './Controllers/LoggerController'; -import { LogsRouter } from './Routers/LogsRouter'; -import { ParseLiveQueryServer } from './LiveQuery/ParseLiveQueryServer'; -import { PublicAPIRouter } from './Routers/PublicAPIRouter'; -import { PushController } from './Controllers/PushController'; -import { PushRouter } from './Routers/PushRouter'; -import { randomString } from './cryptoUtils'; -import { RolesRouter } from './Routers/RolesRouter'; -import { SchemasRouter } from './Routers/SchemasRouter'; -import { SessionsRouter } from './Routers/SessionsRouter'; -import { UserController } from './Controllers/UserController'; -import { UsersRouter } from './Routers/UsersRouter'; -import { PurgeRouter } from './Routers/PurgeRouter'; - -import DatabaseController from './Controllers/DatabaseController'; -import SchemaCache from './Controllers/SchemaCache'; -const SchemaController = require('./Controllers/SchemaController'); -import ParsePushAdapter from 'parse-server-push-adapter'; -import MongoStorageAdapter from './Adapters/Storage/Mongo/MongoStorageAdapter'; -// Mutate the Parse object to add the Cloud Code handlers -addParseCloud(); - - -const requiredUserFields = { fields: { ...SchemaController.defaultColumns._Default, ...SchemaController.defaultColumns._User } }; - - -// ParseServer works like a constructor of an express app. -// The args that we understand are: -// "analyticsAdapter": an adapter class for analytics -// "filesAdapter": a class like GridStoreAdapter providing create, get, -// and delete -// "loggerAdapter": a class like FileLoggerAdapter providing info, error, -// and query -// "jsonLogs": log as structured JSON objects -// "databaseURI": a uri like mongodb://localhost:27017/dbname to tell us -// what database this Parse API connects to. -// "cloud": relative location to cloud code to require, or a function -// that is given an instance of Parse as a parameter. Use this instance of Parse -// to register your cloud code hooks and functions. -// "appId": the application id to host -// "masterKey": the master key for requests to this app -// "facebookAppIds": an array of valid Facebook Application IDs, required -// if using Facebook login -// "collectionPrefix": optional prefix for database collection names -// "fileKey": optional key from Parse dashboard for supporting older files -// hosted by Parse -// "clientKey": optional key from Parse dashboard -// "dotNetKey": optional key from Parse dashboard -// "restAPIKey": optional key from Parse dashboard -// "webhookKey": optional key from Parse dashboard -// "javascriptKey": optional key from Parse dashboard -// "push": optional key from configure push -// "sessionLength": optional length in seconds for how long Sessions should be valid for - -class ParseServer { - - constructor({ - appId = requiredParameter('You must provide an appId!'), - masterKey = requiredParameter('You must provide a masterKey!'), - appName, - analyticsAdapter = undefined, - filesAdapter, - push, - loggerAdapter, - jsonLogs, - logsFolder, - databaseURI, - databaseOptions, - databaseAdapter, - cloud, - collectionPrefix = '', - clientKey, - javascriptKey, - dotNetKey, - restAPIKey, - webhookKey, - fileKey = undefined, - facebookAppIds = [], - enableAnonymousUsers = true, - allowClientClassCreation = true, - oauth = {}, - serverURL = requiredParameter('You must provide a serverURL!'), - maxUploadSize = '20mb', - verifyUserEmails = false, - preventLoginWithUnverifiedEmail = false, - emailVerifyTokenValidityDuration, - cacheAdapter, - emailAdapter, - publicServerURL, - customPages = { - invalidLink: undefined, - verifyEmailSuccess: undefined, - choosePassword: undefined, - passwordResetSuccess: undefined - }, - liveQuery = {}, - sessionLength = 31536000, // 1 Year in seconds - expireInactiveSessions = true, - verbose = false, - revokeSessionOnPasswordReset = true, - schemaCacheTTL = 5, // cache for 5s - __indexBuildCompletionCallbackForTests = () => {}, - }) { - // Initialize the node client SDK automatically - Parse.initialize(appId, javascriptKey || 'unused', masterKey); - Parse.serverURL = serverURL; - if ((databaseOptions || databaseURI || collectionPrefix !== '') && databaseAdapter) { - throw 'You cannot specify both a databaseAdapter and a databaseURI/databaseOptions/connectionPrefix.'; - } else if (!databaseAdapter) { - databaseAdapter = new MongoStorageAdapter({ - uri: databaseURI, - collectionPrefix, - mongoOptions: databaseOptions, - }); - } else { - databaseAdapter = loadAdapter(databaseAdapter) - } - - if (!filesAdapter && !databaseURI) { - throw 'When using an explicit database adapter, you must also use and explicit filesAdapter.'; - } - - if (logsFolder) { - configureLogger({logsFolder, jsonLogs}); - } - - if (cloud) { - addParseCloud(); - if (typeof cloud === 'function') { - cloud(Parse) - } else if (typeof cloud === 'string') { - require(path.resolve(process.cwd(), cloud)); - } else { - throw "argument 'cloud' must either be a string or a function"; - } - } - - if (verbose || process.env.VERBOSE || process.env.VERBOSE_PARSE_SERVER) { - configureLogger({level: 'silly', jsonLogs}); - } - - const filesControllerAdapter = loadAdapter(filesAdapter, () => { - return new GridStoreAdapter(databaseURI); - }); - // Pass the push options too as it works with the default - const pushControllerAdapter = loadAdapter(push && push.adapter, ParsePushAdapter, push || {}); - const loggerControllerAdapter = loadAdapter(loggerAdapter, FileLoggerAdapter); - const emailControllerAdapter = loadAdapter(emailAdapter); - const cacheControllerAdapter = loadAdapter(cacheAdapter, InMemoryCacheAdapter, {appId: appId}); - const analyticsControllerAdapter = loadAdapter(analyticsAdapter, AnalyticsAdapter); - - // We pass the options and the base class for the adatper, - // Note that passing an instance would work too - const filesController = new FilesController(filesControllerAdapter, appId); - const pushController = new PushController(pushControllerAdapter, appId, push); - const loggerController = new LoggerController(loggerControllerAdapter, appId); - const userController = new UserController(emailControllerAdapter, appId, { verifyUserEmails }); - const liveQueryController = new LiveQueryController(liveQuery); - const cacheController = new CacheController(cacheControllerAdapter, appId); - const databaseController = new DatabaseController(databaseAdapter, new SchemaCache(cacheController, schemaCacheTTL)); - const hooksController = new HooksController(appId, databaseController, webhookKey); - const analyticsController = new AnalyticsController(analyticsControllerAdapter); - - // TODO: create indexes on first creation of a _User object. Otherwise it's impossible to - // have a Parse app without it having a _User collection. - let userClassPromise = databaseController.loadSchema() - .then(schema => schema.enforceClassExists('_User')) - - let usernameUniqueness = userClassPromise - .then(() => databaseController.adapter.ensureUniqueness('_User', requiredUserFields, ['username'])) - .catch(error => { - logger.warn('Unable to ensure uniqueness for usernames: ', error); - return Promise.reject(error); - }); - - let emailUniqueness = userClassPromise - .then(() => databaseController.adapter.ensureUniqueness('_User', requiredUserFields, ['email'])) - .catch(error => { - logger.warn('Unable to ensure uniqueness for user email addresses: ', error); - return Promise.reject(error); - }) - - AppCache.put(appId, { - appId, - masterKey: masterKey, - serverURL: serverURL, - collectionPrefix: collectionPrefix, - clientKey: clientKey, - javascriptKey: javascriptKey, - dotNetKey: dotNetKey, - restAPIKey: restAPIKey, - webhookKey: webhookKey, - fileKey: fileKey, - facebookAppIds: facebookAppIds, - analyticsController: analyticsController, - cacheController: cacheController, - filesController: filesController, - pushController: pushController, - loggerController: loggerController, - hooksController: hooksController, - userController: userController, - verifyUserEmails: verifyUserEmails, - preventLoginWithUnverifiedEmail: preventLoginWithUnverifiedEmail, - emailVerifyTokenValidityDuration: emailVerifyTokenValidityDuration, - allowClientClassCreation: allowClientClassCreation, - authDataManager: authDataManager(oauth, enableAnonymousUsers), - appName: appName, - publicServerURL: publicServerURL, - customPages: customPages, - maxUploadSize: maxUploadSize, - liveQueryController: liveQueryController, - sessionLength: Number(sessionLength), - expireInactiveSessions: expireInactiveSessions, - jsonLogs, - revokeSessionOnPasswordReset, - databaseController, - schemaCacheTTL - }); - - // To maintain compatibility. TODO: Remove in some version that breaks backwards compatability - if (process.env.FACEBOOK_APP_ID) { - AppCache.get(appId)['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); - } - - Config.validate(AppCache.get(appId)); - this.config = AppCache.get(appId); - hooksController.load(); - - // Note: Tests will start to fail if any validation happens after this is called. - if (process.env.TESTING) { - __indexBuildCompletionCallbackForTests(Promise.all([usernameUniqueness, emailUniqueness])); - } - } - - get app() { - return ParseServer.app(this.config); - } - - static app({maxUploadSize = '20mb', appId}) { - // This app serves the Parse API directly. - // It's the equivalent of https://api.parse.com/1 in the hosted Parse API. - var api = express(); - //api.use("/apps", express.static(__dirname + "/public")); - // File handling needs to be before default middlewares are applied - api.use('/', middlewares.allowCrossDomain, new FilesRouter().getExpressRouter({ - maxUploadSize: maxUploadSize - })); - - api.use('/', bodyParser.urlencoded({extended: false}), new PublicAPIRouter().expressApp()); - - // TODO: separate this from the regular ParseServer object - if (process.env.TESTING == 1) { - api.use('/', require('./testing-routes').router); - } - - api.use(bodyParser.json({ 'type': '*/*' , limit: maxUploadSize })); - api.use(middlewares.allowCrossDomain); - api.use(middlewares.allowMethodOverride); - api.use(middlewares.handleParseHeaders); - - let routers = [ - new ClassesRouter(), - new UsersRouter(), - new SessionsRouter(), - new RolesRouter(), - new AnalyticsRouter(), - new InstallationsRouter(), - new FunctionsRouter(), - new SchemasRouter(), - new PushRouter(), - new LogsRouter(), - new IAPValidationRouter(), - new FeaturesRouter(), - new GlobalConfigRouter(), - new PurgeRouter(), - ]; - - if (process.env.PARSE_EXPERIMENTAL_HOOKS_ENABLED || process.env.TESTING) { - routers.push(new HooksRouter()); - } - - let routes = routers.reduce((memo, router) => { - return memo.concat(router.routes); - }, []); - - let appRouter = new PromiseRouter(routes, appId); - - batch.mountOnto(appRouter); - - api.use(appRouter.expressApp()); - - api.use(middlewares.handleParseErrors); - - //This causes tests to spew some useless warnings, so disable in test - if (!process.env.TESTING) { - process.on('uncaughtException', (err) => { - if ( err.code === "EADDRINUSE" ) { // user-friendly message for this common error - console.error(`Unable to listen on port ${err.port}. The port is already in use.`); - process.exit(0); - } else { - throw err; - } - }); - } - return api; - } - - static createLiveQueryServer(httpServer, config) { - return new ParseLiveQueryServer(httpServer, config); - } -} - -function addParseCloud() { - const ParseCloud = require("./cloud-code/Parse.Cloud"); - Object.assign(Parse.Cloud, ParseCloud); - global.Parse = Parse; -} - -export default ParseServer; diff --git a/src/ParseServer.ts b/src/ParseServer.ts new file mode 100644 index 0000000000..65d537ae68 --- /dev/null +++ b/src/ParseServer.ts @@ -0,0 +1,688 @@ +// ParseServer - open-source compatible API Server for Parse apps + +var batch = require('./batch'), + express = require('express'), + middlewares = require('./middlewares'), + Parse = require('parse/node').Parse, + { parse } = require('graphql'), + path = require('path'), + fs = require('fs'); + +import { ParseServerOptions, LiveQueryServerOptions } from './Options'; +import { setRegexTimeout } from './LiveQuery/QueryTools'; +import defaults, { DatabaseOptionDefaults } from './defaults'; +import * as logging from './logger'; +import Config from './Config'; +import PromiseRouter from './PromiseRouter'; +import requiredParameter from './requiredParameter'; +import { AnalyticsRouter } from './Routers/AnalyticsRouter'; +import { ClassesRouter } from './Routers/ClassesRouter'; +import { FeaturesRouter } from './Routers/FeaturesRouter'; +import { FilesRouter } from './Routers/FilesRouter'; +import { FunctionsRouter } from './Routers/FunctionsRouter'; +import { GlobalConfigRouter } from './Routers/GlobalConfigRouter'; +import { GraphQLRouter } from './Routers/GraphQLRouter'; +import { HooksRouter } from './Routers/HooksRouter'; +import { IAPValidationRouter } from './Routers/IAPValidationRouter'; +import { InstallationsRouter } from './Routers/InstallationsRouter'; +import { LogsRouter } from './Routers/LogsRouter'; +import { ParseLiveQueryServer } from './LiveQuery/ParseLiveQueryServer'; +import { PagesRouter } from './Routers/PagesRouter'; +import { PushRouter } from './Routers/PushRouter'; +import { CloudCodeRouter } from './Routers/CloudCodeRouter'; +import { RolesRouter } from './Routers/RolesRouter'; +import { SchemasRouter } from './Routers/SchemasRouter'; +import { SessionsRouter } from './Routers/SessionsRouter'; +import { UsersRouter } from './Routers/UsersRouter'; +import { PurgeRouter } from './Routers/PurgeRouter'; +import { AudiencesRouter } from './Routers/AudiencesRouter'; +import { AggregateRouter } from './Routers/AggregateRouter'; +import { ParseServerRESTController } from './ParseServerRESTController'; +import * as controllers from './Controllers'; +import { ParseGraphQLServer } from './GraphQL/ParseGraphQLServer'; +import { SecurityRouter } from './Routers/SecurityRouter'; +import CheckRunner from './Security/CheckRunner'; +import Deprecator from './Deprecator/Deprecator'; +import { DefinedSchemas } from './SchemaMigrations/DefinedSchemas'; +import OptionsDefinitions from './Options/Definitions'; +import { resolvingPromise, Connections } from './TestUtils'; + +// Mutate the Parse object to add the Cloud Code handlers +addParseCloud(); + +// Track connections to destroy them on shutdown +const connections = new Connections(); + +// ParseServer works like a constructor of an express app. +// https://parseplatform.org/parse-server/api/master/ParseServerOptions.html +class ParseServer { + _app: any; + config: any; + server: any; + expressApp: any; + liveQueryServer: any; + /** + * @constructor + * @param {ParseServerOptions} options the parse server initialization options + */ + constructor(options: ParseServerOptions) { + // Scan for deprecated Parse Server options + Deprecator.scanParseServerOptions(options); + + const interfaces = JSON.parse(JSON.stringify(OptionsDefinitions)); + + function getValidObject(root) { + const result = {}; + for (const key in root) { + if (Object.prototype.hasOwnProperty.call(root[key], 'type')) { + if (root[key].type.endsWith('[]')) { + result[key] = [getValidObject(interfaces[root[key].type.slice(0, -2)])]; + } else { + result[key] = getValidObject(interfaces[root[key].type]); + } + } else { + result[key] = ''; + } + } + return result; + } + + const optionsBlueprint = getValidObject(interfaces['ParseServerOptions']); + + function validateKeyNames(original, ref, name = '') { + let result = []; + const prefix = name + (name !== '' ? '.' : ''); + for (const key in original) { + if (!Object.prototype.hasOwnProperty.call(ref, key)) { + result.push(prefix + key); + } else { + if (ref[key] === '') { continue; } + let res = []; + if (Array.isArray(original[key]) && Array.isArray(ref[key])) { + const type = ref[key][0]; + original[key].forEach((item, idx) => { + if (typeof item === 'object' && item !== null) { + res = res.concat(validateKeyNames(item, type, prefix + key + `[${idx}]`)); + } + }); + } else if (typeof original[key] === 'object' && typeof ref[key] === 'object') { + res = validateKeyNames(original[key], ref[key], prefix + key); + } + result = result.concat(res); + } + } + return result; + } + + const diff = validateKeyNames(options, optionsBlueprint); + if (diff.length > 0) { + const logger = (logging as any).logger; + logger.error(`Invalid key(s) found in Parse Server configuration: ${diff.join(', ')}`); + } + + // Set option defaults + injectDefaults(options); + const { + appId = requiredParameter('You must provide an appId!'), + masterKey = requiredParameter('You must provide a masterKey!'), + javascriptKey, + serverURL = requiredParameter('You must provide a serverURL!'), + } = options; + // Initialize the node client SDK automatically + Parse.initialize(appId, javascriptKey || 'unused', masterKey); + Parse.serverURL = serverURL; + Config.validateOptions(options); + const allControllers = controllers.getControllers(options); + + (options as any).state = 'initialized'; + this.config = Config.put(Object.assign({}, options, allControllers)); + this.config.masterKeyIpsStore = new Map(); + this.config.maintenanceKeyIpsStore = new Map(); + this.config.readOnlyMasterKeyIpsStore = new Map(); + setRegexTimeout(options.liveQuery?.regexTimeout); + logging.setLogger(allControllers.loggerController); + } + + /** + * Starts Parse Server as an express app; this promise resolves when Parse Server is ready to accept requests. + */ + + async start(): Promise { + try { + if (this.config.state === 'ok') { + return this; + } + this.config.state = 'starting'; + Config.put(this.config); + const { + databaseController, + hooksController, + cacheController, + cloud, + security, + schema, + liveQueryController, + } = this.config; + try { + await databaseController.performInitialization(); + } catch (e) { + if (e.code !== Parse.Error.DUPLICATE_VALUE) { + throw e; + } + } + const pushController = await controllers.getPushController(this.config); + await hooksController.load(); + const startupPromises = [this.config.loadMasterKey?.()]; + if (schema) { + startupPromises.push(new DefinedSchemas(schema, this.config).execute()); + } + if ( + cacheController.adapter?.connect && + typeof cacheController.adapter.connect === 'function' + ) { + startupPromises.push(cacheController.adapter.connect()); + } + startupPromises.push(liveQueryController.connect()); + await Promise.all(startupPromises); + if (cloud) { + addParseCloud(); + if (typeof cloud === 'function') { + await Promise.resolve(cloud(Parse)); + } else if (typeof cloud === 'string') { + let json; + if (process.env.npm_package_json) { + json = require(process.env.npm_package_json); + } + if (process.env.npm_package_type === 'module' || json?.type === 'module') { + await import(path.resolve(process.cwd(), cloud)); + } else { + require(path.resolve(process.cwd(), cloud)); + } + } else { + throw "argument 'cloud' must either be a string or a function"; + } + await new Promise(resolve => setTimeout(resolve, 10)); + } + if (security && security.enableCheck && security.enableCheckLog) { + new CheckRunner(security).run(); + } + this.config.state = 'ok'; + this.config = { ...this.config, ...pushController }; + Config.put(this.config); + return this; + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + this.config.state = 'error'; + throw error; + } + } + + get app() { + if (!this._app) { + this._app = ParseServer.app(this.config); + } + return this._app; + } + + /** + * Stops the parse server, cancels any ongoing requests and closes all connections. + * + * Currently, express doesn't shut down immediately after receiving SIGINT/SIGTERM + * if it has client connections that haven't timed out. + * (This is a known issue with node - https://github.com/nodejs/node/issues/2642) + * + * @returns {Promise} a promise that resolves when the server is stopped + */ + async handleShutdown() { + const serverClosePromise = resolvingPromise(); + const liveQueryServerClosePromise = resolvingPromise(); + const promises = []; + this.server.close((error) => { + /* istanbul ignore next */ + if (error) { + // eslint-disable-next-line no-console + console.error('Error while closing parse server', error); + } + serverClosePromise.resolve(); + }); + if (this.liveQueryServer?.server?.close && this.liveQueryServer.server !== this.server) { + this.liveQueryServer.server.close((error) => { + /* istanbul ignore next */ + if (error) { + // eslint-disable-next-line no-console + console.error('Error while closing live query server', error); + } + liveQueryServerClosePromise.resolve(); + }); + } else { + liveQueryServerClosePromise.resolve(); + } + const { adapter: databaseAdapter } = this.config.databaseController; + if (databaseAdapter && typeof databaseAdapter.handleShutdown === 'function') { + promises.push(databaseAdapter.handleShutdown()); + } + const { adapter: fileAdapter } = this.config.filesController; + if (fileAdapter && typeof fileAdapter.handleShutdown === 'function') { + promises.push(fileAdapter.handleShutdown()); + } + const { adapter: cacheAdapter } = this.config.cacheController; + if (cacheAdapter && typeof cacheAdapter.handleShutdown === 'function') { + promises.push(cacheAdapter.handleShutdown()); + } + if (this.liveQueryServer) { + promises.push(this.liveQueryServer.shutdown()); + } + await Promise.all(promises); + connections.destroyAll(); + await Promise.all([serverClosePromise, liveQueryServerClosePromise]); + if (this.config.serverCloseComplete) { + this.config.serverCloseComplete(); + } + } + + /** + * @static + * Allow developers to customize each request with inversion of control/dependency injection + */ + static applyRequestContextMiddleware(api, options) { + if (options.requestContextMiddleware) { + if (typeof options.requestContextMiddleware !== 'function') { + throw new Error('requestContextMiddleware must be a function'); + } + api.use(options.requestContextMiddleware); + } + } + /** + * @static + * Create an express app for the parse server + * @param {Object} options let you specify the maxUploadSize when creating the express app */ + static app(options) { + const { + maxUploadSize = '20mb', + appId, + directAccess, + pages, + rateLimit = [], + } = options; + // This app serves the Parse API directly. + // It's the equivalent of https://api.parse.com/1 in the hosted Parse API. + var api = express(); + //api.use("/apps", express.static(__dirname + "/public")); + api.use(middlewares.allowCrossDomain(appId)); + api.use(middlewares.allowDoubleForwardSlash); + api.use(middlewares.handleParseAuth(appId)); + // File handling needs to be before the default JSON body parser because file + // uploads send binary data that should not be parsed as JSON. + api.use( + '/', + new FilesRouter().expressRouter({ + maxUploadSize: maxUploadSize, + }) + ); + + api.use('/health', middlewares.enforceRouteAllowList, middlewares.handleParseHealth(options)); + + api.use( + '/', + express.urlencoded({ extended: false }), + new PagesRouter(pages).expressRouter() + ); + + api.use(express.json({ type: req => !req.is('multipart/form-data'), limit: maxUploadSize })); + api.use(middlewares.allowMethodOverride); + api.use(middlewares.handleParseHeaders); + api.use(middlewares.enforceRouteAllowList); + api.set('query parser', 'extended'); + const routes = Array.isArray(rateLimit) ? rateLimit : [rateLimit]; + for (const route of routes) { + middlewares.addRateLimit(route, options); + } + api.use(middlewares.handleParseSession); + this.applyRequestContextMiddleware(api, options); + const appRouter = ParseServer.promiseRouter({ appId, options }); + api.use(appRouter.expressRouter()); + + api.use(middlewares.handleParseErrors); + + // run the following when not testing + if (!process.env.TESTING) { + //This causes tests to spew some useless warnings, so disable in test + /* istanbul ignore next */ + process.on('uncaughtException', (err: any) => { + if (err.code === 'EADDRINUSE') { + // user-friendly message for this common error + process.stderr.write(`Unable to listen on port ${err.port}. The port is already in use.`); + process.exit(0); + } else { + if (err.message) { + process.stderr.write('An uncaught exception occurred: ' + err.message); + } + if (err.stack) { + process.stderr.write('Stack Trace:\n' + err.stack); + } else { + process.stderr.write(err); + } + process.exit(1); + } + }); + } + if (process.env.PARSE_SERVER_ENABLE_EXPERIMENTAL_DIRECT_ACCESS === '1' || directAccess) { + Parse.CoreManager.setRESTController(ParseServerRESTController(appId, appRouter)); + } + return api; + } + + static promiseRouter({ appId, options }) { + const routers = [ + new ClassesRouter(), + new UsersRouter(), + new SessionsRouter(), + new RolesRouter(), + new AnalyticsRouter(), + new InstallationsRouter(), + new FunctionsRouter(), + new SchemasRouter(), + new PushRouter(), + new LogsRouter(), + new FeaturesRouter(), + new GlobalConfigRouter(), + new GraphQLRouter(), + new PurgeRouter(), + new HooksRouter(), + new CloudCodeRouter(), + new AudiencesRouter(), + new AggregateRouter(), + new SecurityRouter(), + ]; + + if (options?.enableProductPurchaseLegacyApi !== false) { + routers.push(new IAPValidationRouter()); + } + + const routes = routers.reduce((memo, router) => { + return memo.concat(router.routes); + }, []); + + const appRouter = new PromiseRouter(routes, appId); + + batch.mountOnto(appRouter); + return appRouter; + } + + /** + * starts the parse server's express app + * @param {ParseServerOptions} options to use to start the server + * @returns {ParseServer} the parse server instance + */ + + async startApp(options: ParseServerOptions) { + try { + await this.start(); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error on ParseServer.startApp: ', e); + throw e; + } + const app = express(); + if (options.middleware) { + let middleware; + if (typeof options.middleware == 'string') { + middleware = require(path.resolve(process.cwd(), options.middleware)); + } else { + middleware = options.middleware; // use as-is let express fail + } + app.use(middleware); + } + app.use(options.mountPath, this.app); + + if (options.mountGraphQL === true || options.mountPlayground === true) { + let graphQLCustomTypeDefs = undefined; + if (typeof options.graphQLSchema === 'string') { + graphQLCustomTypeDefs = parse(fs.readFileSync(options.graphQLSchema, 'utf8')); + } else if ( + typeof options.graphQLSchema === 'object' || + typeof options.graphQLSchema === 'function' + ) { + graphQLCustomTypeDefs = options.graphQLSchema; + } + + const parseGraphQLServer = new ParseGraphQLServer(this, { + graphQLPath: options.graphQLPath, + playgroundPath: options.playgroundPath, + graphQLCustomTypeDefs, + }); + + if (options.mountGraphQL) { + parseGraphQLServer.applyGraphQL(app); + } + + if (options.mountPlayground) { + parseGraphQLServer.applyPlayground(app); + logging.getLogger().warn( + 'GraphQL Playground is deprecated and will be removed in a future version. It exposes the master key in the browser. Use Parse Dashboard as GraphQL IDE or configure a third-party GraphQL client with custom request headers.' + ); + } + } + const server = await new Promise(resolve => { + app.listen(options.port, options.host, function () { + resolve(this); + }); + }); + this.server = server; + connections.track(server); + + if (options.startLiveQueryServer || options.liveQueryServerOptions) { + this.liveQueryServer = await ParseServer.createLiveQueryServer( + server, + options.liveQueryServerOptions, + options + ); + if (this.liveQueryServer.server !== this.server) { + connections.track(this.liveQueryServer.server); + } + } + if (options.trustProxy) { + app.set('trust proxy', options.trustProxy); + } + /* istanbul ignore next */ + if (!process.env.TESTING) { + configureListeners(this); + if (options.verifyServerUrl !== false) { + await ParseServer.verifyServerUrl(); + } + } + this.expressApp = app; + return this; + } + + /** + * Creates a new ParseServer and starts it. + * @param {ParseServerOptions} options used to start the server + * @returns {ParseServer} the parse server instance + */ + static async startApp(options: ParseServerOptions) { + const parseServer = new ParseServer(options); + return parseServer.startApp(options); + } + + /** + * Helper method to create a liveQuery server + * @static + * @param {Server} httpServer an optional http server to pass + * @param {LiveQueryServerOptions} config options for the liveQueryServer + * @param {ParseServerOptions} options options for the ParseServer + * @returns {Promise} the live query server instance + */ + static async createLiveQueryServer( + httpServer, + config: LiveQueryServerOptions, + options: ParseServerOptions + ): Promise { + if (!httpServer || (config && config.port)) { + var app = express(); + httpServer = require('http').createServer(app); + httpServer.listen(config.port); + } + const server = new ParseLiveQueryServer(httpServer, config, options); + await server.connect(); + return server; + } + + static async verifyServerUrl() { + // perform a health check on the serverURL value + if (Parse.serverURL) { + const isValidHttpUrl = string => { + let url; + try { + url = new URL(string); + } catch { + return false; + } + return url.protocol === 'http:' || url.protocol === 'https:'; + }; + const url = `${Parse.serverURL.replace(/\/$/, '')}/health`; + if (!isValidHttpUrl(url)) { + // eslint-disable-next-line no-console + console.warn( + `\nWARNING, Unable to connect to '${Parse.serverURL}' as the URL is invalid.` + + ` Cloud code and push notifications may be unavailable!\n` + ); + return; + } + const request = require('./request'); + const response = await request({ url }).catch(response => response); + const json = response.data || null; + const retry = response.headers?.['retry-after']; + if (retry) { + await new Promise(resolve => setTimeout(resolve, retry * 1000)); + return this.verifyServerUrl(); + } + if (response.status !== 200 || json?.status !== 'ok') { + /* eslint-disable no-console */ + console.warn( + `\nWARNING, Unable to connect to '${Parse.serverURL}'.` + + ` Cloud code and push notifications may be unavailable!\n` + ); + /* eslint-enable no-console */ + return; + } + return true; + } + } +} + +function addParseCloud() { + const ParseCloud = require('./cloud-code/Parse.Cloud'); + const ParseServer = require('./cloud-code/Parse.Server'); + Object.defineProperty(Parse, 'Server', { + get() { + const conf = Config.get(Parse.applicationId); + return { ...conf, ...ParseServer }; + }, + set(newVal) { + newVal.appId = Parse.applicationId; + Config.put(newVal); + }, + configurable: true, + }); + Object.assign(Parse.Cloud, ParseCloud); + global.Parse = Parse; +} + +function injectDefaults(options: ParseServerOptions) { + Object.keys(defaults).forEach(key => { + if (!Object.prototype.hasOwnProperty.call(options, key)) { + options[key] = defaults[key]; + } + }); + + // Inject defaults for database options; only when no explicit database adapter is set, + // because an explicit adapter manages its own options and passing databaseOptions alongside + // it would cause a conflict error in getDatabaseController. + if (!options.databaseAdapter) { + if (options.databaseOptions == null) { + options.databaseOptions = {}; + } + if (typeof options.databaseOptions === 'object' && !Array.isArray(options.databaseOptions)) { + Object.keys(DatabaseOptionDefaults).forEach(key => { + if (!Object.prototype.hasOwnProperty.call(options.databaseOptions, key)) { + options.databaseOptions[key] = DatabaseOptionDefaults[key]; + } + }); + } + } + + if (!Object.prototype.hasOwnProperty.call(options, 'serverURL')) { + options.serverURL = `http://localhost:${options.port}${options.mountPath}`; + } + + // Reserved Characters + if (options.appId) { + const regex = /[!#$%'()*+&/:;=?@[\]{}^,|<>]/g; + if (options.appId.match(regex)) { + // eslint-disable-next-line no-console + console.warn( + `\nWARNING, appId that contains special characters can cause issues while using with urls.\n` + ); + } + } + + // Backwards compatibility + if (options.userSensitiveFields) { + /* eslint-disable no-console */ + !process.env.TESTING && + console.warn( + `\nDEPRECATED: userSensitiveFields has been replaced by protectedFields allowing the ability to protect fields in all classes with CLP. \n` + ); + /* eslint-enable no-console */ + + const userSensitiveFields = Array.from( + new Set([...(defaults.userSensitiveFields || []), ...(options.userSensitiveFields || [])]) + ); + + // If the options.protectedFields is unset, + // it'll be assigned the default above. + // Here, protect against the case where protectedFields + // is set, but doesn't have _User. + if (!('_User' in options.protectedFields)) { + options.protectedFields = Object.assign({ _User: [] }, options.protectedFields); + } + + options.protectedFields['_User']['*'] = Array.from( + new Set([...(options.protectedFields['_User']['*'] || []), ...userSensitiveFields]) + ); + } + + // Merge protectedFields options with defaults. + Object.keys(defaults.protectedFields).forEach(c => { + const cur = options.protectedFields[c]; + if (!cur) { + options.protectedFields[c] = defaults.protectedFields[c]; + } else { + Object.keys(defaults.protectedFields[c]).forEach(r => { + if (options.protectedFields[c][r] && options.protectedFieldsOwnerExempt === false) { + return; + } + const unq = new Set([ + ...(options.protectedFields[c][r] || []), + ...defaults.protectedFields[c][r], + ]); + options.protectedFields[c][r] = Array.from(unq); + }); + } + }); +} + +// Those can't be tested as it requires a subprocess +/* istanbul ignore next */ +function configureListeners(parseServer) { + const handleShutdown = function () { + process.stdout.write('Termination signal received. Shutting down.'); + parseServer.handleShutdown(); + }; + process.on('SIGTERM', handleShutdown); + process.on('SIGINT', handleShutdown); +} + +export default ParseServer; diff --git a/src/ParseServerRESTController.js b/src/ParseServerRESTController.js new file mode 100644 index 0000000000..3f1c4dd4bf --- /dev/null +++ b/src/ParseServerRESTController.js @@ -0,0 +1,176 @@ +const Config = require('./Config'); +const Auth = require('./Auth'); +import RESTController from 'parse/lib/node/RESTController'; +const Parse = require('parse/node'); + +function getSessionToken(options) { + if (options && typeof options.sessionToken === 'string') { + return Promise.resolve(options.sessionToken); + } + return Promise.resolve(null); +} + +function getAuth(options = {}, config) { + const installationId = options.installationId || 'cloud'; + if (options.useMasterKey) { + return Promise.resolve(new Auth.Auth({ config, isMaster: true, installationId })); + } + return getSessionToken(options).then(sessionToken => { + if (sessionToken) { + options.sessionToken = sessionToken; + return Auth.getAuthForSessionToken({ + config, + sessionToken: sessionToken, + installationId, + }); + } else { + return Promise.resolve(new Auth.Auth({ config, installationId })); + } + }); +} + +function ParseServerRESTController(applicationId, router) { + function handleRequest(method, path, data = {}, options = {}, config) { + // Store the arguments, for later use if internal fails + const args = arguments; + + if (!config) { + config = Config.get(applicationId); + } + const serverURL = new URL(config.serverURL); + if (path.indexOf(serverURL.pathname) === 0) { + path = path.slice(serverURL.pathname.length, path.length); + } + + if (path[0] !== '/') { + path = '/' + path; + } + + if (path === '/batch') { + const batch = transactionRetries => { + let initialPromise = Promise.resolve(); + if (data.transaction === true) { + initialPromise = config.database.createTransactionalSession(); + } + return initialPromise.then(() => { + const promises = data.requests.map(request => { + return handleRequest(request.method, request.path, request.body, options, config).then( + response => { + if (options.returnStatus) { + const status = response._status; + const headers = response._headers; + delete response._status; + delete response._headers; + return { success: response, _status: status, _headers: headers }; + } + return { success: response }; + }, + error => { + return { + error: { code: error.code, error: error.message }, + }; + } + ); + }); + return Promise.all(promises) + .then(result => { + if (data.transaction === true) { + if (result.find(resultItem => typeof resultItem.error === 'object')) { + return config.database.abortTransactionalSession().then(() => { + return Promise.reject(result); + }); + } else { + return config.database.commitTransactionalSession().then(() => { + return result; + }); + } + } else { + return result; + } + }) + .catch(error => { + if ( + error && + error.find( + errorItem => typeof errorItem.error === 'object' && errorItem.error.code === 251 + ) && + transactionRetries > 0 + ) { + return batch(transactionRetries - 1); + } + throw error; + }); + }); + }; + return batch(5); + } + + let query; + if (method === 'GET') { + query = data; + } + + return new Promise((resolve, reject) => { + let requestContext; + try { + requestContext = structuredClone(options.context || {}); + } catch (error) { + reject( + new Parse.Error( + Parse.Error.INVALID_VALUE, + `Context contains non-cloneable values: ${error.message}` + ) + ); + return; + } + getAuth(options, config).then(auth => { + const request = { + body: data, + config, + auth, + info: { + applicationId: applicationId, + sessionToken: options.sessionToken, + installationId: options.installationId, + context: requestContext, + }, + query, + }; + return Promise.resolve() + .then(() => { + return router.tryRouteRequest(method, path, request); + }) + .then( + resp => { + const { response, status, headers = {} } = resp; + if (options.returnStatus) { + resolve({ ...response, _status: status, _headers: headers }); + } else { + resolve(response); + } + }, + err => { + if ( + err instanceof Parse.Error && + err.code == Parse.Error.INVALID_JSON && + err.message == `cannot route ${method} ${path}` + ) { + RESTController.request.apply(null, args).then(resolve, reject); + } else { + reject(err); + } + } + ); + }, reject); + }); + } + + return { + request: handleRequest, + ajax: RESTController.ajax, + handleError: RESTController.handleError, + }; +} + +export default ParseServerRESTController; +export { ParseServerRESTController }; diff --git a/src/PromiseRouter.js b/src/PromiseRouter.js index 1252b22747..3386daf223 100644 --- a/src/PromiseRouter.js +++ b/src/PromiseRouter.js @@ -5,11 +5,25 @@ // themselves use our routing information, without disturbing express // components that external developers may be modifying. -import AppCache from './cache'; -import express from 'express'; -import url from 'url'; -import log from './logger'; -import {inspect} from 'util'; +import Parse from 'parse/node'; +import express from 'express'; +import log from './logger'; +import { inspect } from 'util'; +const Layer = require('router/lib/layer'); + +function validateParameter(key, value) { + if (key == 'className') { + if (value.match(/_?[A-Za-z][A-Za-z_0-9]*/)) { + return value; + } + } else if (key == 'objectId') { + if (value.match(/[A-Za-z0-9]+/)) { + return value; + } + } else { + return value; + } +} export default class PromiseRouter { // Each entry should be an object with: @@ -36,38 +50,38 @@ export default class PromiseRouter { for (var route of router.routes) { this.routes.push(route); } - }; + } route(method, path, ...handlers) { - switch(method) { - case 'POST': - case 'GET': - case 'PUT': - case 'DELETE': - break; - default: - throw 'cannot route method: ' + method; + switch (method) { + case 'POST': + case 'GET': + case 'PUT': + case 'DELETE': + break; + default: + throw 'cannot route method: ' + method; } let handler = handlers[0]; if (handlers.length > 1) { - const length = handlers.length; - handler = function(req) { + handler = function (req) { return handlers.reduce((promise, handler) => { - return promise.then((result) => { + return promise.then(() => { return handler(req); }); }, Promise.resolve()); - } + }; } this.routes.push({ path: path, method: method, - handler: handler + handler: handler, + layer: new Layer(path, null, handler), }); - }; + } // Returns an object with: // handler: the handler that should deal with this request @@ -78,76 +92,41 @@ export default class PromiseRouter { if (route.method != method) { continue; } - // NOTE: we can only route the specific wildcards :className and - // :objectId, and in that order. - // This is pretty hacky but I don't want to rebuild the entire - // express route matcher. Maybe there's a way to reuse its logic. - var pattern = '^' + route.path + '$'; - - pattern = pattern.replace(':className', - '(_?[A-Za-z][A-Za-z_0-9]*)'); - pattern = pattern.replace(':objectId', - '([A-Za-z0-9]+)'); - var re = new RegExp(pattern); - var m = path.match(re); - if (!m) { - continue; + const layer = route.layer || new Layer(route.path, null, route.handler); + const match = layer.match(path); + if (match) { + const params = layer.params; + Object.keys(params).forEach(key => { + params[key] = validateParameter(key, params[key]); + }); + return { params: params, handler: route.handler }; } - var params = {}; - if (m[1]) { - params.className = m[1]; - } - if (m[2]) { - params.objectId = m[2]; - } - - return {params: params, handler: route.handler}; } - }; + } // Mount the routes on this router onto an express app (or express router) mountOnto(expressApp) { - for (var route of this.routes) { - switch(route.method) { - case 'POST': - expressApp.post(route.path, makeExpressHandler(this.appId, route.handler)); - break; - case 'GET': - expressApp.get(route.path, makeExpressHandler(this.appId, route.handler)); - break; - case 'PUT': - expressApp.put(route.path, makeExpressHandler(this.appId, route.handler)); - break; - case 'DELETE': - expressApp.delete(route.path, makeExpressHandler(this.appId, route.handler)); - break; - default: - throw 'unexpected code branch'; - } - } - }; + this.routes.forEach(route => { + const method = route.method.toLowerCase(); + const handler = makeExpressHandler(this.appId, route.handler); + expressApp[method].call(expressApp, route.path, handler); + }); + return expressApp; + } - expressApp() { - var expressApp = express(); - for (var route of this.routes) { - switch(route.method) { - case 'POST': - expressApp.post(route.path, makeExpressHandler(this.appId, route.handler)); - break; - case 'GET': - expressApp.get(route.path, makeExpressHandler(this.appId, route.handler)); - break; - case 'PUT': - expressApp.put(route.path, makeExpressHandler(this.appId, route.handler)); - break; - case 'DELETE': - expressApp.delete(route.path, makeExpressHandler(this.appId, route.handler)); - break; - default: - throw 'unexpected code branch'; - } + expressRouter() { + return this.mountOnto(express.Router()); + } + + tryRouteRequest(method, path, request) { + var match = this.match(method, path); + if (!match) { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'cannot route ' + method + ' ' + path); } - return expressApp; + request.params = match.params; + return new Promise((resolve, reject) => { + match.handler(request).then(resolve, reject); + }); } } @@ -156,92 +135,76 @@ export default class PromiseRouter { // Express handlers should never throw; if a promise handler throws we // just treat it like it resolved to an error. function makeExpressHandler(appId, promiseHandler) { - let config = AppCache.get(appId); - return function(req, res, next) { + return function (req, res, next) { try { - let url = maskSensitiveUrl(req); - let body = maskSensitiveBody(req); - let stringifiedBody = JSON.stringify(body, null, 2); - log.verbose(`REQUEST for [${req.method}] ${url}: ${stringifiedBody}`, { - method: req.method, - url: url, - headers: req.headers, - body: body + const url = maskSensitiveUrl(req); + const body = Object.assign({}, req.body); + const method = req.method; + const headers = req.headers; + log.logRequest({ + method, + url, + headers, + body, }); - promiseHandler(req).then((result) => { - if (!result.response && !result.location && !result.text) { - log.error('the handler did not include a "response" or a "location" field'); - throw 'control should not get here'; - } - - let stringifiedResponse = JSON.stringify(result, null, 2); - log.verbose( - `RESPONSE from [${req.method}] ${url}: ${stringifiedResponse}`, - {result: result} - ); - - var status = result.status || 200; - res.status(status); - - if (result.text) { - res.send(result.text); - return next(); - } - - if (result.location) { - res.set('Location', result.location); - // Override the default expressjs response - // as it double encodes %encoded chars in URL - if (!result.response) { - res.send('Found. Redirecting to '+result.location); - return next(); + promiseHandler(req) + .then( + result => { + if (!result.response && !result.location && !result.text) { + log.error('the handler did not include a "response" or a "location" field'); + throw 'control should not get here'; + } + + log.logResponse({ method, url, result }); + + var status = result.status || 200; + res.status(status); + + if (result.headers) { + Object.keys(result.headers).forEach(header => { + res.set(header, result.headers[header]); + }); + } + + if (result.text) { + res.send(result.text); + return; + } + + if (result.location) { + res.set('Location', result.location); + // Override the default expressjs response + // as it double encodes %encoded chars in URL + if (!result.response) { + res.send('Found. Redirecting to ' + result.location); + return; + } + } + res.json(result.response); + }, + error => { + next(error); } - } - if (result.headers) { - Object.keys(result.headers).forEach((header) => { - res.set(header, result.headers[header]); - }) - } - res.json(result.response); - next(); - }, (e) => { - log.error(`Error generating response. ${inspect(e)}`, {error: e}); - next(e); - }); + ) + .catch(e => { + log.error(`Error generating response. ${inspect(e)}`, { error: e }); + next(e); + }); } catch (e) { - log.error(`Error handling request: ${inspect(e)}`, {error: e}); + log.error(`Error handling request: ${inspect(e)}`, { error: e }); next(e); } - } -} - -function maskSensitiveBody(req) { - let maskBody = Object.assign({}, req.body); - let shouldMaskBody = (req.method === 'POST' && req.originalUrl.endsWith('/users') - && !req.originalUrl.includes('classes')) || - (req.method === 'PUT' && /users\/\w+$/.test(req.originalUrl) - && !req.originalUrl.includes('classes')) || - (req.originalUrl.includes('classes/_User')); - if (shouldMaskBody) { - for (let key of Object.keys(maskBody)) { - if (key == 'password') { - maskBody[key] = '********'; - break; - } - } - } - return maskBody; + }; } function maskSensitiveUrl(req) { let maskUrl = req.originalUrl.toString(); - let shouldMaskUrl = req.method === 'GET' && req.originalUrl.includes('/login') - && !req.originalUrl.includes('classes'); + const shouldMaskUrl = + req.method === 'GET' && + req.originalUrl.includes('/login') && + !req.originalUrl.includes('classes'); if (shouldMaskUrl) { - let password = url.parse(req.originalUrl, true).query.password; - if (password) { - maskUrl = maskUrl.replace('password=' + password, 'password=********') - } + maskUrl = log.maskSensitiveUrl(maskUrl); } return maskUrl; } diff --git a/src/Push/PushQueue.js b/src/Push/PushQueue.js new file mode 100644 index 0000000000..3e70e9995b --- /dev/null +++ b/src/Push/PushQueue.js @@ -0,0 +1,65 @@ +import { ParseMessageQueue } from '../ParseMessageQueue'; +import rest from '../rest'; +import { applyDeviceTokenExists } from './utils'; +import Parse from 'parse/node'; + +const PUSH_CHANNEL = 'parse-server-push'; +const DEFAULT_BATCH_SIZE = 100; + +export class PushQueue { + parsePublisher: Object; + channel: String; + batchSize: Number; + + // config object of the publisher, right now it only contains the redisURL, + // but we may extend it later. + constructor(config: any = {}) { + this.channel = config.channel || PushQueue.defaultPushChannel(); + this.batchSize = config.batchSize || DEFAULT_BATCH_SIZE; + this.parsePublisher = ParseMessageQueue.createPublisher(config); + } + + static defaultPushChannel() { + return `${Parse.applicationId}-${PUSH_CHANNEL}`; + } + + enqueue(body, where, config, auth, pushStatus) { + const limit = this.batchSize; + + where = applyDeviceTokenExists(where); + + // Order by objectId so no impact on the DB + const order = 'objectId'; + return Promise.resolve() + .then(() => { + return rest.find(config, auth, '_Installation', where, { + limit: 0, + count: true, + }); + }) + .then(({ results, count }) => { + if (!results || count == 0) { + return pushStatus.complete(); + } + pushStatus.setRunning(Math.ceil(count / limit)); + let skip = 0; + while (skip < count) { + const query = { + where, + limit, + skip, + order, + }; + + const pushWorkItem = { + body, + query, + pushStatus: { objectId: pushStatus.objectId }, + applicationId: config.applicationId, + }; + this.parsePublisher.publish(this.channel, JSON.stringify(pushWorkItem)); + skip += limit; + } + }); + } +} diff --git a/src/Push/PushWorker.js b/src/Push/PushWorker.js new file mode 100644 index 0000000000..6ffd960f33 --- /dev/null +++ b/src/Push/PushWorker.js @@ -0,0 +1,101 @@ +// @flow +import AdaptableController from '../Controllers/AdaptableController'; +import { master } from '../Auth'; +import Config from '../Config'; +import { PushAdapter } from '../Adapters/Push/PushAdapter'; +import rest from '../rest'; +import { pushStatusHandler } from '../StatusHandler'; +import * as utils from './utils'; +import { ParseMessageQueue } from '../ParseMessageQueue'; +import { PushQueue } from './PushQueue'; +import logger from '../logger'; + +function groupByBadge(installations) { + return installations.reduce((map, installation) => { + const badge = installation.badge + ''; + map[badge] = map[badge] || []; + map[badge].push(installation); + return map; + }, {}); +} + +export class PushWorker { + subscriber: ?any; + adapter: any; + channel: string; + + constructor(pushAdapter: PushAdapter, subscriberConfig: any = {}) { + AdaptableController.validateAdapter(pushAdapter, this, PushAdapter); + this.adapter = pushAdapter; + + this.channel = subscriberConfig.channel || PushQueue.defaultPushChannel(); + this.subscriber = ParseMessageQueue.createSubscriber(subscriberConfig); + if (this.subscriber) { + const subscriber = this.subscriber; + subscriber.subscribe(this.channel); + subscriber.on('message', (channel, messageStr) => { + const workItem = JSON.parse(messageStr); + this.run(workItem); + }); + } + } + + run({ body, query, pushStatus, applicationId, UTCOffset }: any): Promise<*> { + const config = Config.get(applicationId); + const auth = master(config); + const where = utils.applyDeviceTokenExists(query.where); + delete query.where; + pushStatus = pushStatusHandler(config, pushStatus.objectId); + return rest.find(config, auth, '_Installation', where, query).then(({ results }) => { + if (results.length == 0) { + return; + } + return this.sendToAdapter(body, results, pushStatus, config, UTCOffset); + }); + } + + sendToAdapter( + body: any, + installations: any[], + pushStatus: any, + config: Config, + UTCOffset: ?any + ): Promise<*> { + // Check if we have locales in the push body + const locales = utils.getLocalesFromPush(body); + if (locales.length > 0) { + // Get all tranformed bodies for each locale + const bodiesPerLocales = utils.bodiesPerLocales(body, locales); + + // Group installations on the specified locales (en, fr, default etc...) + const grouppedInstallations = utils.groupByLocaleIdentifier(installations, locales); + const promises = Object.keys(grouppedInstallations).map(locale => { + const installations = grouppedInstallations[locale]; + const body = bodiesPerLocales[locale]; + return this.sendToAdapter(body, installations, pushStatus, config, UTCOffset); + }); + return Promise.all(promises); + } + + if (!utils.isPushIncrementing(body)) { + logger.verbose(`Sending push to ${installations.length}`); + return this.adapter.send(body, installations, pushStatus.objectId).then(results => { + return pushStatus.trackSent(results, UTCOffset).then(() => results); + }); + } + + // Collect the badges to reduce the # of calls + const badgeInstallationsMap = groupByBadge(installations); + + // Map the on the badges count and return the send result + const promises = Object.keys(badgeInstallationsMap).map(badge => { + const payload = structuredClone(body); + payload.data.badge = parseInt(badge); + const installations = badgeInstallationsMap[badge]; + return this.sendToAdapter(payload, installations, pushStatus, config, UTCOffset); + }); + return Promise.all(promises); + } +} + +export default PushWorker; diff --git a/src/Push/utils.js b/src/Push/utils.js new file mode 100644 index 0000000000..4437cc8099 --- /dev/null +++ b/src/Push/utils.js @@ -0,0 +1,136 @@ +import Parse from 'parse/node'; + + +export function isPushIncrementing(body) { + if (!body.data || !body.data.badge) { + return false; + } + + const badge = body.data.badge; + if (typeof badge == 'string' && badge.toLowerCase() == 'increment') { + return true; + } + + return ( + typeof badge == 'object' && + typeof badge.__op == 'string' && + badge.__op.toLowerCase() == 'increment' && + Number(badge.amount) + ); +} + +const localizableKeys = ['alert', 'title']; + +export function getLocalesFromPush(body) { + const data = body.data; + if (!data) { + return []; + } + return [ + ...new Set( + Object.keys(data).reduce((memo, key) => { + localizableKeys.forEach(localizableKey => { + if (key.indexOf(`${localizableKey}-`) == 0) { + memo.push(key.slice(localizableKey.length + 1)); + } + }); + return memo; + }, []) + ), + ]; +} + +export function transformPushBodyForLocale(body, locale) { + const data = body.data; + if (!data) { + return body; + } + body = structuredClone(body); + localizableKeys.forEach(key => { + const localeValue = body.data[`${key}-${locale}`]; + if (localeValue) { + body.data[key] = localeValue; + } + }); + return stripLocalesFromBody(body); +} + +export function stripLocalesFromBody(body) { + if (!body.data) { + return body; + } + Object.keys(body.data).forEach(key => { + localizableKeys.forEach(localizableKey => { + if (key.indexOf(`${localizableKey}-`) == 0) { + delete body.data[key]; + } + }); + }); + return body; +} + +export function bodiesPerLocales(body, locales = []) { + // Get all tranformed bodies for each locale + const result = locales.reduce((memo, locale) => { + memo[locale] = transformPushBodyForLocale(body, locale); + return memo; + }, {}); + // Set the default locale, with the stripped body + result.default = stripLocalesFromBody(body); + return result; +} + +export function groupByLocaleIdentifier(installations, locales = []) { + return installations.reduce( + (map, installation) => { + let added = false; + locales.forEach(locale => { + if (added) { + return; + } + if (installation.localeIdentifier && installation.localeIdentifier.indexOf(locale) === 0) { + added = true; + map[locale] = map[locale] || []; + map[locale].push(installation); + } + }); + if (!added) { + map.default.push(installation); + } + return map; + }, + { default: [] } + ); +} + +/** + * Check whether the deviceType parameter in qury condition is valid or not. + * @param {Object} where A query condition + * @param {Array} validPushTypes An array of valid push types(string) + */ +export function validatePushType(where = {}, validPushTypes = []) { + var deviceTypeField = where.deviceType || {}; + var deviceTypes = []; + if (typeof deviceTypeField === 'string') { + deviceTypes.push(deviceTypeField); + } else if (Array.isArray(deviceTypeField['$in'])) { + deviceTypes.concat(deviceTypeField['$in']); + } + for (var i = 0; i < deviceTypes.length; i++) { + var deviceType = deviceTypes[i]; + if (validPushTypes.indexOf(deviceType) < 0) { + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + deviceType + ' is not supported push type.' + ); + } + } +} + +export function applyDeviceTokenExists(where) { + where = structuredClone(where); + if (!Object.prototype.hasOwnProperty.call(where, 'deviceToken')) { + where['deviceToken'] = { $exists: true }; + } + return where; +} diff --git a/src/RestQuery.js b/src/RestQuery.js index 0dc95ff341..f94c0af2c5 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -3,8 +3,12 @@ var SchemaController = require('./Controllers/SchemaController'); var Parse = require('parse/node').Parse; - -import { default as FilesController } from './Controllers/FilesController'; +var logger = require('./logger').default; +const triggers = require('./triggers'); +const { continueWhile } = require('parse/lib/node/promiseUtils'); +const AlwaysSelectedKeys = ['objectId', 'createdAt', 'updatedAt', 'ACL']; +const { enforceRoleSecurity } = require('./SharedRest'); +const { createSanitizedError } = require('./Error'); // restOptions can include: // skip @@ -13,36 +17,130 @@ import { default as FilesController } from './Controllers/FilesController'; // count // include // keys +// excludeKeys // redirectClassNameForKey -function RestQuery(config, auth, className, restWhere = {}, restOptions = {}, clientSDK) { +// readPreference +// includeReadPreference +// subqueryReadPreference +/** + * Use to perform a query on a class. It will run security checks and triggers. + * @param options + * @param options.method {RestQuery.Method} The type of query to perform + * @param options.config {ParseServerConfiguration} The server configuration + * @param options.auth {Auth} The auth object for the request + * @param options.className {string} The name of the class to query + * @param options.restWhere {object} The where object for the query + * @param options.restOptions {object} The options object for the query + * @param options.clientSDK {string} The client SDK that is performing the query + * @param options.runAfterFind {boolean} Whether to run the afterFind trigger + * @param options.runBeforeFind {boolean} Whether to run the beforeFind trigger + * @param options.context {object} The context object for the query + * @returns {Promise<_UnsafeRestQuery>} A promise that is resolved with the _UnsafeRestQuery object + */ +async function RestQuery({ + method, + config, + auth, + className, + restWhere = {}, + restOptions = {}, + clientSDK, + runAfterFind = true, + runBeforeFind = true, + context, +}) { + if (![RestQuery.Method.find, RestQuery.Method.get].includes(method)) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, 'bad query type'); + } + const isGet = method === RestQuery.Method.get; + enforceRoleSecurity(method, className, auth, config); + const result = runBeforeFind + ? await triggers.maybeRunQueryTrigger( + triggers.Types.beforeFind, + className, + restWhere, + restOptions, + config, + auth, + context, + isGet + ) + : Promise.resolve({ restWhere, restOptions }); + + return new _UnsafeRestQuery( + config, + auth, + className, + result.restWhere || restWhere, + result.restOptions || restOptions, + clientSDK, + runAfterFind, + context, + isGet + ); +} +RestQuery.Method = Object.freeze({ + get: 'get', + find: 'find', +}); + +/** + * _UnsafeRestQuery is meant for specific internal usage only. When you need to skip security checks or some triggers. + * Don't use it if you don't know what you are doing. + * @param config + * @param auth + * @param className + * @param restWhere + * @param restOptions + * @param clientSDK + * @param runAfterFind + * @param context + */ +function _UnsafeRestQuery( + config, + auth, + className, + restWhere = {}, + restOptions = {}, + clientSDK, + runAfterFind = true, + context, + isGet +) { this.config = config; this.auth = auth; this.className = className; this.restWhere = restWhere; + this.restOptions = restOptions; this.clientSDK = clientSDK; + this.runAfterFind = runAfterFind; this.response = null; this.findOptions = {}; + this.context = context || {}; + this.isGet = isGet; if (!this.auth.isMaster) { - this.findOptions.acl = this.auth.user ? [this.auth.user.id] : null; if (this.className == '_Session') { - if (!this.findOptions.acl) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, - 'This session token is invalid.'); + if (!this.auth.user) { + throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', config); } this.restWhere = { - '$and': [this.restWhere, { - 'user': { + $and: [ + this.restWhere, + { + user: { __type: 'Pointer', className: '_User', - objectId: this.auth.user.id - } - }] + objectId: this.auth.user.id, + }, + }, + ], }; } } this.doCount = false; + this.includeAll = false; // The format for this.include is not the same as the format for the // include option - it's the paths we should include, in order, @@ -51,57 +149,129 @@ function RestQuery(config, auth, className, restWhere = {}, restOptions = {}, cl // For example, passing an arg of include=foo.bar,foo.baz could lead to // this.include = [['foo'], ['foo', 'baz'], ['foo', 'bar']] this.include = []; + let keysForInclude = ''; + + // If we have keys, we probably want to force some includes (n-1 level) + // See issue: https://github.com/parse-community/parse-server/issues/3185 + if (Object.prototype.hasOwnProperty.call(restOptions, 'keys')) { + keysForInclude = restOptions.keys; + } + + // If we have keys, we probably want to force some includes (n-1 level) + // in order to exclude specific keys. + if (Object.prototype.hasOwnProperty.call(restOptions, 'excludeKeys')) { + keysForInclude += ',' + restOptions.excludeKeys; + } + + if (keysForInclude.length > 0) { + keysForInclude = keysForInclude + .split(',') + .filter(key => { + // At least 2 components + return key.split('.').length > 1; + }) + .map(key => { + // Slice the last component (a.b.c -> a.b) + // Otherwise we'll include one level too much. + return key.slice(0, key.lastIndexOf('.')); + }) + .join(','); + + // Concat the possibly present include string with the one from the keys + // Dedup / sorting is handle in 'include' case. + if (keysForInclude.length > 0) { + if (!restOptions.include || restOptions.include.length == 0) { + restOptions.include = keysForInclude; + } else { + restOptions.include += ',' + keysForInclude; + } + } + } for (var option in restOptions) { - switch(option) { - case 'keys': - this.keys = new Set(restOptions.keys.split(',')); - this.keys.add('objectId'); - this.keys.add('createdAt'); - this.keys.add('updatedAt'); - break; - case 'count': - this.doCount = true; - break; - case 'skip': - case 'limit': - this.findOptions[option] = restOptions[option]; - break; - case 'order': - var fields = restOptions.order.split(','); - var sortMap = {}; - for (var field of fields) { - if (field[0] == '-') { - sortMap[field.slice(1)] = -1; - } else { - sortMap[field] = 1; - } + switch (option) { + case 'keys': { + const keys = restOptions.keys + .split(',') + .filter(key => key.length > 0) + .concat(AlwaysSelectedKeys); + this.keys = Array.from(new Set(keys)); + break; + } + case 'excludeKeys': { + const exclude = restOptions.excludeKeys + .split(',') + .filter(k => AlwaysSelectedKeys.indexOf(k) < 0); + this.excludeKeys = Array.from(new Set(exclude)); + break; } - this.findOptions.sort = sortMap; - break; - case 'include': - var paths = restOptions.include.split(','); - var pathSet = {}; - for (var path of paths) { - // Add all prefixes with a .-split to pathSet - var parts = path.split('.'); - for (var len = 1; len <= parts.length; len++) { - pathSet[parts.slice(0, len).join('.')] = true; + case 'count': + this.doCount = true; + break; + case 'includeAll': + this.includeAll = true; + break; + case 'explain': + case 'hint': + case 'distinct': + case 'pipeline': + case 'skip': + case 'limit': + case 'readPreference': + case 'comment': + case 'rawValues': + case 'rawFieldNames': + this.findOptions[option] = restOptions[option]; + break; + case 'order': + var fields = restOptions.order.split(','); + this.findOptions.sort = fields.reduce((sortMap, field) => { + field = field.trim(); + if (field === '$score' || field === '-$score') { + sortMap.score = { $meta: 'textScore' }; + } else if (field[0] == '-') { + sortMap[field.slice(1)] = -1; + } else { + sortMap[field] = 1; + } + return sortMap; + }, {}); + break; + case 'include': { + const paths = restOptions.include.split(','); + if (paths.includes('*')) { + this.includeAll = true; + break; } + // Load the existing includes (from keys) + const pathSet = paths.reduce((memo, path) => { + // Split each paths on . (a.b.c -> [a,b,c]) + // reduce to create all paths + // ([a,b,c] -> {a: true, 'a.b': true, 'a.b.c': true}) + return path.split('.').reduce((memo, path, index, parts) => { + memo[parts.slice(0, index + 1).join('.')] = true; + return memo; + }, memo); + }, {}); + + this.include = Object.keys(pathSet) + .map(s => { + return s.split('.'); + }) + .sort((a, b) => { + return a.length - b.length; // Sort by number of components + }); + break; } - this.include = Object.keys(pathSet).sort((a, b) => { - return a.length - b.length; - }).map((s) => { - return s.split('.'); - }); - break; - case 'redirectClassNameForKey': - this.redirectKey = restOptions.redirectClassNameForKey; - this.redirectClassName = null; - break; - default: - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'bad option: ' + option); + case 'redirectClassNameForKey': + this.redirectKey = restOptions.redirectClassNameForKey; + this.redirectClassName = null; + break; + case 'includeReadPreference': + case 'subqueryReadPreference': + break; + default: + throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad option: ' + option); } } } @@ -111,78 +281,225 @@ function RestQuery(config, auth, className, restWhere = {}, restOptions = {}, cl // Returns a promise for the response - an object with optional keys // 'results' and 'count'. // TODO: consolidate the replaceX functions -RestQuery.prototype.execute = function() { - return Promise.resolve().then(() => { - return this.buildRestWhere(); - }).then(() => { - return this.runFind(); - }).then(() => { - return this.runCount(); - }).then(() => { - return this.handleInclude(); - }).then(() => { - return this.response; - }); +_UnsafeRestQuery.prototype.execute = function (executeOptions) { + return Promise.resolve() + .then(() => { + return this.validateQueryDepth(); + }) + .then(() => { + return this.buildRestWhere(); + }) + .then(() => { + return this.denyProtectedFields(); + }) + .then(() => { + return this.handleIncludeAll(); + }) + .then(() => { + return this.validateIncludeComplexity(); + }) + .then(() => { + return this.handleExcludeKeys(); + }) + .then(() => { + return this.runFind(executeOptions); + }) + .then(() => { + return this.runCount(); + }) + .then(() => { + return this.handleInclude(); + }) + .then(() => { + return this.runAfterFindTrigger(); + }) + .then(() => { + return this.handleAuthAdapters(); + }) + .then(() => { + return this.response; + }); }; -RestQuery.prototype.buildRestWhere = function() { - return Promise.resolve().then(() => { - return this.getUserAndRoleACL(); - }).then(() => { - return this.redirectClassNameForKey(); - }).then(() => { - return this.validateClientClassCreation(); - }).then(() => { - return this.replaceSelect(); - }).then(() => { - return this.replaceDontSelect(); - }).then(() => { - return this.replaceInQuery(); - }).then(() => { - return this.replaceNotInQuery(); - }); -} +_UnsafeRestQuery.prototype.each = function (callback) { + const { config, auth, className, restWhere, restOptions, clientSDK } = this; + // if the limit is set, use it + restOptions.limit = restOptions.limit || 100; + restOptions.order = 'objectId'; + let finished = false; + + return continueWhile( + () => { + return !finished; + }, + async () => { + // Safe here to use _UnsafeRestQuery because the security was already + // checked during "await RestQuery()" + const query = new _UnsafeRestQuery( + config, + auth, + className, + restWhere, + restOptions, + clientSDK, + this.runAfterFind, + this.context + ); + const { results } = await query.execute(); + results.forEach(callback); + finished = results.length < restOptions.limit; + if (!finished) { + restWhere.objectId = Object.assign({}, restWhere.objectId, { + $gt: results[results.length - 1].objectId, + }); + } + } + ); +}; + +_UnsafeRestQuery.prototype.validateQueryDepth = function () { + if (this.auth.isMaster || this.auth.isMaintenance) { + return; + } + const rc = this.config.requestComplexity; + if (!rc || rc.queryDepth === -1) { + return; + } + const maxDepth = rc.queryDepth; + const checkDepth = (where, depth) => { + if (depth > maxDepth) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Query condition nesting depth exceeds maximum allowed depth of ${maxDepth}` + ); + } + if (typeof where !== 'object' || where === null) { + return; + } + for (const op of ['$or', '$and', '$nor']) { + if (Array.isArray(where[op])) { + for (const subQuery of where[op]) { + checkDepth(subQuery, depth + 1); + } + } + } + }; + checkDepth(this.restWhere, 0); +}; + +_UnsafeRestQuery.prototype.buildRestWhere = function () { + return Promise.resolve() + .then(() => { + return this.getUserAndRoleACL(); + }) + .then(() => { + return this.redirectClassNameForKey(); + }) + .then(() => { + return this.validateClientClassCreation(); + }) + .then(() => { + return this.checkSubqueryDepth(); + }) + .then(() => { + return this.replaceSelect(); + }) + .then(() => { + return this.replaceDontSelect(); + }) + .then(() => { + return this.replaceInQuery(); + }) + .then(() => { + return this.replaceNotInQuery(); + }) + .then(() => { + return this.replaceEquality(); + }); +}; // Uses the Auth object to get the list of roles, adds the user id -RestQuery.prototype.getUserAndRoleACL = function() { - if (this.auth.isMaster || !this.auth.user) { +_UnsafeRestQuery.prototype.getUserAndRoleACL = function () { + if (this.auth.isMaster) { return Promise.resolve(); } - return this.auth.getUserRoles().then((roles) => { - roles.push(this.auth.user.id); - this.findOptions.acl = roles; + + this.findOptions.acl = ['*']; + + if (this.auth.user) { + return this.auth.getUserRoles().then(roles => { + this.findOptions.acl = this.findOptions.acl.concat(roles, [this.auth.user.id]); + return; + }); + } else { return Promise.resolve(); - }); + } }; // Changes the className if redirectClassNameForKey is set. // Returns a promise. -RestQuery.prototype.redirectClassNameForKey = function() { +_UnsafeRestQuery.prototype.redirectClassNameForKey = function () { if (!this.redirectKey) { return Promise.resolve(); } // We need to change the class name based on the schema - return this.config.database.redirectClassNameForKey( - this.className, this.redirectKey).then((newClassName) => { + return this.config.database + .redirectClassNameForKey(this.className, this.redirectKey) + .then(newClassName => { this.className = newClassName; this.redirectClassName = newClassName; + + // Re-apply security checks for the redirected class name, since the + // checks in the constructor and in rest.find ran against the original + // class name before the redirect. + if (!this.auth.isMaster) { + enforceRoleSecurity('find', this.className, this.auth, this.config); + + if (this.className === '_Session') { + if (!this.auth.user) { + throw createSanitizedError( + Parse.Error.INVALID_SESSION_TOKEN, + 'Invalid session token', + this.config + ); + } + this.restWhere = { + $and: [ + this.restWhere, + { + user: { + __type: 'Pointer', + className: '_User', + objectId: this.auth.user.id, + }, + }, + ], + }; + } + } }); }; // Validates this operation against the allowClientClassCreation config. -RestQuery.prototype.validateClientClassCreation = function() { - if (this.config.allowClientClassCreation === false && !this.auth.isMaster - && SchemaController.systemClasses.indexOf(this.className) === -1) { - return this.config.database.loadSchema() +_UnsafeRestQuery.prototype.validateClientClassCreation = function () { + if ( + this.config.allowClientClassCreation === false && + !this.auth.isMaster && + SchemaController.systemClasses.indexOf(this.className) === -1 + ) { + return this.config.database + .loadSchema() .then(schemaController => schemaController.hasClass(this.className)) .then(hasClass => { if (hasClass !== true) { - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, - 'This user is not allowed to access ' + - 'non-existent class: ' + this.className); + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + 'This user is not allowed to access ' + 'non-existent class: ' + this.className, + this.config + ); } - }); + }); } else { return Promise.resolve(); } @@ -194,7 +511,7 @@ function transformInQuery(inQueryObject, className, results) { values.push({ __type: 'Pointer', className: className, - objectId: result.objectId + objectId: result.objectId, }); } delete inQueryObject['$inQuery']; @@ -205,11 +522,27 @@ function transformInQuery(inQueryObject, className, results) { } } +_UnsafeRestQuery.prototype.checkSubqueryDepth = function () { + if (this.auth.isMaster || this.auth.isMaintenance) { + return; + } + const rc = this.config.requestComplexity; + if (!rc || rc.subqueryDepth === -1) { + return; + } + const depth = this.context._subqueryDepth || 0; + if (depth > rc.subqueryDepth) { + const message = `Subquery nesting depth exceeds maximum allowed depth of ${rc.subqueryDepth}`; + logger.warn(message); + throw new Parse.Error(Parse.Error.INVALID_QUERY, message); + } +}; + // Replaces a $inQuery clause by running the subquery, if there is an // $inQuery clause. // The $inQuery clause turns into an $in with values that are just // pointers to the objects returned in the subquery. -RestQuery.prototype.replaceInQuery = function() { +_UnsafeRestQuery.prototype.replaceInQuery = async function () { var inQueryObject = findObjectWithKey(this.restWhere, '$inQuery'); if (!inQueryObject) { return; @@ -218,18 +551,38 @@ RestQuery.prototype.replaceInQuery = function() { // The inQuery value must have precisely two keys - where and className var inQueryValue = inQueryObject['$inQuery']; if (!inQueryValue.where || !inQueryValue.className) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, - 'improper usage of $inQuery'); + throw new Parse.Error(Parse.Error.INVALID_QUERY, 'improper usage of $inQuery'); } - let additionalOptions = { - redirectClassNameForKey: inQueryValue.redirectClassNameForKey + const additionalOptions = { + redirectClassNameForKey: inQueryValue.redirectClassNameForKey, }; - var subquery = new RestQuery( - this.config, this.auth, inQueryValue.className, - inQueryValue.where, additionalOptions); - return subquery.execute().then((response) => { + if (this.restOptions.subqueryReadPreference) { + additionalOptions.readPreference = this.restOptions.subqueryReadPreference; + additionalOptions.subqueryReadPreference = this.restOptions.subqueryReadPreference; + } else if (this.restOptions.readPreference) { + additionalOptions.readPreference = this.restOptions.readPreference; + } + + if (!this.auth.isMaster && !this.auth.isMaintenance) { + const rc = this.config.requestComplexity; + if (rc && rc.subqueryLimit > 0) { + additionalOptions.limit = rc.subqueryLimit; + } + } + + const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 }; + const subquery = await RestQuery({ + method: RestQuery.Method.find, + config: this.config, + auth: this.auth, + className: inQueryValue.className, + restWhere: inQueryValue.where, + restOptions: additionalOptions, + context: childContext, + }); + return subquery.execute().then(response => { transformInQuery(inQueryObject, subquery.className, response.results); // Recurse to repeat return this.replaceInQuery(); @@ -242,7 +595,7 @@ function transformNotInQuery(notInQueryObject, className, results) { values.push({ __type: 'Pointer', className: className, - objectId: result.objectId + objectId: result.objectId, }); } delete notInQueryObject['$notInQuery']; @@ -257,7 +610,7 @@ function transformNotInQuery(notInQueryObject, className, results) { // $notInQuery clause. // The $notInQuery clause turns into a $nin with values that are just // pointers to the objects returned in the subquery. -RestQuery.prototype.replaceNotInQuery = function() { +_UnsafeRestQuery.prototype.replaceNotInQuery = async function () { var notInQueryObject = findObjectWithKey(this.restWhere, '$notInQuery'); if (!notInQueryObject) { return; @@ -266,28 +619,57 @@ RestQuery.prototype.replaceNotInQuery = function() { // The notInQuery value must have precisely two keys - where and className var notInQueryValue = notInQueryObject['$notInQuery']; if (!notInQueryValue.where || !notInQueryValue.className) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, - 'improper usage of $notInQuery'); + throw new Parse.Error(Parse.Error.INVALID_QUERY, 'improper usage of $notInQuery'); } - let additionalOptions = { - redirectClassNameForKey: notInQueryValue.redirectClassNameForKey + const additionalOptions = { + redirectClassNameForKey: notInQueryValue.redirectClassNameForKey, }; - var subquery = new RestQuery( - this.config, this.auth, notInQueryValue.className, - notInQueryValue.where, additionalOptions); - return subquery.execute().then((response) => { + if (this.restOptions.subqueryReadPreference) { + additionalOptions.readPreference = this.restOptions.subqueryReadPreference; + additionalOptions.subqueryReadPreference = this.restOptions.subqueryReadPreference; + } else if (this.restOptions.readPreference) { + additionalOptions.readPreference = this.restOptions.readPreference; + } + + if (!this.auth.isMaster && !this.auth.isMaintenance) { + const rc = this.config.requestComplexity; + if (rc && rc.subqueryLimit > 0) { + additionalOptions.limit = rc.subqueryLimit; + } + } + + const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 }; + const subquery = await RestQuery({ + method: RestQuery.Method.find, + config: this.config, + auth: this.auth, + className: notInQueryValue.className, + restWhere: notInQueryValue.where, + restOptions: additionalOptions, + context: childContext, + }); + + return subquery.execute().then(response => { transformNotInQuery(notInQueryObject, subquery.className, response.results); // Recurse to repeat return this.replaceNotInQuery(); }); }; -const transformSelect = (selectObject, key ,objects) => { +// Used to get the deepest object from json using dot notation. +const getDeepestObjectFromKey = (json, key, idx, src) => { + if (key in json) { + return json[key]; + } + src.splice(1); // Exit Early +}; + +const transformSelect = (selectObject, key, objects) => { var values = []; for (var result of objects) { - values.push(result[key]); + values.push(key.split('.').reduce(getDeepestObjectFromKey, result)); } delete selectObject['$select']; if (Array.isArray(selectObject['$in'])) { @@ -295,14 +677,14 @@ const transformSelect = (selectObject, key ,objects) => { } else { selectObject['$in'] = values; } -} +}; // Replaces a $select clause by running the subquery, if there is a // $select clause. // The $select clause turns into an $in with values selected out of // the subquery. // Returns a possible-promise. -RestQuery.prototype.replaceSelect = function() { +_UnsafeRestQuery.prototype.replaceSelect = async function () { var selectObject = findObjectWithKey(this.restWhere, '$select'); if (!selectObject) { return; @@ -311,33 +693,56 @@ RestQuery.prototype.replaceSelect = function() { // The select value must have precisely two keys - query and key var selectValue = selectObject['$select']; // iOS SDK don't send where if not set, let it pass - if (!selectValue.query || - !selectValue.key || - typeof selectValue.query !== 'object' || - !selectValue.query.className || - Object.keys(selectValue).length !== 2) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, - 'improper usage of $select'); + if ( + !selectValue.query || + !selectValue.key || + typeof selectValue.query !== 'object' || + !selectValue.query.className || + Object.keys(selectValue).length !== 2 + ) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, 'improper usage of $select'); } - let additionalOptions = { - redirectClassNameForKey: selectValue.query.redirectClassNameForKey + const additionalOptions = { + redirectClassNameForKey: selectValue.query.redirectClassNameForKey, }; - var subquery = new RestQuery( - this.config, this.auth, selectValue.query.className, - selectValue.query.where, additionalOptions); - return subquery.execute().then((response) => { + if (this.restOptions.subqueryReadPreference) { + additionalOptions.readPreference = this.restOptions.subqueryReadPreference; + additionalOptions.subqueryReadPreference = this.restOptions.subqueryReadPreference; + } else if (this.restOptions.readPreference) { + additionalOptions.readPreference = this.restOptions.readPreference; + } + + if (!this.auth.isMaster && !this.auth.isMaintenance) { + const rc = this.config.requestComplexity; + if (rc && rc.subqueryLimit > 0) { + additionalOptions.limit = rc.subqueryLimit; + } + } + + const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 }; + const subquery = await RestQuery({ + method: RestQuery.Method.find, + config: this.config, + auth: this.auth, + className: selectValue.query.className, + restWhere: selectValue.query.where, + restOptions: additionalOptions, + context: childContext, + }); + + return subquery.execute().then(response => { transformSelect(selectObject, selectValue.key, response.results); // Keep replacing $select clauses return this.replaceSelect(); - }) + }); }; const transformDontSelect = (dontSelectObject, key, objects) => { var values = []; for (var result of objects) { - values.push(result[key]); + values.push(key.split('.').reduce(getDeepestObjectFromKey, result)); } delete dontSelectObject['$dontSelect']; if (Array.isArray(dontSelectObject['$nin'])) { @@ -345,14 +750,14 @@ const transformDontSelect = (dontSelectObject, key, objects) => { } else { dontSelectObject['$nin'] = values; } -} +}; // Replaces a $dontSelect clause by running the subquery, if there is a // $dontSelect clause. // The $dontSelect clause turns into an $nin with values selected out of // the subquery. // Returns a possible-promise. -RestQuery.prototype.replaceDontSelect = function() { +_UnsafeRestQuery.prototype.replaceDontSelect = async function () { var dontSelectObject = findObjectWithKey(this.restWhere, '$dontSelect'); if (!dontSelectObject) { return; @@ -360,161 +765,520 @@ RestQuery.prototype.replaceDontSelect = function() { // The dontSelect value must have precisely two keys - query and key var dontSelectValue = dontSelectObject['$dontSelect']; - if (!dontSelectValue.query || - !dontSelectValue.key || - typeof dontSelectValue.query !== 'object' || - !dontSelectValue.query.className || - Object.keys(dontSelectValue).length !== 2) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, - 'improper usage of $dontSelect'); - } - let additionalOptions = { - redirectClassNameForKey: dontSelectValue.query.redirectClassNameForKey + if ( + !dontSelectValue.query || + !dontSelectValue.key || + typeof dontSelectValue.query !== 'object' || + !dontSelectValue.query.className || + Object.keys(dontSelectValue).length !== 2 + ) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, 'improper usage of $dontSelect'); + } + const additionalOptions = { + redirectClassNameForKey: dontSelectValue.query.redirectClassNameForKey, }; - var subquery = new RestQuery( - this.config, this.auth, dontSelectValue.query.className, - dontSelectValue.query.where, additionalOptions); - return subquery.execute().then((response) => { + if (this.restOptions.subqueryReadPreference) { + additionalOptions.readPreference = this.restOptions.subqueryReadPreference; + additionalOptions.subqueryReadPreference = this.restOptions.subqueryReadPreference; + } else if (this.restOptions.readPreference) { + additionalOptions.readPreference = this.restOptions.readPreference; + } + + if (!this.auth.isMaster && !this.auth.isMaintenance) { + const rc = this.config.requestComplexity; + if (rc && rc.subqueryLimit > 0) { + additionalOptions.limit = rc.subqueryLimit; + } + } + + const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 }; + const subquery = await RestQuery({ + method: RestQuery.Method.find, + config: this.config, + auth: this.auth, + className: dontSelectValue.query.className, + restWhere: dontSelectValue.query.where, + restOptions: additionalOptions, + context: childContext, + }); + + return subquery.execute().then(response => { transformDontSelect(dontSelectObject, dontSelectValue.key, response.results); // Keep replacing $dontSelect clauses return this.replaceDontSelect(); - }) + }); +}; + +_UnsafeRestQuery.prototype.cleanResultAuthData = function (result) { + delete result.password; + if (result.authData) { + Object.keys(result.authData).forEach(provider => { + if (result.authData[provider] === null) { + delete result.authData[provider]; + } + }); + + if (Object.keys(result.authData).length == 0) { + delete result.authData; + } + } +}; + +const replaceEqualityConstraint = constraint => { + if (typeof constraint !== 'object') { + return constraint; + } + const equalToObject = {}; + let hasDirectConstraint = false; + let hasOperatorConstraint = false; + for (const key in constraint) { + if (key.indexOf('$') !== 0) { + hasDirectConstraint = true; + equalToObject[key] = constraint[key]; + } else { + hasOperatorConstraint = true; + } + } + if (hasDirectConstraint && hasOperatorConstraint) { + constraint['$eq'] = equalToObject; + Object.keys(equalToObject).forEach(key => { + delete constraint[key]; + }); + } + return constraint; +}; + +_UnsafeRestQuery.prototype.replaceEquality = function () { + if (typeof this.restWhere !== 'object') { + return; + } + for (const key in this.restWhere) { + this.restWhere[key] = replaceEqualityConstraint(this.restWhere[key]); + } }; // Returns a promise for whether it was successful. // Populates this.response with an object that only has 'results'. -RestQuery.prototype.runFind = function() { +_UnsafeRestQuery.prototype.runFind = async function (options = {}) { if (this.findOptions.limit === 0) { - this.response = {results: []}; + this.response = { results: [] }; return Promise.resolve(); } - return this.config.database.find( - this.className, this.restWhere, this.findOptions).then((results) => { - if (this.className === '_User') { - for (var result of results) { - delete result.password; - - if (result.authData) { - Object.keys(result.authData).forEach((provider) => { - if (result.authData[provider] === null) { - delete result.authData[provider]; - } - }); - if (Object.keys(result.authData).length == 0) { - delete result.authData; - } + const findOptions = Object.assign({}, this.findOptions); + if (this.keys) { + findOptions.keys = this.keys.map(key => { + return key.split('.')[0]; + }); + // When selecting `authData` on `_User`, also add the internal auth data fields + // (e.g. `_auth_data_facebook`) for each configured auth provider. In MongoDB, + // `authData` is stored as individual `_auth_data_` fields, so the + // projection for `authData` alone won't match them. Adding both ensures it + // works across all database adapters: Mongo uses `_auth_data_*` fields, + // Postgres uses the `authData` column directly. + // + // Note: When selecting `authData`, only auth data of currently configured + // providers is returned. Auth data entries of providers that are no longer + // configured won't be included. To return all auth data regardless of the + // provider configuration, do not use `authData` as a selected key. + if (this.className === '_User' && findOptions.keys.includes('authData')) { + const providers = this.config.authDataManager.getProviders(); + for (const provider of providers) { + const key = `_auth_data_${provider}`; + if (!findOptions.keys.includes(key)) { + findOptions.keys.push(key); } } } - - this.config.filesController.expandFilesInObject(this.config, results); - - if (this.keys) { - var keySet = this.keys; - results = results.map((object) => { - var newObject = {}; - for (var key in object) { - if (keySet.has(key)) { - newObject[key] = object[key]; - } - } - return newObject; - }); + } + if (options.op) { + findOptions.op = options.op; + } + const results = await this.config.database.find(this.className, this.restWhere, findOptions, this.auth); + if (this.className === '_User' && !findOptions.explain) { + for (var result of results) { + this.cleanResultAuthData(result); } + } - if (this.redirectClassName) { - for (var r of results) { - r.className = this.redirectClassName; - } + await this.config.filesController.expandFilesInObject(this.config, results); + + if (this.redirectClassName) { + for (var r of results) { + r.className = this.redirectClassName; } - this.response = {results: results}; - }); + } + this.response = { results: results }; }; // Returns a promise for whether it was successful. // Populates this.response.count with the count -RestQuery.prototype.runCount = function() { +_UnsafeRestQuery.prototype.runCount = function () { if (!this.doCount) { return; } this.findOptions.count = true; delete this.findOptions.skip; delete this.findOptions.limit; - return this.config.database.find( - this.className, this.restWhere, this.findOptions).then((c) => { - this.response.count = c; + return this.config.database.find(this.className, this.restWhere, this.findOptions).then(c => { + this.response.count = c; + }); +}; + +_UnsafeRestQuery.prototype.denyProtectedFields = async function () { + if (this.auth.isMaster || this.auth.isMaintenance) { + return; + } + const schemaController = await this.config.database.loadSchema(); + const protectedFields = + this.config.database.addProtectedFields( + schemaController, + this.className, + this.restWhere, + this.findOptions.acl, + this.auth, + this.findOptions + ) || []; + const checkWhere = (where) => { + if (typeof where !== 'object' || where === null) { + return; + } + for (const whereKey of Object.keys(where)) { + const rootField = whereKey.split('.')[0]; + if (protectedFields.includes(whereKey) || protectedFields.includes(rootField)) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + `This user is not allowed to query ${whereKey} on class ${this.className}`, + this.config + ); + } + } + for (const op of ['$or', '$and', '$nor']) { + if (where[op] !== undefined && !Array.isArray(where[op])) { + throw createSanitizedError( + Parse.Error.INVALID_QUERY, + `${op} must be an array`, + this.config + ); + } + if (Array.isArray(where[op])) { + where[op].forEach(subQuery => checkWhere(subQuery)); + } + } + }; + checkWhere(this.restWhere); + + // Check sort keys against protected fields + if (this.findOptions.sort) { + for (const sortKey of Object.keys(this.findOptions.sort)) { + const rootField = sortKey.split('.')[0]; + if (protectedFields.includes(sortKey) || protectedFields.includes(rootField)) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + `This user is not allowed to sort by ${sortKey} on class ${this.className}`, + this.config + ); + } + } + } +}; + +// Augments this.response with all pointers on an object +_UnsafeRestQuery.prototype.handleIncludeAll = function () { + if (!this.includeAll) { + return; + } + return this.config.database + .loadSchema() + .then(schemaController => schemaController.getOneSchema(this.className)) + .then(schema => { + const includeFields = []; + const keyFields = []; + for (const field in schema.fields) { + if ( + (schema.fields[field].type && schema.fields[field].type === 'Pointer') || + (schema.fields[field].type && schema.fields[field].type === 'Array') + ) { + includeFields.push([field]); + keyFields.push(field); + } + } + // Add fields to include, keys, remove dups + this.include = [...new Set([...this.include, ...includeFields])]; + // if this.keys not set, then all keys are already included + if (this.keys) { + this.keys = [...new Set([...this.keys, ...keyFields])]; + } + }); +}; + +_UnsafeRestQuery.prototype.validateIncludeComplexity = function () { + if (this.auth.isMaster || this.auth.isMaintenance) { + return; + } + const rc = this.config.requestComplexity; + if (!rc) { + return; + } + if (rc.includeDepth !== -1 && this.include && this.include.length > 0) { + const maxDepth = Math.max(...this.include.map(path => path.length)); + if (maxDepth > rc.includeDepth) { + const message = `Include depth of ${maxDepth} exceeds maximum allowed depth of ${rc.includeDepth}`; + logger.warn(message); + throw new Parse.Error(Parse.Error.INVALID_QUERY, message); + } + } + if (rc.includeCount !== -1 && this.include && this.include.length > rc.includeCount) { + const message = `Number of include fields (${this.include.length}) exceeds maximum allowed (${rc.includeCount})`; + logger.warn(message); + throw new Parse.Error(Parse.Error.INVALID_QUERY, message); + } +}; + +// Updates property `this.keys` to contain all keys but the ones unselected. +_UnsafeRestQuery.prototype.handleExcludeKeys = function () { + if (!this.excludeKeys) { + return; + } + if (this.keys) { + this.keys = this.keys.filter(k => !this.excludeKeys.includes(k)); + return; + } + return this.config.database + .loadSchema() + .then(schemaController => schemaController.getOneSchema(this.className)) + .then(schema => { + const fields = Object.keys(schema.fields); + this.keys = fields.filter(k => !this.excludeKeys.includes(k)); }); }; // Augments this.response with data at the paths provided in this.include. -RestQuery.prototype.handleInclude = function() { +_UnsafeRestQuery.prototype.handleInclude = async function () { if (this.include.length == 0) { return; } - var pathResponse = includePath(this.config, this.auth, - this.response, this.include[0]); - if (pathResponse.then) { - return pathResponse.then((newResponse) => { - this.response = newResponse; - this.include = this.include.slice(1); - return this.handleInclude(); + const indexedResults = this.response.results.reduce((indexed, result, i) => { + indexed[result.objectId] = i; + return indexed; + }, {}); + + // Build the execution tree + const executionTree = {} + this.include.forEach(path => { + let current = executionTree; + path.forEach((node) => { + if (!current[node]) { + current[node] = { + path, + children: {} + }; + } + current = current[node].children }); - } else if (this.include.length > 0) { - this.include = this.include.slice(1); - return this.handleInclude(); + }); + + const recursiveExecutionTree = async (treeNode) => { + const { path, children } = treeNode; + const pathResponse = includePath( + this.config, + this.auth, + this.response, + path, + this.context, + this.restOptions, + this, + ); + if (pathResponse.then) { + const newResponse = await pathResponse + newResponse.results.forEach(newObject => { + // We hydrate the root of each result with sub results + this.response.results[indexedResults[newObject.objectId]][path[0]] = newObject[path[0]]; + }) + } + return Promise.all(Object.values(children).map(recursiveExecutionTree)); + } + + await Promise.all(Object.values(executionTree).map(recursiveExecutionTree)); + this.include = [] +}; + +//Returns a promise of a processed set of results +_UnsafeRestQuery.prototype.runAfterFindTrigger = function () { + if (!this.response) { + return; + } + if (!this.runAfterFind) { + return; + } + // Avoid doing any setup for triggers if there is no 'afterFind' trigger for this class. + const hasAfterFindHook = triggers.triggerExists( + this.className, + triggers.Types.afterFind, + this.config.applicationId + ); + if (!hasAfterFindHook) { + return Promise.resolve(); + } + // Skip Aggregate and Distinct Queries + if (this.findOptions.pipeline || this.findOptions.distinct) { + return Promise.resolve(); } - return pathResponse; + const json = Object.assign({}, this.restOptions); + json.where = this.restWhere; + const parseQuery = new Parse.Query(this.className); + parseQuery.withJSON(json); + // Run afterFind trigger and set the new results + return triggers + .maybeRunAfterFindTrigger( + triggers.Types.afterFind, + this.auth, + this.className, + this.response.results, + this.config, + parseQuery, + this.context, + this.isGet + ) + .then(results => { + // Ensure we properly set the className back + if (this.redirectClassName) { + this.response.results = results.map(object => { + if (object instanceof Parse.Object) { + object = object.toJSON(); + } + object.className = this.redirectClassName; + return object; + }); + } else { + this.response.results = results; + } + }); +}; + +_UnsafeRestQuery.prototype.handleAuthAdapters = async function () { + if (this.className !== '_User' || this.findOptions.explain) { + return; + } + await Promise.all( + this.response.results.map(result => + this.config.authDataManager.runAfterFind( + { config: this.config, auth: this.auth }, + result.authData + ) + ) + ); }; // Adds included values to the response. // Path is a list of field names. // Returns a promise for an augmented response. -function includePath(config, auth, response, path) { +function includePath(config, auth, response, path, context, restOptions = {}) { var pointers = findPointers(response.results, path); if (pointers.length == 0) { return response; } - let pointersHash = {}; - var objectIds = {}; + const pointersHash = {}; for (var pointer of pointers) { - let className = pointer.className; + if (!pointer) { + continue; + } + const className = pointer.className; // only include the good pointers if (className) { - pointersHash[className] = pointersHash[className] || []; - pointersHash[className].push(pointer.objectId); + pointersHash[className] = pointersHash[className] || new Set(); + pointersHash[className].add(pointer.objectId); + } + } + const includeRestOptions = {}; + if (restOptions.keys) { + const keys = new Set(restOptions.keys.split(',')); + const keySet = Array.from(keys).reduce((set, key) => { + const keyPath = key.split('.'); + let i = 0; + for (i; i < path.length; i++) { + if (path[i] != keyPath[i]) { + return set; + } + } + if (i < keyPath.length) { + set.add(keyPath[i]); + } + return set; + }, new Set()); + if (keySet.size > 0) { + includeRestOptions.keys = Array.from(keySet).join(','); } } - let queryPromises = Object.keys(pointersHash).map((className) => { - var where = {'objectId': {'$in': pointersHash[className]}}; - var query = new RestQuery(config, auth, className, where); - return query.execute().then((results) => { + if (restOptions.excludeKeys) { + const excludeKeys = new Set(restOptions.excludeKeys.split(',')); + const excludeKeySet = Array.from(excludeKeys).reduce((set, key) => { + const keyPath = key.split('.'); + let i = 0; + for (i; i < path.length; i++) { + if (path[i] != keyPath[i]) { + return set; + } + } + if (i == keyPath.length - 1) { + set.add(keyPath[i]); + } + return set; + }, new Set()); + if (excludeKeySet.size > 0) { + includeRestOptions.excludeKeys = Array.from(excludeKeySet).join(','); + } + } + + if (restOptions.includeReadPreference) { + includeRestOptions.readPreference = restOptions.includeReadPreference; + includeRestOptions.includeReadPreference = restOptions.includeReadPreference; + } else if (restOptions.readPreference) { + includeRestOptions.readPreference = restOptions.readPreference; + } + const queryPromises = Object.keys(pointersHash).map(async className => { + const objectIds = Array.from(pointersHash[className]); + let where; + if (objectIds.length === 1) { + where = { objectId: objectIds[0] }; + } else { + where = { objectId: { $in: objectIds } }; + } + const query = await RestQuery({ + method: objectIds.length === 1 ? RestQuery.Method.get : RestQuery.Method.find, + config, + auth, + className, + restWhere: where, + restOptions: includeRestOptions, + context: context, + }); + return query.execute({ op: 'get' }).then(results => { results.className = className; return Promise.resolve(results); - }) - }) + }); + }); // Get the objects for all these object ids - return Promise.all(queryPromises).then((responses) => { + return Promise.all(queryPromises).then(responses => { var replace = responses.reduce((replace, includeResponse) => { for (var obj of includeResponse.results) { obj.__type = 'Object'; obj.className = includeResponse.className; - if (obj.className == "_User" && !auth.isMaster) { + if (obj.className == '_User' && !auth.isMaster) { delete obj.sessionToken; delete obj.authData; } replace[obj.objectId] = obj; } return replace; - }, {}) - + }, {}); var resp = { - results: replacePointers(response.results, path, replace) + results: replacePointers(response.results, path, replace), }; if (response.count) { resp.count = response.count; @@ -529,20 +1293,16 @@ function includePath(config, auth, response, path) { // Path is a list of fields to search into. // Returns a list of pointers in REST format. function findPointers(object, path) { - if (object instanceof Array) { - var answer = []; - for (var x of object) { - answer = answer.concat(findPointers(x, path)); - } - return answer; + if (Array.isArray(object)) { + return object.map(x => findPointers(x, path)).flat(); } - if (typeof object !== 'object') { + if (typeof object !== 'object' || !object) { return []; } if (path.length == 0) { - if (object.__type == 'Pointer') { + if (object === null || object.__type == 'Pointer') { return [object]; } return []; @@ -562,17 +1322,18 @@ function findPointers(object, path) { // Returns something analogous to object, but with the appropriate // pointers inflated. function replacePointers(object, path, replace) { - if (object instanceof Array) { - return object.map((obj) => replacePointers(obj, path, replace)) - .filter((obj) => obj != null && obj != undefined); + if (Array.isArray(object)) { + return object + .map(obj => replacePointers(obj, path, replace)) + .filter(obj => typeof obj !== 'undefined'); } - if (typeof object !== 'object') { + if (typeof object !== 'object' || !object) { return object; } if (path.length === 0) { - if (object.__type === 'Pointer') { + if (object && object.__type === 'Pointer') { return replace[object.objectId]; } return object; @@ -600,9 +1361,9 @@ function findObjectWithKey(root, key) { if (typeof root !== 'object') { return; } - if (root instanceof Array) { + if (Array.isArray(root)) { for (var item of root) { - var answer = findObjectWithKey(item, key); + const answer = findObjectWithKey(item, key); if (answer) { return answer; } @@ -612,7 +1373,7 @@ function findObjectWithKey(root, key) { return root; } for (var subkey in root) { - var answer = findObjectWithKey(root[subkey], key); + const answer = findObjectWithKey(root[subkey], key); if (answer) { return answer; } @@ -620,3 +1381,5 @@ function findObjectWithKey(root, key) { } module.exports = RestQuery; +// For tests +module.exports._UnsafeRestQuery = _UnsafeRestQuery; diff --git a/src/RestWrite.js b/src/RestWrite.js index c522a99188..6d3c0d35a9 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -3,17 +3,22 @@ // This could be either a "create" or an "update". var SchemaController = require('./Controllers/SchemaController'); -var deepcopy = require('deepcopy'); -var Auth = require('./Auth'); -var Config = require('./Config'); +const Auth = require('./Auth'); +const Utils = require('./Utils'); var cryptoUtils = require('./cryptoUtils'); var passwordCrypto = require('./password'); var Parse = require('parse/node'); var triggers = require('./triggers'); var ClientSDK = require('./ClientSDK'); +const util = require('util'); import RestQuery from './RestQuery'; -import _ from 'lodash'; +import _ from 'lodash'; +import logger from './logger'; +import { requiredColumns } from './Controllers/SchemaController'; +import { createSanitizedError } from './Error'; +import { applyAuthDataOptimisticLock } from './AuthDataLock'; +import * as InstallationDedup from './InstallationDedup'; // query and data are both provided in REST API format. So data // types are encoded by plain old objects. @@ -24,15 +29,42 @@ import _ from 'lodash'; // RestWrite will handle objectId, createdAt, and updatedAt for // everything. It also knows to use triggers and special modifications // for the _User class. -function RestWrite(config, auth, className, query, data, originalData, clientSDK) { +function RestWrite(config, auth, className, query, data, originalData, clientSDK, context, action) { + if (auth.isReadOnly) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + 'Cannot perform a write operation when using readOnlyMasterKey', + config + ); + } this.config = config; this.auth = auth; this.className = className; this.clientSDK = clientSDK; this.storage = {}; this.runOptions = {}; - if (!query && data.objectId) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'objectId is an invalid field name.'); + this.context = context || {}; + + if (action) { + this.runOptions.action = action; + } + + if (!query) { + if (this.config.allowCustomObjectId) { + if (Object.prototype.hasOwnProperty.call(data, 'objectId') && !data.objectId) { + throw new Parse.Error( + Parse.Error.MISSING_OBJECT_ID, + 'objectId must not be empty, null or undefined' + ); + } + } else { + if (data.objectId) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'objectId is an invalid field name.'); + } + if (data.id) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'id is an invalid field name.'); + } + } } // When the operation is complete, this.response may have several @@ -44,67 +76,118 @@ function RestWrite(config, auth, className, query, data, originalData, clientSDK // Processing this operation may mutate our data, so we operate on a // copy - this.query = deepcopy(query); - this.data = deepcopy(data); + this.query = structuredClone(query); + this.data = structuredClone(data); // We never change originalData, so we do not need a deep copy this.originalData = originalData; // The timestamp we'll use for this whole operation this.updatedAt = Parse._encode(new Date()).iso; + + // Shared SchemaController to be reused to reduce the number of loadSchema() calls per request + // Once set the schemaData should be immutable + this.validSchemaController = null; + this.pendingOps = { + operations: null, + identifier: null, + }; } // A convenient method to perform all the steps of processing the // write, in order. // Returns a promise for a {response, status, location} object. // status and location are optional. -RestWrite.prototype.execute = function() { - return Promise.resolve().then(() => { - return this.getUserAndRoleACL(); - }).then(() => { - return this.validateClientClassCreation(); - }).then(() => { - return this.validateSchema(); - }).then(() => { - return this.handleInstallation(); - }).then(() => { - return this.handleSession(); - }).then(() => { - return this.validateAuthData(); - }).then(() => { - return this.runBeforeTrigger(); - }).then(() => { - return this.setRequiredFieldsIfNeeded(); - }).then(() => { - return this.transformUser(); - }).then(() => { - return this.expandFilesForExistingObjects(); - }).then(() => { - return this.runDatabaseOperation(); - }).then(() => { - return this.createSessionTokenIfNeeded(); - }).then(() => { - return this.handleFollowup(); - }).then(() => { - return this.runAfterTrigger(); - }).then(() => { - return this.cleanUserAuthData(); - }).then(() => { - return this.response; - }) +RestWrite.prototype.execute = function () { + return Promise.resolve() + .then(() => { + return this.getUserAndRoleACL(); + }) + .then(() => { + return this.validateClientClassCreation(); + }) + .then(() => { + return this.handleInstallation(); + }) + .then(() => { + return this.handleSession(); + }) + .then(() => { + return this.validateAuthData(); + }) + .then(() => { + return this.checkRestrictedFields(); + }) + .then(() => { + return this.runBeforeSaveTrigger(); + }) + .then(() => { + return this.ensureUniqueAuthDataId(); + }) + .then(() => { + return this.deleteEmailResetTokenIfNeeded(); + }) + .then(() => { + return this.validateSchema(); + }) + .then(schemaController => { + this.validSchemaController = schemaController; + return this.setRequiredFieldsIfNeeded(); + }) + .then(() => { + return this.validateCreatePermission(); + }) + .then(() => { + return this.transformUser(); + }) + .then(() => { + return this.expandFilesForExistingObjects(); + }) + .then(() => { + return this.destroyDuplicatedSessions(); + }) + .then(() => { + return this.runDatabaseOperation(); + }) + .then(() => { + return this.createSessionTokenIfNeeded(); + }) + .then(() => { + return this.handleFollowup(); + }) + .then(() => { + return this.runAfterSaveTrigger(); + }) + .then(() => { + return this.cleanUserAuthData(); + }) + .then(() => { + return this.filterProtectedFieldsInResponse(); + }) + .then(() => { + // Append the authDataResponse if exists + if (this.authDataResponse) { + if (this.response && this.response.response) { + this.response.response.authDataResponse = this.authDataResponse; + } + } + if (this.storage.rejectSignup && this.config.preventSignupWithUnverifiedEmail) { + throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User email is not verified.'); + } + return this.response; + }); }; // Uses the Auth object to get the list of roles, adds the user id -RestWrite.prototype.getUserAndRoleACL = function() { - if (this.auth.isMaster) { +RestWrite.prototype.getUserAndRoleACL = function () { + if (this.auth.isMaster || this.auth.isMaintenance) { return Promise.resolve(); } this.runOptions.acl = ['*']; if (this.auth.user) { - return this.auth.getUserRoles().then((roles) => { - roles.push(this.auth.user.id); - this.runOptions.acl = this.runOptions.acl.concat(roles); + return this.auth.getUserRoles().then(roles => { + this.runOptions.acl = this.runOptions.acl.concat(roles, [this.auth.user.id]); return; }); } else { @@ -113,83 +196,255 @@ RestWrite.prototype.getUserAndRoleACL = function() { }; // Validates this operation against the allowClientClassCreation config. -RestWrite.prototype.validateClientClassCreation = function() { - if (this.config.allowClientClassCreation === false && !this.auth.isMaster - && SchemaController.systemClasses.indexOf(this.className) === -1) { - return this.config.database.loadSchema() +RestWrite.prototype.validateClientClassCreation = function () { + if ( + this.config.allowClientClassCreation === false && + !this.auth.isMaster && + !this.auth.isMaintenance && + SchemaController.systemClasses.indexOf(this.className) === -1 + ) { + return this.config.database + .loadSchema() .then(schemaController => schemaController.hasClass(this.className)) .then(hasClass => { if (hasClass !== true) { - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, - 'This user is not allowed to access ' + - 'non-existent class: ' + this.className); + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + 'This user is not allowed to access non-existent class: ' + this.className, + this.config + ); } - }); + }); } else { return Promise.resolve(); } }; // Validates this operation against the schema. -RestWrite.prototype.validateSchema = function() { - return this.config.database.validateObject(this.className, this.data, this.query, this.runOptions); +RestWrite.prototype.validateSchema = function () { + return this.config.database.validateObject( + this.className, + this.data, + this.query, + this.runOptions, + this.auth.isMaintenance + ); }; // Runs any beforeSave triggers against this operation. // Any change leads to our data being mutated. -RestWrite.prototype.runBeforeTrigger = function() { - if (this.response) { +RestWrite.prototype.runBeforeSaveTrigger = function () { + if (this.response || this.runOptions.many) { return; } // Avoid doing any setup for triggers if there is no 'beforeSave' trigger for this class. - if (!triggers.triggerExists(this.className, triggers.Types.beforeSave, this.config.applicationId)) { + if ( + !triggers.triggerExists(this.className, triggers.Types.beforeSave, this.config.applicationId) + ) { return Promise.resolve(); } - // Cloud code gets a bit of extra data for its objects - var extraData = {className: this.className}; - if (this.query && this.query.objectId) { - extraData.objectId = this.query.objectId; - } - - let originalObject = null; - let updatedObject = triggers.inflate(extraData, this.originalData); - if (this.query && this.query.objectId) { - // This is an update for existing object. - originalObject = triggers.inflate(extraData, this.originalData); - } - updatedObject.set(this.sanitizedData()); + const { originalObject, updatedObject } = this.buildParseObjects(); + const identifier = updatedObject._getStateIdentifier(); + const stateController = Parse.CoreManager.getObjectStateController(); + const [pending] = stateController.getPendingOps(identifier); + this.pendingOps = { + operations: { ...pending }, + identifier, + }; - return Promise.resolve().then(() => { - return triggers.maybeRunTrigger(triggers.Types.beforeSave, this.auth, updatedObject, originalObject, this.config); - }).then((response) => { - if (response && response.object) { - if (!_.isEqual(this.data, response.object)) { - this.storage.changedByTrigger = true; + return Promise.resolve() + .then(() => { + // Before calling the trigger, validate the permissions for the save operation + let databasePromise = null; + if (this.query) { + // Validate for updating + databasePromise = this.config.database.update( + this.className, + this.query, + this.data, + this.runOptions, + true, + true + ); + } else { + // Validate for creating + databasePromise = this.config.database.create( + this.className, + this.data, + this.runOptions, + true + ); } - this.data = response.object; - // We should delete the objectId for an update write - if (this.query && this.query.objectId) { - delete this.data.objectId + // In the case that there is no permission for the operation, it throws an error + return databasePromise.then(result => { + if (!result || result.length <= 0) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); + } + }); + }) + .then(() => { + return triggers.maybeRunTrigger( + triggers.Types.beforeSave, + this.auth, + updatedObject, + originalObject, + this.config, + this.context + ); + }) + .then(response => { + if (response && response.object) { + this.storage.fieldsChangedByTrigger = _.reduce( + response.object, + (result, value, key) => { + if (!_.isEqual(this.data[key], value)) { + result.push(key); + } + return result; + }, + [] + ); + this.data = response.object; + // We should delete the objectId for an update write + if (this.query && this.query.objectId) { + delete this.data.objectId; + } } - return this.validateSchema(); - } - }); + try { + Utils.checkProhibitedKeywords(this.config, this.data); + } catch (error) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `${error}`); + } + }); }; -RestWrite.prototype.setRequiredFieldsIfNeeded = function() { +RestWrite.prototype.runBeforeLoginTrigger = async function (userData) { + // Avoid doing any setup for triggers if there is no 'beforeLogin' trigger + if ( + !triggers.triggerExists(this.className, triggers.Types.beforeLogin, this.config.applicationId) + ) { + return; + } + + // Cloud code gets a bit of extra data for its objects + const extraData = { className: this.className }; + + // Expand file objects + await this.config.filesController.expandFilesInObject(this.config, userData); + + const user = triggers.inflate(extraData, userData); + + // no need to return a response + await triggers.maybeRunTrigger( + triggers.Types.beforeLogin, + this.auth, + user, + null, + this.config, + this.context + ); +}; + +RestWrite.prototype.setRequiredFieldsIfNeeded = function () { if (this.data) { - // Add default fields - this.data.updatedAt = this.updatedAt; - if (!this.query) { - this.data.createdAt = this.updatedAt; + return this.validSchemaController.getAllClasses().then(allClasses => { + const schema = allClasses.find(oneClass => oneClass.className === this.className); + const setRequiredFieldIfNeeded = (fieldName, setDefault) => { + if ( + this.data[fieldName] === undefined || + this.data[fieldName] === null || + this.data[fieldName] === '' || + (typeof this.data[fieldName] === 'object' && this.data[fieldName].__op === 'Delete') + ) { + if ( + setDefault && + schema.fields[fieldName] && + schema.fields[fieldName].defaultValue !== null && + schema.fields[fieldName].defaultValue !== undefined && + (this.data[fieldName] === undefined || + (typeof this.data[fieldName] === 'object' && this.data[fieldName].__op === 'Delete')) + ) { + this.data[fieldName] = schema.fields[fieldName].defaultValue; + this.storage.fieldsChangedByTrigger = this.storage.fieldsChangedByTrigger || []; + if (this.storage.fieldsChangedByTrigger.indexOf(fieldName) < 0) { + this.storage.fieldsChangedByTrigger.push(fieldName); + } + } else if (schema.fields[fieldName] && schema.fields[fieldName].required === true) { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, `${fieldName} is required`); + } + } + }; - // Only assign new objectId if we are creating new object - if (!this.data.objectId) { - this.data.objectId = cryptoUtils.newObjectId(); + // add default ACL (only on CREATE, not UPDATE) + if (!this.query && + schema?.classLevelPermissions?.ACL && + !this.data.ACL && + JSON.stringify(schema.classLevelPermissions.ACL) !== + JSON.stringify({ '*': { read: true, write: true } }) + ) { + const acl = structuredClone(schema.classLevelPermissions.ACL); + if (acl.currentUser) { + if (this.auth.user?.id) { + acl[this.auth.user?.id] = structuredClone(acl.currentUser); + } + delete acl.currentUser; + } + this.data.ACL = acl; + this.storage.fieldsChangedByTrigger = this.storage.fieldsChangedByTrigger || []; + this.storage.fieldsChangedByTrigger.push('ACL'); } - } + + // Add default fields + if (!this.query) { + // allow customizing createdAt and updatedAt when using maintenance key + if ( + this.auth.isMaintenance && + this.data.createdAt && + this.data.createdAt.__type === 'Date' + ) { + this.data.createdAt = this.data.createdAt.iso; + + if (this.data.updatedAt && this.data.updatedAt.__type === 'Date') { + const createdAt = new Date(this.data.createdAt); + const updatedAt = new Date(this.data.updatedAt.iso); + + if (updatedAt < createdAt) { + throw new Parse.Error( + Parse.Error.VALIDATION_ERROR, + 'updatedAt cannot occur before createdAt' + ); + } + + this.data.updatedAt = this.data.updatedAt.iso; + } + // if no updatedAt is provided, set it to createdAt to match default behavior + else { + this.data.updatedAt = this.data.createdAt; + } + } else { + this.data.updatedAt = this.updatedAt; + this.data.createdAt = this.updatedAt; + } + + // Only assign new objectId if we are creating new object + if (!this.data.objectId) { + this.data.objectId = cryptoUtils.newObjectId(this.config.objectIdSize); + } + if (schema) { + Object.keys(schema.fields).forEach(fieldName => { + setRequiredFieldIfNeeded(fieldName, true); + }); + } + } else if (schema) { + this.data.updatedAt = this.updatedAt; + + Object.keys(this.data).forEach(fieldName => { + setRequiredFieldIfNeeded(fieldName, false); + }); + } + }); } return Promise.resolve(); }; @@ -197,348 +452,834 @@ RestWrite.prototype.setRequiredFieldsIfNeeded = function() { // Transforms auth data for a user object. // Does nothing if this isn't a user object. // Returns a promise for when we're done if it can't finish this tick. -RestWrite.prototype.validateAuthData = function() { +RestWrite.prototype.validateAuthData = function () { if (this.className !== '_User') { return; } - if (!this.query && !this.data.authData) { - if (typeof this.data.username !== 'string') { - throw new Parse.Error(Parse.Error.USERNAME_MISSING, - 'bad or missing username'); + const authData = this.data.authData; + const hasUsernameAndPassword = + typeof this.data.username === 'string' && typeof this.data.password === 'string'; + const hasAuthData = + authData && + Object.keys(authData).some(provider => { + const providerData = authData[provider]; + return providerData && typeof providerData === 'object' && Object.keys(providerData).length; + }); + + if (!this.query && !hasAuthData) { + if (typeof this.data.username !== 'string' || _.isEmpty(this.data.username)) { + throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'bad or missing username'); } - if (typeof this.data.password !== 'string') { - throw new Parse.Error(Parse.Error.PASSWORD_MISSING, - 'password is required'); + if (typeof this.data.password !== 'string' || _.isEmpty(this.data.password)) { + throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'password is required'); } } - if (!this.data.authData || !Object.keys(this.data.authData).length) { + if (!Object.prototype.hasOwnProperty.call(this.data, 'authData')) { + // Nothing to validate here return; + } else if (!this.data.authData) { + // Handle saving authData to null + throw new Parse.Error( + Parse.Error.UNSUPPORTED_SERVICE, + 'This authentication method is unsupported.' + ); } - var authData = this.data.authData; var providers = Object.keys(authData); - if (providers.length > 0) { - let canHandleAuthData = providers.reduce((canHandle, provider) => { - var providerAuthData = authData[provider]; - var hasToken = (providerAuthData && providerAuthData.id); - return canHandle && (hasToken || providerAuthData == null); - }, true); - if (canHandleAuthData) { - return this.handleAuthData(authData); - } + if (!providers.length) { + // Empty authData object, nothing to validate + return; } - throw new Parse.Error(Parse.Error.UNSUPPORTED_SERVICE, - 'This authentication method is unsupported.'); + const canHandleAuthData = providers.some(provider => { + const providerAuthData = authData[provider] || {}; + return !!Object.keys(providerAuthData).length; + }); + if (canHandleAuthData || hasUsernameAndPassword || this.auth.isMaster || this.getUserId()) { + return this.handleAuthData(authData); + } + throw new Parse.Error( + Parse.Error.UNSUPPORTED_SERVICE, + 'This authentication method is unsupported.' + ); }; -RestWrite.prototype.handleAuthDataValidation = function(authData) { - let validations = Object.keys(authData).map((provider) => { - if (authData[provider] === null) { - return Promise.resolve(); +RestWrite.prototype.filteredObjectsByACL = function (objects) { + if (this.auth.isMaster || this.auth.isMaintenance) { + return objects; + } + return objects.filter(object => { + if (!object.ACL) { + return true; // legacy users that have no ACL field on them } - let validateAuthData = this.config.authDataManager.getValidatorForProvider(provider); - if (!validateAuthData) { - throw new Parse.Error(Parse.Error.UNSUPPORTED_SERVICE, - 'This authentication method is unsupported.'); - }; - return validateAuthData(authData[provider]); + // Regular users that have been locked out. + return object.ACL && Object.keys(object.ACL).length > 0; }); - return Promise.all(validations); -} +}; -RestWrite.prototype.findUsersWithAuthData = function(authData) { - let providers = Object.keys(authData); - let query = providers.reduce((memo, provider) => { - if (!authData[provider]) { - return memo; - } - let queryKey = `authData.${provider}.id`; - let query = {}; - query[queryKey] = authData[provider].id; - memo.push(query); - return memo; - }, []).filter((q) => { - return typeof q !== undefined; - }); +RestWrite.prototype.getUserId = function () { + if (this.query && this.query.objectId && this.className === '_User') { + return this.query.objectId; + } else if (this.auth && this.auth.user && this.auth.user.id) { + return this.auth.user.id; + } +}; - let findPromise = Promise.resolve([]); - if (query.length > 0) { - findPromise = this.config.database.find( - this.className, - {'$or': query}, {}) +// Developers are allowed to change authData via before save trigger +RestWrite.prototype._throwIfAuthDataDuplicate = function (error) { + if ( + this.className === '_User' && + error?.code === Parse.Error.DUPLICATE_VALUE && + error.userInfo?.duplicated_field?.startsWith('_auth_data_') + ) { + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); } +}; - return findPromise; -} +// we need after before save to ensure that the developer +// is not currently duplicating auth data ID +RestWrite.prototype.ensureUniqueAuthDataId = async function () { + if (this.className !== '_User' || !this.data.authData) { + return; + } + const hasAuthDataId = Object.keys(this.data.authData).some( + key => this.data.authData[key] && this.data.authData[key].id + ); -RestWrite.prototype.handleAuthData = function(authData) { - let results; - return this.handleAuthDataValidation(authData).then(() => { - return this.findUsersWithAuthData(authData); - }).then((r) => { - results = r; - if (results.length > 1) { - // More than 1 user with the passed id's - throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, - 'this auth is already used'); - } + if (!hasAuthDataId) { return; } + + const r = await Auth.findUsersWithAuthData(this.config, this.data.authData); + const results = this.filteredObjectsByACL(r); + if (results.length > 1) { + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); + } + // use data.objectId in case of login time and found user during handle validateAuthData + const userId = this.getUserId() || this.data.objectId; + if (results.length === 1 && userId !== results[0].objectId) { + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); + } +}; - this.storage['authProvider'] = Object.keys(authData).join(','); +RestWrite.prototype.handleAuthData = async function (authData) { + let currentUserAuthData; + if (this.query?.objectId) { + const [currentUser] = await this.config.database.find( + '_User', + { objectId: this.query.objectId } + ); + currentUserAuthData = currentUser?.authData; + } + const r = await Auth.findUsersWithAuthData(this.config, authData, true, currentUserAuthData); + const results = this.filteredObjectsByACL(r); + + const userId = this.getUserId(); + const userResult = results[0]; + const foundUserIsNotCurrentUser = userId && userResult && userId !== userResult.objectId; + + if (results.length > 1 || foundUserIsNotCurrentUser) { + // To avoid https://github.com/parse-community/parse-server/security/advisories/GHSA-8w3j-g983-8jh5 + // Let's run some validation before throwing + await Auth.handleAuthDataValidation(authData, this, userResult); + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); + } - if (results.length > 0) { - if (!this.query) { - // Login with auth data - delete results[0].password; - let userResult = results[0]; - - // need to set the objectId first otherwise location has trailing undefined - this.data.objectId = userResult.objectId; - - // Determine if authData was updated - let mutatedAuthData = {}; - Object.keys(authData).forEach((provider) => { - let providerData = authData[provider]; - let userAuthData = userResult.authData[provider]; - if (!_.isEqual(providerData, userAuthData)) { - mutatedAuthData[provider] = providerData; - } - }); - + // No user found with provided authData we need to validate + if (!results.length) { + const { authData: validatedAuthData, authDataResponse } = await Auth.handleAuthDataValidation( + authData, + this + ); + this.authDataResponse = authDataResponse; + // Replace current authData by the new validated one + this.data.authData = validatedAuthData; + return; + } + + // User found with provided authData + if (results.length === 1) { + this.storage.authProvider = Object.keys(authData).join(','); + + const { hasMutatedAuthData, mutatedAuthData } = Auth.hasMutatedAuthData( + authData, + userResult.authData + ); + + const isCurrentUserLoggedOrMaster = + (this.auth && this.auth.user && this.auth.user.id === userResult.objectId) || + this.auth.isMaster; + + const isLogin = !userId; + + if (isLogin || isCurrentUserLoggedOrMaster) { + // no user making the call + // OR the user making the call is the right one + // Login with auth data + delete results[0].password; + + // need to set the objectId first otherwise location has trailing undefined + this.data.objectId = userResult.objectId; + + if (!this.query || !this.query.objectId) { this.response = { response: userResult, - location: this.location() + location: this.location(), }; + // Run beforeLogin hook before storing any updates + // to authData on the db; changes to userResult + // will be ignored. + await this.runBeforeLoginTrigger(structuredClone(userResult)); + + // If we are in login operation via authData + // we need to be sure that the user has provided + // required authData + Auth.checkIfUserHasProvidedConfiguredProvidersForLogin( + { config: this.config, auth: this.auth }, + authData, + userResult.authData, + this.config + ); + } - // We have authData that is updated on login - // that can happen when token are refreshed, - // We should update the token and let the user in - if (Object.keys(mutatedAuthData).length > 0) { - // Assign the new authData in the response - Object.keys(mutatedAuthData).forEach((provider) => { - this.response.response.authData[provider] = mutatedAuthData[provider]; - }); - // Run the DB update directly, as 'master' - // Just update the authData part - return this.config.database.update(this.className, {objectId: this.data.objectId}, {authData: mutatedAuthData}, {}); - } + // Prevent validating if no mutated data detected on update + if (!hasMutatedAuthData && isCurrentUserLoggedOrMaster) { return; - - } else if (this.query && this.query.objectId) { - // Trying to update auth data but users - // are different - if (results[0].objectId !== this.query.objectId) { - throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, - 'this auth is already used'); + } + + // Always validate all provided authData on login to prevent authentication + // bypass via partial authData (e.g. sending only the provider ID without + // an access token); on update only validate mutated ones + if (isLogin || hasMutatedAuthData || !this.config.allowExpiredAuthDataToken) { + const res = await Auth.handleAuthDataValidation( + isLogin ? authData : mutatedAuthData, + this, + userResult + ); + this.data.authData = res.authData; + this.authDataResponse = res.authDataResponse; + } + + // Capture original authData before mutating userResult via the response reference + const originalAuthData = userResult?.authData + ? Object.fromEntries( + Object.entries(userResult.authData).map(([k, v]) => + [k, v && typeof v === 'object' ? { ...v } : v] + ) + ) + : undefined; + + // IF we are in login we'll skip the database operation / beforeSave / afterSave etc... + // we need to set it up there. + // We are supposed to have a response only on LOGIN with authData, so we skip those + // If we're not logging in, but just updating the current user, we can safely skip that part + if (this.response) { + // Assign the new authData in the response + Object.keys(mutatedAuthData).forEach(provider => { + this.response.response.authData[provider] = mutatedAuthData[provider]; + }); + + // Run the DB update directly, as 'master' only if authData contains some keys + // authData could not contains keys after validation if the authAdapter + // uses the `doNotSave` option. Just update the authData part + // Then we're good for the user, early exit of sorts + if (Object.keys(this.data.authData).length) { + const query = { objectId: this.data.objectId }; + // Optimistic locking: include each changed original field in the WHERE clause + // for providers whose data is being updated. This prevents concurrent requests + // from both succeeding when consuming single-use tokens (e.g. MFA recovery codes + // as arrays, or MFA SMS OTP tokens as strings). + applyAuthDataOptimisticLock(query, originalAuthData, this.data.authData); + try { + await this.config.database.update( + this.className, + query, + { authData: this.data.authData }, + {} + ); + } catch (error) { + if (error.code === Parse.Error.OBJECT_NOT_FOUND) { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid auth data'); + } + this._throwIfAuthDataDuplicate(error); + throw error; + } } + } else if (this.query && this.data.authData && Object.keys(this.data.authData).length) { + // UPDATE path (e.g. PUT /users/:id during linked-provider re-auth): apply + // the same optimistic lock to the subsequent runDatabaseOperation update so + // concurrent single-use token consumers cannot both succeed. + applyAuthDataOptimisticLock(this.query, originalAuthData, this.data.authData); } } + } +}; + +RestWrite.prototype.checkRestrictedFields = async function () { + if (this.className !== '_User') { return; - }); -} + } + if (!this.auth.isMaintenance && !this.auth.isMaster && 'emailVerified' in this.data) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + "Clients aren't allowed to manually update email verification.", + this.config + ); + } +}; -// The non-third-party parts of User transformation -RestWrite.prototype.transformUser = function() { - if (this.className !== '_User') { +// Validates the create class-level permission before transformUser runs. +// This prevents user enumeration (username/email existence) when public +// create is disabled on _User, because transformUser checks uniqueness +// before the CLP is enforced in runDatabaseOperation. +RestWrite.prototype.validateCreatePermission = async function () { + if (this.query || this.auth.isMaster || this.auth.isMaintenance) { + return; + } + if (!this.validSchemaController) { return; } + await this.validSchemaController.validatePermission( + this.className, + this.runOptions.acl || [], + 'create' + ); +}; +// The non-third-party parts of User transformation +RestWrite.prototype.transformUser = async function () { var promise = Promise.resolve(); + if (this.className !== '_User') { + return promise; + } - if (this.query) { + // Do not cleanup session if objectId is not set + if (this.query && this.objectId()) { // If we're updating a _User object, we need to clear out the cache for that user. Find all their // session tokens, and remove them from the cache. - promise = new RestQuery(this.config, Auth.master(this.config), '_Session', { user: { - __type: "Pointer", - className: "_User", - objectId: this.objectId(), - }}).execute() - .then(results => { - results.results.forEach(session => this.config.cacheController.user.del(session.sessionToken)); + const query = await RestQuery({ + method: RestQuery.Method.find, + config: this.config, + auth: Auth.master(this.config), + className: '_Session', + runBeforeFind: false, + restWhere: { + user: { + __type: 'Pointer', + className: '_User', + objectId: this.objectId(), + }, + }, + }); + promise = query.execute().then(results => { + results.results.forEach(session => + this.config.cacheController.user.del(session.sessionToken) + ); }); } - return promise.then(() => { - // Transform the password - if (!this.data.password) { - return; - } - if (this.query && !this.auth.isMaster ) { - this.storage['clearSessions'] = true; - this.storage['generateNewSession'] = true; - } - return passwordCrypto.hash(this.data.password).then((hashedPassword) => { - this.data._hashed_password = hashedPassword; - delete this.data.password; - }); + return promise + .then(() => { + // Transform the password + if (this.data.password === undefined) { + // ignore only if undefined. should proceed if empty ('') + return Promise.resolve(); + } - }).then(() => { - // Check for username uniqueness - if (!this.data.username) { - if (!this.query) { - this.data.username = cryptoUtils.randomString(25); - this.responseShouldHaveUsername = true; + if (this.query) { + this.storage['clearSessions'] = true; + // Generate a new session only if the user requested + if (!this.auth.isMaster && !this.auth.isMaintenance) { + this.storage['generateNewSession'] = true; + } } - return; + + return this._validatePasswordPolicy().then(() => { + return passwordCrypto.hash(this.data.password).then(hashedPassword => { + this.data._hashed_password = hashedPassword; + delete this.data.password; + }); + }); + }) + .then(() => { + return this._validateUserName(); + }) + .then(() => { + return this._validateEmail(); + }); +}; + +RestWrite.prototype._validateUserName = function () { + // Check for username uniqueness + if (!this.data.username) { + if (!this.query) { + this.data.username = cryptoUtils.randomString(25); + this.responseShouldHaveUsername = true; } - // We need to a find to check for duplicate username in case they are missing the unique index on usernames - // TODO: Check if there is a unique index, and if so, skip this query. - return this.config.database.find( + return Promise.resolve(); + } + /* + Usernames should be unique when compared case insensitively + + Users should be able to make case sensitive usernames and + login using the case they entered. I.e. 'Snoopy' should preclude + 'snoopy' as a valid username. + */ + return this.config.database + .find( this.className, - { username: this.data.username, objectId: {'$ne': this.objectId()} }, - { limit: 1 } + { + username: this.data.username, + objectId: { $ne: this.objectId() }, + }, + { limit: 1, caseInsensitive: true }, + {}, + this.validSchemaController ) .then(results => { if (results.length > 0) { - throw new Parse.Error(Parse.Error.USERNAME_TAKEN, 'Account already exists for this username.'); + throw new Parse.Error( + Parse.Error.USERNAME_TAKEN, + 'Account already exists for this username.' + ); } return; }); - }) - .then(() => { - if (!this.data.email || this.data.email.__op === 'Delete') { - return; - } - // Validate basic email address format - if (!this.data.email.match(/^.+@.+$/)) { - throw new Parse.Error(Parse.Error.INVALID_EMAIL_ADDRESS, 'Email address format is invalid.'); - } - // Same problem for email as above for username - return this.config.database.find( +}; + +RestWrite.buildCreatedWith = function (action, authProvider) { + return { action, authProvider: authProvider || 'password' }; +}; + +RestWrite.prototype.getCreatedWith = function () { + if (this.storage.createdWith) { + return this.storage.createdWith; + } + const isCreateOperation = !this.query; + const authDataProvider = + this.data?.authData && + Object.keys(this.data.authData).length && + Object.keys(this.data.authData).join(','); + const authProvider = this.storage.authProvider || authDataProvider; + // storage.authProvider is only set for login (existing user found in handleAuthData) + const action = this.storage.authProvider ? 'login' : isCreateOperation ? 'signup' : undefined; + if (!action) { + return; + } + const resolvedAuthProvider = authProvider || (action === 'signup' ? 'password' : undefined); + this.storage.createdWith = RestWrite.buildCreatedWith(action, resolvedAuthProvider); + return this.storage.createdWith; +}; + +/* + As with usernames, Parse should not allow case insensitive collisions of email. + unlike with usernames (which can have case insensitive collisions in the case of + auth adapters), emails should never have a case insensitive collision. + + This behavior can be enforced through a properly configured index see: + https://docs.mongodb.com/manual/core/index-case-insensitive/#create-a-case-insensitive-index + which could be implemented instead of this code based validation. + + Given that this lookup should be a relatively low use case and that the case sensitive + unique index will be used by the db for the query, this is an adequate solution. +*/ +RestWrite.prototype._validateEmail = function () { + if (!this.data.email || this.data.email.__op === 'Delete') { + return Promise.resolve(); + } + // Validate basic email address format + if (!this.data.email.match(/^.+@.+$/)) { + return Promise.reject( + new Parse.Error(Parse.Error.INVALID_EMAIL_ADDRESS, 'Email address format is invalid.') + ); + } + // Case insensitive match, see note above function. + return this.config.database + .find( this.className, - { email: this.data.email, objectId: {'$ne': this.objectId()} }, - { limit: 1 } + { + email: this.data.email, + objectId: { $ne: this.objectId() }, + }, + { limit: 1, caseInsensitive: true }, + {}, + this.validSchemaController ) .then(results => { if (results.length > 0) { - throw new Parse.Error(Parse.Error.EMAIL_TAKEN, 'Account already exists for this email address.'); + throw new Parse.Error( + Parse.Error.EMAIL_TAKEN, + 'Account already exists for this email address.' + ); + } + if ( + !this.data.authData || + !Object.keys(this.data.authData).length || + (Object.keys(this.data.authData).length === 1 && + Object.keys(this.data.authData)[0] === 'anonymous') + ) { + // We updated the email, send a new validation + const { originalObject, updatedObject } = this.buildParseObjects(); + const request = { + original: originalObject, + object: updatedObject, + master: this.auth.isMaster, + ip: this.config.ip, + installationId: this.auth.installationId, + createdWith: this.getCreatedWith(), + }; + return this.config.userController.setEmailVerifyToken(this.data, request, this.storage); } - // We updated the email, send a new validation - this.storage['sendVerificationEmail'] = true; - this.config.userController.setEmailVerifyToken(this.data); }); - }) }; -RestWrite.prototype.createSessionTokenIfNeeded = function() { +RestWrite.prototype._validatePasswordPolicy = function () { + if (!this.config.passwordPolicy) { return Promise.resolve(); } + return this._validatePasswordRequirements().then(() => { + return this._validatePasswordHistory(); + }); +}; + +RestWrite.prototype._validatePasswordRequirements = function () { + // check if the password conforms to the defined password policy if configured + // If we specified a custom error in our configuration use it. + // Example: "Passwords must include a Capital Letter, Lowercase Letter, and a number." + // + // This is especially useful on the generic "password reset" page, + // as it allows the programmer to communicate specific requirements instead of: + // a. making the user guess whats wrong + // b. making a custom password reset page that shows the requirements + const policyError = this.config.passwordPolicy.validationError + ? this.config.passwordPolicy.validationError + : 'Password does not meet the Password Policy requirements.'; + const containsUsernameError = 'Password cannot contain your username.'; + + // check whether the password meets the password strength requirements + if ( + (this.config.passwordPolicy.patternValidator && + !this.config.passwordPolicy.patternValidator(this.data.password)) || + (this.config.passwordPolicy.validatorCallback && + !this.config.passwordPolicy.validatorCallback(this.data.password)) + ) { + return Promise.reject(new Parse.Error(Parse.Error.VALIDATION_ERROR, policyError)); + } + + // check whether password contain username + if (this.config.passwordPolicy.doNotAllowUsername === true) { + if (this.data.username) { + // username is not passed during password reset + if (this.data.password.indexOf(this.data.username) >= 0) + { return Promise.reject(new Parse.Error(Parse.Error.VALIDATION_ERROR, containsUsernameError)); } + } else { + // retrieve the User object using objectId during password reset + return this.config.database.find('_User', { objectId: this.objectId() }).then(results => { + if (results.length != 1) { + throw undefined; + } + if (this.data.password.indexOf(results[0].username) >= 0) + { return Promise.reject( + new Parse.Error(Parse.Error.VALIDATION_ERROR, containsUsernameError) + ); } + return Promise.resolve(); + }); + } + } + return Promise.resolve(); +}; + +RestWrite.prototype._validatePasswordHistory = function () { + // check whether password is repeating from specified history + if (this.query && this.config.passwordPolicy.maxPasswordHistory) { + return this.config.database + .find( + '_User', + { objectId: this.objectId() }, + { keys: ['_password_history', '_hashed_password'] }, + Auth.maintenance(this.config) + ) + .then(results => { + if (results.length != 1) { + throw undefined; + } + const user = results[0]; + let oldPasswords = []; + if (user._password_history) + { oldPasswords = _.take( + user._password_history, + this.config.passwordPolicy.maxPasswordHistory - 1 + ); } + oldPasswords.push(user.password); + const newPassword = this.data.password; + // compare the new password hash with all old password hashes + const promises = oldPasswords.map(function (hash) { + return passwordCrypto.compare(newPassword, hash).then(result => { + if (result) + // reject if there is a match + { return Promise.reject('REPEAT_PASSWORD'); } + return Promise.resolve(); + }); + }); + // wait for all comparisons to complete + return Promise.all(promises) + .then(() => { + return Promise.resolve(); + }) + .catch(err => { + if (err === 'REPEAT_PASSWORD') + // a match was found + { return Promise.reject( + new Parse.Error( + Parse.Error.VALIDATION_ERROR, + `New password should not be the same as last ${this.config.passwordPolicy.maxPasswordHistory} passwords.` + ) + ); } + throw err; + }); + }); + } + return Promise.resolve(); +}; + +RestWrite.prototype.createSessionTokenIfNeeded = async function () { if (this.className !== '_User') { return; } - if (this.query) { + // Don't generate session for updating user (this.query is set) unless authData exists + if (this.query && !this.data.authData) { + return; + } + // Don't generate new sessionToken if linking via sessionToken + if (this.auth.user && this.data.authData) { return; } + // If sign-up call + if (!this.storage.authProvider) { + // Create request object for verification functions + const { originalObject, updatedObject } = this.buildParseObjects(); + const request = { + original: originalObject, + object: updatedObject, + master: this.auth.isMaster, + ip: this.config.ip, + installationId: this.auth.installationId, + createdWith: this.getCreatedWith(), + }; + // Get verification conditions which can be booleans or functions; the purpose of this async/await + // structure is to avoid unnecessarily executing subsequent functions if previous ones fail in the + // conditional statement below, as a developer may decide to execute expensive operations in them + const verifyUserEmails = async () => this.config.verifyUserEmails === true || (typeof this.config.verifyUserEmails === 'function' && await Promise.resolve(this.config.verifyUserEmails(request)) === true); + const preventLoginWithUnverifiedEmail = async () => this.config.preventLoginWithUnverifiedEmail === true || (typeof this.config.preventLoginWithUnverifiedEmail === 'function' && await Promise.resolve(this.config.preventLoginWithUnverifiedEmail(request)) === true); + // If verification is required + if (await verifyUserEmails() && await preventLoginWithUnverifiedEmail()) { + this.storage.rejectSignup = true; + return; + } + } return this.createSessionToken(); -} +}; + +RestWrite.prototype.createSessionToken = async function () { + // cloud installationId from Cloud Code, + // never create session tokens from there. + if (this.auth.installationId && this.auth.installationId === 'cloud') { + return; + } + + if (this.storage.authProvider == null && this.data.authData) { + this.storage.authProvider = Object.keys(this.data.authData).join(','); + // Invalidate cached createdWith since authProvider was just resolved + delete this.storage.createdWith; + } -RestWrite.prototype.createSessionToken = function() { - var token = 'r:' + cryptoUtils.newToken(); + const createdWith = this.getCreatedWith(); + const { sessionData, createSession } = RestWrite.createSession(this.config, { + userId: this.objectId(), + createdWith, + installationId: this.auth.installationId, + }); + + if (this.response && this.response.response) { + this.response.response.sessionToken = sessionData.sessionToken; + } + + return createSession(); +}; - var expiresAt = this.config.generateSessionExpiresAt(); - var sessionData = { +RestWrite.createSession = function ( + config, + { userId, createdWith, installationId, additionalSessionData } +) { + const token = 'r:' + cryptoUtils.newToken(); + const expiresAt = config.generateSessionExpiresAt(); + const sessionData = { sessionToken: token, user: { __type: 'Pointer', className: '_User', - objectId: this.objectId() - }, - createdWith: { - 'action': 'signup', - 'authProvider': this.storage['authProvider'] || 'password' + objectId: userId, }, - restricted: false, - installationId: this.auth.installationId, - expiresAt: Parse._encode(expiresAt) + createdWith, + expiresAt: Parse._encode(expiresAt), }; - if (this.response && this.response.response) { - this.response.response.sessionToken = token; + + if (installationId) { + sessionData.installationId = installationId; } - var create = new RestWrite(this.config, Auth.master(this.config), '_Session', null, sessionData); - return create.execute(); -} + + Object.assign(sessionData, additionalSessionData); + + return { + sessionData, + createSession: () => + new RestWrite(config, Auth.master(config), '_Session', null, sessionData).execute(), + }; +}; + +// Delete email reset tokens if user is changing password or email. +RestWrite.prototype.deleteEmailResetTokenIfNeeded = function () { + if (this.className !== '_User' || this.query === null) { + // null query means create + return; + } + + if ('password' in this.data || 'email' in this.data) { + const addOps = { + _perishable_token: { __op: 'Delete' }, + _perishable_token_expires_at: { __op: 'Delete' }, + }; + this.data = Object.assign(this.data, addOps); + } +}; + +RestWrite.prototype.destroyDuplicatedSessions = function () { + // Only for _Session, and at creation time + if (this.className != '_Session' || this.query) { + return; + } + // Destroy the sessions in 'Background' + const { user, installationId, sessionToken } = this.data; + if (!user || !installationId) { + return; + } + if (!user.objectId) { + return; + } + return this.config.database.destroy( + '_Session', + { + user, + installationId, + sessionToken: { $ne: sessionToken }, + }, + {}, + this.validSchemaController + ).catch(e => { + if (e.code !== Parse.Error.OBJECT_NOT_FOUND) { + throw e; + } + }); +}; // Handles any followup logic -RestWrite.prototype.handleFollowup = function() { +RestWrite.prototype.handleFollowup = function () { if (this.storage && this.storage['clearSessions'] && this.config.revokeSessionOnPasswordReset) { var sessionQuery = { user: { - __type: 'Pointer', - className: '_User', - objectId: this.objectId() - } + __type: 'Pointer', + className: '_User', + objectId: this.objectId(), + }, }; delete this.storage['clearSessions']; - return this.config.database.destroy('_Session', sessionQuery) - .then(this.handleFollowup.bind(this)); + return this.config.database + .destroy('_Session', sessionQuery) + .then(this.handleFollowup.bind(this)); } - + if (this.storage && this.storage['generateNewSession']) { delete this.storage['generateNewSession']; - return this.createSessionToken() - .then(this.handleFollowup.bind(this)); + return this.createSessionToken().then(this.handleFollowup.bind(this)); } if (this.storage && this.storage['sendVerificationEmail']) { delete this.storage['sendVerificationEmail']; // Fire and forget! - this.config.userController.sendVerificationEmail(this.data); + this.config.userController.sendVerificationEmail(this.data, { auth: this.auth }); return this.handleFollowup.bind(this); } }; // Handles the _Session class specialness. -// Does nothing if this isn't an installation object. -RestWrite.prototype.handleSession = function() { +// Does nothing if this isn't an _Session object. +RestWrite.prototype.handleSession = function () { if (this.response || this.className !== '_Session') { return; } - if (!this.auth.user && !this.auth.isMaster) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, - 'Session token required.'); + if (!this.auth.user && !this.auth.isMaster && !this.auth.isMaintenance) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Session token required.'); } // TODO: Verify proper error to throw - if (this.data.ACL) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'Cannot set ' + - 'ACL on a Session.'); + if ('ACL' in this.data) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'Cannot set ' + 'ACL on a Session.'); } - if (!this.query && !this.auth.isMaster) { - var token = 'r:' + cryptoUtils.newToken(); - var expiresAt = this.config.generateSessionExpiresAt(); - var sessionData = { - sessionToken: token, - user: { - __type: 'Pointer', - className: '_User', - objectId: this.auth.user.id - }, - createdWith: { - 'action': 'create' - }, - restricted: true, - expiresAt: Parse._encode(expiresAt) - }; + if (this.query) { + if ('user' in this.data && !this.auth.isMaster && this.data.user?.objectId !== this.auth.user.id) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'Invalid key name: user'); + } else if ('installationId' in this.data) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'Invalid key name: installationId'); + } else if ('sessionToken' in this.data) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'Invalid key name: sessionToken'); + } else if ('expiresAt' in this.data && !this.auth.isMaster && !this.auth.isMaintenance) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'Invalid key name: expiresAt'); + } else if ('createdWith' in this.data && !this.auth.isMaster && !this.auth.isMaintenance) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'Invalid key name: createdWith'); + } + if (!this.auth.isMaster) { + this.query = { + $and: [ + this.query, + { + user: { + __type: 'Pointer', + className: '_User', + objectId: this.auth.user.id, + }, + }, + ], + }; + } + } + + if (!this.query && !this.auth.isMaster && !this.auth.isMaintenance) { + const additionalSessionData = {}; for (var key in this.data) { - if (key == 'objectId') { + if (key === 'objectId' || key === 'user' || key === 'sessionToken' || key === 'expiresAt' || key === 'createdWith') { continue; } - sessionData[key] = this.data[key]; + additionalSessionData[key] = this.data[key]; } - var create = new RestWrite(this.config, Auth.master(this.config), '_Session', null, sessionData); - return create.execute().then((results) => { + + const { sessionData, createSession } = RestWrite.createSession(this.config, { + userId: this.auth.user.id, + createdWith: { + action: 'create', + }, + additionalSessionData, + }); + + return createSession().then(results => { if (!results.response) { - throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, - 'Error creating session.'); + throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Error creating session.'); } sessionData['objectId'] = results.response['objectId']; this.response = { status: 201, location: results.location, - response: sessionData + response: sessionData, }; }); } @@ -549,20 +1290,21 @@ RestWrite.prototype.handleSession = function() { // If an installation is found, this can mutate this.query and turn a create // into an update. // Returns a promise for when we're done if it can't finish this tick. -RestWrite.prototype.handleInstallation = function() { +RestWrite.prototype.handleInstallation = function () { if (this.response || this.className !== '_Installation') { return; } - if (!this.query && !this.data.deviceToken && !this.data.installationId) { - throw new Parse.Error(135, - 'at least one ID field (deviceToken, installationId) ' + - 'must be specified in this operation'); - } - - if (!this.query && !this.data.deviceType) { - throw new Parse.Error(135, - 'deviceType must be specified in this operation'); + if ( + !this.query && + !this.data.deviceToken && + !this.data.installationId && + !this.auth.installationId + ) { + throw new Parse.Error( + 135, + 'at least one ID field (deviceToken, installationId) ' + 'must be specified in this operation' + ); } // If the device token is 64 characters long, we assume it is for iOS @@ -571,14 +1313,27 @@ RestWrite.prototype.handleInstallation = function() { this.data.deviceToken = this.data.deviceToken.toLowerCase(); } - // TODO: We may need installationId from headers, plumb through Auth? - // per installation_handler.go - // We lowercase the installationId if present if (this.data.installationId) { this.data.installationId = this.data.installationId.toLowerCase(); } + let installationId = this.data.installationId; + + // If data.installationId is not set and we're not master, we can lookup in auth + if (!installationId && !this.auth.isMaster && !this.auth.isMaintenance) { + installationId = this.auth.installationId; + } + + if (installationId) { + installationId = installationId.toLowerCase(); + } + + // Updating _Installation but not updating anything critical + if (this.query && !this.data.deviceToken && !installationId && !this.data.deviceType) { + return; + } + var promise = Promise.resolve(); var idMatch; // Will be a match on either objectId or installationId @@ -587,189 +1342,233 @@ RestWrite.prototype.handleInstallation = function() { var deviceTokenMatches = []; // Instead of issuing 3 reads, let's do it with one OR. - let orQueries = []; + const orQueries = []; if (this.query && this.query.objectId) { orQueries.push({ - objectId: this.query.objectId + objectId: this.query.objectId, }); } - if (this.data.installationId) { + if (installationId) { orQueries.push({ - 'installationId': this.data.installationId + installationId: installationId, }); } if (this.data.deviceToken) { - orQueries.push({'deviceToken': this.data.deviceToken}); + orQueries.push({ deviceToken: this.data.deviceToken }); } if (orQueries.length == 0) { return; } - promise = promise.then(() => { - return this.config.database.find('_Installation', { - '$or': orQueries - }, {}); - }).then((results) => { - results.forEach((result) => { - if (this.query && this.query.objectId && result.objectId == this.query.objectId) { - objectIdMatch = result; - } - if (result.installationId == this.data.installationId) { - installationIdMatch = result; - } - if (result.deviceToken == this.data.deviceToken) { - deviceTokenMatches.push(result); - } - }); + promise = promise + .then(() => { + return this.config.database.find( + '_Installation', + { + $or: orQueries, + }, + {} + ); + }) + .then(results => { + results.forEach(result => { + if (this.query && this.query.objectId && result.objectId == this.query.objectId) { + objectIdMatch = result; + } + if (result.installationId == installationId) { + installationIdMatch = result; + } + if (result.deviceToken == this.data.deviceToken) { + deviceTokenMatches.push(result); + } + }); - // Sanity checks when running a query - if (this.query && this.query.objectId) { - if (!objectIdMatch) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found for update.'); - } - if (this.data.installationId && objectIdMatch.installationId && - this.data.installationId !== objectIdMatch.installationId) { - throw new Parse.Error(136, - 'installationId may not be changed in this ' + - 'operation'); + // Sanity checks when running a query + if (this.query && this.query.objectId) { + if (!objectIdMatch) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found for update.'); + } + if ( + this.data.installationId && + objectIdMatch.installationId && + this.data.installationId !== objectIdMatch.installationId + ) { + throw new Parse.Error(136, 'installationId may not be changed in this ' + 'operation'); } - if (this.data.deviceToken && objectIdMatch.deviceToken && + if ( + this.data.deviceToken && + objectIdMatch.deviceToken && this.data.deviceToken !== objectIdMatch.deviceToken && - !this.data.installationId && !objectIdMatch.installationId) { - throw new Parse.Error(136, - 'deviceToken may not be changed in this ' + - 'operation'); + !this.data.installationId && + !objectIdMatch.installationId + ) { + throw new Parse.Error(136, 'deviceToken may not be changed in this ' + 'operation'); } - if (this.data.deviceType && this.data.deviceType && - this.data.deviceType !== objectIdMatch.deviceType) { - throw new Parse.Error(136, - 'deviceType may not be changed in this ' + - 'operation'); + if ( + this.data.deviceType && + this.data.deviceType && + this.data.deviceType !== objectIdMatch.deviceType + ) { + throw new Parse.Error(136, 'deviceType may not be changed in this ' + 'operation'); } - } - - if (this.query && this.query.objectId && objectIdMatch) { - idMatch = objectIdMatch; - } + } - if (this.data.installationId && installationIdMatch) { - idMatch = installationIdMatch; - } + if (this.query && this.query.objectId && objectIdMatch) { + idMatch = objectIdMatch; + } - }).then(() => { - if (!idMatch) { - if (!deviceTokenMatches.length) { - return; - } else if (deviceTokenMatches.length == 1 && - (!deviceTokenMatches[0]['installationId'] || !this.data.installationId) - ) { - // Single match on device token but none on installationId, and either - // the passed object or the match is missing an installationId, so we - // can just return the match. - return deviceTokenMatches[0]['objectId']; - } else if (!this.data.installationId) { - throw new Parse.Error(132, - 'Must specify installationId when deviceToken ' + - 'matches multiple Installation objects'); - } else { - // Multiple device token matches and we specified an installation ID, - // or a single match where both the passed and matching objects have - // an installation ID. Try cleaning out old installations that match - // the deviceToken, and return nil to signal that a new object should - // be created. - var delQuery = { - 'deviceToken': this.data.deviceToken, - 'installationId': { - '$ne': this.data.installationId - } - }; - if (this.data.appIdentifier) { - delQuery['appIdentifier'] = this.data.appIdentifier; - } - this.config.database.destroy('_Installation', delQuery); - return; + if (installationId && installationIdMatch) { + idMatch = installationIdMatch; } - } else { - if (deviceTokenMatches.length == 1 && - !deviceTokenMatches[0]['installationId']) { - // Exactly one device token match and it doesn't have an installation - // ID. This is the one case where we want to merge with the existing - // object. - var delQuery = {objectId: idMatch.objectId}; - return this.config.database.destroy('_Installation', delQuery) - .then(() => { - return deviceTokenMatches[0]['objectId']; + // need to specify deviceType only if it's new + if (!this.query && !this.data.deviceType && !idMatch) { + throw new Parse.Error(135, 'deviceType must be specified in this operation'); + } + }) + .then(() => { + if (!idMatch) { + if (!deviceTokenMatches.length) { + return; + } else if ( + deviceTokenMatches.length == 1 && + (!deviceTokenMatches[0]['installationId'] || !installationId) + ) { + // Single match on device token but none on installationId, and either + // the passed object or the match is missing an installationId, so we + // can just return the match. + return deviceTokenMatches[0]['objectId']; + } else if (!this.data.installationId) { + throw new Parse.Error( + 132, + 'Must specify installationId when deviceToken ' + + 'matches multiple Installation objects' + ); + } else { + // Multiple device token matches and we specified an installation ID, + // or a single match where both the passed and matching objects have + // an installation ID. Clean out other installations that match the + // deviceToken, and return nil to signal that a new object should be + // created. + const delQuery = { + deviceToken: this.data.deviceToken, + installationId: { + $ne: installationId, + }, + }; + if (this.data.appIdentifier) { + delQuery['appIdentifier'] = this.data.appIdentifier; + } + const installationOpts = this.config.installation || {}; + return InstallationDedup.removeConflictingDeviceToken({ + database: this.config.database, + query: delQuery, + action: installationOpts.duplicateDeviceTokenAction || 'delete', + enforceAuth: installationOpts.duplicateDeviceTokenActionEnforceAuth === true, + runOptions: this.runOptions, + validSchemaController: this.validSchemaController, }); + } } else { - if (this.data.deviceToken && - idMatch.deviceToken != this.data.deviceToken) { - // We're setting the device token on an existing installation, so - // we should try cleaning out old installations that match this - // device token. - var delQuery = { - 'deviceToken': this.data.deviceToken, - }; - // We have a unique install Id, use that to preserve - // the interesting installation - if (this.data.installationId) { - delQuery['installationId'] = { - '$ne': this.data.installationId + if (deviceTokenMatches.length == 1 && !deviceTokenMatches[0]['installationId']) { + // Exactly one device token match and it doesn't have an installation + // ID. The two rows represent the same install; resolve the merge per + // the configured options. + const installationOpts = this.config.installation || {}; + return InstallationDedup.applyDuplicateDeviceTokenMerge({ + database: this.config.database, + idMatch, + deviceTokenMatch: deviceTokenMatches[0], + action: installationOpts.duplicateDeviceTokenAction || 'delete', + mergePriority: installationOpts.duplicateDeviceTokenMergePriority || 'deviceToken', + enforceAuth: installationOpts.duplicateDeviceTokenActionEnforceAuth === true, + runOptions: this.runOptions, + validSchemaController: this.validSchemaController, + }); + } else { + if (this.data.deviceToken && idMatch.deviceToken != this.data.deviceToken) { + // We're setting the device token on an existing installation, so + // we should try cleaning out old installations that match this + // device token. + const delQuery = { + deviceToken: this.data.deviceToken, + }; + // We have a unique install Id, use that to preserve + // the interesting installation + if (this.data.installationId) { + delQuery['installationId'] = { + $ne: this.data.installationId, + }; + } else if ( + idMatch.objectId && + this.data.objectId && + idMatch.objectId == this.data.objectId + ) { + // we passed an objectId, preserve that instalation + delQuery['objectId'] = { + $ne: idMatch.objectId, + }; + } else { + // What to do here? can't really clean up everything... + return idMatch.objectId; } - } else if (idMatch.objectId && this.data.objectId - && idMatch.objectId == this.data.objectId) { - // we passed an objectId, preserve that instalation - delQuery['objectId'] = { - '$ne': idMatch.objectId + if (this.data.appIdentifier) { + delQuery['appIdentifier'] = this.data.appIdentifier; } - } else { - // What to do here? can't really clean up everything... - return idMatch.objectId; - } - if (this.data.appIdentifier) { - delQuery['appIdentifier'] = this.data.appIdentifier; + const installationOpts = this.config.installation || {}; + return InstallationDedup.removeConflictingDeviceToken({ + database: this.config.database, + query: delQuery, + action: installationOpts.duplicateDeviceTokenAction || 'delete', + enforceAuth: installationOpts.duplicateDeviceTokenActionEnforceAuth === true, + runOptions: this.runOptions, + validSchemaController: this.validSchemaController, + }).then(() => idMatch.objectId); } - this.config.database.destroy('_Installation', delQuery); + // In non-merge scenarios, just return the installation match id + return idMatch.objectId; } - // In non-merge scenarios, just return the installation match id - return idMatch.objectId; } - } - }).then((objId) => { - if (objId) { - this.query = {objectId: objId}; - delete this.data.objectId; - delete this.data.createdAt; - } - // TODO: Validate ops (add/remove on channels, $inc on badge, etc.) - }); + }) + .then(objId => { + if (objId) { + this.query = { objectId: objId }; + delete this.data.objectId; + delete this.data.createdAt; + } + // TODO: Validate ops (add/remove on channels, $inc on badge, etc.) + }); return promise; }; -// If we short-circuted the object response - then we need to make sure we expand all the files, +// If we short-circuited the object response - then we need to make sure we expand all the files, // since this might not have a query, meaning it won't return the full result back. // TODO: (nlutsenko) This should die when we move to per-class based controllers on _Session/_User -RestWrite.prototype.expandFilesForExistingObjects = function() { +RestWrite.prototype.expandFilesForExistingObjects = async function () { // Check whether we have a short-circuited response - only then run expansion. if (this.response && this.response.response) { - this.config.filesController.expandFilesInObject(this.config, this.response.response); + await this.config.filesController.expandFilesInObject(this.config, this.response.response); } }; -RestWrite.prototype.runDatabaseOperation = function() { +RestWrite.prototype.runDatabaseOperation = function () { if (this.response) { return; } if (this.className === '_Role') { this.config.cacheController.role.clear(); + if (this.config.liveQueryController) { + this.config.liveQueryController.clearCachedRoles(this.auth.user); + } } - if (this.className === '_User' && - this.query && - !this.auth.couldUpdateUserId(this.query.objectId)) { - throw new Parse.Error(Parse.Error.SESSION_MISSING, `Cannot modify user ${this.query.objectId}.`); + if (this.className === '_User' && this.query && this.auth.isUnauthenticated()) { + throw createSanitizedError( + Parse.Error.SESSION_MISSING, + `Cannot modify user ${this.query.objectId}.`, + this.config + ); } if (this.className === '_Product' && this.data.download) { @@ -785,150 +1584,335 @@ RestWrite.prototype.runDatabaseOperation = function() { if (this.query) { // Force the user to not lockout // Matched with parse.com - if (this.className === '_User' && this.data.ACL) { + if ( + this.className === '_User' && + this.data.ACL && + this.auth.isMaster !== true && + this.auth.isMaintenance !== true + ) { this.data.ACL[this.query.objectId] = { read: true, write: true }; } - // Run an update - return this.config.database.update(this.className, this.query, this.data, this.runOptions) - .then(response => { - response.updatedAt = this.updatedAt; - if (this.storage.changedByTrigger) { - this.updateResponseWithData(response, this.data); - } - this.response = { response }; + // update password timestamp if user password is being changed + if ( + this.className === '_User' && + this.data._hashed_password && + this.config.passwordPolicy && + this.config.passwordPolicy.maxPasswordAge + ) { + this.data._password_changed_at = Parse._encode(new Date()); + } + // Ignore createdAt when update + delete this.data.createdAt; + + let defer = Promise.resolve(); + // if password history is enabled then save the current password to history + if ( + this.className === '_User' && + this.data._hashed_password && + this.config.passwordPolicy && + this.config.passwordPolicy.maxPasswordHistory + ) { + defer = this.config.database + .find( + '_User', + { objectId: this.objectId() }, + { keys: ['_password_history', '_hashed_password'] }, + Auth.maintenance(this.config) + ) + .then(results => { + if (results.length != 1) { + throw undefined; + } + const user = results[0]; + let oldPasswords = []; + if (user._password_history) { + oldPasswords = _.take( + user._password_history, + this.config.passwordPolicy.maxPasswordHistory + ); + } + //n-1 passwords go into history including last password + while ( + oldPasswords.length > Math.max(0, this.config.passwordPolicy.maxPasswordHistory - 2) + ) { + oldPasswords.shift(); + } + oldPasswords.push(user.password); + this.data._password_history = oldPasswords; + }); + } + + return defer.then(() => { + // Run an update + return this.config.database + .update( + this.className, + this.query, + this.data, + this.runOptions, + false, + false, + this.validSchemaController + ) + .catch(error => { + this._throwIfAuthDataDuplicate(error); + throw error; + }) + .then(response => { + response.updatedAt = this.updatedAt; + this._updateResponseWithData(response, this.data); + this.response = { response }; + }); }); } else { - // Set the default ACL for the new _User + // Set the default ACL and password timestamp for the new _User if (this.className === '_User') { var ACL = this.data.ACL; // default public r/w ACL if (!ACL) { ACL = {}; - ACL['*'] = { read: true, write: false }; + if (!this.config.enforcePrivateUsers) { + ACL['*'] = { read: true, write: false }; + } } // make sure the user is not locked down ACL[this.data.objectId] = { read: true, write: true }; this.data.ACL = ACL; + // password timestamp to be used when password expiry policy is enforced + if (this.config.passwordPolicy && this.config.passwordPolicy.maxPasswordAge) { + this.data._password_changed_at = Parse._encode(new Date()); + } } // Run a create - return this.config.database.create(this.className, this.data, this.runOptions) - .catch(error => { - if (this.className !== '_User' || error.code !== Parse.Error.DUPLICATE_VALUE) { - throw error; - } - // If this was a failed user creation due to username or email already taken, we need to - // check whether it was username or email and return the appropriate error. + return this.config.database + .create(this.className, this.data, this.runOptions, false, this.validSchemaController) + .catch(error => { + if (this.className !== '_User' || error.code !== Parse.Error.DUPLICATE_VALUE) { + throw error; + } - // TODO: See if we can later do this without additional queries by using named indexes. - return this.config.database.find( - this.className, - { username: this.data.username, objectId: {'$ne': this.objectId()} }, - { limit: 1 } - ) - .then(results => { - if (results.length > 0) { - throw new Parse.Error(Parse.Error.USERNAME_TAKEN, 'Account already exists for this username.'); + this._throwIfAuthDataDuplicate(error); + + // Quick check, if we were able to infer the duplicated field name + if (error && error.userInfo && error.userInfo.duplicated_field === 'username') { + throw new Parse.Error( + Parse.Error.USERNAME_TAKEN, + 'Account already exists for this username.' + ); } - return this.config.database.find( - this.className, - { email: this.data.email, objectId: {'$ne': this.objectId()} }, - { limit: 1 } - ); + + if (error && error.userInfo && error.userInfo.duplicated_field === 'email') { + throw new Parse.Error( + Parse.Error.EMAIL_TAKEN, + 'Account already exists for this email address.' + ); + } + + // If this was a failed user creation due to username or email already taken, we need to + // check whether it was username or email and return the appropriate error. + // Fallback to the original method + // TODO: See if we can later do this without additional queries by using named indexes. + return this.config.database + .find( + this.className, + { + username: this.data.username, + objectId: { $ne: this.objectId() }, + }, + { limit: 1 } + ) + .then(results => { + if (results.length > 0) { + throw new Parse.Error( + Parse.Error.USERNAME_TAKEN, + 'Account already exists for this username.' + ); + } + return this.config.database.find( + this.className, + { email: this.data.email, objectId: { $ne: this.objectId() } }, + { limit: 1 } + ); + }) + .then(results => { + if (results.length > 0) { + throw new Parse.Error( + Parse.Error.EMAIL_TAKEN, + 'Account already exists for this email address.' + ); + } + throw new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'A duplicate value for a field with unique values was provided' + ); + }); }) - .then(results => { - if (results.length > 0) { - throw new Parse.Error(Parse.Error.EMAIL_TAKEN, 'Account already exists for this email address.'); + .then(response => { + response.objectId = this.data.objectId; + response.createdAt = this.data.createdAt; + + if (this.responseShouldHaveUsername) { + response.username = this.data.username; } - throw new Parse.Error(Parse.Error.DUPLICATE_VALUE, 'A duplicate value for a field with unique values was provided'); + this._updateResponseWithData(response, this.data); + this.response = { + status: 201, + response, + location: this.location(), + }; }); - }) - .then(response => { - response.objectId = this.data.objectId; - response.createdAt = this.data.createdAt; - - if (this.responseShouldHaveUsername) { - response.username = this.data.username; - } - if (this.storage.changedByTrigger) { - this.updateResponseWithData(response, this.data); - } - this.response = { - status: 201, - response, - location: this.location() - }; - }); } }; // Returns nothing - doesn't wait for the trigger. -RestWrite.prototype.runAfterTrigger = function() { - if (!this.response || !this.response.response) { +RestWrite.prototype.runAfterSaveTrigger = function () { + if (!this.response || !this.response.response || this.runOptions.many) { return; } // Avoid doing any setup for triggers if there is no 'afterSave' trigger for this class. - let hasAfterSaveHook = triggers.triggerExists(this.className, triggers.Types.afterSave, this.config.applicationId); - let hasLiveQuery = this.config.liveQueryController.hasLiveQuery(this.className); + const hasAfterSaveHook = triggers.triggerExists( + this.className, + triggers.Types.afterSave, + this.config.applicationId + ); + const hasLiveQuery = this.config.liveQueryController.hasLiveQuery(this.className); if (!hasAfterSaveHook && !hasLiveQuery) { return Promise.resolve(); } - var extraData = {className: this.className}; - if (this.query && this.query.objectId) { - extraData.objectId = this.query.objectId; - } - - // Build the original object, we only do this for a update write. - let originalObject; - if (this.query && this.query.objectId) { - originalObject = triggers.inflate(extraData, this.originalData); - } - - // Build the inflated object, different from beforeSave, originalData is not empty - // since developers can change data in the beforeSave. - let updatedObject = triggers.inflate(extraData, this.originalData); - updatedObject.set(this.sanitizedData()); + const { originalObject, updatedObject } = this.buildParseObjects(); updatedObject._handleSaveResponse(this.response.response, this.response.status || 200); - // Notifiy LiveQueryServer if possible - this.config.liveQueryController.onAfterSave(updatedObject.className, updatedObject, originalObject); - + if (hasLiveQuery) { + this.config.database.loadSchema().then(schemaController => { + // Notify LiveQueryServer if possible + const perms = schemaController.getClassLevelPermissions(updatedObject.className); + this.config.liveQueryController.onAfterSave( + updatedObject.className, + updatedObject, + originalObject, + perms + ); + }); + } + if (!hasAfterSaveHook) { + return Promise.resolve(); + } // Run afterSave trigger - triggers.maybeRunTrigger(triggers.Types.afterSave, this.auth, updatedObject, originalObject, this.config); + return triggers + .maybeRunTrigger( + triggers.Types.afterSave, + this.auth, + updatedObject, + originalObject, + this.config, + this.context + ) + .then(result => { + const jsonReturned = result && !result._toFullJSON; + if (jsonReturned) { + this.pendingOps.operations = {}; + this.response.response = result; + } else { + this.response.response = this._updateResponseWithData( + (result || updatedObject).toJSON(), + this.data + ); + } + }) + .catch(function (err) { + logger.warn('afterSave caught an error', err); + }); }; // A helper to figure out what location this operation happens at. -RestWrite.prototype.location = function() { - var middle = (this.className === '_User' ? '/users/' : - '/classes/' + this.className + '/'); - return this.config.mount + middle + this.data.objectId; +RestWrite.prototype.location = function () { + var middle = this.className === '_User' ? '/users/' : '/classes/' + this.className + '/'; + const mount = this.config.mount || this.config.serverURL; + return mount + middle + this.data.objectId; }; // A helper to get the object id for this operation. // Because it could be either on the query or on the data -RestWrite.prototype.objectId = function() { +RestWrite.prototype.objectId = function () { return this.data.objectId || this.query.objectId; }; // Returns a copy of the data and delete bad keys (_auth_data, _hashed_password...) -RestWrite.prototype.sanitizedData = function() { - let data = Object.keys(this.data).reduce((data, key) => { +RestWrite.prototype.sanitizedData = function () { + const data = Object.keys(this.data).reduce((data, key) => { // Regexp comes from Parse.Object.prototype.validate - if (!(/^[A-Za-z][0-9A-Za-z_]*$/).test(key)) { + if (!/^[A-Za-z][0-9A-Za-z_]*$/.test(key)) { delete data[key]; } return data; - }, deepcopy(this.data)); + }, structuredClone(this.data)); return Parse._decode(undefined, data); -} +}; + +// Returns an updated copy of the object +RestWrite.prototype.buildParseObjects = function () { + const extraData = { className: this.className, objectId: this.query?.objectId }; + let originalObject; + if (this.query && this.query.objectId) { + originalObject = triggers.inflate(extraData, this.originalData); + } -RestWrite.prototype.cleanUserAuthData = function() { + const className = Parse.Object.fromJSON(extraData); + const readOnlyAttributes = className.constructor.readOnlyAttributes + ? className.constructor.readOnlyAttributes() + : []; + + // For _Role class, 'name' cannot be set after the role has an objectId. + // In afterSave context, _handleSaveResponse has already set the objectId, + // so we treat 'name' as read-only to avoid Parse SDK validation errors. + const isRoleAfterSave = this.className === '_Role' && this.response && !this.query; + if (isRoleAfterSave && this.data.name && !readOnlyAttributes.includes('name')) { + readOnlyAttributes.push('name'); + } + if (!this.originalData) { + for (const attribute of readOnlyAttributes) { + extraData[attribute] = this.data[attribute]; + } + } + const updatedObject = triggers.inflate(extraData, this.originalData); + Object.keys(this.data).reduce(function (data, key) { + if (key.indexOf('.') > 0) { + if (typeof data[key].__op === 'string') { + if (!readOnlyAttributes.includes(key)) { + updatedObject.set(key, data[key]); + } + } else { + // subdocument key with dot notation { 'x.y': v } => { 'x': { 'y' : v } }) + const splittedKey = key.split('.'); + const parentProp = splittedKey[0]; + let parentVal = updatedObject.get(parentProp); + if (typeof parentVal !== 'object') { + parentVal = {}; + } + parentVal[splittedKey[1]] = data[key]; + updatedObject.set(parentProp, parentVal); + } + delete data[key]; + } + return data; + }, structuredClone(this.data)); + + const sanitized = this.sanitizedData(); + for (const attribute of readOnlyAttributes) { + delete sanitized[attribute]; + } + updatedObject.set(sanitized); + return { updatedObject, originalObject }; +}; + +RestWrite.prototype.cleanUserAuthData = function () { if (this.response && this.response.response && this.className === '_User') { - let user = this.response.response; + const user = this.response.response; if (user.authData) { - Object.keys(user.authData).forEach((provider) => { + Object.keys(user.authData).forEach(provider => { if (user.authData[provider] === null) { delete user.authData[provider]; } @@ -940,14 +1924,75 @@ RestWrite.prototype.cleanUserAuthData = function() { } }; -RestWrite.prototype.updateResponseWithData = function(response, data) { - let clientSupportsDelete = ClientSDK.supportsForwardDelete(this.clientSDK); - Object.keys(data).forEach(fieldName => { - let dataValue = data[fieldName]; - let responseValue = response[fieldName]; +// Strips protected fields from the write response when protectedFieldsSaveResponseExempt is false. +RestWrite.prototype.filterProtectedFieldsInResponse = async function () { + if (this.config.protectedFieldsSaveResponseExempt !== false) { + return; + } + if (this.auth.isMaster || this.auth.isMaintenance) { + return; + } + if (!this.response || !this.response.response) { + return; + } + const schemaController = await this.config.database.loadSchema(); + const protectedFields = this.config.database.addProtectedFields( + schemaController, + this.className, + this.query ? { objectId: this.query.objectId } : {}, + this.auth.user ? [this.auth.user.id].concat(this.auth.userRoles || []) : [], + this.auth, + {} + ); + if (!protectedFields) { + return; + } + for (const field of protectedFields) { + delete this.response.response[field]; + } +}; + +RestWrite.prototype._updateResponseWithData = function (response, data) { + const stateController = Parse.CoreManager.getObjectStateController(); + const [pending] = stateController.getPendingOps(this.pendingOps.identifier); + for (const key in this.pendingOps.operations) { + if (!pending[key]) { + data[key] = this.originalData ? this.originalData[key] : { __op: 'Delete' }; + this.storage.fieldsChangedByTrigger.push(key); + } + } + const skipKeys = [...(requiredColumns.read[this.className] || [])]; + if (!this.query) { + skipKeys.push('objectId', 'createdAt'); + } else { + skipKeys.push('updatedAt'); + delete response.objectId; + } + for (const key in response) { + if (skipKeys.includes(key)) { + continue; + } + const value = response[key]; + if ( + value == null || + (value.__type && value.__type === 'Pointer') || + util.isDeepStrictEqual(data[key], value) || + util.isDeepStrictEqual((this.originalData || {})[key], value) + ) { + delete response[key]; + } + } + if (_.isEmpty(this.storage.fieldsChangedByTrigger)) { + return response; + } + const clientSupportsDelete = ClientSDK.supportsForwardDelete(this.clientSDK); + this.storage.fieldsChangedByTrigger.forEach(fieldName => { + const dataValue = data[fieldName]; + + if (!Object.prototype.hasOwnProperty.call(response, fieldName)) { + response[fieldName] = dataValue; + } - response[fieldName] = responseValue || dataValue; - // Strips operations from responses if (response[fieldName] && response[fieldName].__op) { delete response[fieldName]; @@ -957,7 +2002,7 @@ RestWrite.prototype.updateResponseWithData = function(response, data) { } }); return response; -} +}; export default RestWrite; module.exports = RestWrite; diff --git a/src/Routers/AggregateRouter.js b/src/Routers/AggregateRouter.js new file mode 100644 index 0000000000..753be4e7e0 --- /dev/null +++ b/src/Routers/AggregateRouter.js @@ -0,0 +1,161 @@ +import Parse from 'parse/node'; +import * as middleware from '../middlewares'; +import rest from '../rest'; +import ClassesRouter from './ClassesRouter'; +import UsersRouter from './UsersRouter'; + +export class AggregateRouter extends ClassesRouter { + async handleFind(req) { + const body = Object.assign(req.body || {}, ClassesRouter.JSONFromQuery(req.query)); + const options = {}; + if (body.distinct) { + options.distinct = String(body.distinct); + } + if (body.hint) { + options.hint = body.hint; + delete body.hint; + } + if (body.explain) { + options.explain = body.explain; + delete body.explain; + } + if (body.comment) { + options.comment = body.comment; + delete body.comment; + } + if (body.readPreference) { + options.readPreference = body.readPreference; + delete body.readPreference; + } + if (typeof body.rawValues === 'boolean') { + options.rawValues = body.rawValues; + delete body.rawValues; + } + if (typeof body.rawFieldNames === 'boolean') { + options.rawFieldNames = body.rawFieldNames; + delete body.rawFieldNames; + } + const queryOptions = (req.config && req.config.query) || {}; + if (options.rawValues === undefined && typeof queryOptions.aggregationRawValues === 'boolean') { + options.rawValues = queryOptions.aggregationRawValues; + } + if ( + options.rawFieldNames === undefined && + typeof queryOptions.aggregationRawFieldNames === 'boolean' + ) { + options.rawFieldNames = queryOptions.aggregationRawFieldNames; + } + options.pipeline = AggregateRouter.getPipeline(body); + if (typeof body.where === 'string') { + try { + body.where = JSON.parse(body.where); + } catch { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'where parameter is not valid JSON'); + } + } + try { + const response = await rest.find( + req.config, + req.auth, + this.className(req), + body.where, + options, + req.info.clientSDK, + req.info.context + ); + if (!options.rawValues && !options.rawFieldNames) { + for (const result of response.results) { + if (typeof result === 'object') { + UsersRouter.removeHiddenProperties(result); + } + } + } + return { response }; + } catch (e) { + if (e instanceof Parse.Error) { + throw e; + } + throw new Parse.Error(Parse.Error.INVALID_QUERY, e.message); + } + } + + /* Builds a pipeline from the body. Originally the body could be passed as a single object, + * and now we support many options. + * + * Array + * + * body: [{ + * group: { objectId: '$name' }, + * }] + * + * Object + * + * body: { + * group: { objectId: '$name' }, + * } + * + * + * Pipeline Operator with an Array or an Object + * + * body: { + * pipeline: { + * $group: { objectId: '$name' }, + * } + * } + * + */ + static getPipeline(body) { + let pipeline = body.pipeline || body; + if (!Array.isArray(pipeline)) { + pipeline = Object.keys(pipeline) + .filter(key => pipeline[key] !== undefined) + .map(key => { + return { [key]: pipeline[key] }; + }); + } + + return pipeline.map(stage => { + const keys = Object.keys(stage); + if (keys.length !== 1) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Pipeline stages should only have one key but found ${keys.join(', ')}.` + ); + } + return AggregateRouter.transformStage(keys[0], stage); + }); + } + + static transformStage(stageName, stage) { + const skipKeys = ['distinct', 'where']; + if (skipKeys.includes(stageName)) { + return; + } + if (stageName[0] !== '$') { + throw new Parse.Error(Parse.Error.INVALID_QUERY, `Invalid aggregate stage '${stageName}'.`); + } + if (stageName === '$group') { + if (Object.prototype.hasOwnProperty.call(stage[stageName], 'objectId')) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Cannot use 'objectId' in aggregation stage $group.` + ); + } + if (!Object.prototype.hasOwnProperty.call(stage[stageName], '_id')) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Invalid parameter for query: group. Missing key _id` + ); + } + } + return { [stageName]: stage[stageName] }; + } + + mountRoutes() { + this.route('GET', '/aggregate/:className', middleware.promiseEnforceMasterKeyAccess, req => { + return this.handleFind(req); + }); + } +} + +export default AggregateRouter; diff --git a/src/Routers/AnalyticsRouter.js b/src/Routers/AnalyticsRouter.js index 511781d807..90ffcdcc4a 100644 --- a/src/Routers/AnalyticsRouter.js +++ b/src/Routers/AnalyticsRouter.js @@ -11,10 +11,9 @@ function trackEvent(req) { return analyticsController.trackEvent(req); } - export class AnalyticsRouter extends PromiseRouter { mountRoutes() { - this.route('POST','/events/AppOpened', appOpened); - this.route('POST','/events/:eventName', trackEvent); + this.route('POST', '/events/AppOpened', appOpened); + this.route('POST', '/events/:eventName', trackEvent); } } diff --git a/src/Routers/AudiencesRouter.js b/src/Routers/AudiencesRouter.js new file mode 100644 index 0000000000..d16a34fb30 --- /dev/null +++ b/src/Routers/AudiencesRouter.js @@ -0,0 +1,75 @@ +import ClassesRouter from './ClassesRouter'; +import rest from '../rest'; +import * as middleware from '../middlewares'; + +export class AudiencesRouter extends ClassesRouter { + className() { + return '_Audience'; + } + + handleFind(req) { + const body = Object.assign(req.body || {}, ClassesRouter.JSONFromQuery(req.query)); + const options = ClassesRouter.optionsFromBody(body, req.config.defaultLimit); + + return rest + .find( + req.config, + req.auth, + '_Audience', + body.where, + options, + req.info.clientSDK, + req.info.context + ) + .then(response => { + response.results.forEach(item => { + item.query = JSON.parse(item.query); + }); + + return { response: response }; + }); + } + + handleGet(req) { + return super.handleGet(req).then(data => { + data.response.query = JSON.parse(data.response.query); + + return data; + }); + } + + mountRoutes() { + this.route('GET', '/push_audiences', middleware.promiseEnforceMasterKeyAccess, req => { + return this.handleFind(req); + }); + this.route( + 'GET', + '/push_audiences/:objectId', + middleware.promiseEnforceMasterKeyAccess, + req => { + return this.handleGet(req); + } + ); + this.route('POST', '/push_audiences', middleware.promiseEnforceMasterKeyAccess, req => { + return this.handleCreate(req); + }); + this.route( + 'PUT', + '/push_audiences/:objectId', + middleware.promiseEnforceMasterKeyAccess, + req => { + return this.handleUpdate(req); + } + ); + this.route( + 'DELETE', + '/push_audiences/:objectId', + middleware.promiseEnforceMasterKeyAccess, + req => { + return this.handleDelete(req); + } + ); + } +} + +export default AudiencesRouter; diff --git a/src/Routers/ClassesRouter.js b/src/Routers/ClassesRouter.js index e1d186c1ae..234c216103 100644 --- a/src/Routers/ClassesRouter.js +++ b/src/Routers/ClassesRouter.js @@ -1,93 +1,105 @@ - import PromiseRouter from '../PromiseRouter'; -import rest from '../rest'; - -import url from 'url'; +import rest from '../rest'; +import _ from 'lodash'; +import Parse from 'parse/node'; +import { promiseEnsureIdempotency } from '../middlewares'; +import { createSanitizedError } from '../Error'; -const ALLOWED_GET_QUERY_KEYS = ['keys', 'include']; +const ALLOWED_GET_QUERY_KEYS = [ + 'keys', + 'include', + 'excludeKeys', + 'readPreference', + 'includeReadPreference', + 'subqueryReadPreference', +]; export class ClassesRouter extends PromiseRouter { + className(req) { + return req.params.className; + } handleFind(req) { - let body = Object.assign(req.body, ClassesRouter.JSONFromQuery(req.query)); - let options = {}; - let allowConstraints = ['skip', 'limit', 'order', 'count', 'keys', - 'include', 'redirectClassNameForKey', 'where']; - - for (let key of Object.keys(body)) { - if (allowConstraints.indexOf(key) === -1) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, `Invalid parameter for query: ${key}`); - } - } - - if (body.skip) { - options.skip = Number(body.skip); - } - if (body.limit || body.limit === 0) { - options.limit = Number(body.limit); - } else { - options.limit = Number(100); - } - if (body.order) { - options.order = String(body.order); - } - if (body.count) { - options.count = true; - } - if (typeof body.keys == 'string') { - options.keys = body.keys; - } - if (body.include) { - options.include = String(body.include); + const body = Object.assign(req.body || {}, ClassesRouter.JSONFromQuery(req.query)); + const options = ClassesRouter.optionsFromBody(body, req.config.defaultLimit); + if (req.config.maxLimit && body.limit > req.config.maxLimit) { + // Silently replace the limit on the query with the max configured + options.limit = Number(req.config.maxLimit); } if (body.redirectClassNameForKey) { options.redirectClassNameForKey = String(body.redirectClassNameForKey); } if (typeof body.where === 'string') { - body.where = JSON.parse(body.where); - } - return rest.find(req.config, req.auth, req.params.className, body.where, options, req.info.clientSDK) - .then((response) => { - if (response && response.results) { - for (let result of response.results) { - if (result.sessionToken) { - result.sessionToken = req.info.sessionToken || result.sessionToken; - } - } - } + try { + body.where = JSON.parse(body.where); + } catch { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'where parameter is not valid JSON'); + } + } + return rest + .find( + req.config, + req.auth, + this.className(req), + body.where, + options, + req.info.clientSDK, + req.info.context + ) + .then(response => { return { response: response }; }); } // Returns a promise for a {response} object. handleGet(req) { - let body = Object.assign(req.body, ClassesRouter.JSONFromQuery(req.query)); - let options = {}; + const body = Object.assign(req.body || {}, ClassesRouter.JSONFromQuery(req.query)); + const options = {}; - for (let key of Object.keys(body)) { + for (const key of Object.keys(body)) { if (ALLOWED_GET_QUERY_KEYS.indexOf(key) === -1) { throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Improper encode of parameter'); } } - if (typeof body.keys == 'string') { - options.keys = body.keys; + if (body.keys != null) { + options.keys = String(body.keys); } - if (body.include) { + if (body.include != null) { options.include = String(body.include); } + if (body.excludeKeys != null) { + options.excludeKeys = String(body.excludeKeys); + } + if (typeof body.readPreference === 'string') { + options.readPreference = body.readPreference; + } + if (typeof body.includeReadPreference === 'string') { + options.includeReadPreference = body.includeReadPreference; + } + if (typeof body.subqueryReadPreference === 'string') { + options.subqueryReadPreference = body.subqueryReadPreference; + } - return rest.get(req.config, req.auth, req.params.className, req.params.objectId, options, req.info.clientSDK) - .then((response) => { + return rest + .get( + req.config, + req.auth, + this.className(req), + req.params.objectId, + options, + req.info.clientSDK, + req.info.context + ) + .then(response => { if (!response.results || response.results.length == 0) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); } - if (req.params.className === "_User") { - + if (this.className(req) === '_User') { delete response.results[0].sessionToken; - const user = response.results[0]; + const user = response.results[0]; if (req.auth.user && user.objectId == req.auth.user.id) { // Force the session token @@ -99,38 +111,145 @@ export class ClassesRouter extends PromiseRouter { } handleCreate(req) { - return rest.create(req.config, req.auth, req.params.className, req.body, req.info.clientSDK); + if ( + this.className(req) === '_User' && + typeof req.body?.objectId === 'string' && + req.body.objectId.startsWith('role:') + ) { + throw createSanitizedError(Parse.Error.OPERATION_FORBIDDEN, 'Invalid object ID.', req.config); + } + return rest.create( + req.config, + req.auth, + this.className(req), + req.body || {}, + req.info.clientSDK, + req.info.context + ); } handleUpdate(req) { - return rest.update(req.config, req.auth, req.params.className, req.params.objectId, req.body, req.info.clientSDK); + const where = { objectId: req.params.objectId }; + return rest.update( + req.config, + req.auth, + this.className(req), + where, + req.body || {}, + req.info.clientSDK, + req.info.context + ); } handleDelete(req) { - return rest.del(req.config, req.auth, req.params.className, req.params.objectId, req.info.clientSDK) + return rest + .del(req.config, req.auth, this.className(req), req.params.objectId, req.info.context) .then(() => { - return {response: {}}; + return { response: {} }; }); } static JSONFromQuery(query) { - let json = {}; - for (let [key, value] of Object.entries(query)) { + const json = {}; + for (const [key, value] of _.entries(query)) { try { json[key] = JSON.parse(value); - } catch (e) { + } catch { json[key] = value; } } - return json + return json; + } + + static optionsFromBody(body, defaultLimit) { + const allowConstraints = [ + 'skip', + 'limit', + 'order', + 'count', + 'keys', + 'excludeKeys', + 'include', + 'includeAll', + 'redirectClassNameForKey', + 'where', + 'readPreference', + 'includeReadPreference', + 'subqueryReadPreference', + 'hint', + 'explain', + 'comment', + ]; + + for (const key of Object.keys(body)) { + if (allowConstraints.indexOf(key) === -1) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, `Invalid parameter for query: ${key}`); + } + } + const options = {}; + if (body.skip) { + options.skip = Number(body.skip); + } + if (body.limit || body.limit === 0) { + options.limit = Number(body.limit); + } else { + options.limit = Number(defaultLimit); + } + if (body.order) { + options.order = String(body.order); + } + if (body.count) { + options.count = true; + } + if (body.keys != null) { + options.keys = String(body.keys); + } + if (body.excludeKeys != null) { + options.excludeKeys = String(body.excludeKeys); + } + if (body.include != null) { + options.include = String(body.include); + } + if (body.includeAll) { + options.includeAll = true; + } + if (typeof body.readPreference === 'string') { + options.readPreference = body.readPreference; + } + if (typeof body.includeReadPreference === 'string') { + options.includeReadPreference = body.includeReadPreference; + } + if (typeof body.subqueryReadPreference === 'string') { + options.subqueryReadPreference = body.subqueryReadPreference; + } + if (body.hint && (typeof body.hint === 'string' || typeof body.hint === 'object')) { + options.hint = body.hint; + } + if (body.explain) { + options.explain = body.explain; + } + if (body.comment && typeof body.comment === 'string') { + options.comment = body.comment; + } + return options; } mountRoutes() { - this.route('GET', '/classes/:className', (req) => { return this.handleFind(req); }); - this.route('GET', '/classes/:className/:objectId', (req) => { return this.handleGet(req); }); - this.route('POST', '/classes/:className', (req) => { return this.handleCreate(req); }); - this.route('PUT', '/classes/:className/:objectId', (req) => { return this.handleUpdate(req); }); - this.route('DELETE', '/classes/:className/:objectId', (req) => { return this.handleDelete(req); }); + this.route('GET', '/classes/:className', req => { + return this.handleFind(req); + }); + this.route('GET', '/classes/:className/:objectId', req => { + return this.handleGet(req); + }); + this.route('POST', '/classes/:className', promiseEnsureIdempotency, req => { + return this.handleCreate(req); + }); + this.route('PUT', '/classes/:className/:objectId', promiseEnsureIdempotency, req => { + return this.handleUpdate(req); + }); + this.route('DELETE', '/classes/:className/:objectId', req => { + return this.handleDelete(req); + }); } } diff --git a/src/Routers/CloudCodeRouter.js b/src/Routers/CloudCodeRouter.js new file mode 100644 index 0000000000..58408151e7 --- /dev/null +++ b/src/Routers/CloudCodeRouter.js @@ -0,0 +1,123 @@ +import PromiseRouter from '../PromiseRouter'; +import Parse from 'parse/node'; +import rest from '../rest'; +const triggers = require('../triggers'); +const middleware = require('../middlewares'); + +function formatJobSchedule(job_schedule) { + if (typeof job_schedule.startAfter === 'undefined') { + job_schedule.startAfter = new Date().toISOString(); + } + return job_schedule; +} + +function validateJobSchedule(config, job_schedule) { + const jobs = triggers.getJobs(config.applicationId) || {}; + if (job_schedule.jobName && !jobs[job_schedule.jobName]) { + throw new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + 'Cannot Schedule a job that is not deployed' + ); + } +} + +export class CloudCodeRouter extends PromiseRouter { + mountRoutes() { + this.route( + 'GET', + '/cloud_code/jobs', + middleware.promiseEnforceMasterKeyAccess, + CloudCodeRouter.getJobs + ); + this.route( + 'GET', + '/cloud_code/jobs/data', + middleware.promiseEnforceMasterKeyAccess, + CloudCodeRouter.getJobsData + ); + this.route( + 'POST', + '/cloud_code/jobs', + middleware.promiseEnforceMasterKeyAccess, + CloudCodeRouter.createJob + ); + this.route( + 'PUT', + '/cloud_code/jobs/:objectId', + middleware.promiseEnforceMasterKeyAccess, + CloudCodeRouter.editJob + ); + this.route( + 'DELETE', + '/cloud_code/jobs/:objectId', + middleware.promiseEnforceMasterKeyAccess, + CloudCodeRouter.deleteJob + ); + } + + static getJobs(req) { + return rest.find(req.config, req.auth, '_JobSchedule', {}, {}).then(scheduledJobs => { + return { + response: scheduledJobs.results, + }; + }); + } + + static getJobsData(req) { + const config = req.config; + const jobs = triggers.getJobs(config.applicationId) || {}; + return rest.find(req.config, req.auth, '_JobSchedule', {}, {}).then(scheduledJobs => { + return { + response: { + in_use: scheduledJobs.results.map(job => job.jobName), + jobs: Object.keys(jobs), + }, + }; + }); + } + + static createJob(req) { + const { job_schedule } = req.body || {}; + validateJobSchedule(req.config, job_schedule); + return rest.create( + req.config, + req.auth, + '_JobSchedule', + formatJobSchedule(job_schedule), + req.client, + req.info.context + ); + } + + static editJob(req) { + const { objectId } = req.params; + const { job_schedule } = req.body || {}; + validateJobSchedule(req.config, job_schedule); + return rest + .update( + req.config, + req.auth, + '_JobSchedule', + { objectId }, + formatJobSchedule(job_schedule), + undefined, + req.info.context + ) + .then(response => { + return { + response, + }; + }); + } + + static deleteJob(req) { + const { objectId } = req.params; + return rest + .del(req.config, req.auth, '_JobSchedule', objectId, req.info.context) + .then(response => { + return { + response, + }; + }); + } +} diff --git a/src/Routers/FeaturesRouter.js b/src/Routers/FeaturesRouter.js index 03bf12e74a..df26338955 100644 --- a/src/Routers/FeaturesRouter.js +++ b/src/Routers/FeaturesRouter.js @@ -1,10 +1,11 @@ -import { version } from '../../package.json'; -import PromiseRouter from '../PromiseRouter'; -import * as middleware from "../middlewares"; +import { version } from '../../package.json'; +import PromiseRouter from '../PromiseRouter'; +import * as middleware from '../middlewares'; export class FeaturesRouter extends PromiseRouter { mountRoutes() { - this.route('GET','/serverInfo', middleware.promiseEnforceMasterKeyAccess, req => { + this.route('GET', '/serverInfo', middleware.promiseEnforceMasterKeyAccess, req => { + const { config } = req; const features = { globalConfig: { create: true, @@ -13,10 +14,13 @@ export class FeaturesRouter extends PromiseRouter { delete: true, }, hooks: { - create: false, - read: false, - update: false, - delete: false, + create: true, + read: true, + update: true, + delete: true, + }, + cloudCode: { + jobs: true, }, logs: { level: true, @@ -26,10 +30,11 @@ export class FeaturesRouter extends PromiseRouter { from: true, }, push: { - immediatePush: req.config.pushController.pushIsAvailable, - scheduledPush: false, - storedPushData: req.config.pushController.pushIsAvailable, - pushAudiences: false, + immediatePush: config.hasPushSupport, + scheduledPush: config.hasPushScheduledSupport, + storedPushData: config.hasPushSupport, + pushAudiences: true, + localization: true, }, schemas: { addField: true, @@ -41,12 +46,17 @@ export class FeaturesRouter extends PromiseRouter { editClassLevelPermissions: true, editPointerPermissions: true, }, + settings: { + securityCheck: !!config.security?.enableCheck, + }, }; - return { response: { - features: features, - parseServerVersion: version, - } }; + return { + response: { + features: features, + parseServerVersion: version, + }, + }; }); } } diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index 160574e1b7..df6f710135 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -1,95 +1,844 @@ -import express from 'express'; -import BodyParser from 'body-parser'; -import * as Middlewares from '../middlewares'; -import { randomHexString } from '../cryptoUtils'; -import Config from '../Config'; -import mime from 'mime'; +import express from 'express'; +import * as Middlewares from '../middlewares'; +import Parse from 'parse/node'; +import Config from '../Config'; +import logger from '../logger'; +const triggers = require('../triggers'); +const Utils = require('../Utils'); +import { Readable } from 'stream'; +import { createSanitizedHttpError } from '../Error'; -export class FilesRouter { +/** + * Wraps a readable stream in a Readable that enforces a byte size limit. + * Data flow is lazy: the source is not read until a consumer starts reading + * from the returned stream (via pipe or 'data' listener). This ensures the + * consumer's error listener is attached before any data (or error) is emitted. + */ +export function createSizeLimitedStream(source, maxBytes) { + let totalBytes = 0; + let started = false; + let sourceEnded = false; + let onData, onEnd, onError; + + const output = new Readable({ + read() { + if (!started) { + started = true; + + onData = (chunk) => { + totalBytes += chunk.length; + if (totalBytes > maxBytes) { + output.destroy( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File size exceeds maximum allowed: ${maxBytes} bytes.` + ) + ); + return; + } + if (!output.push(chunk)) { + source.pause(); + } + }; + + onEnd = () => { + sourceEnded = true; + output.push(null); + }; - getExpressRouter(options = {}) { + onError = (err) => output.destroy(err); + + source.on('data', onData); + source.on('end', onEnd); + source.on('error', onError); + } + + // Resume source in case it was paused due to backpressure + if (!sourceEnded) { + source.resume(); + } + }, + destroy(err, callback) { + if (onData) { + source.removeListener('data', onData); + } + if (onEnd) { + source.removeListener('end', onEnd); + } + if (onError) { + source.removeListener('error', onError); + } + // Suppress errors emitted during drain (e.g. client disconnect) + source.on('error', () => {}); + if (!sourceEnded) { + source.resume(); + } + callback(err); + } + }); + + return output; +} + +// Segments that conflict with sub-routes under GET /files/:appId/*. If a file +// directory starts with one of these, its URL would match the wrong route +// handler. Update this list when adding new sub-routes to expressRouter(). +export const RESERVED_DIRECTORY_SEGMENTS = ['metadata']; + +export class FilesRouter { + expressRouter({ maxUploadSize = '20Mb' } = {}) { var router = express.Router(); - router.get('/files/:appId/:filename', this.getHandler); + // Lightweight info initializer so handleParseSession can resolve session tokens. + // Unlike POST/DELETE routes, GET file routes skip handleParseHeaders (which + // normally sets req.info) because those requests may not carry Parse headers. + const initInfo = (req, res, next) => { + if (!req.info) { + const sessionToken = req.get('X-Parse-Session-Token'); + req.info = { + sessionToken, + installationId: req.get('X-Parse-Installation-Id'), + }; + // If no session token and no auth yet (public access), set a minimal + // auth object so handleParseSession skips session resolution. + if (!sessionToken && !req.auth) { + req.auth = { isMaster: false }; + } + } + next(); + }; + // Metadata route must come before the catch-all GET route + router.get('/files/:appId/metadata/*filepath', initInfo, Middlewares.handleParseSession, this.metadataHandler); + router.get('/files/:appId/*filepath', initInfo, Middlewares.handleParseSession, this.getHandler); - router.post('/files', function(req, res, next) { - next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, - 'Filename not provided.')); + router.post('/files', function (req, res, next) { + next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Filename not provided.')); }); - router.post('/files/:filename', - Middlewares.allowCrossDomain, - BodyParser.raw({type: () => { return true; }, limit: options.maxUploadSize || '20mb'}), // Allow uploads without Content-Type, or with any Content-Type. + router.post( + '/files/:filename', + this._earlyHeadersMiddleware(), + this._bodyParsingMiddleware(maxUploadSize), Middlewares.handleParseHeaders, - this.createHandler + Middlewares.handleParseSession, + this.createHandler.bind(this) ); - router.delete('/files/:filename', - Middlewares.allowCrossDomain, + router.delete( + '/files/*filepath', Middlewares.handleParseHeaders, + Middlewares.handleParseSession, Middlewares.enforceMasterKeyAccess, this.deleteHandler ); return router; } - getHandler(req, res) { - const config = new Config(req.params.appId); - const filesController = config.filesController; - const filename = req.params.filename; - filesController.getFileData(config, filename).then((data) => { + static _getFilenameFromParams(req) { + const parts = req.params.filepath; + return Array.isArray(parts) ? parts.join('/') : parts; + } + + static validateDirectory(directory) { + if (typeof directory !== 'string') { + return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Directory must be a string.'); + } + if (directory.length === 0) { + return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Directory must not be empty.'); + } + if (directory.length > 256) { + return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Directory path is too long.'); + } + if (directory.includes('..')) { + return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Directory must not contain "..".'); + } + if (directory.startsWith('/') || directory.endsWith('/')) { + return new Parse.Error( + Parse.Error.INVALID_FILE_NAME, + 'Directory must not start or end with "/".' + ); + } + if (directory.includes('//')) { + return new Parse.Error( + Parse.Error.INVALID_FILE_NAME, + 'Directory must not contain consecutive slashes.' + ); + } + const firstSegment = directory.split('/')[0]; + if (RESERVED_DIRECTORY_SEGMENTS.includes(firstSegment)) { + return new Parse.Error( + Parse.Error.INVALID_FILE_NAME, + `Directory must not start with reserved segment "${firstSegment}".` + ); + } + const dirRegex = /^[a-zA-Z0-9][a-zA-Z0-9_\-/]*$/; + if (!dirRegex.test(directory)) { + return new Parse.Error( + Parse.Error.INVALID_FILE_NAME, + 'Directory contains invalid characters.' + ); + } + return null; + } + + static _validateFileDownload(req, config) { + const isMaster = req.auth?.isMaster; + const isMaintenance = req.auth?.isMaintenance; + if (isMaster || isMaintenance) { + return; + } + const user = req.auth?.user; + const isLinked = user && Parse.AnonymousUtils.isLinked(user); + if (!config.fileDownload.enableForAnonymousUser && isLinked) { + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + 'File download by anonymous user is disabled.' + ); + } + if (!config.fileDownload.enableForAuthenticatedUser && !isLinked && user) { + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + 'File download by authenticated user is disabled.' + ); + } + if (!config.fileDownload.enableForPublic && !user) { + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + 'File download by public is disabled.' + ); + } + } + + async getHandler(req, res) { + const config = Config.get(req.params.appId); + if (!config) { + const error = createSanitizedHttpError(403, 'Invalid application ID.', config); + res.status(error.status); + res.json({ error: error.message }); + return; + } + + FilesRouter._validateFileDownload(req, config); + + let filename = FilesRouter._getFilenameFromParams(req); + try { + const filesController = config.filesController; + const mime = (await import('mime')).default; + let contentType = mime.getType(filename); + let file = new Parse.File(filename, { base64: '' }, contentType); + const fileAuth = req.auth; + const triggerResult = await triggers.maybeRunFileTrigger( + triggers.Types.beforeFind, + { file }, + config, + fileAuth + ); + if (triggerResult?.file?._name) { + filename = triggerResult?.file?._name; + contentType = mime.getType(filename); + } + + const defaultResponseHeaders = { 'X-Content-Type-Options': 'nosniff' }; + + if (isFileStreamable(req, filesController)) { + const afterFind = await triggers.maybeRunFileTrigger( + triggers.Types.afterFind, + { file, forceDownload: false, responseHeaders: { ...defaultResponseHeaders } }, + config, + fileAuth + ); + if (afterFind?.forceDownload) { + res.set('Content-Disposition', `attachment;filename=${afterFind.file?._name || filename}`); + } + for (const [key, value] of Object.entries(afterFind?.responseHeaders ?? defaultResponseHeaders)) { + res.set(key, value); + } + filesController.handleFileStream(config, filename, req, res, contentType).catch(() => { + res.status(404); + res.set('Content-Type', 'text/plain'); + res.end('File not found.'); + }); + return; + } + + let data = await filesController.getFileData(config, filename).catch(() => { + res.status(404); + res.set('Content-Type', 'text/plain'); + res.end('File not found.'); + }); + if (!data) { + return; + } + file = new Parse.File(filename, { base64: data.toString('base64') }, contentType); + const afterFind = await triggers.maybeRunFileTrigger( + triggers.Types.afterFind, + { file, forceDownload: false, responseHeaders: { ...defaultResponseHeaders } }, + config, + fileAuth + ); + + if (afterFind?.file) { + contentType = mime.getType(afterFind.file._name); + data = Buffer.from(afterFind.file._data, 'base64'); + } + res.status(200); - var contentType = mime.lookup(filename); res.set('Content-Type', contentType); + res.set('Content-Length', data.length); + if (afterFind.forceDownload) { + res.set('Content-Disposition', `attachment;filename=${afterFind.file._name}`); + } + if (afterFind.responseHeaders) { + for (const [key, value] of Object.entries(afterFind.responseHeaders)) { + res.set(key, value); + } + } res.end(data); - }).catch((err) => { - res.status(404); - res.set('Content-Type', 'text/plain'); - res.end('File not found.'); - }); + } catch (e) { + const err = triggers.resolveError(e, { + code: Parse.Error.SCRIPT_FAILED, + message: `Could not find file: ${filename}.`, + }); + res.status(403); + res.json({ code: err.code, error: err.message }); + } } - createHandler(req, res, next) { - if (!req.body || !req.body.length) { - next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, - 'Invalid file upload.')); + /** + * Middleware that runs before body parsing to handle headers that must be + * resolved before the request body is consumed. Currently supports: + * + * - `X-Parse-File-Max-Upload-Size`: Overrides the server-wide `maxUploadSize` + * for this request. Requires the master key. The value uses the same format + * as the server option (e.g. `'50mb'`, `'1gb'`). Sets `req._maxUploadSizeOverride` + * (in bytes) for `_bodyParsingMiddleware` to use. + */ + _earlyHeadersMiddleware() { + return async (req, res, next) => { + const maxUploadSizeOverride = req.get('X-Parse-File-Max-Upload-Size'); + if (!maxUploadSizeOverride) { + return next(); + } + const appId = req.get('X-Parse-Application-Id'); + const config = Config.get(appId); + if (!config) { + const error = createSanitizedHttpError(403, 'Invalid application ID.', undefined); + res.status(error.status); + res.json({ error: error.message }); + return; + } + const masterKey = await config.loadMasterKey(); + if (req.get('X-Parse-Master-Key') !== masterKey) { + const error = createSanitizedHttpError(403, 'unauthorized: master key is required', config); + res.status(error.status); + res.json({ error: error.message }); + return; + } + if (config.masterKeyIps?.length && !Middlewares.checkIp(req.ip, config.masterKeyIps, config.masterKeyIpsStore)) { + const error = createSanitizedHttpError(403, 'unauthorized: master key is required', config); + res.status(error.status); + res.json({ error: error.message }); + return; + } + let parsedBytes; + try { + parsedBytes = Utils.parseSizeToBytes(maxUploadSizeOverride); + } catch { + return next( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `Invalid maxUploadSize override value: ${maxUploadSizeOverride}` + ) + ); + } + req._maxUploadSizeOverride = parsedBytes; + next(); + }; + } + + _bodyParsingMiddleware(maxUploadSize) { + const defaultMaxBytes = Utils.parseSizeToBytes(maxUploadSize); + return (req, res, next) => { + if (req.get('X-Parse-Upload-Mode') === 'stream') { + req._maxUploadSizeBytes = req._maxUploadSizeOverride ?? defaultMaxBytes; + return next(); + } + const limit = req._maxUploadSizeOverride ?? maxUploadSize; + return express.raw({ type: () => true, limit })(req, res, next); + }; + } + + async createHandler(req, res, next) { + if (req.auth.isReadOnly) { + const error = createSanitizedHttpError(403, "read-only masterKey isn't allowed to create a file.", req.config); + res.status(error.status); + res.end(`{"error":"${error.message}"}`); + return; + } + const config = req.config; + const isMaster = req.auth.isMaster; + const isMaintenance = req.auth.isMaintenance; + if (!isMaster && !isMaintenance) { + const user = req.auth.user; + const isLinked = user && Parse.AnonymousUtils.isLinked(user); + if (!config.fileUpload.enableForAnonymousUser && isLinked) { + next( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'File upload by anonymous user is disabled.') + ); + return; + } + if (!config.fileUpload.enableForAuthenticatedUser && !isLinked && user) { + next( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + 'File upload by authenticated user is disabled.' + ) + ); + return; + } + if (!config.fileUpload.enableForPublic && !user) { + next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'File upload by public is disabled.')); + return; + } + } + const filesController = config.filesController; + const { filename } = req.params; + const contentType = req.get('Content-type'); + + const error = filesController.validateFilename(filename); + if (error) { + next(error); return; } - if (req.params.filename.length > 128) { - next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, - 'Filename too long.')); + const fileExtensions = config.fileUpload?.fileExtensions; + if (!isMaster && fileExtensions) { + const isValidExtension = extension => { + return fileExtensions.some(ext => { + if (ext === '*') { + return true; + } + const regex = new RegExp(ext); + if (regex.test(extension)) { + return true; + } + }); + }; + let extension = contentType; + if (filename && filename.includes('.')) { + extension = filename.substring(filename.lastIndexOf('.') + 1); + } else if (contentType && contentType.includes('/')) { + extension = contentType.split('/')[1]; + } + // Strip MIME parameters (e.g. ";charset=utf-8") and whitespace + extension = extension?.split(';')[0]?.replace(/\s+/g, ''); + + if (extension && !isValidExtension(extension)) { + next( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File upload of extension ${extension} is disabled.` + ) + ); + return; + } + } + + // For streaming uploads, read file data from headers since the body is the raw stream + if (req.get('X-Parse-Upload-Mode') === 'stream') { + req.fileData = {}; + if (req.get('X-Parse-File-Directory')) { + req.fileData.directory = req.get('X-Parse-File-Directory'); + } + if (req.get('X-Parse-File-Metadata')) { + try { + const parsed = JSON.parse(req.get('X-Parse-File-Metadata')); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(); + } + req.fileData.metadata = parsed; + } catch { + next(new Parse.Error(Parse.Error.INVALID_JSON, 'Invalid JSON in X-Parse-File-Metadata header.')); + return; + } + } + if (req.get('X-Parse-File-Tags')) { + try { + const parsed = JSON.parse(req.get('X-Parse-File-Tags')); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(); + } + req.fileData.tags = parsed; + } catch { + next(new Parse.Error(Parse.Error.INVALID_JSON, 'Invalid JSON in X-Parse-File-Tags header.')); + return; + } + } + } + + // Validate directory option (requires master key) + const directory = req.fileData?.directory; + if (directory !== undefined) { + if (!isMaster) { + next( + new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + 'Directory can only be set using the Master Key.' + ) + ); + return; + } + const directoryError = FilesRouter.validateDirectory(directory); + if (directoryError) { + next(directoryError); + return; + } + } + + // Dispatch to the appropriate handler based on whether the body was buffered + if (Buffer.isBuffer(req.body)) { + return this._handleBufferedUpload(req, res, next); + } + return this._handleStreamUpload(req, res, next); + } + + async _handleBufferedUpload(req, res, next) { + const config = req.config; + const filesController = config.filesController; + const { filename } = req.params; + const contentType = req.get('Content-type'); + + if (!req.body || !req.body.length) { + next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid file upload.')); return; } - if (!req.params.filename.match(/^[_a-zA-Z0-9][a-zA-Z0-9@\.\ ~_-]*$/)) { - next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, - 'Filename contains invalid characters.')); + const base64 = req.body.toString('base64'); + const file = new Parse.File(filename, { base64 }, contentType); + const { metadata = {}, tags = {}, directory } = req.fileData || {}; + try { + // Scan request data for denied keywords + Utils.checkProhibitedKeywords(config, metadata); + Utils.checkProhibitedKeywords(config, tags); + } catch (error) { + next(new Parse.Error(Parse.Error.INVALID_KEY_NAME, error)); return; } + file.setTags(tags); + file.setMetadata(metadata); + if (directory) { + file.setDirectory(directory); + } + const fileSize = Buffer.byteLength(req.body); + const fileObject = { file, fileSize }; + try { + // run beforeSaveFile trigger + const triggerResult = await triggers.maybeRunFileTrigger( + triggers.Types.beforeSave, + fileObject, + config, + req.auth + ); + let saveResult; + // if a new ParseFile is returned check if it's an already saved file + if (triggerResult instanceof Parse.File) { + fileObject.file = triggerResult; + if (triggerResult.url()) { + // set fileSize to null because we wont know how big it is here + fileObject.fileSize = null; + saveResult = { + url: triggerResult.url(), + name: triggerResult._name, + }; + } + } + // if the file returned by the trigger has already been saved skip saving anything + if (!saveResult) { + // update fileSize + let bufferData; + if (fileObject.file._source?.format === 'buffer') { + bufferData = fileObject.file._source.buffer; + } else { + bufferData = Buffer.from(fileObject.file._data, 'base64'); + } + fileObject.fileSize = Buffer.byteLength(bufferData); + // prepare file options + const fileOptions = { + metadata: fileObject.file._metadata, + }; + // some s3-compatible providers (DigitalOcean, Linode) do not accept tags + // so we do not include the tags option if it is empty. + const fileTags = + Object.keys(fileObject.file._tags).length > 0 ? { tags: fileObject.file._tags } : {}; + Object.assign(fileOptions, fileTags); + // include directory if set (from client request or beforeSaveFile trigger) + if (fileObject.file._directory) { + fileOptions.directory = fileObject.file._directory; + } + // save file + const createFileResult = await filesController.createFile( + config, + fileObject.file._name, + bufferData, + fileObject.file._source.type, + fileOptions + ); + // update file with new data + fileObject.file._name = createFileResult.name; + fileObject.file._url = createFileResult.url; + fileObject.file._requestTask = null; + fileObject.file._previousSave = Promise.resolve(fileObject.file); + saveResult = { + url: createFileResult.url, + name: createFileResult.name, + }; + } + // run afterSaveFile trigger + await triggers.maybeRunFileTrigger(triggers.Types.afterSave, fileObject, config, req.auth); + res.status(201); + res.set('Location', saveResult.url); + res.json(saveResult); + } catch (e) { + logger.error('Error creating a file: ', e); + const error = triggers.resolveError(e, { + code: Parse.Error.FILE_SAVE_ERROR, + message: `Could not store file: ${fileObject.file._name}.`, + }); + next(error); + } + } - const filename = req.params.filename; - const contentType = req.get('Content-type'); + async _handleStreamUpload(req, res, next) { const config = req.config; const filesController = config.filesController; + const { filename } = req.params; + let contentType = req.get('Content-Type'); + const maxBytes = req._maxUploadSizeBytes; + let stream; - filesController.createFile(config, filename, req.body, contentType).then((result) => { + try { + // Early rejection via Content-Length header + const contentLength = req.get('Content-Length'); + if (contentLength && parseInt(contentLength, 10) > maxBytes) { + req.resume(); + next(new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File size exceeds maximum allowed: ${maxBytes} bytes.` + )); + return; + } + + const mime = (await import('mime')).default; + + // Infer content type from extension or add extension from content type + const hasExtension = filename && filename.includes('.'); + if (hasExtension && !contentType) { + contentType = mime.getType(filename); + } else if (!hasExtension && contentType) { + // extension will be added by filesController.createFile + } + + // Create size-limited stream wrapping the request + stream = createSizeLimitedStream(req, maxBytes); + + // Build a Parse.File with no _data (streaming mode) + const file = new Parse.File(filename, { base64: '' }, contentType); + const { metadata = {}, tags = {}, directory } = req.fileData || {}; + + // Validate metadata and tags for prohibited keywords + try { + Utils.checkProhibitedKeywords(config, metadata); + Utils.checkProhibitedKeywords(config, tags); + } catch (error) { + stream.destroy(); + next(new Parse.Error(Parse.Error.INVALID_KEY_NAME, error)); + return; + } + + file.setTags(tags); + file.setMetadata(metadata); + if (directory) { + file.setDirectory(directory); + } + + const fileSize = req.get('Content-Length') + ? parseInt(req.get('Content-Length'), 10) + : null; + const fileObject = { file, fileSize, stream: true }; + + // Run beforeSaveFile trigger + const triggerResult = await triggers.maybeRunFileTrigger( + triggers.Types.beforeSave, + fileObject, + config, + req.auth + ); + + let saveResult; + // If a new ParseFile is returned, check if it's an already saved file + if (triggerResult instanceof Parse.File) { + fileObject.file = triggerResult; + if (triggerResult.url()) { + fileObject.fileSize = null; + saveResult = { + url: triggerResult.url(), + name: triggerResult._name, + }; + // Destroy stream to remove listeners and drain request + stream.destroy(); + } + } + + // If the file returned by the trigger has already been saved, skip saving + if (!saveResult) { + // Prepare file options + const fileOptions = { + metadata: fileObject.file._metadata, + }; + const fileTags = + Object.keys(fileObject.file._tags).length > 0 ? { tags: fileObject.file._tags } : {}; + Object.assign(fileOptions, fileTags); + // include directory if set (from client request or beforeSaveFile trigger) + if (fileObject.file._directory) { + fileOptions.directory = fileObject.file._directory; + } + + // Pass stream directly to filesController — it will buffer if adapter doesn't support streaming + const sourceType = fileObject.file._source?.type || contentType; + const createFileResult = await filesController.createFile( + config, + fileObject.file._name, + stream, + sourceType, + fileOptions + ); + + // Update file with new data + fileObject.file._name = createFileResult.name; + fileObject.file._url = createFileResult.url; + fileObject.file._requestTask = null; + fileObject.file._previousSave = Promise.resolve(fileObject.file); + saveResult = { + url: createFileResult.url, + name: createFileResult.name, + }; + } + + // Run afterSaveFile trigger + await triggers.maybeRunFileTrigger(triggers.Types.afterSave, fileObject, config, req.auth); res.status(201); - res.set('Location', result.url); - res.json(result); - }).catch((err) => { - next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Could not store file.')); - }); + res.set('Location', saveResult.url); + res.json(saveResult); + } catch (e) { + // Destroy stream to remove listeners and drain request, or resume directly + if (stream) { + stream.destroy(); + } else { + req.resume(); + } + logger.error('Error creating a file: ', e); + const error = triggers.resolveError(e, { + code: Parse.Error.FILE_SAVE_ERROR, + message: `Could not store file: ${filename}.`, + }); + next(error); + } } - deleteHandler(req, res, next) { - const filesController = req.config.filesController; - filesController.deleteFile(req.config, req.params.filename).then(() => { + async deleteHandler(req, res, next) { + if (req.auth.isReadOnly) { + const error = createSanitizedHttpError(403, "read-only masterKey isn't allowed to delete a file.", req.config); + res.status(error.status); + res.end(`{"error":"${error.message}"}`); + return; + } + try { + const { filesController } = req.config; + const filename = FilesRouter._getFilenameFromParams(req); + // run beforeDeleteFile trigger + const file = new Parse.File(filename); + file._url = await filesController.adapter.getFileLocation(req.config, filename); + const fileObject = { file, fileSize: null }; + await triggers.maybeRunFileTrigger( + triggers.Types.beforeDelete, + fileObject, + req.config, + req.auth + ); + // delete file + await filesController.deleteFile(req.config, filename); + // run afterDeleteFile trigger + await triggers.maybeRunFileTrigger( + triggers.Types.afterDelete, + fileObject, + req.config, + req.auth + ); res.status(200); // TODO: return useful JSON here? res.end(); - }).catch((error) => { - next(new Parse.Error(Parse.Error.FILE_DELETE_ERROR, - 'Could not delete file.')); - }); + } catch (e) { + logger.error('Error deleting a file: ', e); + const error = triggers.resolveError(e, { + code: Parse.Error.FILE_DELETE_ERROR, + message: 'Could not delete file.', + }); + next(error); + } } + + async metadataHandler(req, res) { + try { + const config = Config.get(req.params.appId); + if (!config) { + res.status(200); + res.json({}); + return; + } + FilesRouter._validateFileDownload(req, config); + const { filesController } = config; + let filename = FilesRouter._getFilenameFromParams(req); + const file = new Parse.File(filename, { base64: '' }); + const fileAuth = req.auth; + const triggerResult = await triggers.maybeRunFileTrigger( + triggers.Types.beforeFind, + { file }, + config, + fileAuth + ); + if (triggerResult?.file?._name) { + filename = triggerResult.file._name; + } + const data = await filesController.getMetadata(filename).catch(() => { + res.status(200); + res.json({}); + }); + if (!data) { + return; + } + await triggers.maybeRunFileTrigger( + triggers.Types.afterFind, + { file }, + config, + fileAuth + ); + res.status(200); + res.json(data); + } catch (e) { + const err = triggers.resolveError(e, { + code: Parse.Error.SCRIPT_FAILED, + message: 'Could not get file metadata.', + }); + res.status(403); + res.json({ code: err.code, error: err.message }); + } + } +} + +function isFileStreamable(req, filesController) { + const range = (req.get('Range') || '/-/').split('-'); + const start = Number(range[0]); + const end = Number(range[1]); + return ( + (!isNaN(start) || !isNaN(end)) && typeof filesController.adapter.handleFileStream === 'function' + ); } diff --git a/src/Routers/FunctionsRouter.js b/src/Routers/FunctionsRouter.js index 41df21e954..8bcf8a5858 100644 --- a/src/Routers/FunctionsRouter.js +++ b/src/Routers/FunctionsRouter.js @@ -1,105 +1,387 @@ // FunctionsRouter.js -var express = require('express'), - Parse = require('parse/node').Parse, - triggers = require('../triggers'); +var Parse = require('parse/node').Parse, + triggers = require('../triggers'); import PromiseRouter from '../PromiseRouter'; +import { promiseEnforceMasterKeyAccess, promiseEnsureIdempotency } from '../middlewares'; +import { jobStatusHandler } from '../StatusHandler'; import _ from 'lodash'; import { logger } from '../logger'; +import { createSanitizedError } from '../Error'; +import Busboy from '@fastify/busboy'; +import Utils from '../Utils'; -function parseObject(obj) { +function redactBuffers(obj) { + if (Buffer.isBuffer(obj)) { + return `[Buffer: ${obj.length} bytes]`; + } if (Array.isArray(obj)) { - return obj.map((item) => { - return parseObject(item); - }); + return obj.map(redactBuffers); + } + if (obj && typeof obj === 'object') { + const result = {}; + for (const key of Object.keys(obj)) { + result[key] = redactBuffers(obj[key]); + } + return result; + } + return obj; +} + +function parseObject(obj, config) { + if (Array.isArray(obj)) { + return obj.map(item => { + return parseObject(item, config); + }); } else if (obj && obj.__type == 'Date') { return Object.assign(new Date(obj.iso), obj); } else if (obj && obj.__type == 'File') { + if (obj.url) { + const { validateFileUrl } = require('../FileUrlValidator'); + validateFileUrl(obj.url, config); + } return Parse.File.fromJSON(obj); + } else if (obj && obj.__type == 'Pointer') { + return Parse.Object.fromJSON({ + __type: 'Pointer', + className: obj.className, + objectId: obj.objectId, + }); + } else if (Buffer.isBuffer(obj)) { + return obj; } else if (obj && typeof obj === 'object') { - return parseParams(obj); + return parseParams(obj, config); } else { return obj; } } -function parseParams(params) { - return _.mapValues(params, parseObject); +function parseParams(params, config) { + return _.mapValues(params, item => parseObject(item, config)); } export class FunctionsRouter extends PromiseRouter { - mountRoutes() { - this.route('POST', '/functions/:functionName', FunctionsRouter.handleCloudFunction); + this.route( + 'POST', + '/functions/:functionName', + promiseEnsureIdempotency, + FunctionsRouter.multipartMiddleware, + FunctionsRouter.handleCloudFunction + ); + this.route( + 'POST', + '/jobs/:jobName', + promiseEnsureIdempotency, + promiseEnforceMasterKeyAccess, + function (req) { + return FunctionsRouter.handleCloudJob(req); + } + ); + this.route('POST', '/jobs', promiseEnforceMasterKeyAccess, function (req) { + return FunctionsRouter.handleCloudJob(req); + }); + } + + static handleCloudJob(req) { + if (req.auth.isReadOnly) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to run a job.", + req.config + ); + } + const jobName = req.params.jobName || req.body?.jobName; + const applicationId = req.config.applicationId; + const jobHandler = jobStatusHandler(req.config); + const jobFunction = triggers.getJob(jobName, applicationId); + if (!jobFunction) { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid job.'); + } + let params = Object.assign({}, req.body, req.query); + params = parseParams(params, req.config); + const request = { + params: params, + log: req.config.loggerController, + headers: req.config.headers, + ip: req.config.ip, + jobName, + config: req.config, + message: jobHandler.setMessage.bind(jobHandler), + }; + + return jobHandler.setRunning(jobName).then(jobStatus => { + request.jobId = jobStatus.objectId; + // run the function async + process.nextTick(() => { + Promise.resolve() + .then(() => { + return jobFunction(request); + }) + .then( + result => { + jobHandler.setSucceeded(result); + }, + error => { + jobHandler.setFailed(error); + } + ); + }); + return { + headers: { + 'X-Parse-Job-Status-Id': jobStatus.objectId, + }, + response: {}, + }; + }); } - static createResponseObject(resolve, reject) { - return { - success: function(result) { - resolve({ + static createResponseObject(resolve, reject, statusCode = null) { + let httpStatusCode = statusCode; + const customHeaders = {}; + let responseSent = false; + const responseObject = { + success: function (result) { + if (responseSent) { + throw new Error('Cannot call success() after response has already been sent. Make sure to call success() or error() only once per cloud function execution.'); + } + responseSent = true; + const response = { response: { - result: Parse._encode(result) - } - }); + result: Parse._encode(result), + }, + }; + if (httpStatusCode !== null) { + response.status = httpStatusCode; + } + if (Object.keys(customHeaders).length > 0) { + response.headers = customHeaders; + } + resolve(response); }, - error: function(code, message) { - if (!message) { - message = code; - code = Parse.Error.SCRIPT_FAILED; + error: function (message) { + if (responseSent) { + throw new Error('Cannot call error() after response has already been sent. Make sure to call success() or error() only once per cloud function execution.'); } - reject(new Parse.Error(code, message)); - } - } + responseSent = true; + const error = triggers.resolveError(message); + // If a custom status code was set, attach it to the error + if (httpStatusCode !== null) { + error.status = httpStatusCode; + } + reject(error); + }, + status: function (code) { + httpStatusCode = code; + return responseObject; + }, + header: function (key, value) { + customHeaders[key] = value; + return responseObject; + }, + _isResponseSent: () => responseSent, + }; + return responseObject; } - static handleCloudFunction(req) { - var applicationId = req.config.applicationId; - var theFunction = triggers.getFunction(req.params.functionName, applicationId); - var theValidator = triggers.getValidator(req.params.functionName, applicationId); - if (theFunction) { - let params = Object.assign({}, req.body, req.query); - params = parseParams(params); - var request = { - params: params, - master: req.auth && req.auth.isMaster, - user: req.auth && req.auth.user, - installationId: req.info.installationId, - log: req.config.loggerController && req.config.loggerController.adapter, - headers: req.headers + /** + * Parses multipart/form-data requests for Cloud Function invocation. + * For non-multipart requests, this is a no-op. + * + * Text fields are set as strings in `req.body`. File fields are set as + * objects with the shape `{ filename: string, contentType: string, data: Buffer }`. + * All fields are merged flat into `req.body`; the caller is responsible for + * avoiding name collisions between text and file fields. + * + * The total request size is limited by the server's `maxUploadSize` option. + */ + static multipartMiddleware(req) { + if (!req.is || !req.is('multipart/form-data')) { + return Promise.resolve(); + } + const maxBytes = Utils.parseSizeToBytes(req.config.maxUploadSize); + return new Promise((resolve, reject) => { + const fields = Object.create(null); + let totalBytes = 0; + let settled = false; + let busboy; + try { + busboy = Busboy({ headers: req.headers, limits: { fieldSize: maxBytes } }); + } catch (err) { + return reject( + new Parse.Error(Parse.Error.INVALID_JSON, `Invalid multipart request: ${err.message}`) + ); + } + const safeReject = (err) => { + if (settled) { + return; + } + settled = true; + busboy.destroy(); + reject(err); }; - - if (theValidator && typeof theValidator === "function") { - var result = theValidator(request); - if (!result) { - throw new Parse.Error(Parse.Error.VALIDATION_ERROR, 'Validation failed.'); + busboy.on('field', (name, value, fieldnameTruncated, valueTruncated) => { + if (valueTruncated) { + return safeReject( + new Parse.Error( + Parse.Error.OBJECT_TOO_LARGE, + 'Multipart request exceeds maximum upload size.' + ) + ); } - } - - return new Promise(function (resolve, reject) { - var response = FunctionsRouter.createResponseObject((result) => { - logger.info(`Ran cloud function ${req.params.functionName} with:\nInput: ${JSON.stringify(params)}\nResult: ${JSON.stringify(result.response.result)}`, { - functionName: req.params.functionName, - params, - result: result.response.resut - }); - resolve(result); - }, (error) => { - logger.error(`Failed running cloud function ${req.params.functionName} with:\nInput: ${JSON.stringify(params)}\Error: ${JSON.stringify(error)}`, { - functionName: req.params.functionName, - params, - error - }); - reject(error); + totalBytes += Buffer.byteLength(value); + if (totalBytes > maxBytes) { + return safeReject( + new Parse.Error( + Parse.Error.OBJECT_TOO_LARGE, + 'Multipart request exceeds maximum upload size.' + ) + ); + } + fields[name] = value; + }); + busboy.on('file', (name, stream, filename, transferEncoding, mimeType) => { + const chunks = []; + stream.on('data', chunk => { + totalBytes += chunk.length; + if (totalBytes > maxBytes) { + stream.destroy(); + return safeReject( + new Parse.Error( + Parse.Error.OBJECT_TOO_LARGE, + 'Multipart request exceeds maximum upload size.' + ) + ); + } + chunks.push(chunk); + }); + stream.on('end', () => { + if (settled) { + return; + } + fields[name] = { + filename, + contentType: mimeType || 'application/octet-stream', + data: Buffer.concat(chunks), + }; }); - // Force the keys before the function calls. - Parse.applicationId = req.config.applicationId; - Parse.javascriptKey = req.config.javascriptKey; - Parse.masterKey = req.config.masterKey; - theFunction(request, response); }); - } else { - throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid function.'); + busboy.on('finish', () => { + if (settled) { + return; + } + settled = true; + req.body = fields; + resolve(); + }); + busboy.on('error', err => { + safeReject( + new Parse.Error(Parse.Error.INVALID_JSON, `Invalid multipart request: ${err.message}`) + ); + }); + req.pipe(busboy); + }); + } + + static handleCloudFunction(req) { + const functionName = req.params.functionName; + const applicationId = req.config.applicationId; + const theFunction = triggers.getFunction(functionName, applicationId); + + if (!theFunction) { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, `Invalid function: "${functionName}"`); } + let params = Object.assign({}, req.body, req.query); + params = parseParams(params, req.config); + const request = { + params: params, + config: req.config, + master: req.auth && req.auth.isMaster, + isReadOnly: !!(req.auth && req.auth.isReadOnly), + user: req.auth && req.auth.user, + installationId: req.info.installationId, + log: req.config.loggerController, + headers: req.config.headers, + ip: req.config.ip, + functionName, + context: req.info.context, + }; + + return new Promise(function (resolve, reject) { + const userString = req.auth && req.auth.user ? req.auth.user.id : undefined; + const responseObject = FunctionsRouter.createResponseObject( + result => { + try { + if (req.config.logLevels.cloudFunctionSuccess !== 'silent') { + const cleanInput = logger.truncateLogMessage(JSON.stringify(redactBuffers(params))); + const cleanResult = logger.truncateLogMessage(JSON.stringify(result.response.result)); + logger[req.config.logLevels.cloudFunctionSuccess]( + `Ran cloud function ${functionName} for user ${userString} with:\n Input: ${cleanInput}\n Result: ${cleanResult}`, + { + functionName, + params, + user: userString, + } + ); + } + resolve(result); + } catch (e) { + reject(e); + } + }, + error => { + try { + if (req.config.logLevels.cloudFunctionError !== 'silent') { + const cleanInput = logger.truncateLogMessage(JSON.stringify(redactBuffers(params))); + logger[req.config.logLevels.cloudFunctionError]( + `Failed running cloud function ${functionName} for user ${userString} with:\n Input: ${cleanInput}\n Error: ` + + JSON.stringify(error), + { + functionName, + error, + params, + user: userString, + } + ); + } + reject(error); + } catch (e) { + reject(e); + } + } + ); + const { success, error } = responseObject; + + return Promise.resolve() + .then(() => { + return triggers.maybeRunValidator(request, functionName, req.auth); + }) + .then(() => { + // Check if function expects 2 parameters (req, res) - Express style + if (theFunction.length >= 2) { + return theFunction(request, responseObject); + } else { + // Traditional style - single parameter + return theFunction(request); + } + }) + .then(result => { + // For Express-style functions, only send response if not already sent + if (theFunction.length >= 2) { + if (!responseObject._isResponseSent()) { + // If Express-style function returns a value without calling res.success/error + if (result !== undefined) { + success(result); + } + // If no response sent and no value returned, this is an error in user code + // but we don't handle it here to maintain backward compatibility + } + } else { + // For traditional functions, always call success with the result (even if undefined) + success(result); + } + }, error); + }); } } diff --git a/src/Routers/GlobalConfigRouter.js b/src/Routers/GlobalConfigRouter.js index 30a0e11395..6a05f7308f 100644 --- a/src/Routers/GlobalConfigRouter.js +++ b/src/Routers/GlobalConfigRouter.js @@ -1,33 +1,101 @@ // global_config.js +import Parse from 'parse/node'; +import PromiseRouter from '../PromiseRouter'; +import * as middleware from '../middlewares'; +import * as triggers from '../triggers'; +import { createSanitizedError } from '../Error'; -import PromiseRouter from '../PromiseRouter'; -import * as middleware from "../middlewares"; +const getConfigFromParams = params => { + const config = new Parse.Config(); + for (const attr in params) { + config.attributes[attr] = Parse._decode(undefined, params[attr]); + } + return config; +}; export class GlobalConfigRouter extends PromiseRouter { getGlobalConfig(req) { - return req.config.database.find('_GlobalConfig', { objectId: 1 }, { limit: 1 }).then((results) => { - if (results.length != 1) { - // If there is no config in the database - return empty config. - return { response: { params: {} } }; - } - let globalConfig = results[0]; - return { response: { params: globalConfig.params } }; - }); + return req.config.database + .find('_GlobalConfig', { objectId: '1' }, { limit: 1 }) + .then(results => { + if (results.length != 1) { + // If there is no config in the database - return empty config. + return { response: { params: {} } }; + } + const globalConfig = results[0]; + if (!req.auth.isMaster && globalConfig.masterKeyOnly !== undefined) { + for (const param in globalConfig.params) { + if (globalConfig.masterKeyOnly[param]) { + delete globalConfig.params[param]; + delete globalConfig.masterKeyOnly[param]; + } + } + } + return { + response: { + params: globalConfig.params, + masterKeyOnly: globalConfig.masterKeyOnly, + }, + }; + }); } - updateGlobalConfig(req) { - let params = req.body.params; + async updateGlobalConfig(req) { + if (req.auth.isReadOnly) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to update the config.", + req.config + ); + } + const params = req.body.params || {}; + const masterKeyOnly = req.body?.masterKeyOnly || {}; // Transform in dot notation to make sure it works const update = Object.keys(params).reduce((acc, key) => { acc[`params.${key}`] = params[key]; + acc[`masterKeyOnly.${key}`] = masterKeyOnly[key] || false; return acc; }, {}); - return req.config.database.update('_GlobalConfig', {objectId: 1}, update, {upsert: true}).then(() => ({ response: { result: true } })); + const className = triggers.getClassName(Parse.Config); + const hasBeforeSaveHook = triggers.triggerExists(className, triggers.Types.beforeSave, req.config.applicationId); + const hasAfterSaveHook = triggers.triggerExists(className, triggers.Types.afterSave, req.config.applicationId); + let originalConfigObject; + let updatedConfigObject; + const configObject = new Parse.Config(); + configObject.attributes = params; + + const results = await req.config.database.find('_GlobalConfig', { objectId: '1' }, { limit: 1 }); + const isNew = results.length !== 1; + if (!isNew && (hasBeforeSaveHook || hasAfterSaveHook)) { + originalConfigObject = getConfigFromParams(results[0].params); + } + try { + await triggers.maybeRunGlobalConfigTrigger(triggers.Types.beforeSave, req.auth, configObject, originalConfigObject, req.config, req.context); + if (isNew) { + await req.config.database.update('_GlobalConfig', { objectId: '1' }, update, { upsert: true }, true) + updatedConfigObject = configObject; + } else { + const result = await req.config.database.update('_GlobalConfig', { objectId: '1' }, update, {}, true); + updatedConfigObject = getConfigFromParams(result.params); + } + await triggers.maybeRunGlobalConfigTrigger(triggers.Types.afterSave, req.auth, updatedConfigObject, originalConfigObject, req.config, req.context); + return { response: { result: true } } + } catch (err) { + const error = triggers.resolveError(err, { + code: Parse.Error.SCRIPT_FAILED, + message: 'Script failed. Unknown error.', + }); + throw error; + } } mountRoutes() { - this.route('GET', '/config', req => { return this.getGlobalConfig(req) }); - this.route('PUT', '/config', middleware.promiseEnforceMasterKeyAccess, req => { return this.updateGlobalConfig(req) }); + this.route('GET', '/config', req => { + return this.getGlobalConfig(req); + }); + this.route('PUT', '/config', middleware.promiseEnforceMasterKeyAccess, req => { + return this.updateGlobalConfig(req); + }); } } diff --git a/src/Routers/GraphQLRouter.js b/src/Routers/GraphQLRouter.js new file mode 100644 index 0000000000..67d7a24e46 --- /dev/null +++ b/src/Routers/GraphQLRouter.js @@ -0,0 +1,40 @@ +import Parse from 'parse/node'; +import PromiseRouter from '../PromiseRouter'; +import * as middleware from '../middlewares'; +import { createSanitizedError } from '../Error'; + +const GraphQLConfigPath = '/graphql-config'; + +export class GraphQLRouter extends PromiseRouter { + async getGraphQLConfig(req) { + const result = await req.config.parseGraphQLController.getGraphQLConfig(); + return { + response: result, + }; + } + + async updateGraphQLConfig(req) { + if (req.auth.isReadOnly) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to update the GraphQL config.", + req.config + ); + } + const data = await req.config.parseGraphQLController.updateGraphQLConfig(req.body?.params || {}); + return { + response: data, + }; + } + + mountRoutes() { + this.route('GET', GraphQLConfigPath, middleware.promiseEnforceMasterKeyAccess, req => { + return this.getGraphQLConfig(req); + }); + this.route('PUT', GraphQLConfigPath, middleware.promiseEnforceMasterKeyAccess, req => { + return this.updateGraphQLConfig(req); + }); + } +} + +export default GraphQLRouter; diff --git a/src/Routers/HooksRouter.js b/src/Routers/HooksRouter.js index 967057899e..5123efc381 100644 --- a/src/Routers/HooksRouter.js +++ b/src/Routers/HooksRouter.js @@ -1,84 +1,104 @@ -import { Parse } from 'parse/node'; -import PromiseRouter from '../PromiseRouter'; -import * as middleware from "../middlewares"; +import { Parse } from 'parse/node'; +import PromiseRouter from '../PromiseRouter'; +import * as middleware from '../middlewares'; +import { createSanitizedError } from '../Error'; export class HooksRouter extends PromiseRouter { createHook(aHook, config) { - return config.hooksController.createHook(aHook).then( (hook) => ({response: hook})); - }; + return config.hooksController.createHook(aHook).then(hook => ({ response: hook })); + } updateHook(aHook, config) { - return config.hooksController.updateHook(aHook).then((hook) => ({response: hook})); - }; + return config.hooksController.updateHook(aHook).then(hook => ({ response: hook })); + } handlePost(req) { - return this.createHook(req.body, req.config); - }; + if (req.auth.isReadOnly) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to create a hook.", + req.config + ); + } + return this.createHook(req.body || {}, req.config); + } handleGetFunctions(req) { var hooksController = req.config.hooksController; if (req.params.functionName) { - return hooksController.getFunction(req.params.functionName).then( (foundFunction) => { + return hooksController.getFunction(req.params.functionName).then(foundFunction => { if (!foundFunction) { throw new Parse.Error(143, `no function named: ${req.params.functionName} is defined`); } - return Promise.resolve({response: foundFunction}); + return Promise.resolve({ response: foundFunction }); }); } - return hooksController.getFunctions().then((functions) => { - return { response: functions || [] }; - }, (err) => { - throw err; - }); + return hooksController.getFunctions().then( + functions => { + return { response: functions || [] }; + }, + err => { + throw err; + } + ); } handleGetTriggers(req) { var hooksController = req.config.hooksController; if (req.params.className && req.params.triggerName) { - - return hooksController.getTrigger(req.params.className, req.params.triggerName).then((foundTrigger) => { - if (!foundTrigger) { - throw new Parse.Error(143,`class ${req.params.className} does not exist`); - } - return Promise.resolve({response: foundTrigger}); - }); + return hooksController + .getTrigger(req.params.className, req.params.triggerName) + .then(foundTrigger => { + if (!foundTrigger) { + throw new Parse.Error(143, `class ${req.params.className} does not exist`); + } + return Promise.resolve({ response: foundTrigger }); + }); } - return hooksController.getTriggers().then((triggers) => ({ response: triggers || [] })); + return hooksController.getTriggers().then(triggers => ({ response: triggers || [] })); } handleDelete(req) { var hooksController = req.config.hooksController; if (req.params.functionName) { - return hooksController.deleteFunction(req.params.functionName).then(() => ({response: {}})) - + return hooksController.deleteFunction(req.params.functionName).then(() => ({ response: {} })); } else if (req.params.className && req.params.triggerName) { - return hooksController.deleteTrigger(req.params.className, req.params.triggerName).then(() => ({response: {}})) + return hooksController + .deleteTrigger(req.params.className, req.params.triggerName) + .then(() => ({ response: {} })); } - return Promise.resolve({response: {}}); + return Promise.resolve({ response: {} }); } handleUpdate(req) { var hook; - if (req.params.functionName && req.body.url) { - hook = {} + if (req.params.functionName && req.body?.url) { + hook = {}; hook.functionName = req.params.functionName; hook.url = req.body.url; - } else if (req.params.className && req.params.triggerName && req.body.url) { - hook = {} + } else if (req.params.className && req.params.triggerName && req.body?.url) { + hook = {}; hook.className = req.params.className; hook.triggerName = req.params.triggerName; - hook.url = req.body.url + hook.url = req.body.url; } else { - throw new Parse.Error(143, "invalid hook declaration"); + throw new Parse.Error(143, 'invalid hook declaration'); } return this.updateHook(hook, req.config); } handlePut(req) { - var body = req.body; - if (body.__op == "Delete") { + if (req.auth.isReadOnly) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to modify a hook.", + req.config + ); + } + var body = req.body || {}; + if (body.__op == 'Delete') { return this.handleDelete(req); } else { return this.handleUpdate(req); @@ -86,14 +106,54 @@ export class HooksRouter extends PromiseRouter { } mountRoutes() { - this.route('GET', '/hooks/functions', middleware.promiseEnforceMasterKeyAccess, this.handleGetFunctions.bind(this)); - this.route('GET', '/hooks/triggers', middleware.promiseEnforceMasterKeyAccess, this.handleGetTriggers.bind(this)); - this.route('GET', '/hooks/functions/:functionName', middleware.promiseEnforceMasterKeyAccess, this.handleGetFunctions.bind(this)); - this.route('GET', '/hooks/triggers/:className/:triggerName', middleware.promiseEnforceMasterKeyAccess, this.handleGetTriggers.bind(this)); - this.route('POST', '/hooks/functions', middleware.promiseEnforceMasterKeyAccess, this.handlePost.bind(this)); - this.route('POST', '/hooks/triggers', middleware.promiseEnforceMasterKeyAccess, this.handlePost.bind(this)); - this.route('PUT', '/hooks/functions/:functionName', middleware.promiseEnforceMasterKeyAccess, this.handlePut.bind(this)); - this.route('PUT', '/hooks/triggers/:className/:triggerName', middleware.promiseEnforceMasterKeyAccess, this.handlePut.bind(this)); + this.route( + 'GET', + '/hooks/functions', + middleware.promiseEnforceMasterKeyAccess, + this.handleGetFunctions.bind(this) + ); + this.route( + 'GET', + '/hooks/triggers', + middleware.promiseEnforceMasterKeyAccess, + this.handleGetTriggers.bind(this) + ); + this.route( + 'GET', + '/hooks/functions/:functionName', + middleware.promiseEnforceMasterKeyAccess, + this.handleGetFunctions.bind(this) + ); + this.route( + 'GET', + '/hooks/triggers/:className/:triggerName', + middleware.promiseEnforceMasterKeyAccess, + this.handleGetTriggers.bind(this) + ); + this.route( + 'POST', + '/hooks/functions', + middleware.promiseEnforceMasterKeyAccess, + this.handlePost.bind(this) + ); + this.route( + 'POST', + '/hooks/triggers', + middleware.promiseEnforceMasterKeyAccess, + this.handlePost.bind(this) + ); + this.route( + 'PUT', + '/hooks/functions/:functionName', + middleware.promiseEnforceMasterKeyAccess, + this.handlePut.bind(this) + ); + this.route( + 'PUT', + '/hooks/triggers/:className/:triggerName', + middleware.promiseEnforceMasterKeyAccess, + this.handlePut.bind(this) + ); } } diff --git a/src/Routers/IAPValidationRouter.js b/src/Routers/IAPValidationRouter.js index 70ba3adef8..bae6f593e9 100644 --- a/src/Routers/IAPValidationRouter.js +++ b/src/Routers/IAPValidationRouter.js @@ -1,112 +1,123 @@ import PromiseRouter from '../PromiseRouter'; -var request = require("request"); -var rest = require("../rest"); -var Auth = require("../Auth"); +const request = require('../request'); +const rest = require('../rest'); +import Parse from 'parse/node'; // TODO move validation logic in IAPValidationController -const IAP_SANDBOX_URL = "https://sandbox.itunes.apple.com/verifyReceipt"; -const IAP_PRODUCTION_URL = "https://buy.itunes.apple.com/verifyReceipt"; +const IAP_SANDBOX_URL = 'https://sandbox.itunes.apple.com/verifyReceipt'; +const IAP_PRODUCTION_URL = 'https://buy.itunes.apple.com/verifyReceipt'; const APP_STORE_ERRORS = { - 21000: "The App Store could not read the JSON object you provided.", - 21002: "The data in the receipt-data property was malformed or missing.", - 21003: "The receipt could not be authenticated.", - 21004: "The shared secret you provided does not match the shared secret on file for your account.", - 21005: "The receipt server is not currently available.", - 21006: "This receipt is valid but the subscription has expired.", - 21007: "This receipt is from the test environment, but it was sent to the production environment for verification. Send it to the test environment instead.", - 21008: "This receipt is from the production environment, but it was sent to the test environment for verification. Send it to the production environment instead." -} + 21000: 'The App Store could not read the JSON object you provided.', + 21002: 'The data in the receipt-data property was malformed or missing.', + 21003: 'The receipt could not be authenticated.', + 21004: 'The shared secret you provided does not match the shared secret on file for your account.', + 21005: 'The receipt server is not currently available.', + 21006: 'This receipt is valid but the subscription has expired.', + 21007: 'This receipt is from the test environment, but it was sent to the production environment for verification. Send it to the test environment instead.', + 21008: 'This receipt is from the production environment, but it was sent to the test environment for verification. Send it to the production environment instead.', +}; function appStoreError(status) { status = parseInt(status); - var errorString = APP_STORE_ERRORS[status] || "unknown error."; - return { status: status, error: errorString } + var errorString = APP_STORE_ERRORS[status] || 'unknown error.'; + return { status: status, error: errorString }; } function validateWithAppStore(url, receipt) { - return new Promise(function(fulfill, reject) { - request.post({ - url: url, - body: { "receipt-data": receipt }, - json: true, - }, function(err, res, body) { - var status = body.status; - if (status == 0) { - // No need to pass anything, status is OK - return fulfill(); - } - // receipt is from test and should go to test - return reject(body); - }); - }); -} - -function getFileForProductIdentifier(productIdentifier, req) { - return rest.find(req.config, req.auth, '_Product', { productIdentifier: productIdentifier }, undefined, req.info.clientSDK).then(function(result){ - const products = result.results; - if (!products || products.length != 1) { - // Error not found or too many - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.') + return request({ + url: url, + method: 'POST', + body: { 'receipt-data': receipt }, + headers: { + 'Content-Type': 'application/json', + }, + }).then(httpResponse => { + const body = httpResponse.data; + if (body && body.status === 0) { + // No need to pass anything, status is OK + return; } - - var download = products[0].download; - return Promise.resolve({response: download}); + // receipt is from test and should go to test + throw body; }); } +function getFileForProductIdentifier(productIdentifier, req) { + return rest + .find( + req.config, + req.auth, + '_Product', + { productIdentifier: productIdentifier }, + undefined, + req.info.clientSDK, + req.info.context + ) + .then(function (result) { + const products = result.results; + if (!products || products.length != 1) { + // Error not found or too many + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); + } + var download = products[0].download; + return Promise.resolve({ response: download }); + }); +} export class IAPValidationRouter extends PromiseRouter { - - handleRequest(req) { - let receipt = req.body.receipt; - const productIdentifier = req.body.productIdentifier; - - if (!receipt || ! productIdentifier) { + handleRequest(req) { + let receipt = req.body?.receipt; + const productIdentifier = req.body?.productIdentifier; + + if (!receipt || !productIdentifier) { // TODO: Error, malformed request - throw new Parse.Error(Parse.Error.INVALID_JSON, "missing receipt or productIdentifier"); + throw new Parse.Error(Parse.Error.INVALID_JSON, 'missing receipt or productIdentifier'); } - + // Transform the object if there // otherwise assume it's in Base64 already - if (typeof receipt == "object") { - if (receipt["__type"] == "Bytes") { + if (typeof receipt == 'object') { + if (receipt['__type'] == 'Bytes') { receipt = receipt.base64; } } - - if (process.env.NODE_ENV == "test" && req.body.bypassAppStoreValidation) { + + if (process.env.TESTING == '1' && req.body?.bypassAppStoreValidation) { return getFileForProductIdentifier(productIdentifier, req); } function successCallback() { - return getFileForProductIdentifier(productIdentifier, req); - }; + return getFileForProductIdentifier(productIdentifier, req); + } function errorCallback(error) { - return Promise.resolve({response: appStoreError(error.status) }); + return Promise.resolve({ response: appStoreError(error.status) }); } - - return validateWithAppStore(IAP_PRODUCTION_URL, receipt).then( () => { - - return successCallback(); - }, (error) => { - if (error.status == 21007) { - return validateWithAppStore(IAP_SANDBOX_URL, receipt).then( () => { - return successCallback(); - }, (error) => { - return errorCallback(error); - } - ); - } + return validateWithAppStore(IAP_PRODUCTION_URL, receipt).then( + () => { + return successCallback(); + }, + error => { + if (error.status == 21007) { + return validateWithAppStore(IAP_SANDBOX_URL, receipt).then( + () => { + return successCallback(); + }, + error => { + return errorCallback(error); + } + ); + } - return errorCallback(error); - }); + return errorCallback(error); + } + ); } - + mountRoutes() { - this.route("POST","/validate_purchase", this.handleRequest); + this.route('POST', '/validate_purchase', this.handleRequest); } } diff --git a/src/Routers/InstallationsRouter.js b/src/Routers/InstallationsRouter.js index 792f4e202d..7142d0fe5c 100644 --- a/src/Routers/InstallationsRouter.js +++ b/src/Routers/InstallationsRouter.js @@ -1,63 +1,48 @@ // InstallationsRouter.js import ClassesRouter from './ClassesRouter'; -import PromiseRouter from '../PromiseRouter'; import rest from '../rest'; +import { promiseEnsureIdempotency } from '../middlewares'; export class InstallationsRouter extends ClassesRouter { - handleFind(req) { - let body = Object.assign(req.body, ClassesRouter.JSONFromQuery(req.query)); - var options = {}; - - if (body.skip) { - options.skip = Number(body.skip); - } - if (body.limit || body.limit === 0) { - options.limit = Number(body.limit); - } - if (body.order) { - options.order = String(body.order); - } - if (body.count) { - options.count = true; - } - if (body.include) { - options.include = String(body.include); - } - - return rest.find(req.config, req.auth, - '_Installation', body.where, options, req.info.clientSDK) - .then((response) => { - return {response: response}; - }); - } - - handleGet(req) { - req.params.className = '_Installation'; - return super.handleGet(req); + className() { + return '_Installation'; } - handleCreate(req) { - req.params.className = '_Installation'; - return super.handleCreate(req); - } - - handleUpdate(req) { - req.params.className = '_Installation'; - return super.handleUpdate(req); - } - - handleDelete(req) { - req.params.className = '_Installation'; - return super.handleDelete(req); + handleFind(req) { + const body = Object.assign(req.body || {}, ClassesRouter.JSONFromQuery(req.query)); + const options = ClassesRouter.optionsFromBody(body, req.config.defaultLimit); + return rest + .find( + req.config, + req.auth, + '_Installation', + body.where, + options, + req.info.clientSDK, + req.info.context + ) + .then(response => { + return { response: response }; + }); } mountRoutes() { - this.route('GET','/installations', req => { return this.handleFind(req); }); - this.route('GET','/installations/:objectId', req => { return this.handleGet(req); }); - this.route('POST','/installations', req => { return this.handleCreate(req); }); - this.route('PUT','/installations/:objectId', req => { return this.handleUpdate(req); }); - this.route('DELETE','/installations/:objectId', req => { return this.handleDelete(req); }); + this.route('GET', '/installations', req => { + return this.handleFind(req); + }); + this.route('GET', '/installations/:objectId', req => { + return this.handleGet(req); + }); + this.route('POST', '/installations', promiseEnsureIdempotency, req => { + return this.handleCreate(req); + }); + this.route('PUT', '/installations/:objectId', promiseEnsureIdempotency, req => { + return this.handleUpdate(req); + }); + this.route('DELETE', '/installations/:objectId', req => { + return this.handleDelete(req); + }); } } diff --git a/src/Routers/LogsRouter.js b/src/Routers/LogsRouter.js index fbc8ec99d4..182a4f1669 100644 --- a/src/Routers/LogsRouter.js +++ b/src/Routers/LogsRouter.js @@ -1,19 +1,23 @@ import { Parse } from 'parse/node'; import PromiseRouter from '../PromiseRouter'; -import * as middleware from "../middlewares"; +import * as middleware from '../middlewares'; export class LogsRouter extends PromiseRouter { - mountRoutes() { - this.route('GET','/scriptlog', middleware.promiseEnforceMasterKeyAccess, this.validateRequest, (req) => { - return this.handleGET(req); - }); + this.route( + 'GET', + '/scriptlog', + middleware.promiseEnforceMasterKeyAccess, + this.validateRequest, + req => { + return this.handleGET(req); + } + ); } validateRequest(req) { if (!req.config || !req.config.loggerController) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - 'Logger adapter is not availabe'); + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'Logger adapter is not available'); } } @@ -30,24 +34,24 @@ export class LogsRouter extends PromiseRouter { const until = req.query.until; let size = req.query.size; if (req.query.n) { - size = req.query.n; + size = req.query.n; } - - const order = req.query.order + + const order = req.query.order; const level = req.query.level; const options = { from, until, size, order, - level + level, }; - return req.config.loggerController.getLogs(options).then((result) => { + return req.config.loggerController.getLogs(options).then(result => { return Promise.resolve({ - response: result + response: result, }); - }) + }); } } diff --git a/src/Routers/PagesRouter.js b/src/Routers/PagesRouter.js new file mode 100644 index 0000000000..687440c37a --- /dev/null +++ b/src/Routers/PagesRouter.js @@ -0,0 +1,771 @@ +import PromiseRouter from '../PromiseRouter'; +import Config from '../Config'; +import express from 'express'; +import path from 'path'; +import { promises as fs } from 'fs'; +import { Parse } from 'parse/node'; +import Utils from '../Utils'; +import mustache from 'mustache'; +import Page from '../Page'; + +// All pages with custom page key for reference and file name +const pages = Object.freeze({ + passwordReset: new Page({ id: 'passwordReset', defaultFile: 'password_reset.html' }), + passwordResetSuccess: new Page({ + id: 'passwordResetSuccess', + defaultFile: 'password_reset_success.html', + }), + passwordResetLinkInvalid: new Page({ + id: 'passwordResetLinkInvalid', + defaultFile: 'password_reset_link_invalid.html', + }), + emailVerificationSuccess: new Page({ + id: 'emailVerificationSuccess', + defaultFile: 'email_verification_success.html', + }), + emailVerificationSendFail: new Page({ + id: 'emailVerificationSendFail', + defaultFile: 'email_verification_send_fail.html', + }), + emailVerificationSendSuccess: new Page({ + id: 'emailVerificationSendSuccess', + defaultFile: 'email_verification_send_success.html', + }), + emailVerificationLinkInvalid: new Page({ + id: 'emailVerificationLinkInvalid', + defaultFile: 'email_verification_link_invalid.html', + }), + emailVerificationLinkExpired: new Page({ + id: 'emailVerificationLinkExpired', + defaultFile: 'email_verification_link_expired.html', + }), +}); + +// All page parameters for reference to be used as template placeholders or query params +const pageParams = Object.freeze({ + appName: 'appName', + appId: 'appId', + token: 'token', + username: 'username', + error: 'error', + locale: 'locale', + publicServerUrl: 'publicServerUrl', +}); + +// The header prefix to add page params as response headers +const pageParamHeaderPrefix = 'x-parse-page-param-'; + +// The errors being thrown +const errors = Object.freeze({ + jsonFailedFileLoading: 'failed to load JSON file', + fileOutsideAllowedScope: 'not allowed to read file outside of pages directory', +}); + +export class PagesRouter extends PromiseRouter { + /** + * Constructs a PagesRouter. + * @param {Object} pages The pages options from the Parse Server configuration. + */ + constructor(pages = {}) { + super(); + + // Set instance properties + this.pagesConfig = pages; + this.pagesEndpoint = pages.pagesEndpoint ? pages.pagesEndpoint : 'apps'; + this.pagesPath = pages.pagesPath + ? path.resolve('./', pages.pagesPath) + : path.resolve(__dirname, '../../public'); + this.loadJsonResource(); + this.mountPagesRoutes(); + this.mountCustomRoutes(); + this.mountStaticRoute(); + } + + verifyEmail(req) { + const config = req.config; + const { token: rawToken } = req.query; + const token = typeof rawToken === 'string' ? rawToken : undefined; + + if (!config) { + this.invalidRequest(); + } + + if (!token) { + return this.goToPage(req, pages.emailVerificationLinkInvalid); + } + + const userController = config.userController; + return userController.verifyEmail(token).then( + () => { + return this.goToPage(req, pages.emailVerificationSuccess); + }, + () => { + return this.goToPage(req, pages.emailVerificationLinkInvalid); + } + ); + } + + resendVerificationEmail(req) { + const config = req.config; + const username = req.body?.username; + const rawToken = req.body?.token; + const token = typeof rawToken === 'string' ? rawToken : undefined; + + if (!config) { + this.invalidRequest(); + } + + if (!username && !token) { + return this.goToPage(req, pages.emailVerificationLinkInvalid); + } + + const userController = config.userController; + const suppressError = config.emailVerifySuccessOnInvalidEmail ?? true; + + return userController.resendVerificationEmail(username, req, token).then( + () => { + return this.goToPage(req, pages.emailVerificationSendSuccess); + }, + () => { + if (suppressError) { + return this.goToPage(req, pages.emailVerificationSendSuccess); + } + return this.goToPage(req, pages.emailVerificationSendFail); + } + ); + } + + passwordReset(req) { + const config = req.config; + const params = { + [pageParams.appId]: req.params.appId, + [pageParams.appName]: config.appName, + [pageParams.token]: req.query.token, + [pageParams.username]: req.query.username, + [pageParams.publicServerUrl]: config.publicServerURL, + }; + return this.goToPage(req, pages.passwordReset, params); + } + + requestResetPassword(req) { + const config = req.config; + + if (!config) { + this.invalidRequest(); + } + + const { token: rawToken } = req.query; + const token = typeof rawToken === 'string' ? rawToken : undefined; + + if (!token) { + return this.goToPage(req, pages.passwordResetLinkInvalid); + } + + return config.userController.checkResetTokenValidity(token).then( + () => { + const params = { + [pageParams.token]: token, + [pageParams.appId]: config.applicationId, + [pageParams.appName]: config.appName, + }; + return this.goToPage(req, pages.passwordReset, params); + }, + () => { + return this.goToPage(req, pages.passwordResetLinkInvalid); + } + ); + } + + resetPassword(req) { + const config = req.config; + + if (!config) { + this.invalidRequest(); + } + + const { new_password, token: rawToken } = req.body || {}; + const token = typeof rawToken === 'string' ? rawToken : undefined; + + if ((!token || !new_password) && req.xhr === false) { + return this.goToPage(req, pages.passwordResetLinkInvalid); + } + + if (!token) { + throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'Missing token'); + } + + if (!new_password) { + throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'Missing password'); + } + + return config.userController + .updatePassword(token, new_password) + .then( + () => { + return Promise.resolve({ + success: true, + }); + }, + err => { + return Promise.resolve({ + success: false, + err, + }); + } + ) + .then(result => { + if (req.xhr) { + if (result.success) { + return Promise.resolve({ + status: 200, + response: 'Password successfully reset', + }); + } + if (result.err) { + throw new Parse.Error(Parse.Error.OTHER_CAUSE, `${result.err}`); + } + } + + const query = result.success + ? {} + : { + [pageParams.token]: token, + [pageParams.appId]: config.applicationId, + [pageParams.error]: result.err, + [pageParams.appName]: config.appName, + }; + + if (result?.err === 'The password reset link has expired') { + delete query[pageParams.token]; + query[pageParams.token] = token; + } + const page = result.success ? pages.passwordResetSuccess : pages.passwordReset; + + return this.goToPage(req, page, query, false); + }); + } + + /** + * Returns page content if the page is a local file or returns a + * redirect to a custom page. + * @param {Object} req The express request. + * @param {Page} page The page to go to. + * @param {Object} [params={}] The query parameters to attach to the URL in case of + * HTTP redirect responses for POST requests, or the placeholders to fill into + * the response content in case of HTTP content responses for GET requests. + * @param {Boolean} [responseType] Is true if a redirect response should be forced, + * false if a content response should be forced, undefined if the response type + * should depend on the request type by default: + * - GET request -> content response + * - POST request -> redirect response (PRG pattern) + * @returns {Promise} The PromiseRouter response. + */ + goToPage(req, page, params = {}, responseType) { + const config = req.config; + + // Determine redirect either by force, response setting or request method + const redirect = config.pages.forceRedirect + ? true + : responseType !== undefined + ? responseType + : req.method == 'POST'; + + // Include default parameters + const defaultParams = this.getDefaultParams(config); + if (Object.values(defaultParams).includes(undefined)) { + return this.notFound(); + } + params = Object.assign(params, defaultParams); + + // Add locale to params to ensure it is passed on with every request; + // that means, once a locale is set, it is passed on to any follow-up page, + // e.g. request_password_reset -> password_reset -> password_reset_success + const locale = this.getLocale(req); + params[pageParams.locale] = locale; + + // Compose paths and URLs + const defaultFile = page.defaultFile; + const defaultPath = this.defaultPagePath(defaultFile); + const defaultUrl = this.composePageUrl(defaultFile, config.publicServerURL); + + // If custom URL is set redirect to it without localization + const customUrl = config.pages.customUrls[page.id]; + if (customUrl && !Utils.isPath(customUrl)) { + return this.redirectResponse(customUrl, params); + } + + // Get JSON placeholders + let placeholders = {}; + if (config.pages.enableLocalization && config.pages.localizationJsonPath) { + placeholders = this.getJsonPlaceholders(locale, params); + } + + // Send response + if (config.pages.enableLocalization && locale) { + return Utils.getLocalizedPath(defaultPath, locale).then(({ path, subdir }) => + redirect + ? this.redirectResponse( + this.composePageUrl(defaultFile, config.publicServerURL, subdir), + params + ) + : this.pageResponse(path, params, placeholders) + ); + } else { + return redirect + ? this.redirectResponse(defaultUrl, params) + : this.pageResponse(defaultPath, params, placeholders); + } + } + + /** + * Serves a request to a static resource and localizes the resource if it + * is a HTML file. + * @param {Object} req The request object. + * @returns {Promise} The response. + */ + staticRoute(req) { + // Get requested path + const relativePath = req.params['resource'][0]; + + // Resolve requested path to absolute path + const absolutePath = path.resolve(this.pagesPath, relativePath); + + // If the requested file is not a HTML file send its raw content + if (!absolutePath || !absolutePath.endsWith('.html')) { + return this.fileResponse(absolutePath); + } + + // Get parameters + const params = this.getDefaultParams(req.config); + const locale = this.getLocale(req); + if (locale) { + params.locale = locale; + } + + // Get JSON placeholders + const placeholders = this.getJsonPlaceholders(locale, params); + + return this.pageResponse(absolutePath, params, placeholders); + } + + /** + * Returns a translation from the JSON resource for a given locale. The JSON + * resource is parsed according to i18next syntax. + * + * Example JSON content: + * ```js + * { + * "en": { // resource for language `en` (English) + * "translation": { + * "greeting": "Hello!" + * } + * }, + * "de": { // resource for language `de` (German) + * "translation": { + * "greeting": "Hallo!" + * } + * } + * "de-CH": { // resource for locale `de-CH` (Swiss German) + * "translation": { + * "greeting": "GrÃŧezi!" + * } + * } + * } + * ``` + * @param {String} locale The locale to translate to. + * @returns {Object} The translation or an empty object if no matching + * translation was found. + */ + getJsonTranslation(locale) { + // If there is no JSON resource + if (this.jsonParameters === undefined) { + return {}; + } + + // If locale is not set use the fallback locale + locale = locale || this.pagesConfig.localizationFallbackLocale; + + // Get matching translation by locale, language or fallback locale + const language = locale.split('-')[0]; + const resource = + this.jsonParameters[locale] || + this.jsonParameters[language] || + this.jsonParameters[this.pagesConfig.localizationFallbackLocale] || + {}; + const translation = resource.translation || {}; + return translation; + } + + /** + * Returns a translation from the JSON resource for a given locale with + * placeholders filled in by given parameters. + * @param {String} locale The locale to translate to. + * @param {Object} params The parameters to fill into any placeholders + * within the translations. + * @returns {Object} The translation or an empty object if no matching + * translation was found. + */ + getJsonPlaceholders(locale, params = {}) { + // If localization is disabled or there is no JSON resource + if (!this.pagesConfig.enableLocalization || !this.pagesConfig.localizationJsonPath) { + return {}; + } + + // Get JSON placeholders + let placeholders = this.getJsonTranslation(locale); + + // Fill in any placeholders in the translation; this allows a translation + // to contain default placeholders like {{appName}} which are filled here + placeholders = JSON.stringify(placeholders); + placeholders = mustache.render(placeholders, params); + placeholders = JSON.parse(placeholders); + + return placeholders; + } + + /** + * Creates a response with file content. + * @param {String} path The path of the file to return. + * @param {Object} [params={}] The parameters to be included in the response + * header. These will also be used to fill placeholders. + * @param {Object} [placeholders={}] The placeholders to fill in the content. + * These will not be included in the response header. + * @returns {Object} The Promise Router response. + */ + async pageResponse(path, params = {}, placeholders = {}) { + // Get file content + let data; + try { + data = await this.readFile(path); + } catch { + return this.notFound(); + } + + // Get config placeholders; can be an object, a function or an async function + let configPlaceholders = + typeof this.pagesConfig.placeholders === 'function' + ? this.pagesConfig.placeholders(params) + : Object.prototype.toString.call(this.pagesConfig.placeholders) === '[object Object]' + ? this.pagesConfig.placeholders + : {}; + if (Utils.isPromise(configPlaceholders)) { + configPlaceholders = await configPlaceholders; + } + + // Fill placeholders + const allPlaceholders = Object.assign({}, configPlaceholders, placeholders); + const paramsAndPlaceholders = Object.assign({}, params, allPlaceholders); + data = mustache.render(data, paramsAndPlaceholders); + + // Add placeholders in header to allow parsing for programmatic use + // of response, instead of having to parse the HTML content. + const headers = this.composePageParamHeaders(params); + + return { text: data, headers: headers }; + } + + /** + * Creates a response with file content. + * @param {String} path The path of the file to return. + * @returns {Object} The PromiseRouter response. + */ + async fileResponse(path) { + // Get file content + let data; + try { + data = await this.readFile(path); + } catch { + return this.notFound(); + } + + return { text: data }; + } + + /** + * Reads and returns the content of a file at a given path. File reading to + * serve content on the static route is only allowed from the pages + * directory on downwards. + * ----------------------------------------------------------------------- + * **WARNING:** All file reads in the PagesRouter must be executed by this + * wrapper because it also detects and prevents common exploits. + * ----------------------------------------------------------------------- + * @param {String} filePath The path to the file to read. + * @returns {Promise} The file content. + */ + async readFile(filePath) { + // Normalize path to prevent it from containing any directory changing + // UNIX patterns which could expose the whole file system, e.g. + // `http://example.com/parse/apps/../file.txt` requests a file outside + // of the pages directory scope. + const normalizedPath = path.normalize(filePath); + + // Abort if the path is outside of the path directory scope + if (!normalizedPath.startsWith(this.pagesPath + path.sep)) { + throw errors.fileOutsideAllowedScope; + } + + return await fs.readFile(normalizedPath, 'utf-8'); + } + + /** + * Loads a language resource JSON file that is used for translations. + */ + loadJsonResource() { + if (this.pagesConfig.localizationJsonPath === undefined) { + return; + } + try { + const json = require(path.resolve('./', this.pagesConfig.localizationJsonPath)); + this.jsonParameters = json; + } catch { + throw errors.jsonFailedFileLoading; + } + } + + /** + * Extracts and returns the page default parameters from the Parse Server + * configuration. These parameters are made accessible in every page served + * by this router. + * @param {Object} config The Parse Server configuration. + * @returns {Object} The default parameters. + */ + getDefaultParams(config) { + return config + ? { + [pageParams.appId]: config.appId, + [pageParams.appName]: config.appName, + [pageParams.publicServerUrl]: config.publicServerURL, + } + : {}; + } + + /** + * Extracts and returns the locale from an express request. + * @param {Object} req The express request. + * @returns {String|undefined} The locale, or undefined if no locale was set. + */ + getLocale(req) { + const locale = + (req.query || {})[pageParams.locale] || + (req.body || {})[pageParams.locale] || + (req.params || {})[pageParams.locale] || + (req.headers || {})[pageParamHeaderPrefix + pageParams.locale]; + + // Validate locale format to prevent path traversal; only allow + // standard locale patterns like "en", "en-US", "de-AT", "zh-Hans-CN" + if (locale !== undefined && typeof locale !== 'string') { + return undefined; + } + if (typeof locale === 'string' && !/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(locale)) { + return undefined; + } + return locale; + } + + /** + * Composes page parameter headers from the given parameters. Control + * characters are always stripped from header values to prevent + * ERR_INVALID_CHAR errors. Values are URI-encoded if the + * `encodePageParamHeaders` option is enabled. + * @param {Object} params The parameters to include in the headers. + * @returns {Object} The headers object. + */ + composePageParamHeaders(params) { + const encode = this.pagesConfig.encodePageParamHeaders; + return Object.entries(params).reduce((m, p) => { + if (p[1] !== undefined) { + let value = encode ? encodeURIComponent(p[1]) : p[1]; + if (typeof value === 'string') { + // eslint-disable-next-line no-control-regex + value = value.replace(/[\x00-\x1f\x7f]/g, ''); + } + m[`${pageParamHeaderPrefix}${p[0].toLowerCase()}`] = value; + } + return m; + }, {}); + } + + /** + * Creates a response with http redirect. + * @param {Object} req The express request. + * @param {String} path The path of the file to return. + * @param {Object} params The query parameters to include. + * @returns {Object} The Promise Router response. + */ + async redirectResponse(url, params) { + // Remove any parameters with undefined value + params = Object.entries(params).reduce((m, p) => { + if (p[1] !== undefined) { + m[p[0]] = p[1]; + } + return m; + }, {}); + + // Compose URL with parameters in query + const location = new URL(url); + Object.entries(params).forEach(p => location.searchParams.set(p[0], p[1])); + const locationString = location.toString(); + + // Add parameters to header to allow parsing for programmatic use + // of response, instead of having to parse the HTML content. + const headers = this.composePageParamHeaders(params); + + return { + status: 303, + location: locationString, + headers: headers, + }; + } + + defaultPagePath(file) { + return path.join(this.pagesPath, file); + } + + composePageUrl(file, publicServerUrl, locale) { + let url = publicServerUrl; + url += url.endsWith('/') ? '' : '/'; + url += this.pagesEndpoint + '/'; + url += locale === undefined ? '' : locale + '/'; + url += file; + return url; + } + + notFound() { + return { + text: 'Not found.', + status: 404, + }; + } + + invalidRequest() { + const error = new Error(); + error.status = 403; + error.message = 'unauthorized'; + throw error; + } + + /** + * Sets the Parse Server configuration in the request object to make it + * easily accessible throughtout request processing. + * @param {Object} req The request. + * @param {Boolean} failGracefully Is true if failing to set the config should + * not result in an invalid request response. Default is `false`. + */ + async setConfig(req, failGracefully = false) { + req.config = Config.get(req.params.appId || req.query.appId); + if (!req.config && !failGracefully) { + this.invalidRequest(); + } + if (req.config) { + await req.config.loadKeys(); + } + } + + mountPagesRoutes() { + this.route( + 'GET', + `/${this.pagesEndpoint}/:appId/verify_email`, + req => { + return this.setConfig(req); + }, + req => { + return this.verifyEmail(req); + } + ); + + this.route( + 'POST', + `/${this.pagesEndpoint}/:appId/resend_verification_email`, + req => { + return this.setConfig(req); + }, + req => { + return this.resendVerificationEmail(req); + } + ); + + this.route( + 'GET', + `/${this.pagesEndpoint}/choose_password`, + req => { + return this.setConfig(req); + }, + req => { + return this.passwordReset(req); + } + ); + + this.route( + 'POST', + `/${this.pagesEndpoint}/:appId/request_password_reset`, + req => { + return this.setConfig(req); + }, + req => { + return this.resetPassword(req); + } + ); + + this.route( + 'GET', + `/${this.pagesEndpoint}/:appId/request_password_reset`, + req => { + return this.setConfig(req); + }, + req => { + return this.requestResetPassword(req); + } + ); + } + + mountCustomRoutes() { + for (const route of this.pagesConfig.customRoutes || []) { + this.route( + route.method, + `/${this.pagesEndpoint}/:appId/${route.path}`, + req => { + return this.setConfig(req); + }, + async req => { + const { file, query = {} } = (await route.handler(req)) || {}; + + // If route handler did not return a page send 404 response + if (!file) { + return this.notFound(); + } + + // Send page response + const page = new Page({ id: file, defaultFile: file }); + return this.goToPage(req, page, query, false); + } + ); + } + } + + mountStaticRoute() { + this.route( + 'GET', + `/${this.pagesEndpoint}/*resource`, + req => { + return this.setConfig(req, true); + }, + req => { + return this.staticRoute(req); + } + ); + } + + expressRouter() { + const router = express.Router(); + router.use('/', super.expressRouter()); + return router; + } +} + +export default PagesRouter; +module.exports = { + PagesRouter, + pageParamHeaderPrefix, + pageParams, + pages, +}; diff --git a/src/Routers/PublicAPIRouter.js b/src/Routers/PublicAPIRouter.js deleted file mode 100644 index c5d94e7862..0000000000 --- a/src/Routers/PublicAPIRouter.js +++ /dev/null @@ -1,163 +0,0 @@ -import PromiseRouter from '../PromiseRouter'; -import UserController from '../Controllers/UserController'; -import Config from '../Config'; -import express from 'express'; -import path from 'path'; -import fs from 'fs'; -import qs from 'querystring'; - -let public_html = path.resolve(__dirname, "../../public_html"); -let views = path.resolve(__dirname, '../../views'); - -export class PublicAPIRouter extends PromiseRouter { - - verifyEmail(req) { - let { token, username }= req.query; - let appId = req.params.appId; - let config = new Config(appId); - - if (!config.publicServerURL) { - return this.missingPublicServerURL(); - } - - if (!token || !username) { - return this.invalidLink(req); - } - - let userController = config.userController; - return userController.verifyEmail(username, token).then( () => { - let params = qs.stringify({username}); - return Promise.resolve({ - status: 302, - location: `${config.verifyEmailSuccessURL}?${params}` - }); - }, ()=> { - return this.invalidLink(req); - }) - } - - changePassword(req) { - return new Promise((resolve, reject) => { - let config = new Config(req.query.id); - if (!config.publicServerURL) { - return resolve({ - status: 404, - text: 'Not found.' - }); - } - // Should we keep the file in memory or leave like that? - fs.readFile(path.resolve(views, "choose_password"), 'utf-8', (err, data) => { - if (err) { - return reject(err); - } - data = data.replace("PARSE_SERVER_URL", `'${config.publicServerURL}'`); - resolve({ - text: data - }) - }); - }); - } - - requestResetPassword(req) { - - let config = req.config; - - if (!config.publicServerURL) { - return this.missingPublicServerURL(); - } - - let { username, token } = req.query; - - if (!username || !token) { - return this.invalidLink(req); - } - - return config.userController.checkResetTokenValidity(username, token).then( (user) => { - let params = qs.stringify({token, id: config.applicationId, username, app: config.appName, }); - return Promise.resolve({ - status: 302, - location: `${config.choosePasswordURL}?${params}` - }) - }, () => { - return this.invalidLink(req); - }) - } - - resetPassword(req) { - - let config = req.config; - - if (!config.publicServerURL) { - return this.missingPublicServerURL(); - } - - let { - username, - token, - new_password - } = req.body; - - if (!username || !token || !new_password) { - return this.invalidLink(req); - } - - return config.userController.updatePassword(username, token, new_password).then((result) => { - return Promise.resolve({ - status: 302, - location: config.passwordResetSuccessURL - }); - }, (err) => { - let params = qs.stringify({username: username, token: token, id: config.applicationId, error:err, app:config.appName}) - return Promise.resolve({ - status: 302, - location: `${config.choosePasswordURL}?${params}` - }); - }); - - } - - invalidLink(req) { - return Promise.resolve({ - status: 302, - location: req.config.invalidLinkURL - }); - } - - missingPublicServerURL() { - return Promise.resolve({ - text: 'Not found.', - status: 404 - }); - } - - setConfig(req) { - req.config = new Config(req.params.appId); - return Promise.resolve(); - } - - mountRoutes() { - this.route('GET','/apps/:appId/verify_email', - req => { this.setConfig(req) }, - req => { return this.verifyEmail(req); }); - - this.route('GET','/apps/choose_password', - req => { return this.changePassword(req); }); - - this.route('POST','/apps/:appId/request_password_reset', - req => { this.setConfig(req) }, - req => { return this.resetPassword(req); }); - - this.route('GET','/apps/:appId/request_password_reset', - req => { this.setConfig(req) }, - req => { return this.requestResetPassword(req); }); - } - - expressApp() { - let router = express(); - router.use("/apps", express.static(public_html)); - router.use("/", super.expressApp()); - return router; - } -} - -export default PublicAPIRouter; diff --git a/src/Routers/PurgeRouter.js b/src/Routers/PurgeRouter.js index 1f0eed816f..f346d64176 100644 --- a/src/Routers/PurgeRouter.js +++ b/src/Routers/PurgeRouter.js @@ -1,23 +1,40 @@ import PromiseRouter from '../PromiseRouter'; import * as middleware from '../middlewares'; +import Parse from 'parse/node'; +import { createSanitizedError } from '../Error'; export class PurgeRouter extends PromiseRouter { - handlePurge(req) { - return req.config.database.purgeCollection(req.params.className) - .then(() => { - var cacheAdapter = req.config.cacheController; - if (req.params.className == '_Session') { - cacheAdapter.user.clear(); - } else if (req.params.className == '_Role') { - cacheAdapter.role.clear(); - } - return {response: {}}; - }); + if (req.auth.isReadOnly) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to purge a schema.", + req.config + ); + } + return req.config.database + .purgeCollection(req.params.className) + .then(() => { + var cacheAdapter = req.config.cacheController; + if (req.params.className == '_Session') { + cacheAdapter.user.clear(); + } else if (req.params.className == '_Role') { + cacheAdapter.role.clear(); + } + return { response: {} }; + }) + .catch(error => { + if (!error || (error && error.code === Parse.Error.OBJECT_NOT_FOUND)) { + return { response: {} }; + } + throw error; + }); } mountRoutes() { - this.route('DELETE', '/purge/:className', middleware.promiseEnforceMasterKeyAccess, (req) => { return this.handlePurge(req); }); + this.route('DELETE', '/purge/:className', middleware.promiseEnforceMasterKeyAccess, req => { + return this.handlePurge(req); + }); } } diff --git a/src/Routers/PushRouter.js b/src/Routers/PushRouter.js index 08a633fda2..696f19ed88 100644 --- a/src/Routers/PushRouter.js +++ b/src/Routers/PushRouter.js @@ -1,34 +1,50 @@ -import PromiseRouter from '../PromiseRouter'; -import * as middleware from "../middlewares"; -import { Parse } from "parse/node"; +import PromiseRouter from '../PromiseRouter'; +import * as middleware from '../middlewares'; +import { Parse } from 'parse/node'; +import { createSanitizedError } from '../Error'; export class PushRouter extends PromiseRouter { - mountRoutes() { - this.route("POST", "/push", middleware.promiseEnforceMasterKeyAccess, PushRouter.handlePOST); + this.route('POST', '/push', middleware.promiseEnforceMasterKeyAccess, PushRouter.handlePOST); } static handlePOST(req) { + if (req.auth.isReadOnly) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to send push notifications.", + req.config + ); + } const pushController = req.config.pushController; if (!pushController) { throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'Push controller is not set'); } - let where = PushRouter.getQueryCondition(req); + const where = PushRouter.getQueryCondition(req); let resolve; - let promise = new Promise((_resolve) => { + const promise = new Promise(_resolve => { resolve = _resolve; }); - pushController.sendPush(req.body, where, req.config, req.auth, (pushStatusId) => { - resolve({ - headers: { - 'X-Parse-Push-Status-Id': pushStatusId - }, - response: { - result: true - } + let pushStatusId; + pushController + .sendPush(req.body || {}, where, req.config, req.auth, objectId => { + pushStatusId = objectId; + resolve({ + headers: { + 'X-Parse-Push-Status-Id': pushStatusId, + }, + response: { + result: true, + }, + }); + }) + .catch(err => { + req.config.loggerController.error( + `_PushStatus ${pushStatusId}: error while sending push`, + err + ); }); - }); return promise; } @@ -38,24 +54,29 @@ export class PushRouter extends PromiseRouter { * @returns {Object} The query condition, the where field in a query api call */ static getQueryCondition(req) { - let body = req.body || {}; - let hasWhere = typeof body.where !== 'undefined'; - let hasChannels = typeof body.channels !== 'undefined'; + const body = req.body || {}; + const hasWhere = typeof body.where !== 'undefined'; + const hasChannels = typeof body.channels !== 'undefined'; let where; if (hasWhere && hasChannels) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - 'Channels and query can not be set at the same time.'); + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + 'Channels and query can not be set at the same time.' + ); } else if (hasWhere) { where = body.where; } else if (hasChannels) { where = { - "channels": { - "$in": body.channels - } - } + channels: { + $in: body.channels, + }, + }; } else { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'Sending a push requires either "channels" or a "where" query.'); + throw new Parse.Error( + Parse.Error.PUSH_MISCONFIGURED, + 'Sending a push requires either "channels" or a "where" query.' + ); } return where; } diff --git a/src/Routers/RolesRouter.js b/src/Routers/RolesRouter.js index c9b4f999c5..e6a10df77b 100644 --- a/src/Routers/RolesRouter.js +++ b/src/Routers/RolesRouter.js @@ -1,40 +1,26 @@ - import ClassesRouter from './ClassesRouter'; -import PromiseRouter from '../PromiseRouter'; -import rest from '../rest'; export class RolesRouter extends ClassesRouter { - handleFind(req) { - req.params.className = '_Role'; - return super.handleFind(req); - } - - handleGet(req) { - req.params.className = '_Role'; - return super.handleGet(req); - } - - handleCreate(req) { - req.params.className = '_Role'; - return super.handleCreate(req); - } - - handleUpdate(req) { - req.params.className = '_Role'; - return super.handleUpdate(req); - } - - handleDelete(req) { - req.params.className = '_Role'; - return super.handleDelete(req); + className() { + return '_Role'; } mountRoutes() { - this.route('GET','/roles', req => { return this.handleFind(req); }); - this.route('GET','/roles/:objectId', req => { return this.handleGet(req); }); - this.route('POST','/roles', req => { return this.handleCreate(req); }); - this.route('PUT','/roles/:objectId', req => { return this.handleUpdate(req); }); - this.route('DELETE','/roles/:objectId', req => { return this.handleDelete(req); }); + this.route('GET', '/roles', req => { + return this.handleFind(req); + }); + this.route('GET', '/roles/:objectId', req => { + return this.handleGet(req); + }); + this.route('POST', '/roles', req => { + return this.handleCreate(req); + }); + this.route('PUT', '/roles/:objectId', req => { + return this.handleUpdate(req); + }); + this.route('DELETE', '/roles/:objectId', req => { + return this.handleDelete(req); + }); } } diff --git a/src/Routers/SchemasRouter.js b/src/Routers/SchemasRouter.js index 71c40d55ee..8713c95518 100644 --- a/src/Routers/SchemasRouter.js +++ b/src/Routers/SchemasRouter.js @@ -1,11 +1,11 @@ // schemas.js -var express = require('express'), - Parse = require('parse/node').Parse, +var Parse = require('parse/node').Parse, SchemaController = require('../Controllers/SchemaController'); -import PromiseRouter from '../PromiseRouter'; -import * as middleware from "../middlewares"; +import PromiseRouter from '../PromiseRouter'; +import * as middleware from '../middlewares'; +import { createSanitizedError } from '../Error'; function classNameMismatchResponse(bodyClass, pathClass) { throw new Parse.Error( @@ -15,70 +15,145 @@ function classNameMismatchResponse(bodyClass, pathClass) { } function getAllSchemas(req) { - return req.config.database.loadSchema({ clearCache: true}) - .then(schemaController => schemaController.getAllClasses(true)) - .then(schemas => ({ response: { results: schemas } })); + return req.config.database + .loadSchema({ clearCache: true }) + .then(schemaController => schemaController.getAllClasses({ clearCache: true })) + .then(schemas => ({ response: { results: schemas } })); } function getOneSchema(req) { const className = req.params.className; - return req.config.database.loadSchema({ clearCache: true}) - .then(schemaController => schemaController.getOneSchema(className, true)) - .then(schema => ({ response: schema })) - .catch(error => { - if (error === undefined) { - throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} does not exist.`); - } else { - throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Database adapter error.'); - } - }); + return req.config.database + .loadSchema({ clearCache: true }) + .then(schemaController => schemaController.getOneSchema(className, true)) + .then(schema => ({ response: schema })) + .catch(error => { + if (error === undefined) { + throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} does not exist.`); + } else { + throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Database adapter error.'); + } + }); } -function createSchema(req) { - if (req.params.className && req.body.className) { +const checkIfDefinedSchemasIsUsed = req => { + if (req.config?.schema?.lockSchemas === true) { + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + 'Cannot perform this operation when schemas options is used.' + ); + } +}; + +export const internalCreateSchema = async (className, body, config) => { + const controller = await config.database.loadSchema({ clearCache: true }); + const response = await controller.addClassIfNotExists( + className, + body.fields, + body.classLevelPermissions, + body.indexes + ); + return { + response, + }; +}; + +export const internalUpdateSchema = async (className, body, config) => { + const controller = await config.database.loadSchema({ clearCache: true }); + const response = await controller.updateClass( + className, + body.fields || {}, + body.classLevelPermissions, + body.indexes, + config.database + ); + return { response }; +}; + +async function createSchema(req) { + checkIfDefinedSchemasIsUsed(req); + if (req.auth.isReadOnly) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to create a schema.", + req.config + ); + } + if (req.params.className && req.body?.className) { if (req.params.className != req.body.className) { return classNameMismatchResponse(req.body.className, req.params.className); } } - const className = req.params.className || req.body.className; + const className = req.params.className || req.body?.className; if (!className) { throw new Parse.Error(135, `POST ${req.path} needs a class name.`); } - return req.config.database.loadSchema({ clearCache: true}) - .then(schema => schema.addClassIfNotExists(className, req.body.fields, req.body.classLevelPermissions)) - .then(schema => ({ response: schema })); + return await internalCreateSchema(className, req.body || {}, req.config); } function modifySchema(req) { - if (req.body.className && req.body.className != req.params.className) { + checkIfDefinedSchemasIsUsed(req); + if (req.auth.isReadOnly) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to update a schema.", + req.config + ); + } + if (req.body?.className && req.body.className != req.params.className) { return classNameMismatchResponse(req.body.className, req.params.className); } + const className = req.params.className; - let submittedFields = req.body.fields || {}; - let className = req.params.className; - - return req.config.database.loadSchema({ clearCache: true}) - .then(schema => schema.updateClass(className, submittedFields, req.body.classLevelPermissions, req.config.database)) - .then(result => ({response: result})); + return internalUpdateSchema(className, req.body || {}, req.config); } const deleteSchema = req => { + if (req.auth.isReadOnly) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to delete a schema.", + req.config + ); + } if (!SchemaController.classNameIsValid(req.params.className)) { - throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, SchemaController.invalidClassNameMessage(req.params.className)); + throw new Parse.Error( + Parse.Error.INVALID_CLASS_NAME, + SchemaController.invalidClassNameMessage(req.params.className) + ); } - return req.config.database.deleteSchema(req.params.className) - .then(() => ({ response: {} })); -} + return req.config.database.deleteSchema(req.params.className).then(() => ({ response: {} })); +}; export class SchemasRouter extends PromiseRouter { mountRoutes() { this.route('GET', '/schemas', middleware.promiseEnforceMasterKeyAccess, getAllSchemas); - this.route('GET', '/schemas/:className', middleware.promiseEnforceMasterKeyAccess, getOneSchema); + this.route( + 'GET', + '/schemas/:className', + middleware.promiseEnforceMasterKeyAccess, + getOneSchema + ); this.route('POST', '/schemas', middleware.promiseEnforceMasterKeyAccess, createSchema); - this.route('POST', '/schemas/:className', middleware.promiseEnforceMasterKeyAccess, createSchema); - this.route('PUT', '/schemas/:className', middleware.promiseEnforceMasterKeyAccess, modifySchema); - this.route('DELETE', '/schemas/:className', middleware.promiseEnforceMasterKeyAccess, deleteSchema); + this.route( + 'POST', + '/schemas/:className', + middleware.promiseEnforceMasterKeyAccess, + createSchema + ); + this.route( + 'PUT', + '/schemas/:className', + middleware.promiseEnforceMasterKeyAccess, + modifySchema + ); + this.route( + 'DELETE', + '/schemas/:className', + middleware.promiseEnforceMasterKeyAccess, + deleteSchema + ); } } diff --git a/src/Routers/SecurityRouter.js b/src/Routers/SecurityRouter.js new file mode 100644 index 0000000000..c7c217a048 --- /dev/null +++ b/src/Routers/SecurityRouter.js @@ -0,0 +1,33 @@ +import PromiseRouter from '../PromiseRouter'; +import * as middleware from '../middlewares'; +import CheckRunner from '../Security/CheckRunner'; + +export class SecurityRouter extends PromiseRouter { + mountRoutes() { + this.route( + 'GET', + '/security', + middleware.promiseEnforceMasterKeyAccess, + this._enforceSecurityCheckEnabled, + async req => { + const report = await new CheckRunner(req.config.security).run(); + return { + status: 200, + response: report, + }; + } + ); + } + + async _enforceSecurityCheckEnabled(req) { + const config = req.config; + if (!config.security || !config.security.enableCheck) { + const error = new Error(); + error.status = 409; + error.message = 'Enable Parse Server option `security.enableCheck` to run security check.'; + throw error; + } + } +} + +export default SecurityRouter; diff --git a/src/Routers/SessionsRouter.js b/src/Routers/SessionsRouter.js index c94b289d51..9d80b5db4f 100644 --- a/src/Routers/SessionsRouter.js +++ b/src/Routers/SessionsRouter.js @@ -1,60 +1,139 @@ - import ClassesRouter from './ClassesRouter'; -import PromiseRouter from '../PromiseRouter'; -import rest from '../rest'; -import Auth from '../Auth'; +import Parse from 'parse/node'; +import rest from '../rest'; +import Auth from '../Auth'; +import RestWrite from '../RestWrite'; export class SessionsRouter extends ClassesRouter { - handleFind(req) { - req.params.className = '_Session'; - return super.handleFind(req); - } - - handleGet(req) { - req.params.className = '_Session'; - return super.handleGet(req); + className() { + return '_Session'; } - handleCreate(req) { - req.params.className = '_Session'; - return super.handleCreate(req); - } - - handleUpdate(req) { - req.params.className = '_Session'; - return super.handleUpdate(req); + async handleMe(req) { + if (!req.info || !req.info.sessionToken) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Session token required.'); + } + const sessionToken = req.info.sessionToken; + // Query with master key to validate the session token and get the session objectId + const sessionResponse = await rest.find( + req.config, + Auth.master(req.config), + '_Session', + { sessionToken }, + {}, + req.info.clientSDK, + req.info.context + ); + if ( + !sessionResponse.results || + sessionResponse.results.length == 0 || + !sessionResponse.results[0].user + ) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Session token not found.'); + } + const sessionObjectId = sessionResponse.results[0].objectId; + const userId = sessionResponse.results[0].user.objectId; + // Re-fetch the session with the caller's auth context so that + // protectedFields and CLP apply correctly; if the caller used master key, + // protectedFields are bypassed, matching the behavior of GET /sessions/:id + const refetchAuth = + req.auth?.isMaster || req.auth?.isMaintenance + ? req.auth + : new Auth.Auth({ + config: req.config, + isMaster: false, + user: Parse.Object.fromJSON({ className: '_User', objectId: userId }), + installationId: req.info.installationId, + }); + const response = await rest.get( + req.config, + refetchAuth, + '_Session', + sessionObjectId, + {}, + req.info.clientSDK, + req.info.context + ); + if (!response.results || response.results.length == 0) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Session token not found.'); + } + return { + response: response.results[0], + }; } - handleDelete(req) { - req.params.className = '_Session'; - return super.handleDelete(req); - } + async handleUpdateToRevocableSession(req) { + const config = req.config; + const user = req.auth.user; + // Issue #2720 + // Calling without a session token would result in a not found user + if (!user) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'invalid session'); + } + const { sessionData, createSession } = RestWrite.createSession(config, { + userId: user.id, + createdWith: { + action: 'upgrade', + }, + installationId: req.auth.installationId, + }); - handleMe(req) { - // TODO: Verify correct behavior - if (!req.info || !req.info.sessionToken) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, - 'Session token required.'); + await createSession(); + // delete the session token, use the db to skip beforeSave + await config.database.update( + '_User', + { objectId: user.id }, + { sessionToken: { __op: 'Delete' } } + ); + // Re-fetch the session with the caller's auth context so that + // protectedFields filtering applies correctly; if the caller used master key, + // protectedFields are bypassed, matching the behavior of GET /sessions/:id + const refetchAuth = + req.auth.isMaster || req.auth.isMaintenance + ? req.auth + : new Auth.Auth({ + config, + isMaster: false, + user: Parse.Object.fromJSON({ className: '_User', objectId: user.id }), + installationId: req.auth.installationId, + }); + const response = await rest.find( + config, + refetchAuth, + '_Session', + { sessionToken: sessionData.sessionToken }, + {}, + req.info.clientSDK, + req.info.context + ); + if (!response.results || response.results.length === 0) { + throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Failed to load upgraded session.'); } - return rest.find(req.config, Auth.master(req.config), '_Session', { sessionToken: req.info.sessionToken }, undefined, req.info.clientSDK) - .then((response) => { - if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, - 'Session token not found.'); - } - return { - response: response.results[0] - }; - }); + return { response: response.results[0] }; } mountRoutes() { - this.route('GET','/sessions/me', req => { return this.handleMe(req); }); - this.route('GET', '/sessions', req => { return this.handleFind(req); }); - this.route('GET', '/sessions/:objectId', req => { return this.handleGet(req); }); - this.route('POST', '/sessions', req => { return this.handleCreate(req); }); - this.route('PUT', '/sessions/:objectId', req => { return this.handleUpdate(req); }); - this.route('DELETE', '/sessions/:objectId', req => { return this.handleDelete(req); }); + this.route('GET', '/sessions/me', req => { + return this.handleMe(req); + }); + this.route('GET', '/sessions', req => { + return this.handleFind(req); + }); + this.route('GET', '/sessions/:objectId', req => { + return this.handleGet(req); + }); + this.route('POST', '/sessions', req => { + return this.handleCreate(req); + }); + this.route('PUT', '/sessions/:objectId', req => { + return this.handleUpdate(req); + }); + this.route('DELETE', '/sessions/:objectId', req => { + return this.handleDelete(req); + }); + this.route('POST', '/upgradeToRevocableSession', req => { + return this.handleUpdateToRevocableSession(req); + }); } } diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index d6477b10f1..34271a7dd7 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -1,210 +1,853 @@ // These methods handle the User-related routes. -import deepcopy from 'deepcopy'; -import Config from '../Config'; -import ClassesRouter from './ClassesRouter'; -import PromiseRouter from '../PromiseRouter'; -import rest from '../rest'; -import Auth from '../Auth'; +import Parse from 'parse/node'; +import Config from '../Config'; +import AccountLockout from '../AccountLockout'; +import ClassesRouter from './ClassesRouter'; +import rest from '../rest'; +import Auth from '../Auth'; import passwordCrypto from '../password'; -import RestWrite from '../RestWrite'; -let cryptoUtils = require('../cryptoUtils'); -let triggers = require('../triggers'); +import { + maybeRunTrigger, + Types as TriggerTypes, + getRequestObject, + resolveError, + inflate, +} from '../triggers'; +import { promiseEnsureIdempotency } from '../middlewares'; +import RestWrite from '../RestWrite'; +import { logger } from '../logger'; +import { createSanitizedError } from '../Error'; +import { applyAuthDataOptimisticLock } from '../AuthDataLock'; export class UsersRouter extends ClassesRouter { - handleFind(req) { - req.params.className = '_User'; - return super.handleFind(req); + className() { + return '_User'; } - handleGet(req) { - req.params.className = '_User'; - return super.handleGet(req); + /** + * Removes all "_" prefixed properties from an object, except "__type" + * @param {Object} obj An object. + */ + static removeHiddenProperties(obj) { + for (var key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + // Regexp comes from Parse.Object.prototype.validate + if (key !== '__type' && !/^[A-Za-z][0-9A-Za-z_]*$/.test(key)) { + delete obj[key]; + } + } + } } - handleCreate(req) { - let data = deepcopy(req.body); - req.body = data; - req.params.className = '_User'; + /** + * After retrieving a user directly from the database, we need to remove the + * password from the object (for security), and fix an issue some SDKs have + * with null values + */ + _sanitizeAuthData(user) { + delete user.password; - return super.handleCreate(req); + // Sometimes the authData still has null on that keys + // https://github.com/parse-community/parse-server/issues/935 + if (user.authData) { + Object.keys(user.authData).forEach(provider => { + if (user.authData[provider] === null) { + delete user.authData[provider]; + } + }); + if (Object.keys(user.authData).length == 0) { + delete user.authData; + } + } } - handleUpdate(req) { - req.params.className = '_User'; - return super.handleUpdate(req); - } + /** + * Validates a password request in login and verifyPassword + * @param {Object} req The request + * @returns {Object} User object + * @private + */ + _authenticateUserFromRequest(req) { + return new Promise((resolve, reject) => { + // Use query parameters instead if provided in url + let payload = req.body || {}; + if ( + (!payload.username && req.query && req.query.username) || + (!payload.email && req.query && req.query.email) + ) { + payload = req.query; + } + const { username, email, password, ignoreEmailVerification } = payload; + + // TODO: use the right error codes / descriptions. + if (!username && !email) { + throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'username/email is required.'); + } + if (!password) { + throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'password is required.'); + } + if ( + typeof password !== 'string' || + (email && typeof email !== 'string') || + (username && typeof username !== 'string') + ) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); + } + + let user; + let isValidPassword = false; + let query; + if (email && username) { + query = { email, username }; + } else if (email) { + query = { email }; + } else { + query = { $or: [{ username }, { email: username }] }; + } + return req.config.database + .find('_User', query, {}, Auth.maintenance(req.config)) + .then(results => { + if (!results.length) { + // Perform a dummy bcrypt compare to normalize response timing, + // preventing user enumeration via timing side-channel + return passwordCrypto + .compare(password, passwordCrypto.dummyHash) + .then(() => { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); + }); + } + + if (results.length > 1) { + // corner case where user1 has username == user2 email + req.config.loggerController.warn( + "There is a user which email is the same as another user's username, logging in based on username" + ); + user = results.filter(user => user.username === username)[0]; + } else { + user = results[0]; + } + + if (typeof user.password !== 'string' || user.password.length === 0) { + // Passwordless account (e.g. OAuth-only): run dummy compare for + // timing normalization, discard result, always reject + return passwordCrypto.compare(password, passwordCrypto.dummyHash).then(() => false); + } + return passwordCrypto.compare(password, user.password); + }) + .then(correct => { + isValidPassword = correct; + const accountLockoutPolicy = new AccountLockout(user, req.config); + return accountLockoutPolicy.handleLoginAttempt(isValidPassword); + }) + .then(async () => { + if (!isValidPassword) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); + } + // A user with an empty ACL (master key only) is considered locked out and + // cannot log in. This only prevents new logins; existing session tokens + // remain valid. To immediately revoke access, also destroy the user's + // sessions via master key. + if (!req.auth.isMaster && user.ACL && Object.keys(user.ACL).length == 0) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); + } + // Create request object for verification functions + const authProvider = + req.body && + req.body.authData && + Object.keys(req.body.authData).length && + Object.keys(req.body.authData).join(','); + const request = { + master: req.auth.isMaster, + ip: req.config.ip, + installationId: req.auth.installationId, + object: Parse.User.fromJSON(Object.assign({ className: '_User' }, user)), + createdWith: RestWrite.buildCreatedWith('login', authProvider), + }; + + // If request doesn't use master or maintenance key with ignoring email verification + if (!((req.auth.isMaster || req.auth.isMaintenance) && ignoreEmailVerification)) { + + // Get verification conditions which can be booleans or functions; the purpose of this async/await + // structure is to avoid unnecessarily executing subsequent functions if previous ones fail in the + // conditional statement below, as a developer may decide to execute expensive operations in them + const verifyUserEmails = async () => req.config.verifyUserEmails === true || (typeof req.config.verifyUserEmails === 'function' && await Promise.resolve(req.config.verifyUserEmails(request)) === true); + const preventLoginWithUnverifiedEmail = async () => req.config.preventLoginWithUnverifiedEmail === true || (typeof req.config.preventLoginWithUnverifiedEmail === 'function' && await Promise.resolve(req.config.preventLoginWithUnverifiedEmail(request)) === true); + if (await verifyUserEmails() && await preventLoginWithUnverifiedEmail() && !user.emailVerified) { + throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User email is not verified.'); + } + } - handleDelete(req) { - req.params.className = '_User'; - return super.handleDelete(req); + this._sanitizeAuthData(user); + + return resolve(user); + }) + .catch(error => { + return reject(error); + }); + }); } - handleMe(req) { + async handleMe(req) { if (!req.info || !req.info.sessionToken) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'invalid session token'); + throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config); } - let sessionToken = req.info.sessionToken; - return rest.find(req.config, Auth.master(req.config), '_Session', + const sessionToken = req.info.sessionToken; + // Query the session with master key to validate the session token, + // but do NOT include 'user' to avoid leaking user data via master context + const sessionResponse = await rest.find( + req.config, + Auth.master(req.config), + '_Session', { sessionToken }, - { include: 'user' }, req.info.clientSDK) - .then((response) => { - if (!response.results || - response.results.length == 0 || - !response.results[0].user) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'invalid session token'); - } else { - let user = response.results[0].user; - // Send token back on the login, because SDKs expect that. - user.sessionToken = sessionToken; - return { response: user }; + {}, + req.info.clientSDK, + req.info.context + ); + if ( + !sessionResponse.results || + sessionResponse.results.length == 0 || + !sessionResponse.results[0].user + ) { + throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config); + } + const userId = sessionResponse.results[0].user.objectId; + // Re-fetch the user with the caller's auth context so that + // protectedFields, CLP, and auth adapter afterFind apply correctly + const userResponse = await rest.get( + req.config, + req.auth, + '_User', + userId, + {}, + req.info.clientSDK, + req.info.context + ); + if (!userResponse.results || userResponse.results.length == 0) { + throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config); + } + const user = userResponse.results[0]; + // Send token back on the login, because SDKs expect that. + user.sessionToken = sessionToken; + // Remove hidden properties. + UsersRouter.removeHiddenProperties(user); + return { response: user }; + } + + async handleLogIn(req) { + const user = await this._authenticateUserFromRequest(req); + const authData = req.body && req.body.authData; + // Check if user has provided their required auth providers + Auth.checkIfUserHasProvidedConfiguredProvidersForLogin( + req, + authData, + user.authData, + req.config + ); + + let authDataResponse; + let validatedAuthData; + if (authData) { + const res = await Auth.handleAuthDataValidation( + authData, + new RestWrite( + req.config, + req.auth, + '_User', + { objectId: user.objectId }, + req.body || {}, + user, + req.info.clientSDK, + req.info.context + ), + user + ); + authDataResponse = res.authDataResponse; + validatedAuthData = res.authData; + } + + // handle password expiry policy + if (req.config.passwordPolicy && req.config.passwordPolicy.maxPasswordAge) { + let changedAt = user._password_changed_at; + + if (!changedAt) { + // password was created before expiry policy was enabled. + // simply update _User object so that it will start enforcing from now + changedAt = new Date(); + req.config.database.update( + '_User', + { username: user.username }, + { _password_changed_at: Parse._encode(changedAt) } + ); + } else { + // check whether the password has expired + if (changedAt.__type == 'Date') { + changedAt = new Date(changedAt.iso); } - }); + // Calculate the expiry time. + const expiresAt = new Date( + changedAt.getTime() + 86400000 * req.config.passwordPolicy.maxPasswordAge + ); + if (expiresAt < new Date()) + // fail of current time is past password expiry time + { throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Your password has expired. Please reset your password.' + ); } + } + } + + // Remove hidden properties. + UsersRouter.removeHiddenProperties(user); + + await req.config.filesController.expandFilesInObject(req.config, user); + + // Before login trigger; throws if failure + await maybeRunTrigger( + TriggerTypes.beforeLogin, + req.auth, + Parse.User.fromJSON(Object.assign({ className: '_User' }, user)), + null, + req.config, + req.info.context + ); + + // If we have some new validated authData update directly + if (validatedAuthData && Object.keys(validatedAuthData).length) { + const query = { objectId: user.objectId }; + // Prevent concurrent requests from both succeeding when consuming single-use + // tokens (e.g. MFA recovery codes or SMS OTP tokens) by extending the update + // WHERE clause with the original values of changed primitive/array fields. + applyAuthDataOptimisticLock(query, user.authData, validatedAuthData); + try { + await req.config.database.update('_User', query, { authData: validatedAuthData }, {}); + } catch (error) { + if (error.code === Parse.Error.OBJECT_NOT_FOUND) { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid auth data'); + } + throw error; + } + } + + const { sessionData, createSession } = RestWrite.createSession(req.config, { + userId: user.objectId, + createdWith: RestWrite.buildCreatedWith('login'), + installationId: req.info.installationId, + }); + + user.sessionToken = sessionData.sessionToken; + + await createSession(); + + const afterLoginUser = Parse.User.fromJSON(Object.assign({ className: '_User' }, user)); + await maybeRunTrigger( + TriggerTypes.afterLogin, + { ...req.auth, user: afterLoginUser }, + afterLoginUser, + null, + req.config, + req.info.context + ); + + // Re-fetch the user with the caller's auth context so that + // protectedFields and CLP apply correctly; if the caller used master key, + // protectedFields are bypassed, matching the behavior of GET /users/:id + const refetchAuth = + req.auth.isMaster || req.auth.isMaintenance + ? req.auth + : new Auth.Auth({ + config: req.config, + isMaster: false, + user: Parse.Object.fromJSON({ className: '_User', objectId: user.objectId }), + installationId: req.info.installationId, + }); + let filteredUser; + try { + const filteredUserResponse = await rest.get( + req.config, + refetchAuth, + '_User', + user.objectId, + {}, + req.info.clientSDK, + req.info.context + ); + filteredUser = filteredUserResponse.results?.[0]; + } catch { + // re-fetch may fail for legacy users without ACL; fall through + } + if (!filteredUser) { + filteredUser = user; + } + UsersRouter.removeHiddenProperties(filteredUser); + filteredUser.sessionToken = user.sessionToken; + if (authDataResponse) { + filteredUser.authDataResponse = authDataResponse; + } + + return { response: filteredUser }; } - handleLogIn(req) { - // Use query parameters instead if provided in url - if (!req.body.username && req.query.username) { - req.body = req.query; + /** + * This allows master-key clients to create user sessions without access to + * user credentials. This enables systems that can authenticate access another + * way (API key, app administrators) to act on a user's behalf. + * + * We create a new session rather than looking for an existing session; we + * want this to work in situations where the user is logged out on all + * devices, since this can be used by automated systems acting on the user's + * behalf. + * + * For the moment, we're omitting event hooks and lockout checks, since + * immediate use cases suggest /loginAs could be used for semantically + * different reasons from /login + */ + async handleLogInAs(req) { + if (!req.auth.isMaster) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + 'master key is required', + req.config + ); + } + if (req.auth.isReadOnly) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + "read-only masterKey isn't allowed to login as another user.", + req.config + ); } - // TODO: use the right error codes / descriptions. - if (!req.body.username) { - throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'username is required.'); + const userId = req.body?.userId || req.query.userId; + if (!userId) { + throw new Parse.Error( + Parse.Error.INVALID_VALUE, + 'userId must not be empty, null, or undefined' + ); } - if (!req.body.password) { - throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'password is required.'); + + const queryResults = await req.config.database.find('_User', { objectId: userId }); + const user = queryResults[0]; + if (!user) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'user not found'); } - let user; - return req.config.database.find('_User', { username: req.body.username }) - .then((results) => { - if (!results.length) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); - } - user = results[0]; + this._sanitizeAuthData(user); - if (req.config.verifyUserEmails && req.config.preventLoginWithUnverifiedEmail && !user.emailVerified) { - throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User email is not verified.'); - } + const { sessionData, createSession } = RestWrite.createSession(req.config, { + userId, + createdWith: RestWrite.buildCreatedWith('login', 'masterkey'), + installationId: req.info.installationId, + }); - return passwordCrypto.compare(req.body.password, user.password); - }).then((correct) => { + user.sessionToken = sessionData.sessionToken; - if (!correct) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); - } + await createSession(); - let token = 'r:' + cryptoUtils.newToken(); - user.sessionToken = token; - delete user.password; + return { response: user }; + } - // Sometimes the authData still has null on that keys - // https://github.com/ParsePlatform/parse-server/issues/935 - if (user.authData) { - Object.keys(user.authData).forEach((provider) => { - if (user.authData[provider] === null) { - delete user.authData[provider]; - } - }); - if (Object.keys(user.authData).length == 0) { - delete user.authData; - } + handleVerifyPassword(req) { + return this._authenticateUserFromRequest(req) + .then(async user => { + // Remove hidden properties. + UsersRouter.removeHiddenProperties(user); + // Re-fetch the user with the caller's auth context so that + // protectedFields and CLP apply correctly; if the caller used master key, + // protectedFields are bypassed, matching the behavior of GET /users/:id + const refetchAuth = + req.auth.isMaster || req.auth.isMaintenance + ? req.auth + : new Auth.Auth({ + config: req.config, + isMaster: false, + user: Parse.Object.fromJSON({ className: '_User', objectId: user.objectId }), + installationId: req.info.installationId, + }); + let filteredUser; + try { + const filteredUserResponse = await rest.get( + req.config, + refetchAuth, + '_User', + user.objectId, + {}, + req.info.clientSDK, + req.info.context + ); + filteredUser = filteredUserResponse.results?.[0]; + } catch { + // re-fetch may fail for legacy users without ACL; fall through } - - req.config.filesController.expandFilesInObject(req.config, user); - - let expiresAt = req.config.generateSessionExpiresAt(); - let sessionData = { - sessionToken: token, - user: { - __type: 'Pointer', - className: '_User', - objectId: user.objectId - }, - createdWith: { - 'action': 'login', - 'authProvider': 'password' - }, - restricted: false, - expiresAt: Parse._encode(expiresAt) - }; - - if (req.info.installationId) { - sessionData.installationId = req.info.installationId + if (!filteredUser) { + filteredUser = user; } - - let create = new RestWrite(req.config, Auth.master(req.config), '_Session', null, sessionData); - return create.execute(); - }).then(() => { - return { response: user }; + UsersRouter.removeHiddenProperties(filteredUser); + return { response: filteredUser }; + }) + .catch(error => { + throw error; }); } - handleLogOut(req) { - let success = {response: {}}; + async handleLogOut(req) { + const success = { response: {} }; if (req.info && req.info.sessionToken) { - return rest.find(req.config, Auth.master(req.config), '_Session', - { sessionToken: req.info.sessionToken }, undefined, req.info.clientSDK - ).then((records) => { - if (records.results && records.results.length) { - return rest.del(req.config, Auth.master(req.config), '_Session', - records.results[0].objectId - ).then(() => { - return Promise.resolve(success); - }); - } - return Promise.resolve(success); - }); + const records = await rest.find( + req.config, + Auth.master(req.config), + '_Session', + { sessionToken: req.info.sessionToken }, + undefined, + req.info.clientSDK, + req.info.context + ); + if (records.results && records.results.length) { + await rest.del( + req.config, + Auth.master(req.config), + '_Session', + records.results[0].objectId, + req.info.context + ); + await maybeRunTrigger( + TriggerTypes.afterLogout, + req.auth, + Parse.Session.fromJSON(Object.assign({ className: '_Session' }, records.results[0])), + null, + req.config + ); + } } - return Promise.resolve(success); + return success; } - handleResetRequest(req) { + _throwOnBadEmailConfig(req) { try { Config.validateEmailConfiguration({ emailAdapter: req.config.userController.adapter, appName: req.config.appName, - publicServerURL: req.config.publicServerURL, - emailVerifyTokenValidityDuration: req.config.emailVerifyTokenValidityDuration + publicServerURL: req.config.publicServerURL || req.config._publicServerURL, + emailVerifyTokenValidityDuration: req.config.emailVerifyTokenValidityDuration, + emailVerifyTokenReuseIfValid: req.config.emailVerifyTokenReuseIfValid, }); } catch (e) { if (typeof e === 'string') { // Maybe we need a Bad Configuration error, but the SDKs won't understand it. For now, Internal Server Error. - throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'An appName, publicServerURL, and emailAdapter are required for password reset functionality.'); + throw new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + 'An appName, publicServerURL, and emailAdapter are required for password reset and email verification functionality.' + ); } else { throw e; } } - let { email } = req.body; - if (!email) { - throw new Parse.Error(Parse.Error.EMAIL_MISSING, "you must provide an email"); - } - let userController = req.config.userController; - return userController.sendPasswordResetEmail(email).then(token => { - return Promise.resolve({ - response: {} - }); - }, err => { + } + + async handleResetRequest(req) { + this._throwOnBadEmailConfig(req); + + let email = req.body?.email; + const token = req.body?.token; + + if (!email && !token) { + throw new Parse.Error(Parse.Error.EMAIL_MISSING, 'you must provide an email'); + } + + if (token && typeof token !== 'string') { + throw new Parse.Error(Parse.Error.INVALID_VALUE, 'token must be a string'); + } + + let userResults = null; + let userData = null; + + // We can find the user using token + if (token) { + userResults = await req.config.database.find('_User', { + _perishable_token: token, + _perishable_token_expires_at: { $lt: Parse._encode(new Date()) }, + }); + if (userResults?.length > 0) { + userData = userResults[0]; + if (userData.email) { + email = userData.email; + } + } + // Or using email if no token provided + } else if (typeof email === 'string') { + userResults = await req.config.database.find( + '_User', + { $or: [{ email }, { username: email, email: { $exists: false } }] }, + { limit: 1 }, + Auth.maintenance(req.config) + ); + if (userResults?.length > 0) { + userData = userResults[0]; + } + } + + if (typeof email !== 'string') { + throw new Parse.Error( + Parse.Error.INVALID_EMAIL_ADDRESS, + 'you must provide a valid email string' + ); + } + + if (userData) { + this._sanitizeAuthData(userData); + // Get files attached to user + await req.config.filesController.expandFilesInObject(req.config, userData); + + const user = inflate('_User', userData); + + await maybeRunTrigger( + TriggerTypes.beforePasswordResetRequest, + req.auth, + user, + null, + req.config, + req.info.context + ); + } + + const userController = req.config.userController; + try { + await userController.sendPasswordResetEmail(email); + return { + response: {}, + }; + } catch (err) { if (err.code === Parse.Error.OBJECT_NOT_FOUND) { - throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, `No user found with email ${email}.`); - } else { - throw err; + if (req.config.passwordPolicy?.resetPasswordSuccessOnInvalidEmail ?? true) { + return { + response: {}, + }; + } + err.message = `A user with that email does not exist.`; } - }); + throw err; + } + } + + async handleVerificationEmailRequest(req) { + this._throwOnBadEmailConfig(req); + + const { email } = req.body || {}; + if (!email) { + throw new Parse.Error(Parse.Error.EMAIL_MISSING, 'you must provide an email'); + } + if (typeof email !== 'string') { + throw new Parse.Error( + Parse.Error.INVALID_EMAIL_ADDRESS, + 'you must provide a valid email string' + ); + } + + const verifyEmailSuccessOnInvalidEmail = req.config.emailVerifySuccessOnInvalidEmail ?? true; + + const results = await req.config.database.find('_User', { email: email }, {}, Auth.maintenance(req.config)); + if (!results.length || results.length < 1) { + if (verifyEmailSuccessOnInvalidEmail) { + return { response: {} }; + } + throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, `No user found with email ${email}`); + } + const user = results[0]; + + // remove password field, messes with saving on postgres + delete user.password; + + if (user.emailVerified) { + if (verifyEmailSuccessOnInvalidEmail) { + return { response: {} }; + } + throw new Parse.Error(Parse.Error.OTHER_CAUSE, `Email ${email} is already verified.`); + } + + const userController = req.config.userController; + const send = await userController.regenerateEmailVerifyToken(user, req.auth.isMaster, req.auth.installationId, req.ip); + if (send) { + userController.sendVerificationEmail(user, req); + } + return { response: {} }; } + async handleChallenge(req) { + const { username, email, password, authData, challengeData } = req.body || {}; + + // if username or email provided with password try to authenticate the user by username + let user; + if (username || email) { + if (!password) { + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + 'You provided username or email, you need to also provide password.' + ); + } + user = await this._authenticateUserFromRequest(req); + } + + if (!challengeData) { + throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'Nothing to challenge.'); + } + + if (typeof challengeData !== 'object') { + throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'challengeData should be an object.'); + } + + let request; + let parseUser; + + // Try to find user by authData + if (authData) { + if (typeof authData !== 'object') { + throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'authData should be an object.'); + } + if (user) { + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + 'You cannot provide username/email and authData, only use one identification method.' + ); + } + + for (const key of Object.keys(authData)) { + if (authData[key] !== null && (typeof authData[key] !== 'object' || Array.isArray(authData[key]))) { + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + `authData.${key} should be an object.` + ); + } + } + + if (Object.keys(authData).filter(key => authData[key] && authData[key].id).length > 1) { + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + 'You cannot provide more than one authData provider with an id.' + ); + } + + const results = await Auth.findUsersWithAuthData(req.config, authData); + + try { + if (!results[0] || results.length > 1) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'User not found.'); + } + // Find the provider used to find the user + const provider = Object.keys(authData).find(key => authData[key] && authData[key].id); + + parseUser = Parse.User.fromJSON({ className: '_User', ...results[0] }); + request = getRequestObject(undefined, req.auth, parseUser, parseUser, req.config); + request.isChallenge = true; + // Validate authData used to identify the user to avoid brute-force attack on `id` + const { validator } = req.config.authDataManager.getValidatorForProvider(provider); + const validatorResponse = await validator(authData[provider], req, parseUser, request); + if (validatorResponse && validatorResponse.validator) { + await validatorResponse.validator(); + } + } catch (e) { + // Rewrite the error to avoid guess id attack + logger.error(e); + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'User not found.'); + } + } + + if (!parseUser) { + parseUser = user ? Parse.User.fromJSON({ className: '_User', ...user }) : undefined; + } + + if (!request) { + request = getRequestObject(undefined, req.auth, parseUser, parseUser, req.config); + request.isChallenge = true; + } + const acc = {}; + // Execute challenge step-by-step with consistent order for better error feedback + // and to avoid to trigger others challenges if one of them fails + for (const provider of Object.keys(challengeData).sort()) { + try { + const authAdapter = req.config.authDataManager.getValidatorForProvider(provider); + if (!authAdapter) { + continue; + } + const { + adapter: { challenge }, + } = authAdapter; + if (typeof challenge === 'function') { + const providerChallengeResponse = await challenge( + challengeData[provider], + authData && authData[provider], + req.config.auth[provider], + request + ); + acc[provider] = providerChallengeResponse || true; + } + } catch (err) { + const e = resolveError(err, { + code: Parse.Error.SCRIPT_FAILED, + message: 'Challenge failed. Unknown error.', + }); + const userString = req.auth && req.auth.user ? req.auth.user.id : undefined; + logger.error( + `Failed running auth step challenge for ${provider} for user ${userString} with Error: ` + + JSON.stringify(e), + { + authenticationStep: 'challenge', + error: e, + user: userString, + provider, + } + ); + throw e; + } + } + return { response: { challengeData: acc } }; + } mountRoutes() { - this.route('GET', '/users', req => { return this.handleFind(req); }); - this.route('POST', '/users', req => { return this.handleCreate(req); }); - this.route('GET', '/users/me', req => { return this.handleMe(req); }); - this.route('GET', '/users/:objectId', req => { return this.handleGet(req); }); - this.route('PUT', '/users/:objectId', req => { return this.handleUpdate(req); }); - this.route('DELETE', '/users/:objectId', req => { return this.handleDelete(req); }); - this.route('GET', '/login', req => { return this.handleLogIn(req); }); - this.route('POST', '/logout', req => { return this.handleLogOut(req); }); - this.route('POST', '/requestPasswordReset', req => { return this.handleResetRequest(req); }) + this.route('GET', '/users', req => { + return this.handleFind(req); + }); + this.route('POST', '/users', promiseEnsureIdempotency, req => { + return this.handleCreate(req); + }); + this.route('GET', '/users/me', req => { + return this.handleMe(req); + }); + this.route('GET', '/users/:objectId', req => { + return this.handleGet(req); + }); + this.route('PUT', '/users/:objectId', promiseEnsureIdempotency, req => { + return this.handleUpdate(req); + }); + this.route('DELETE', '/users/:objectId', req => { + return this.handleDelete(req); + }); + this.route('GET', '/login', req => { + return this.handleLogIn(req); + }); + this.route('POST', '/login', req => { + return this.handleLogIn(req); + }); + this.route('POST', '/loginAs', req => { + return this.handleLogInAs(req); + }); + this.route('POST', '/logout', req => { + return this.handleLogOut(req); + }); + this.route('POST', '/requestPasswordReset', req => { + return this.handleResetRequest(req); + }); + this.route('POST', '/verificationEmailRequest', req => { + return this.handleVerificationEmailRequest(req); + }); + this.route('GET', '/verifyPassword', req => { + return this.handleVerifyPassword(req); + }); + this.route('POST', '/verifyPassword', req => { + return this.handleVerifyPassword(req); + }); + this.route('POST', '/challenge', req => { + return this.handleChallenge(req); + }); } } diff --git a/src/SchemaMigrations/DefinedSchemas.js b/src/SchemaMigrations/DefinedSchemas.js new file mode 100644 index 0000000000..0a94a5e4ad --- /dev/null +++ b/src/SchemaMigrations/DefinedSchemas.js @@ -0,0 +1,447 @@ +// @flow +// @flow-disable-next Cannot resolve module `parse/node`. +const Parse = require('parse/node'); +import { logger } from '../logger'; +import Config from '../Config'; +import { internalCreateSchema, internalUpdateSchema } from '../Routers/SchemasRouter'; +import { defaultColumns, systemClasses } from '../Controllers/SchemaController'; +import { ParseServerOptions } from '../Options'; +import * as Migrations from './Migrations'; +import Auth from '../Auth'; +import rest from '../rest'; + +export class DefinedSchemas { + config: ParseServerOptions; + schemaOptions: Migrations.SchemaOptions; + localSchemas: Migrations.JSONSchema[]; + retries: number; + maxRetries: number; + allCloudSchemas: Parse.Schema[]; + + constructor(schemaOptions: Migrations.SchemaOptions, config: ParseServerOptions) { + this.localSchemas = []; + this.config = Config.get(config.appId); + this.schemaOptions = schemaOptions; + if (schemaOptions && schemaOptions.definitions) { + if (!Array.isArray(schemaOptions.definitions)) { + throw `"schema.definitions" must be an array of schemas`; + } + + this.localSchemas = schemaOptions.definitions; + } + + this.retries = 0; + this.maxRetries = 3; + } + + async saveSchemaToDB(schema: Parse.Schema): Promise { + const payload = { + className: schema.className, + fields: schema._fields, + indexes: schema._indexes, + classLevelPermissions: schema._clp, + }; + await internalCreateSchema(schema.className, payload, this.config); + this.resetSchemaOps(schema); + } + + resetSchemaOps(schema: Parse.Schema) { + // Reset ops like SDK + schema._fields = {}; + schema._indexes = {}; + } + + // Simulate update like the SDK + // We cannot use SDK since routes are disabled + async updateSchemaToDB(schema: Parse.Schema) { + const payload = { + className: schema.className, + fields: schema._fields, + indexes: schema._indexes, + classLevelPermissions: schema._clp, + }; + await internalUpdateSchema(schema.className, payload, this.config); + this.resetSchemaOps(schema); + } + + async execute() { + try { + logger.info('Running Migrations'); + if (this.schemaOptions && this.schemaOptions.beforeMigration) { + await Promise.resolve(this.schemaOptions.beforeMigration()); + } + + await this.executeMigrations(); + + if (this.schemaOptions && this.schemaOptions.afterMigration) { + await Promise.resolve(this.schemaOptions.afterMigration()); + } + + logger.info('Running Migrations Completed'); + } catch (e) { + logger.error(`Failed to run migrations: ${e}`); + if (process.env.NODE_ENV === 'production') { process.exit(1); } + } + } + + async executeMigrations() { + let timeout = null; + try { + // Set up a time out in production + // if we fail to get schema + // pm2 or K8s and many other process managers will try to restart the process + // after the exit + if (process.env.NODE_ENV === 'production') { + timeout = setTimeout(() => { + logger.error('Timeout occurred during execution of migrations. Exiting...'); + process.exit(1); + }, 20000); + } + + await this.createDeleteSession(); + // @flow-disable-next-line + const schemaController = await this.config.database.loadSchema(); + this.allCloudSchemas = await schemaController.getAllClasses(); + clearTimeout(timeout); + await Promise.all(this.localSchemas.map(async localSchema => this.saveOrUpdate(localSchema))); + + this.checkForMissingSchemas(); + await this.enforceCLPForNonProvidedClass(); + } catch (e) { + if (timeout) { clearTimeout(timeout); } + if (this.retries < this.maxRetries) { + this.retries++; + // first retry 1sec, 2sec, 3sec total 6sec retry sequence + // retry will only happen in case of deploying multi parse server instance + // at the same time. Modern systems like k8 avoid this by doing rolling updates + await this.wait(1000 * this.retries); + await this.executeMigrations(); + } else { + logger.error(`Failed to run migrations: ${e}`); + if (process.env.NODE_ENV === 'production') { process.exit(1); } + } + } + } + + checkForMissingSchemas() { + if (this.schemaOptions.strict !== true) { + return; + } + + const cloudSchemas = this.allCloudSchemas.map(s => s.className); + const localSchemas = this.localSchemas.map(s => s.className); + const missingSchemas = cloudSchemas.filter( + c => !localSchemas.includes(c) && !systemClasses.includes(c) + ); + + if (new Set(localSchemas).size !== localSchemas.length) { + logger.error( + `The list of schemas provided contains duplicated "className" "${localSchemas.join( + '","' + )}"` + ); + process.exit(1); + } + + if (this.schemaOptions.strict && missingSchemas.length) { + logger.warn( + `The following schemas are currently present in the database, but not explicitly defined in a schema: "${missingSchemas.join( + '", "' + )}"` + ); + } + } + + // Required for testing purpose + wait(time: number) { + return new Promise(resolve => setTimeout(resolve, time)); + } + + async enforceCLPForNonProvidedClass(): Promise { + const nonProvidedClasses = this.allCloudSchemas.filter( + cloudSchema => + !this.localSchemas.some(localSchema => localSchema.className === cloudSchema.className) + ); + await Promise.all( + nonProvidedClasses.map(async schema => { + const parseSchema = new Parse.Schema(schema.className); + this.handleCLP(schema, parseSchema); + await this.updateSchemaToDB(parseSchema); + }) + ); + } + + // Create a fake session since Parse do not create the _Session until + // a session is created + async createDeleteSession() { + const { response } = await rest.create(this.config, Auth.master(this.config), '_Session', {}); + await rest.del(this.config, Auth.master(this.config), '_Session', response.objectId); + } + + async saveOrUpdate(localSchema: Migrations.JSONSchema) { + const cloudSchema = this.allCloudSchemas.find(sc => sc.className === localSchema.className); + if (cloudSchema) { + try { + await this.updateSchema(localSchema, cloudSchema); + } catch (e) { + throw `Error during update of schema for type ${cloudSchema.className}: ${e}`; + } + } else { + try { + await this.saveSchema(localSchema); + } catch (e) { + throw `Error while saving Schema for type ${localSchema.className}: ${e}`; + } + } + } + + async saveSchema(localSchema: Migrations.JSONSchema) { + const newLocalSchema = new Parse.Schema(localSchema.className); + if (localSchema.fields) { + // Handle fields + Object.keys(localSchema.fields) + .filter(fieldName => !this.isProtectedFields(localSchema.className, fieldName)) + .forEach(fieldName => { + if (localSchema.fields) { + const field = localSchema.fields[fieldName]; + this.handleFields(newLocalSchema, fieldName, field); + } + }); + } + // Handle indexes + if (localSchema.indexes) { + Object.keys(localSchema.indexes).forEach(indexName => { + if (localSchema.indexes && !this.isProtectedIndex(localSchema.className, indexName)) { + newLocalSchema.addIndex(indexName, localSchema.indexes[indexName]); + } + }); + } + + this.handleCLP(localSchema, newLocalSchema); + + return await this.saveSchemaToDB(newLocalSchema); + } + + async updateSchema(localSchema: Migrations.JSONSchema, cloudSchema: Parse.Schema) { + const newLocalSchema = new Parse.Schema(localSchema.className); + + // Handle fields + // Check addition + if (localSchema.fields) { + Object.keys(localSchema.fields) + .filter(fieldName => !this.isProtectedFields(localSchema.className, fieldName)) + .forEach(fieldName => { + // @flow-disable-next + const field = localSchema.fields[fieldName]; + if (!cloudSchema.fields[fieldName]) { + this.handleFields(newLocalSchema, fieldName, field); + } + }); + } + + const fieldsToDelete: string[] = []; + const fieldsToRecreate: { + fieldName: string, + from: { type: string, targetClass?: string }, + to: { type: string, targetClass?: string }, + }[] = []; + const fieldsWithChangedParams: string[] = []; + + // Check deletion + Object.keys(cloudSchema.fields) + .filter(fieldName => !this.isProtectedFields(localSchema.className, fieldName)) + .forEach(fieldName => { + const field = cloudSchema.fields[fieldName]; + if (!localSchema.fields || !localSchema.fields[fieldName]) { + fieldsToDelete.push(fieldName); + return; + } + + const localField = localSchema.fields[fieldName]; + // Check if field has a changed type + if ( + !this.paramsAreEquals( + { type: field.type, targetClass: field.targetClass }, + { type: localField.type, targetClass: localField.targetClass } + ) + ) { + fieldsToRecreate.push({ + fieldName, + from: { type: field.type, targetClass: field.targetClass }, + to: { type: localField.type, targetClass: localField.targetClass }, + }); + return; + } + + // Check if something changed other than the type (like required, defaultValue) + if (!this.paramsAreEquals(field, localField)) { + fieldsWithChangedParams.push(fieldName); + } + }); + + if (this.schemaOptions.deleteExtraFields === true) { + fieldsToDelete.forEach(fieldName => { + newLocalSchema.deleteField(fieldName); + }); + + // Delete fields from the schema then apply changes + await this.updateSchemaToDB(newLocalSchema); + } else if (this.schemaOptions.strict === true && fieldsToDelete.length) { + logger.warn( + `The following fields exist in the database for "${ + localSchema.className + }", but are missing in the schema : "${fieldsToDelete.join('" ,"')}"` + ); + } + + if (this.schemaOptions.recreateModifiedFields === true) { + fieldsToRecreate.forEach(field => { + newLocalSchema.deleteField(field.fieldName); + }); + + // Delete fields from the schema then apply changes + await this.updateSchemaToDB(newLocalSchema); + + fieldsToRecreate.forEach(fieldInfo => { + if (localSchema.fields) { + const field = localSchema.fields[fieldInfo.fieldName]; + this.handleFields(newLocalSchema, fieldInfo.fieldName, field); + } + }); + } else if (this.schemaOptions.strict === true && fieldsToRecreate.length) { + fieldsToRecreate.forEach(field => { + const from = + field.from.type + (field.from.targetClass ? ` (${field.from.targetClass})` : ''); + const to = field.to.type + (field.to.targetClass ? ` (${field.to.targetClass})` : ''); + + logger.warn( + `The field "${field.fieldName}" type differ between the schema and the database for "${localSchema.className}"; Schema is defined as "${to}" and current database type is "${from}"` + ); + }); + } + + fieldsWithChangedParams.forEach(fieldName => { + if (localSchema.fields) { + const field = localSchema.fields[fieldName]; + this.handleFields(newLocalSchema, fieldName, field); + } + }); + + // Handle Indexes + // Check addition + if (localSchema.indexes) { + Object.keys(localSchema.indexes).forEach(indexName => { + if ( + (!cloudSchema.indexes || !cloudSchema.indexes[indexName]) && + !this.isProtectedIndex(localSchema.className, indexName) + ) { + if (localSchema.indexes) { + newLocalSchema.addIndex(indexName, localSchema.indexes[indexName]); + } + } + }); + } + + const indexesToAdd = []; + + // Check deletion + if (cloudSchema.indexes) { + Object.keys(cloudSchema.indexes).forEach(indexName => { + if (!this.isProtectedIndex(localSchema.className, indexName)) { + if (!localSchema.indexes || !localSchema.indexes[indexName]) { + // If keepUnknownIndex is falsy, then delete all unknown indexes from the db. + if(!this.schemaOptions.keepUnknownIndexes){ + newLocalSchema.deleteIndex(indexName); + } + } else if ( + !this.paramsAreEquals(localSchema.indexes[indexName], cloudSchema.indexes[indexName]) + ) { + newLocalSchema.deleteIndex(indexName); + if (localSchema.indexes) { + indexesToAdd.push({ + indexName, + index: localSchema.indexes[indexName], + }); + } + } + } + }); + } + + this.handleCLP(localSchema, newLocalSchema, cloudSchema); + // Apply changes + await this.updateSchemaToDB(newLocalSchema); + // Apply new/changed indexes + if (indexesToAdd.length) { + logger.debug( + `Updating indexes for "${newLocalSchema.className}" : ${indexesToAdd.join(' ,')}` + ); + indexesToAdd.forEach(o => newLocalSchema.addIndex(o.indexName, o.index)); + await this.updateSchemaToDB(newLocalSchema); + } + } + + handleCLP( + localSchema: Migrations.JSONSchema, + newLocalSchema: Parse.Schema, + cloudSchema: Parse.Schema + ) { + if (!localSchema.classLevelPermissions && !cloudSchema) { + logger.warn(`classLevelPermissions not provided for ${localSchema.className}.`); + } + // Use spread to avoid read only issue (encountered by Moumouls using directAccess) + const clp = ({ ...localSchema.classLevelPermissions || {} }: Parse.CLP.PermissionsMap); + // To avoid inconsistency we need to remove all rights on addField + clp.addField = {}; + newLocalSchema.setCLP(clp); + } + + isProtectedFields(className: string, fieldName: string) { + return ( + !!defaultColumns._Default[fieldName] || + !!(defaultColumns[className] && defaultColumns[className][fieldName]) + ); + } + + isProtectedIndex(className: string, indexName: string) { + const indexes = ['_id_']; + switch (className) { + case '_User': + indexes.push( + 'case_insensitive_username', + 'case_insensitive_email', + 'username_1', + 'email_1' + ); + break; + case '_Role': + indexes.push('name_1'); + break; + + case '_Idempotency': + indexes.push('reqId_1'); + break; + } + + return indexes.indexOf(indexName) !== -1; + } + + paramsAreEquals(objA: T, objB: T) { + const keysA: string[] = Object.keys(objA); + const keysB: string[] = Object.keys(objB); + + // Check key name + if (keysA.length !== keysB.length) { return false; } + return keysA.every(k => objA[k] === objB[k]); + } + + handleFields(newLocalSchema: Parse.Schema, fieldName: string, field: Migrations.FieldType) { + if (field.type === 'Relation') { + newLocalSchema.addRelation(fieldName, field.targetClass); + } else if (field.type === 'Pointer') { + newLocalSchema.addPointer(fieldName, field.targetClass, field); + } else { + newLocalSchema.addField(fieldName, field.type, field); + } + } +} diff --git a/src/SchemaMigrations/Migrations.js b/src/SchemaMigrations/Migrations.js new file mode 100644 index 0000000000..23499bdba7 --- /dev/null +++ b/src/SchemaMigrations/Migrations.js @@ -0,0 +1,95 @@ +// @flow + +export interface SchemaOptions { + definitions: JSONSchema[]; + strict: ?boolean; + deleteExtraFields: ?boolean; + recreateModifiedFields: ?boolean; + lockSchemas: ?boolean; + keepUnknownIndexes: ?boolean; + beforeMigration: ?() => void | Promise; + afterMigration: ?() => void | Promise; +} + +export type FieldValueType = + | 'String' + | 'Boolean' + | 'File' + | 'Number' + | 'Relation' + | 'Pointer' + | 'Date' + | 'GeoPoint' + | 'Polygon' + | 'Array' + | 'Object' + | 'ACL'; + +export interface FieldType { + type: FieldValueType; + required?: boolean; + defaultValue?: mixed; + targetClass?: string; +} + +type ClassNameType = '_User' | '_Role' | string; + +export interface ProtectedFieldsInterface { + [key: string]: string[]; +} + +export interface IndexInterface { + [key: string]: number; +} + +export interface IndexesInterface { + [key: string]: IndexInterface; +} + +export type CLPOperation = 'find' | 'count' | 'get' | 'update' | 'create' | 'delete'; +// @Typescript 4.1+ // type CLPPermission = 'requiresAuthentication' | '*' | `user:${string}` | `role:${string}` + +type CLPValue = { [key: string]: boolean }; +type CLPData = { [key: string]: CLPOperation[] }; +type CLPInterface = { [key: string]: CLPValue }; + +export interface JSONSchema { + className: ClassNameType; + fields?: { [key: string]: FieldType }; + indexes?: IndexesInterface; + classLevelPermissions?: { + find?: CLPValue, + count?: CLPValue, + get?: CLPValue, + update?: CLPValue, + create?: CLPValue, + delete?: CLPValue, + addField?: CLPValue, + protectedFields?: ProtectedFieldsInterface, + }; +} + +export class CLP { + static allow(perms: { [key: string]: CLPData }): CLPInterface { + const out = {}; + + for (const [perm, ops] of Object.entries(perms)) { + // @flow-disable-next Property `@@iterator` is missing in mixed [1] but exists in `$Iterable` [2]. + for (const op of ops) { + out[op] = out[op] || {}; + out[op][perm] = true; + } + } + + return out; + } +} + +export function makeSchema(className: ClassNameType, schema: JSONSchema): JSONSchema { + // This function solve two things: + // 1. It provides auto-completion to the users who are implementing schemas + // 2. It allows forward-compatible point in order to allow future changes to the internal structure of JSONSchema without affecting all the users + schema.className = className; + + return schema; +} diff --git a/src/Security/Check.js b/src/Security/Check.js new file mode 100644 index 0000000000..c7c3aba5e4 --- /dev/null +++ b/src/Security/Check.js @@ -0,0 +1,85 @@ +/** + * @module SecurityCheck + */ + +import Utils from '../Utils'; +import { isFunction, isString } from 'lodash'; + +/** + * A security check. + * @class + */ +class Check { + /** + * Constructs a new security check. + * @param {Object} params The parameters. + * @param {String} params.title The title. + * @param {String} params.warning The warning message if the check fails. + * @param {String} params.solution The solution to fix the check. + * @param {Promise} params.check The check as synchronous or asynchronous function. + */ + constructor(params) { + this._validateParams(params); + const { title, warning, solution, check } = params; + + this.title = title; + this.warning = warning; + this.solution = solution; + this.check = check; + + // Set default properties + this._checkState = CheckState.none; + this.error; + } + + /** + * Returns the current check state. + * @return {CheckState} The check state. + */ + checkState() { + return this._checkState; + } + + async run() { + // Get check as synchronous or asynchronous function + const check = Utils.isPromise(this.check) ? await this.check : this.check; + + // Run check + try { + check(); + this._checkState = CheckState.success; + } catch (e) { + this.stateFailError = e; + this._checkState = CheckState.fail; + } + } + + /** + * Validates the constructor parameters. + * @param {Object} params The parameters to validate. + */ + _validateParams(params) { + Utils.validateParams(params, { + group: { t: 'string', v: isString }, + title: { t: 'string', v: isString }, + warning: { t: 'string', v: isString }, + solution: { t: 'string', v: isString }, + check: { t: 'function', v: isFunction }, + }); + } +} + +/** + * The check state. + */ +const CheckState = Object.freeze({ + none: 'none', + fail: 'fail', + success: 'success', +}); + +export default Check; +module.exports = { + Check, + CheckState, +}; diff --git a/src/Security/CheckGroup.js b/src/Security/CheckGroup.js new file mode 100644 index 0000000000..d8e3f8e08e --- /dev/null +++ b/src/Security/CheckGroup.js @@ -0,0 +1,42 @@ +/** + * A group of security checks. + * @interface + * @memberof module:SecurityCheck + */ +class CheckGroup { + constructor() { + this._name = this.setName(); + this._checks = this.setChecks(); + } + + /** + * The security check group name; to be overridden by child class. + */ + setName() { + throw `Check group has no name.`; + } + name() { + return this._name; + } + + /** + * The security checks; to be overridden by child class. + */ + setChecks() { + throw `Check group has no checks.`; + } + checks() { + return this._checks; + } + + /** + * Runs all checks. + */ + async run() { + for (const check of this._checks) { + check.run(); + } + } +} + +module.exports = CheckGroup; diff --git a/src/Security/CheckGroups/CheckGroupDatabase.js b/src/Security/CheckGroups/CheckGroupDatabase.js new file mode 100644 index 0000000000..bc57fef8a3 --- /dev/null +++ b/src/Security/CheckGroups/CheckGroupDatabase.js @@ -0,0 +1,45 @@ +import { Check } from '../Check'; +import CheckGroup from '../CheckGroup'; +import Config from '../../Config'; +import Parse from 'parse/node'; + +/** + * The security checks group for Parse Server configuration. + * Checks common Parse Server parameters such as access keys + * @memberof module:SecurityCheck + */ +class CheckGroupDatabase extends CheckGroup { + setName() { + return 'Database'; + } + setChecks() { + const config = Config.get(Parse.applicationId); + const databaseAdapter = config.database.adapter; + const databaseUrl = databaseAdapter._uri; + return [ + new Check({ + title: 'Secure database password', + warning: 'The database password is insecure and vulnerable to brute force attacks.', + solution: + 'Choose a longer and/or more complex password with a combination of upper- and lowercase characters, numbers and special characters.', + check: () => { + const password = databaseUrl.match(/\/\/\S+:(\S+)@/)[1]; + const hasUpperCase = /[A-Z]/.test(password); + const hasLowerCase = /[a-z]/.test(password); + const hasNumbers = /\d/.test(password); + const hasNonAlphasNumerics = /\W/.test(password); + // Ensure length + if (password.length < 14) { + throw 1; + } + // Ensure at least 3 out of 4 requirements passed + if (hasUpperCase + hasLowerCase + hasNumbers + hasNonAlphasNumerics < 3) { + throw 1; + } + }, + }), + ]; + } +} + +module.exports = CheckGroupDatabase; diff --git a/src/Security/CheckGroups/CheckGroupServerConfig.js b/src/Security/CheckGroups/CheckGroupServerConfig.js new file mode 100644 index 0000000000..345c0617f6 --- /dev/null +++ b/src/Security/CheckGroups/CheckGroupServerConfig.js @@ -0,0 +1,194 @@ +import { Check } from '../Check'; +import CheckGroup from '../CheckGroup'; +import Config from '../../Config'; +import Parse from 'parse/node'; + +/** + * The security checks group for Parse Server configuration. + * Checks common Parse Server parameters such as access keys. + * @memberof module:SecurityCheck + */ +class CheckGroupServerConfig extends CheckGroup { + setName() { + return 'Parse Server Configuration'; + } + setChecks() { + const config = Config.get(Parse.applicationId); + return [ + new Check({ + title: 'Secure master key', + warning: 'The Parse Server master key is insecure and vulnerable to brute force attacks.', + solution: + 'Choose a longer and/or more complex master key with a combination of upper- and lowercase characters, numbers and special characters.', + check: () => { + const masterKey = config.masterKey; + const hasUpperCase = /[A-Z]/.test(masterKey); + const hasLowerCase = /[a-z]/.test(masterKey); + const hasNumbers = /\d/.test(masterKey); + const hasNonAlphasNumerics = /\W/.test(masterKey); + // Ensure length + if (masterKey.length < 14) { + throw 1; + } + // Ensure at least 3 out of 4 requirements passed + if (hasUpperCase + hasLowerCase + hasNumbers + hasNonAlphasNumerics < 3) { + throw 1; + } + }, + }), + new Check({ + title: 'Security log disabled', + warning: + 'Security checks in logs may expose vulnerabilities to anyone with access to logs.', + solution: "Change Parse Server configuration to 'security.enableCheckLog: false'.", + check: () => { + if (config.security && config.security.enableCheckLog) { + throw 1; + } + }, + }), + new Check({ + title: 'Client class creation disabled', + warning: + 'Attackers are allowed to create new classes without restriction and flood the database.', + solution: "Change Parse Server configuration to 'allowClientClassCreation: false'.", + check: () => { + if (config.allowClientClassCreation || config.allowClientClassCreation == null) { + throw 1; + } + }, + }), + new Check({ + title: 'Users are created without public access', + warning: + 'Users with public read access are exposed to anyone who knows their object IDs, or to anyone who can query the Parse.User class.', + solution: "Change Parse Server configuration to 'enforcePrivateUsers: true'.", + check: () => { + if (!config.enforcePrivateUsers) { + throw 1; + } + }, + }), + new Check({ + title: 'Insecure auth adapters disabled', + warning: + "Attackers may explore insecure auth adapters' vulnerabilities and log in on behalf of another user.", + solution: "Change Parse Server configuration to 'enableInsecureAuthAdapters: false'.", + check: () => { + if (config.enableInsecureAuthAdapters !== false) { + throw 1; + } + }, + }), + new Check({ + title: 'GraphQL public introspection disabled', + warning: 'GraphQL public introspection is enabled, which allows anyone to access the GraphQL schema.', + solution: "Change Parse Server configuration to 'graphQLPublicIntrospection: false'. You will need to use master key or maintenance key to access the GraphQL schema.", + check: () => { + if (config.graphQLPublicIntrospection !== false) { + throw 1; + } + }, + }), + new Check({ + title: 'GraphQL Playground disabled', + warning: + 'GraphQL Playground is enabled and exposes the master key in the browser page.', + solution: + "Change Parse Server configuration to 'mountPlayground: false'. Use Parse Dashboard for GraphQL exploration in production.", + check: () => { + if (config.mountPlayground) { + throw 1; + } + }, + }), + new Check({ + title: 'Public database explain disabled', + warning: + 'Database explain queries are publicly accessible, which may expose sensitive database performance information and schema details.', + solution: + "Change Parse Server configuration to 'databaseOptions.allowPublicExplain: false'. You will need to use master key to run explain queries.", + check: () => { + if ( + config.databaseOptions?.allowPublicExplain === true || + config.databaseOptions?.allowPublicExplain == null + ) { + throw 1; + } + }, + }), + new Check({ + title: 'Read-only master key IP range restricted', + warning: + 'The read-only master key can be used from any IP address, which increases the attack surface if the key is compromised.', + solution: + "Change Parse Server configuration to 'readOnlyMasterKeyIps: [\"127.0.0.1\", \"::1\"]' to restrict access to localhost, or set it to a list of specific IP addresses.", + check: () => { + if (!config.readOnlyMasterKey) { + return; + } + const ips = config.readOnlyMasterKeyIps || []; + const wildcards = ['0.0.0.0/0', '0.0.0.0', '::/0', '::', '::0']; + if (ips.some(ip => wildcards.includes(ip))) { + throw 1; + } + }, + }), + new Check({ + title: 'Request complexity limits enabled', + warning: + 'One or more request complexity limits are disabled, which may allow denial-of-service attacks through deeply nested or excessively broad queries.', + solution: + "Ensure all properties in 'requestComplexity' are set to positive integers. Set to '-1' only if you have other mitigations in place.", + check: () => { + const rc = config.requestComplexity; + if (!rc) { + throw 1; + } + const values = [rc.includeDepth, rc.includeCount, rc.subqueryDepth, rc.queryDepth, rc.graphQLDepth, rc.graphQLFields, rc.batchRequestLimit]; + if (values.some(v => v === -1)) { + throw 1; + } + }, + }), + new Check({ + title: 'Password reset endpoint user enumeration mitigated', + warning: + 'The password reset endpoint returns distinct error responses for invalid email addresses, which allows attackers to enumerate registered users.', + solution: + "Change Parse Server configuration to 'passwordPolicy.resetPasswordSuccessOnInvalidEmail: true'.", + check: () => { + if (config.passwordPolicy?.resetPasswordSuccessOnInvalidEmail === false) { + throw 1; + } + }, + }), + new Check({ + title: 'Email verification endpoint user enumeration mitigated', + warning: + 'The email verification endpoint returns distinct error responses for invalid email addresses, which allows attackers to enumerate registered users.', + solution: + "Change Parse Server configuration to 'emailVerifySuccessOnInvalidEmail: true'.", + check: () => { + if (config.emailVerifySuccessOnInvalidEmail === false) { + throw 1; + } + }, + }), + new Check({ + title: 'LiveQuery regex timeout enabled', + warning: + 'LiveQuery regex timeout is disabled. A malicious client can subscribe with a crafted $regex pattern that causes catastrophic backtracking, blocking the Node.js event loop and making the server unresponsive.', + solution: + "Change Parse Server configuration to 'liveQuery.regexTimeout: 100' to set a 100ms timeout for regex evaluation in LiveQuery.", + check: () => { + if (config.liveQuery?.classNames?.length > 0 && config.liveQuery?.regexTimeout === 0) { + throw 1; + } + }, + }), + ]; + } +} + +module.exports = CheckGroupServerConfig; diff --git a/src/Security/CheckGroups/CheckGroups.js b/src/Security/CheckGroups/CheckGroups.js new file mode 100644 index 0000000000..36f90e019c --- /dev/null +++ b/src/Security/CheckGroups/CheckGroups.js @@ -0,0 +1,9 @@ +/** + * @memberof module:SecurityCheck + */ + +/** + * The list of security check groups. + */ +export { default as CheckGroupDatabase } from './CheckGroupDatabase'; +export { default as CheckGroupServerConfig } from './CheckGroupServerConfig'; diff --git a/src/Security/CheckRunner.js b/src/Security/CheckRunner.js new file mode 100644 index 0000000000..4be1c3acbf --- /dev/null +++ b/src/Security/CheckRunner.js @@ -0,0 +1,206 @@ +import Utils from '../Utils'; +import { CheckState } from './Check'; +import * as CheckGroups from './CheckGroups/CheckGroups'; +import logger from '../logger'; +import { isArray, isBoolean } from 'lodash'; + +/** + * The security check runner. + * @memberof module:SecurityCheck + */ +class CheckRunner { + /** + * The security check runner. + * @param {Object} [config] The configuration options. + * @param {Boolean} [config.enableCheck=false] Is true if Parse Server should report weak security settings. + * @param {Boolean} [config.enableCheckLog=false] Is true if the security check report should be written to logs. + * @param {Object} [config.checkGroups] The check groups to run. Default are the groups defined in `./CheckGroups/CheckGroups.js`. + */ + constructor(config = {}) { + this._validateParams(config); + const { enableCheck = false, enableCheckLog = false, checkGroups = CheckGroups } = config; + this.enableCheck = enableCheck; + this.enableCheckLog = enableCheckLog; + this.checkGroups = checkGroups; + } + + /** + * Runs all security checks and returns the results. + * @params + * @returns {Object} The security check report. + */ + async run({ version = '1.0.0' } = {}) { + // Instantiate check groups + const groups = Object.values(this.checkGroups) + .filter(c => typeof c === 'function') + .map(CheckGroup => new CheckGroup()); + + // Run checks + groups.forEach(group => group.run()); + + // Generate JSON report + const report = this._generateReport({ groups, version }); + + // If report should be written to logs + if (this.enableCheckLog) { + this._logReport(report); + } + return report; + } + + /** + * Generates a security check report in JSON format with schema: + * ``` + * { + * report: { + * version: "1.0.0", // The report version, defines the schema + * state: "fail" // The disjunctive indicator of failed checks in all groups. + * groups: [ // The check groups + * { + * name: "House", // The group name + * state: "fail" // The disjunctive indicator of failed checks in this group. + * checks: [ // The checks + * title: "Door locked", // The check title + * state: "fail" // The check state + * warning: "Anyone can enter your house." // The warning. + * solution: "Lock your door." // The solution. + * ] + * }, + * ... + * ] + * } + * } + * ``` + * @param {Object} params The parameters. + * @param {Array} params.groups The check groups. + * @param {String} params.version: The report schema version. + * @returns {Object} The report. + */ + _generateReport({ groups, version }) { + // Create report template + const report = { + report: { + version, + state: CheckState.success, + groups: [], + }, + }; + + // Identify report version + switch (version) { + case '1.0.0': + default: + // For each check group + for (const group of groups) { + // Create group report + const groupReport = { + name: group.name(), + state: CheckState.success, + checks: [], + }; + + // Create check reports + groupReport.checks = group.checks().map(check => { + const checkReport = { + title: check.title, + state: check.checkState(), + }; + if (check.checkState() == CheckState.fail) { + checkReport.warning = check.warning; + checkReport.solution = check.solution; + report.report.state = CheckState.fail; + groupReport.state = CheckState.fail; + } + return checkReport; + }); + + report.report.groups.push(groupReport); + } + } + return report; + } + + /** + * Logs the security check report. + * @param {Object} report The report to log. + */ + _logReport(report) { + // Determine log level depending on whether any check failed + const log = + report.report.state == CheckState.success ? s => logger.info(s) : s => logger.warn(s); + + // Declare output + const indent = ' '; + let output = ''; + let checksCount = 0; + let failedChecksCount = 0; + let skippedCheckCount = 0; + + // Traverse all groups and checks for compose output + for (const group of report.report.groups) { + output += `\n- ${group.name}`; + + for (const check of group.checks) { + checksCount++; + output += `\n${indent}${this._getLogIconForState(check.state)} ${check.title}`; + + if (check.state == CheckState.fail) { + failedChecksCount++; + output += `\n${indent}${indent}Warning: ${check.warning}`; + output += ` ${check.solution}`; + } else if (check.state == CheckState.none) { + skippedCheckCount++; + output += `\n${indent}${indent}Test did not execute, this is likely an internal server issue, please report.`; + } + } + } + + output = + `\n###################################` + + `\n# #` + + `\n# Parse Server Security Check #` + + `\n# #` + + `\n###################################` + + `\n` + + `\n${ + failedChecksCount > 0 ? 'Warning: ' : '' + }${failedChecksCount} weak security setting(s) found${failedChecksCount > 0 ? '!' : ''}` + + `\n${checksCount} check(s) executed` + + `\n${skippedCheckCount} check(s) skipped` + + `\n` + + `${output}`; + + // Write log + log(output); + } + + /** + * Returns an icon for use in the report log output. + * @param {CheckState} state The check state. + * @returns {String} The icon. + */ + _getLogIconForState(state) { + switch (state) { + case CheckState.success: + return '✅'; + case CheckState.fail: + return '❌'; + default: + return 'â„šī¸'; + } + } + + /** + * Validates the constructor parameters. + * @param {Object} params The parameters to validate. + */ + _validateParams(params) { + Utils.validateParams(params, { + enableCheck: { t: 'boolean', v: isBoolean, o: true }, + enableCheckLog: { t: 'boolean', v: isBoolean, o: true }, + checkGroups: { t: 'array', v: isArray, o: true }, + }); + } +} + +module.exports = CheckRunner; diff --git a/src/SharedRest.js b/src/SharedRest.js new file mode 100644 index 0000000000..e36a9703ea --- /dev/null +++ b/src/SharedRest.js @@ -0,0 +1,59 @@ +const classesWithMasterOnlyAccess = [ + '_JobStatus', + '_PushStatus', + '_Hooks', + '_GlobalConfig', + '_GraphQLConfig', + '_JobSchedule', + '_Audience', + '_Idempotency', +]; +const { createSanitizedError } = require('./Error'); + +// Disallowing access to the _Role collection except by master key +function enforceRoleSecurity(method, className, auth, config) { + if (className === '_Installation' && !auth.isMaster && !auth.isMaintenance) { + if (method === 'delete' || method === 'find') { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + `Clients aren't allowed to perform the ${method} operation on the installation collection.`, + config + ); + } + } + + //all volatileClasses are masterKey only + if ( + classesWithMasterOnlyAccess.indexOf(className) >= 0 && + !auth.isMaster && + !auth.isMaintenance + ) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + `Clients aren't allowed to perform the ${method} operation on the ${className} collection.`, + config + ); + } + + // _Join tables are internal and must only be modified through relation operations + if (className.startsWith('_Join:') && !auth.isMaster && !auth.isMaintenance) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + `Clients aren't allowed to perform the ${method} operation on the ${className} collection.`, + config + ); + } + + // readOnly masterKey is not allowed + if (auth.isReadOnly && (method === 'delete' || method === 'create' || method === 'update')) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + `read-only masterKey isn't allowed to perform the ${method} operation.`, + config + ); + } +} + +module.exports = { + enforceRoleSecurity, +}; diff --git a/src/StatusHandler.js b/src/StatusHandler.js new file mode 100644 index 0000000000..05c4d09e75 --- /dev/null +++ b/src/StatusHandler.js @@ -0,0 +1,343 @@ +import { md5Hash, newObjectId } from './cryptoUtils'; +import Utils from './Utils'; +import { KeyPromiseQueue } from './KeyPromiseQueue'; +import { logger } from './logger'; +import rest from './rest'; +import Auth from './Auth'; + +const PUSH_STATUS_COLLECTION = '_PushStatus'; +const JOB_STATUS_COLLECTION = '_JobStatus'; + +const pushPromiseQueue = new KeyPromiseQueue(); +const jobPromiseQueue = new KeyPromiseQueue(); + +const incrementOp = function (object = {}, key, amount = 1) { + if (!object[key]) { + object[key] = { __op: 'Increment', amount: amount }; + } else { + object[key].amount += amount; + } + return object[key]; +}; + +export function flatten(array) { + var flattened = []; + for (var i = 0; i < array.length; i++) { + if (Array.isArray(array[i])) { + flattened = flattened.concat(flatten(array[i])); + } else { + flattened.push(array[i]); + } + } + return flattened; +} + +function statusHandler(className, database) { + function create(object) { + return database.create(className, object).then(() => { + return Promise.resolve(object); + }); + } + + function update(where, object) { + return jobPromiseQueue.enqueue(where.objectId, () => database.update(className, where, object)); + } + + return Object.freeze({ + create, + update, + }); +} + +function restStatusHandler(className, config) { + const auth = Auth.master(config); + function create(object) { + return rest.create(config, auth, className, object).then(({ response }) => { + return { ...object, ...response }; + }); + } + + function update(where, object) { + return pushPromiseQueue.enqueue(where.objectId, () => + rest + .update(config, auth, className, { objectId: where.objectId }, object) + .then(({ response }) => { + return { ...object, ...response }; + }) + ); + } + + return Object.freeze({ + create, + update, + }); +} + +export function jobStatusHandler(config) { + let jobStatus; + const objectId = newObjectId(config.objectIdSize); + const database = config.database; + const handler = statusHandler(JOB_STATUS_COLLECTION, database); + const setRunning = function (jobName) { + const now = new Date(); + jobStatus = { + objectId, + jobName, + status: 'running', + source: 'api', + createdAt: now, + // lockdown! + ACL: {}, + }; + + return handler.create(jobStatus); + }; + + const setMessage = function (message) { + if (!message || typeof message !== 'string') { + return Promise.resolve(); + } + return handler.update({ objectId }, { message }); + }; + + const setSucceeded = function (message) { + return setFinalStatus('succeeded', message); + }; + + const setFailed = function (message) { + return setFinalStatus('failed', message); + }; + + const setFinalStatus = function (status, message = undefined) { + const finishedAt = new Date(); + const update = { status, finishedAt }; + if (message && typeof message === 'string') { + update.message = message; + } + if (Utils.isNativeError(message) && typeof message.message === 'string') { + update.message = message.message; + } + return handler.update({ objectId }, update); + }; + + return Object.freeze({ + setRunning, + setSucceeded, + setMessage, + setFailed, + }); +} + +export function pushStatusHandler(config, existingObjectId) { + let pushStatus; + const database = config.database; + const handler = restStatusHandler(PUSH_STATUS_COLLECTION, config); + let objectId = existingObjectId; + const setInitial = function (body = {}, where, options = { source: 'rest' }) { + const now = new Date(); + let pushTime = now.toISOString(); + let status = 'pending'; + if (Object.prototype.hasOwnProperty.call(body, 'push_time')) { + if (config.hasPushScheduledSupport) { + pushTime = body.push_time; + status = 'scheduled'; + } else { + logger.warn('Trying to schedule a push while server is not configured.'); + logger.warn('Push will be sent immediately'); + } + } + + const data = body.data || {}; + const payloadString = JSON.stringify(data); + let pushHash; + if (typeof data.alert === 'string') { + pushHash = md5Hash(data.alert); + } else if (typeof data.alert === 'object') { + pushHash = md5Hash(JSON.stringify(data.alert)); + } else { + pushHash = 'd41d8cd98f00b204e9800998ecf8427e'; + } + const object = { + pushTime, + query: JSON.stringify(where), + payload: payloadString, + source: options.source, + title: options.title, + expiry: body.expiration_time, + expiration_interval: body.expiration_interval, + status: status, + numSent: 0, + pushHash, + // lockdown! + ACL: {}, + }; + return handler.create(object).then(result => { + objectId = result.objectId; + pushStatus = { + objectId, + }; + return Promise.resolve(pushStatus); + }); + }; + + const setRunning = function (batches) { + logger.verbose( + `_PushStatus ${objectId}: sending push to installations with %d batches`, + batches + ); + return handler.update( + { + status: 'pending', + objectId: objectId, + }, + { + status: 'running', + count: batches, + } + ); + }; + + const trackSent = function ( + results, + UTCOffset, + cleanupInstallations = process.env.PARSE_SERVER_CLEANUP_INVALID_INSTALLATIONS + ) { + const update = { + numSent: 0, + numFailed: 0, + }; + const devicesToRemove = []; + if (Array.isArray(results)) { + results = flatten(results); + results.reduce((memo, result) => { + // Cannot handle that + if (!result || !result.device || !result.device.deviceType) { + return memo; + } + const deviceType = result.device.deviceType; + const key = result.transmitted + ? `sentPerType.${deviceType}` + : `failedPerType.${deviceType}`; + memo[key] = incrementOp(memo, key); + if (typeof UTCOffset !== 'undefined') { + const offsetKey = result.transmitted + ? `sentPerUTCOffset.${UTCOffset}` + : `failedPerUTCOffset.${UTCOffset}`; + memo[offsetKey] = incrementOp(memo, offsetKey); + } + if (result.transmitted) { + memo.numSent++; + } else { + if ( + result && + result.response && + result.response.error && + result.device && + result.device.deviceToken + ) { + const token = result.device.deviceToken; + const error = result.response.error; + // GCM / FCM HTTP v1 API errors; see: + // https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode + if (error === 'NotRegistered' || error === 'InvalidRegistration') { + devicesToRemove.push(token); + } + // FCM API v2 errors; see: + // https://firebase.google.com/docs/cloud-messaging/manage-tokens + // https://github.com/firebase/functions-samples/blob/703c0359eacf07a551751d1319d34f912a2cd828/Node/fcm-notifications/functions/index.js#L89-L93C16 + if ( + error?.code === 'messaging/registration-token-not-registered' || + error?.code === 'messaging/invalid-registration-token' || + (error?.code === 'messaging/invalid-argument' && error?.message === 'The registration token is not a valid FCM registration token') + ) { + devicesToRemove.push(token); + } + // APNS errors; see: + // https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns + if (error === 'Unregistered' || error === 'BadDeviceToken') { + devicesToRemove.push(token); + } + } + memo.numFailed++; + } + return memo; + }, update); + } + + logger.verbose( + `_PushStatus ${objectId}: sent push! %d success, %d failures`, + update.numSent, + update.numFailed + ); + logger.verbose(`_PushStatus ${objectId}: needs cleanup`, { + devicesToRemove, + }); + ['numSent', 'numFailed'].forEach(key => { + if (update[key] > 0) { + update[key] = { + __op: 'Increment', + amount: update[key], + }; + } else { + delete update[key]; + } + }); + + if (devicesToRemove.length > 0 && cleanupInstallations) { + logger.info(`Removing device tokens on ${devicesToRemove.length} _Installations`); + database.update( + '_Installation', + { deviceToken: { $in: devicesToRemove } }, + { deviceToken: { __op: 'Delete' } }, + { + acl: undefined, + many: true, + } + ); + } + incrementOp(update, 'count', -1); + update.status = 'running'; + + return handler.update({ objectId }, update).then(res => { + if (res && res.count === 0) { + return this.complete(); + } + }); + }; + + const complete = function () { + return handler.update( + { objectId }, + { + status: 'succeeded', + count: { __op: 'Delete' }, + } + ); + }; + + const fail = function (err) { + if (typeof err === 'string') { + err = { message: err }; + } + const update = { + errorMessage: err, + status: 'failed', + }; + return handler.update({ objectId }, update); + }; + + const rval = { + setInitial, + setRunning, + trackSent, + complete, + fail, + }; + + // define objectId to be dynamic + Object.defineProperty(rval, 'objectId', { + get: () => objectId, + }); + + return Object.freeze(rval); +} diff --git a/src/TestUtils.js b/src/TestUtils.js index ebdb9f9914..ec4cb29554 100644 --- a/src/TestUtils.js +++ b/src/TestUtils.js @@ -1,15 +1,84 @@ -import { destroyAllDataPermanently } from './DatabaseAdapter'; +import AppCache from './cache'; +import SchemaCache from './Adapters/Cache/SchemaCache'; -let unsupported = function() { - throw 'Only supported in test environment'; +/** + * Destroys all data in the database + * @param {boolean} fast set to true if it's ok to just drop objects and not indexes. + */ +export function destroyAllDataPermanently(fast) { + if (!process.env.TESTING) { + throw 'Only supported in test environment'; + } + return Promise.all( + Object.keys(AppCache.cache).map(appId => { + const app = AppCache.get(appId); + const deletePromises = []; + if (app.cacheAdapter && app.cacheAdapter.clear) { + deletePromises.push(app.cacheAdapter.clear()); + } + if (app.databaseController) { + deletePromises.push(app.databaseController.deleteEverything(fast)); + } else if (app.databaseAdapter) { + SchemaCache.clear(); + deletePromises.push(app.databaseAdapter.deleteAllClasses(fast)); + } + return Promise.all(deletePromises); + }) + ); +} + +export function resolvingPromise() { + let res; + let rej; + const promise = new Promise((resolve, reject) => { + res = resolve; + rej = reject; + }); + promise.resolve = res; + promise.reject = rej; + return promise; +} + +export function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function getConnectionsCount(server) { + return new Promise((resolve, reject) => { + server.getConnections((err, count) => { + /* istanbul ignore next */ + if (err) { + reject(err); + } else { + resolve(count); + } + }); + }); }; -let _destroyAllDataPermanently; -if (process.env.TESTING) { - _destroyAllDataPermanently = destroyAllDataPermanently; -} else { - _destroyAllDataPermanently = unsupported; +export class Connections { + constructor() { + this.sockets = new Set(); + } + + track(server) { + server.on('connection', socket => { + this.sockets.add(socket); + socket.on('close', () => { + this.sockets.delete(socket); + }); + }); + } + + destroyAll() { + for (const socket of this.sockets.values()) { + socket.destroy(); + } + this.sockets.clear(); + } + + count() { + return this.sockets.size; + } } -export default { - destroyAllDataPermanently: _destroyAllDataPermanently}; diff --git a/src/Utils.js b/src/Utils.js new file mode 100644 index 0000000000..1c6077231d --- /dev/null +++ b/src/Utils.js @@ -0,0 +1,581 @@ +/** + * utils.js + * @file General purpose utilities + * @description General purpose utilities. + */ + +const path = require('path'); +const fs = require('fs').promises; +const { types } = require('util'); + +/** + * The general purpose utilities. + */ +class Utils { + /** + * @function getLocalizedPath + * @description Returns a localized file path accoring to the locale. + * + * Localized files are searched in subfolders of a given path, e.g. + * + * root/ + * ├── base/ // base path to files + * │ ├── example.html // default file + * │ └── de/ // de language folder + * │ │ └── example.html // de localized file + * │ └── de-AT/ // de-AT locale folder + * │ │ └── example.html // de-AT localized file + * + * Files are matched with the locale in the following order: + * 1. Locale match, e.g. locale `de-AT` matches file in folder `de-AT`. + * 2. Language match, e.g. locale `de-AT` matches file in folder `de`. + * 3. Default; file in base folder is returned. + * + * @param {String} defaultPath The absolute file path, which is also + * the default path returned if localization is not available. + * @param {String} locale The locale. + * @returns {Promise} The object contains: + * - `path`: The path to the localized file, or the original path if + * localization is not available. + * - `subdir`: The subdirectory of the localized file, or undefined if + * there is no matching localized file. + */ + static async getLocalizedPath(defaultPath, locale) { + // Get file name and paths + const file = path.basename(defaultPath); + const basePath = path.dirname(defaultPath); + + // If locale is not set return default file + if (!locale) { + return { path: defaultPath }; + } + + // Check file for locale exists + const localePath = path.join(basePath, locale, file); + const localeFileExists = await Utils.fileExists(localePath); + + // If file for locale exists return file + if (localeFileExists) { + return { path: localePath, subdir: locale }; + } + + // Check file for language exists + const language = locale.split('-')[0]; + const languagePath = path.join(basePath, language, file); + const languageFileExists = await Utils.fileExists(languagePath); + + // If file for language exists return file + if (languageFileExists) { + return { path: languagePath, subdir: language }; + } + + // Return default file + return { path: defaultPath }; + } + + /** + * @function fileExists + * @description Checks whether a file exists. + * @param {String} path The file path. + * @returns {Promise} Is true if the file can be accessed, false otherwise. + */ + static async fileExists(path) { + try { + await fs.access(path); + return true; + } catch { + return false; + } + } + + /** + * @function isPath + * @description Evaluates whether a string is a file path (as opposed to a URL for example). + * @param {String} s The string to evaluate. + * @returns {Boolean} Returns true if the evaluated string is a path. + */ + static isPath(s) { + return /(^\/)|(^\.\/)|(^\.\.\/)/.test(s); + } + + /** + * Flattens an object and crates new keys with custom delimiters. + * @param {Object} obj The object to flatten. + * @param {String} [delimiter='.'] The delimiter of the newly generated keys. + * @param {Object} result + * @returns {Object} The flattened object. + **/ + static flattenObject(obj, parentKey, delimiter = '.', result = {}) { + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + const newKey = parentKey ? parentKey + delimiter + key : key; + + if (typeof obj[key] === 'object' && obj[key] !== null) { + this.flattenObject(obj[key], newKey, delimiter, result); + } else { + result[newKey] = obj[key]; + } + } + } + return result; + } + + /** + * Realm-safe check for Date. + * @param {any} value The value to check. + * @returns {Boolean} Returns true if the value is a Date. + */ + static isDate(value) { + return types.isDate(value); + } + + /** + * Realm-safe check for RegExp. + * @param {any} value The value to check. + * @returns {Boolean} Returns true if the value is a RegExp. + */ + static isRegExp(value) { + return types.isRegExp(value); + } + + /** + * Realm-safe check for Map. + * @param {any} value The value to check. + * @returns {Boolean} Returns true if the value is a Map. + */ + static isMap(value) { + return types.isMap(value); + } + + /** + * Realm-safe check for Set. + * @param {any} value The value to check. + * @returns {Boolean} Returns true if the value is a Set. + */ + static isSet(value) { + return types.isSet(value); + } + + /** + * Realm-safe check for native Error. + * @param {any} value The value to check. + * @returns {Boolean} Returns true if the value is a native Error. + */ + static isNativeError(value) { + return types.isNativeError(value); + } + + /** + * Realm-safe check for Promise (duck-typed as thenable). + * Guards against Object.prototype pollution by ensuring `then` is not + * inherited solely from Object.prototype. + * @param {any} value The value to check. + * @returns {Boolean} Returns true if the value is a Promise or thenable. + */ + static isPromise(value) { + if (value == null || typeof value.then !== 'function') { + return false; + } + return Object.getPrototypeOf(value) !== Object.prototype || Object.prototype.hasOwnProperty.call(value, 'then'); + } + + /** + * Realm-safe check for object type. Uses `typeof` instead of `instanceof Object` + * which fails across realms. Returns true for any non-null value where + * `typeof` is `'object'`, including plain objects, arrays, dates, maps, sets, + * regex, and boxed primitives (e.g. `new String()`). Returns false for `null`, + * `undefined`, unboxed primitives, and functions. + * @param {any} value The value to check. + * @returns {Boolean} Returns true if the value is a non-null object type. + */ + static isObject(value) { + return typeof value === 'object' && value !== null; + } + + /** + * Creates an object with all permutations of the original keys. + * For example, this definition: + * ``` + * { + * a: [true, false], + * b: [1, 2], + * c: ['x'] + * } + * ``` + * permutates to: + * ``` + * [ + * { a: true, b: 1, c: 'x' }, + * { a: true, b: 2, c: 'x' }, + * { a: false, b: 1, c: 'x' }, + * { a: false, b: 2, c: 'x' } + * ] + * ``` + * @param {Object} object The object to permutate. + * @param {Integer} [index=0] The current key index. + * @param {Object} [current={}] The current result entry being composed. + * @param {Array} [results=[]] The resulting array of permutations. + */ + static getObjectKeyPermutations(object, index = 0, current = {}, results = []) { + const keys = Object.keys(object); + const key = keys[index]; + const values = object[key]; + + for (const value of values) { + current[key] = value; + const nextIndex = index + 1; + + if (nextIndex < keys.length) { + Utils.getObjectKeyPermutations(object, nextIndex, current, results); + } else { + const result = Object.assign({}, current); + results.push(result); + } + } + return results; + } + + /** + * Validates parameters and throws if a parameter is invalid. + * Example parameter types syntax: + * ``` + * { + * parameterName: { + * t: 'boolean', + * v: isBoolean, + * o: true + * }, + * ... + * } + * ``` + * @param {Object} params The parameters to validate. + * @param {Array} types The parameter types used for validation. + * @param {Object} types.t The parameter type; used for error message, not for validation. + * @param {Object} types.v The function to validate the parameter value. + * @param {Boolean} [types.o=false] Is true if the parameter is optional. + */ + static validateParams(params, types) { + for (const key of Object.keys(params)) { + const type = types[key]; + const isOptional = !!type.o; + const param = params[key]; + if (!(isOptional && param == null) && !type.v(param)) { + throw `Invalid parameter ${key} must be of type ${type.t} but is ${typeof param}`; + } + } + } + + /** + * Computes the relative date based on a string. + * @param {String} text The string to interpret the date from. + * @param {Date} now The date the string is comparing against. + * @returns {Object} The relative date object. + **/ + static relativeTimeToDate(text, now = new Date()) { + text = text.toLowerCase(); + let parts = text.split(' '); + + // Filter out whitespace + parts = parts.filter(part => part !== ''); + + const future = parts[0] === 'in'; + const past = parts[parts.length - 1] === 'ago'; + + if (!future && !past && text !== 'now') { + return { + status: 'error', + info: "Time should either start with 'in' or end with 'ago'", + }; + } + + if (future && past) { + return { + status: 'error', + info: "Time cannot have both 'in' and 'ago'", + }; + } + + // strip the 'ago' or 'in' + if (future) { + parts = parts.slice(1); + } else { + // past + parts = parts.slice(0, parts.length - 1); + } + + if (parts.length % 2 !== 0 && text !== 'now') { + return { + status: 'error', + info: 'Invalid time string. Dangling unit or number.', + }; + } + + const pairs = []; + while (parts.length) { + pairs.push([parts.shift(), parts.shift()]); + } + + let seconds = 0; + for (const [num, interval] of pairs) { + const val = Number(num); + if (!Number.isInteger(val)) { + return { + status: 'error', + info: `'${num}' is not an integer.`, + }; + } + + switch (interval) { + case 'yr': + case 'yrs': + case 'year': + case 'years': + seconds += val * 31536000; // 365 * 24 * 60 * 60 + break; + + case 'wk': + case 'wks': + case 'week': + case 'weeks': + seconds += val * 604800; // 7 * 24 * 60 * 60 + break; + + case 'd': + case 'day': + case 'days': + seconds += val * 86400; // 24 * 60 * 60 + break; + + case 'hr': + case 'hrs': + case 'hour': + case 'hours': + seconds += val * 3600; // 60 * 60 + break; + + case 'min': + case 'mins': + case 'minute': + case 'minutes': + seconds += val * 60; + break; + + case 'sec': + case 'secs': + case 'second': + case 'seconds': + seconds += val; + break; + + default: + return { + status: 'error', + info: `Invalid interval: '${interval}'`, + }; + } + } + + const milliseconds = seconds * 1000; + if (future) { + return { + status: 'success', + info: 'future', + result: new Date(now.valueOf() + milliseconds), + }; + } else if (past) { + return { + status: 'success', + info: 'past', + result: new Date(now.valueOf() - milliseconds), + }; + } else { + return { + status: 'success', + info: 'present', + result: new Date(now.valueOf()), + }; + } + } + + /** + * Deep-scans an object for a matching key/value definition. + * @param {Object} obj The object to scan. + * @param {String | undefined} key The key to match, or undefined if only the value should be matched. + * @param {any | undefined} value The value to match, or undefined if only the key should be matched. + * @returns {Boolean} True if a match was found, false otherwise. + */ + static objectContainsKeyValue(obj, key, value) { + const isMatch = (a, b) => (typeof a === 'string' && new RegExp(b).test(a)) || a === b; + const isKeyMatch = k => isMatch(k, key); + const isValueMatch = v => isMatch(v, value); + const stack = [obj]; + const seen = new WeakSet(); + while (stack.length > 0) { + const current = stack.pop(); + if (seen.has(current)) { + continue; + } + seen.add(current); + for (const [k, v] of Object.entries(current)) { + if (key !== undefined && value === undefined && isKeyMatch(k)) { + return true; + } else if (key === undefined && value !== undefined && isValueMatch(v)) { + return true; + } else if (key !== undefined && value !== undefined && isKeyMatch(k) && isValueMatch(v)) { + return true; + } + if (['[object Object]', '[object Array]'].includes(Object.prototype.toString.call(v))) { + stack.push(v); + } + } + } + return false; + } + + static checkProhibitedKeywords(config, data) { + if (config?.requestKeywordDenylist) { + // Scan request data for denied keywords + for (const keyword of config.requestKeywordDenylist) { + const match = Utils.objectContainsKeyValue(data, keyword.key, keyword.value); + if (match) { + throw `Prohibited keyword in request data: ${JSON.stringify(keyword)}.`; + } + } + } + } + + /** + * Moves the nested keys of a specified key in an object to the root of the object. + * + * @param {Object} obj The object to modify. + * @param {String} key The key whose nested keys will be moved to root. + * @returns {Object} The modified object, or the original object if no modification happened. + * @example + * const obj = { + * a: 1, + * b: { + * c: 2, + * d: 3 + * }, + * e: 4 + * }; + * addNestedKeysToRoot(obj, 'b'); + * console.log(obj); + * // Output: { a: 1, e: 4, c: 2, d: 3 } + */ + static addNestedKeysToRoot(obj, key) { + if (obj[key] && typeof obj[key] === 'object') { + // Add nested keys to root + Object.assign(obj, { ...obj[key] }); + // Delete original nested key + delete obj[key]; + } + return obj; + } + + /** + * Encodes a string to be used in a URL. + * @param {String} input The string to encode. + * @returns {String} The encoded string. + */ + static encodeForUrl(input) { + return encodeURIComponent(input).replace(/[!'.()*]/g, char => + '%' + char.charCodeAt(0).toString(16).toUpperCase() + ); + } + + /** + * Creates a JSON replacer function that handles Map, Set, and circular references. + * This replacer can be used with JSON.stringify to safely serialize complex objects. + * + * @returns {Function} A replacer function for JSON.stringify that: + * - Converts Map instances to plain objects + * - Converts Set instances to arrays + * - Replaces circular references with '[Circular]' marker + * + * @example + * const obj = { name: 'test', map: new Map([['key', 'value']]) }; + * obj.self = obj; // circular reference + * JSON.stringify(obj, Utils.getCircularReplacer()); + * // Output: {"name":"test","map":{"key":"value"},"self":"[Circular]"} + */ + static getCircularReplacer() { + const seen = new WeakSet(); + return (key, value) => { + if (Utils.isMap(value)) { + return Object.fromEntries(value); + } + if (Utils.isSet(value)) { + return Array.from(value); + } + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return '[Circular]'; + } + seen.add(value); + } + return value; + }; + } + + /** + * Gets a nested property value from an object using dot notation. + * @param {Object} obj The object to get the property from. + * @param {String} path The property path in dot notation, e.g. 'databaseOptions.allowPublicExplain'. + * @returns {any} The property value or undefined if not found. + * @example + * const obj = { database: { options: { enabled: true } } }; + * Utils.getNestedProperty(obj, 'database.options.enabled'); + * // Output: true + */ + static getNestedProperty(obj, path) { + if (!obj || !path) { + return undefined; + } + const keys = path.split('.'); + let current = obj; + for (const key of keys) { + if (current == null || typeof current !== 'object') { + return undefined; + } + current = current[key]; + } + return current; + } + + /** + * Parses a human-readable size string into a byte count. + * @param {number | string} size - A number (floored to an integer), a numeric string + * (treated as bytes), or a string with a unit suffix: `b`, `kb`, `mb`, `gb` + * (case-insensitive). Examples: `'20mb'`, `'512kb'`, `'1.5gb'`, `1048576`. + * @returns {number} The size in bytes, floored to the nearest integer. + * @throws {Error} If the string does not match the expected format. + */ + static parseSizeToBytes(size) { + if (typeof size === 'number') { + if (!Number.isFinite(size) || size < 0) { + throw new Error(`Invalid size value: ${size}`); + } + return Math.floor(size); + } + const str = String(size).trim().toLowerCase(); + const match = str.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/); + if (!match) { + throw new Error(`Invalid size value: ${size}`); + } + const num = parseFloat(match[1]); + const unit = match[2]; + switch (unit) { + case 'kb': + return Math.floor(num * 1024); + case 'mb': + return Math.floor(num * 1024 * 1024); + case 'gb': + return Math.floor(num * 1024 * 1024 * 1024); + default: + return Math.floor(num); + } + } +} + +module.exports = Utils; diff --git a/src/authDataManager/OAuth1Client.js b/src/authDataManager/OAuth1Client.js deleted file mode 100644 index 2c70be0cd5..0000000000 --- a/src/authDataManager/OAuth1Client.js +++ /dev/null @@ -1,226 +0,0 @@ -var https = require('https'), - crypto = require('crypto'); - -var OAuth = function(options) { - this.consumer_key = options.consumer_key; - this.consumer_secret = options.consumer_secret; - this.auth_token = options.auth_token; - this.auth_token_secret = options.auth_token_secret; - this.host = options.host; - this.oauth_params = options.oauth_params || {}; -}; - -OAuth.prototype.send = function(method, path, params, body){ - - var request = this.buildRequest(method, path, params, body); - // Encode the body properly, the current Parse Implementation don't do it properly - return new Promise(function(resolve, reject) { - var httpRequest = https.request(request, function(res) { - var data = ''; - res.on('data', function(chunk) { - data += chunk; - }); - res.on('end', function() { - data = JSON.parse(data); - resolve(data); - }); - }).on('error', function(e) { - reject('Failed to make an OAuth request'); - }); - if (request.body) { - httpRequest.write(request.body); - } - httpRequest.end(); - }); -}; - -OAuth.prototype.buildRequest = function(method, path, params, body) { - if (path.indexOf("/") != 0) { - path = "/"+path; - } - if (params && Object.keys(params).length > 0) { - path += "?" + OAuth.buildParameterString(params); - } - - var request = { - host: this.host, - path: path, - method: method.toUpperCase() - }; - - var oauth_params = this.oauth_params || {}; - oauth_params.oauth_consumer_key = this.consumer_key; - if(this.auth_token){ - oauth_params["oauth_token"] = this.auth_token; - } - - request = OAuth.signRequest(request, oauth_params, this.consumer_secret, this.auth_token_secret); - - if (body && Object.keys(body).length > 0) { - request.body = OAuth.buildParameterString(body); - } - return request; -} - -OAuth.prototype.get = function(path, params) { - return this.send("GET", path, params); -} - -OAuth.prototype.post = function(path, params, body) { - return this.send("POST", path, params, body); -} - -/* - Proper string %escape encoding -*/ -OAuth.encode = function(str) { - // discuss at: http://phpjs.org/functions/rawurlencode/ - // original by: Brett Zamir (http://brett-zamir.me) - // input by: travc - // input by: Brett Zamir (http://brett-zamir.me) - // input by: Michael Grier - // input by: Ratheous - // bugfixed by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) - // bugfixed by: Brett Zamir (http://brett-zamir.me) - // bugfixed by: Joris - // reimplemented by: Brett Zamir (http://brett-zamir.me) - // reimplemented by: Brett Zamir (http://brett-zamir.me) - // note: This reflects PHP 5.3/6.0+ behavior - // note: Please be aware that this function expects to encode into UTF-8 encoded strings, as found on - // note: pages served as UTF-8 - // example 1: rawurlencode('Kevin van Zonneveld!'); - // returns 1: 'Kevin%20van%20Zonneveld%21' - // example 2: rawurlencode('http://kevin.vanzonneveld.net/'); - // returns 2: 'http%3A%2F%2Fkevin.vanzonneveld.net%2F' - // example 3: rawurlencode('http://www.google.nl/search?q=php.js&ie=utf-8&oe=utf-8&aq=t&rls=com.ubuntu:en-US:unofficial&client=firefox-a'); - // returns 3: 'http%3A%2F%2Fwww.google.nl%2Fsearch%3Fq%3Dphp.js%26ie%3Dutf-8%26oe%3Dutf-8%26aq%3Dt%26rls%3Dcom.ubuntu%3Aen-US%3Aunofficial%26client%3Dfirefox-a' - - str = (str + '') - .toString(); - - // Tilde should be allowed unescaped in future versions of PHP (as reflected below), but if you want to reflect current - // PHP behavior, you would need to add ".replace(/~/g, '%7E');" to the following. - return encodeURIComponent(str) - .replace(/!/g, '%21') - .replace(/'/g, '%27') - .replace(/\(/g, '%28') - .replace(/\)/g, '%29') - .replace(/\*/g, '%2A'); -} - -OAuth.signatureMethod = "HMAC-SHA1"; -OAuth.version = "1.0"; - -/* - Generate a nonce -*/ -OAuth.nonce = function(){ - var text = ""; - var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - - for( var i=0; i < 30; i++ ) - text += possible.charAt(Math.floor(Math.random() * possible.length)); - - return text; -} - -OAuth.buildParameterString = function(obj){ - var result = {}; - - // Sort keys and encode values - if (obj) { - var keys = Object.keys(obj).sort(); - - // Map key=value, join them by & - return keys.map(function(key){ - return key + "=" + OAuth.encode(obj[key]); - }).join("&"); - } - - return ""; -} - -/* - Build the signature string from the object -*/ - -OAuth.buildSignatureString = function(method, url, parameters){ - return [method.toUpperCase(), OAuth.encode(url), OAuth.encode(parameters)].join("&"); -} - -/* - Retuns encoded HMAC-SHA1 from key and text -*/ -OAuth.signature = function(text, key){ - crypto = require("crypto"); - return OAuth.encode(crypto.createHmac('sha1', key).update(text).digest('base64')); -} - -OAuth.signRequest = function(request, oauth_parameters, consumer_secret, auth_token_secret){ - oauth_parameters = oauth_parameters || {}; - - // Set default values - if (!oauth_parameters.oauth_nonce) { - oauth_parameters.oauth_nonce = OAuth.nonce(); - } - if (!oauth_parameters.oauth_timestamp) { - oauth_parameters.oauth_timestamp = Math.floor(new Date().getTime()/1000); - } - if (!oauth_parameters.oauth_signature_method) { - oauth_parameters.oauth_signature_method = OAuth.signatureMethod; - } - if (!oauth_parameters.oauth_version) { - oauth_parameters.oauth_version = OAuth.version; - } - - if(!auth_token_secret){ - auth_token_secret=""; - } - // Force GET method if unset - if (!request.method) { - request.method = "GET" - } - - // Collect all the parameters in one signatureParameters object - var signatureParams = {}; - var parametersToMerge = [request.params, request.body, oauth_parameters]; - for(var i in parametersToMerge) { - var parameters = parametersToMerge[i]; - for(var k in parameters) { - signatureParams[k] = parameters[k]; - } - } - - // Create a string based on the parameters - var parameterString = OAuth.buildParameterString(signatureParams); - - // Build the signature string - var url = "https://"+request.host+""+request.path; - - var signatureString = OAuth.buildSignatureString(request.method, url, parameterString); - // Hash the signature string - var signatureKey = [OAuth.encode(consumer_secret), OAuth.encode(auth_token_secret)].join("&"); - - var signature = OAuth.signature(signatureString, signatureKey); - - // Set the signature in the params - oauth_parameters.oauth_signature = signature; - if(!request.headers){ - request.headers = {}; - } - - // Set the authorization header - var signature = Object.keys(oauth_parameters).sort().map(function(key){ - var value = oauth_parameters[key]; - return key+'="'+value+'"'; - }).join(", ") - - request.headers.Authorization = 'OAuth ' + signature; - - // Set the content type header - request.headers["Content-Type"] = "application/x-www-form-urlencoded"; - return request; - -} - -module.exports = OAuth; \ No newline at end of file diff --git a/src/authDataManager/facebook.js b/src/authDataManager/facebook.js deleted file mode 100644 index 77e0e2134f..0000000000 --- a/src/authDataManager/facebook.js +++ /dev/null @@ -1,58 +0,0 @@ -// Helper functions for accessing the Facebook Graph API. -var https = require('https'); -var Parse = require('parse/node').Parse; - -// Returns a promise that fulfills iff this user id is valid. -function validateAuthData(authData) { - return graphRequest('me?fields=id&access_token=' + authData.access_token) - .then((data) => { - if (data && data.id == authData.id) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Facebook auth is invalid for this user.'); - }); -} - -// Returns a promise that fulfills iff this app id is valid. -function validateAppId(appIds, authData) { - var access_token = authData.access_token; - if (!appIds.length) { - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Facebook auth is not configured.'); - } - return graphRequest('app?access_token=' + access_token) - .then((data) => { - if (data && appIds.indexOf(data.id) != -1) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Facebook auth is invalid for this user.'); - }); -} - -// A promisey wrapper for FB graph requests. -function graphRequest(path) { - return new Promise(function(resolve, reject) { - https.get('https://graph.facebook.com/v2.5/' + path, function(res) { - var data = ''; - res.on('data', function(chunk) { - data += chunk; - }); - res.on('end', function() { - data = JSON.parse(data); - resolve(data); - }); - }).on('error', function(e) { - reject('Failed to validate this access token with Facebook.'); - }); - }); -} - -module.exports = { - validateAppId: validateAppId, - validateAuthData: validateAuthData -}; diff --git a/src/authDataManager/github.js b/src/authDataManager/github.js deleted file mode 100644 index ab6715b185..0000000000 --- a/src/authDataManager/github.js +++ /dev/null @@ -1,51 +0,0 @@ -// Helper functions for accessing the github API. -var https = require('https'); -var Parse = require('parse/node').Parse; - -// Returns a promise that fulfills iff this user id is valid. -function validateAuthData(authData) { - return request('user', authData.access_token) - .then((data) => { - if (data && data.id == authData.id) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Github auth is invalid for this user.'); - }); -} - -// Returns a promise that fulfills iff this app id is valid. -function validateAppId() { - return Promise.resolve(); -} - -// A promisey wrapper for api requests -function request(path, access_token) { - return new Promise(function(resolve, reject) { - https.get({ - host: 'api.github.com', - path: '/' + path, - headers: { - 'Authorization': 'bearer '+access_token, - 'User-Agent': 'parse-server' - } - }, function(res) { - var data = ''; - res.on('data', function(chunk) { - data += chunk; - }); - res.on('end', function() { - data = JSON.parse(data); - resolve(data); - }); - }).on('error', function(e) { - reject('Failed to validate this access token with Github.'); - }); - }); -} - -module.exports = { - validateAppId: validateAppId, - validateAuthData: validateAuthData -}; diff --git a/src/authDataManager/google.js b/src/authDataManager/google.js deleted file mode 100644 index ee82d278bd..0000000000 --- a/src/authDataManager/google.js +++ /dev/null @@ -1,44 +0,0 @@ -// Helper functions for accessing the google API. -var https = require('https'); -var Parse = require('parse/node').Parse; - -// Returns a promise that fulfills iff this user id is valid. -function validateAuthData(authData) { - return request("tokeninfo?id_token="+authData.access_token) - .then((response) => { - if (response && response.sub == authData.id) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Google auth is invalid for this user.'); - }); -} - -// Returns a promise that fulfills iff this app id is valid. -function validateAppId() { - return Promise.resolve(); -} - -// A promisey wrapper for api requests -function request(path) { - return new Promise(function(resolve, reject) { - https.get("https://www.googleapis.com/oauth2/v3/" + path, function(res) { - var data = ''; - res.on('data', function(chunk) { - data += chunk; - }); - res.on('end', function() { - data = JSON.parse(data); - resolve(data); - }); - }).on('error', function(e) { - reject('Failed to validate this access token with Google.'); - }); - }); -} - -module.exports = { - validateAppId: validateAppId, - validateAuthData: validateAuthData -}; diff --git a/src/authDataManager/index.js b/src/authDataManager/index.js deleted file mode 100644 index 0478ffd7eb..0000000000 --- a/src/authDataManager/index.js +++ /dev/null @@ -1,98 +0,0 @@ -let facebook = require('./facebook'); -let instagram = require("./instagram"); -let linkedin = require("./linkedin"); -let meetup = require("./meetup"); -let google = require("./google"); -let github = require("./github"); -let twitter = require("./twitter"); -let spotify = require("./spotify"); -let digits = require("./twitter"); // digits tokens are validated by twitter - -let anonymous = { - validateAuthData: () => { - return Promise.resolve(); - }, - validateAppId: () => { - return Promise.resolve(); - } -} - -let providers = { - facebook, - instagram, - linkedin, - meetup, - google, - github, - twitter, - spotify, - anonymous, - digits -} - -module.exports = function(oauthOptions = {}, enableAnonymousUsers = true) { - let _enableAnonymousUsers = enableAnonymousUsers; - let setEnableAnonymousUsers = function(enable) { - _enableAnonymousUsers = enable; - } - // To handle the test cases on configuration - let getValidatorForProvider = function(provider) { - - if (provider === 'anonymous' && !_enableAnonymousUsers) { - return; - } - - let defaultProvider = providers[provider]; - let optionalProvider = oauthOptions[provider]; - - if (!defaultProvider && !optionalProvider) { - return; - } - - let appIds; - if (optionalProvider) { - appIds = optionalProvider.appIds; - } - - var validateAuthData; - var validateAppId; - - if (defaultProvider) { - validateAuthData = defaultProvider.validateAuthData; - validateAppId = defaultProvider.validateAppId; - } - - // Try the configuration methods - if (optionalProvider) { - if (optionalProvider.module) { - validateAuthData = require(optionalProvider.module).validateAuthData; - validateAppId = require(optionalProvider.module).validateAppId; - }; - - if (optionalProvider.validateAuthData) { - validateAuthData = optionalProvider.validateAuthData; - } - if (optionalProvider.validateAppId) { - validateAppId = optionalProvider.validateAppId; - } - } - - if (!validateAuthData || !validateAppId) { - return; - } - - return function(authData) { - return validateAuthData(authData, optionalProvider).then(() => { - if (appIds) { - return validateAppId(appIds, authData, optionalProvider); - } - return Promise.resolve(); - }) - } - } - - return Object.freeze({ - getValidatorForProvider, - setEnableAnonymousUsers, - }) -} diff --git a/src/authDataManager/instagram.js b/src/authDataManager/instagram.js deleted file mode 100644 index 03971695ff..0000000000 --- a/src/authDataManager/instagram.js +++ /dev/null @@ -1,44 +0,0 @@ -// Helper functions for accessing the instagram API. -var https = require('https'); -var Parse = require('parse/node').Parse; - -// Returns a promise that fulfills iff this user id is valid. -function validateAuthData(authData) { - return request("users/self/?access_token="+authData.access_token) - .then((response) => { - if (response && response.data && response.data.id == authData.id) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Instagram auth is invalid for this user.'); - }); -} - -// Returns a promise that fulfills iff this app id is valid. -function validateAppId() { - return Promise.resolve(); -} - -// A promisey wrapper for api requests -function request(path) { - return new Promise(function(resolve, reject) { - https.get("https://api.instagram.com/v1/" + path, function(res) { - var data = ''; - res.on('data', function(chunk) { - data += chunk; - }); - res.on('end', function() { - data = JSON.parse(data); - resolve(data); - }); - }).on('error', function(e) { - reject('Failed to validate this access token with Instagram.'); - }); - }); -} - -module.exports = { - validateAppId: validateAppId, - validateAuthData: validateAuthData -}; diff --git a/src/authDataManager/linkedin.js b/src/authDataManager/linkedin.js deleted file mode 100644 index efcd13cd9f..0000000000 --- a/src/authDataManager/linkedin.js +++ /dev/null @@ -1,51 +0,0 @@ -// Helper functions for accessing the linkedin API. -var https = require('https'); -var Parse = require('parse/node').Parse; - -// Returns a promise that fulfills iff this user id is valid. -function validateAuthData(authData) { - return request('people/~:(id)', authData.access_token) - .then((data) => { - if (data && data.id == authData.id) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Meetup auth is invalid for this user.'); - }); -} - -// Returns a promise that fulfills iff this app id is valid. -function validateAppId() { - return Promise.resolve(); -} - -// A promisey wrapper for api requests -function request(path, access_token) { - return new Promise(function(resolve, reject) { - https.get({ - host: 'api.linkedin.com', - path: '/v1/' + path, - headers: { - 'Authorization': 'Bearer '+access_token, - 'x-li-format': 'json' - } - }, function(res) { - var data = ''; - res.on('data', function(chunk) { - data += chunk; - }); - res.on('end', function() { - data = JSON.parse(data); - resolve(data); - }); - }).on('error', function(e) { - reject('Failed to validate this access token with Linkedin.'); - }); - }); -} - -module.exports = { - validateAppId: validateAppId, - validateAuthData: validateAuthData -}; diff --git a/src/authDataManager/meetup.js b/src/authDataManager/meetup.js deleted file mode 100644 index 04d16c5acd..0000000000 --- a/src/authDataManager/meetup.js +++ /dev/null @@ -1,50 +0,0 @@ -// Helper functions for accessing the meetup API. -var https = require('https'); -var Parse = require('parse/node').Parse; - -// Returns a promise that fulfills iff this user id is valid. -function validateAuthData(authData) { - return request('member/self', authData.access_token) - .then((data) => { - if (data && data.id == authData.id) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Meetup auth is invalid for this user.'); - }); -} - -// Returns a promise that fulfills iff this app id is valid. -function validateAppId() { - return Promise.resolve(); -} - -// A promisey wrapper for api requests -function request(path, access_token) { - return new Promise(function(resolve, reject) { - https.get({ - host: 'api.meetup.com', - path: '/2/' + path, - headers: { - 'Authorization': 'bearer '+access_token - } - }, function(res) { - var data = ''; - res.on('data', function(chunk) { - data += chunk; - }); - res.on('end', function() { - data = JSON.parse(data); - resolve(data); - }); - }).on('error', function(e) { - reject('Failed to validate this access token with Meetup.'); - }); - }); -} - -module.exports = { - validateAppId: validateAppId, - validateAuthData: validateAuthData -}; diff --git a/src/authDataManager/spotify.js b/src/authDataManager/spotify.js deleted file mode 100644 index 89c72b8472..0000000000 --- a/src/authDataManager/spotify.js +++ /dev/null @@ -1,64 +0,0 @@ -// Helper functions for accessing the Spotify API. -var https = require('https'); -var Parse = require('parse/node').Parse; - -// Returns a promise that fulfills iff this user id is valid. -function validateAuthData(authData) { - return request('me', authData.access_token) - .then((data) => { - if (data && data.id == authData.id) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Spotify auth is invalid for this user.'); - }); -} - -// Returns a promise that fulfills if this app id is valid. -function validateAppId(appIds, authData) { - var access_token = authData.access_token; - if (!appIds.length) { - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Spotify auth is not configured.'); - } - return request('me', access_token) - .then((data) => { - if (data && appIds.indexOf(data.id) != -1) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Spotify auth is invalid for this user.'); - }); -} - -// A promisey wrapper for Spotify API requests. -function request(path, access_token) { - return new Promise(function(resolve, reject) { - https.get({ - host: 'api.spotify.com', - path: '/v1/' + path, - headers: { - 'Authorization': 'Bearer '+access_token - } - }, function(res) { - var data = ''; - res.on('data', function(chunk) { - data += chunk; - }); - res.on('end', function() { - data = JSON.parse(data); - resolve(data); - }); - }).on('error', function(e) { - reject('Failed to validate this access token with Spotify.'); - }); - }); -} - -module.exports = { - validateAppId: validateAppId, - validateAuthData: validateAuthData -}; diff --git a/src/authDataManager/twitter.js b/src/authDataManager/twitter.js deleted file mode 100644 index 20d73a3968..0000000000 --- a/src/authDataManager/twitter.js +++ /dev/null @@ -1,53 +0,0 @@ -// Helper functions for accessing the twitter API. -var OAuth = require('./OAuth1Client'); -var Parse = require('parse/node').Parse; -var logger = require('../logger').default; - -// Returns a promise that fulfills iff this user id is valid. -function validateAuthData(authData, options) { - options = handleMultipleConfigurations(authData, options); - var client = new OAuth(options); - client.host = "api.twitter.com"; - client.auth_token = authData.auth_token; - client.auth_token_secret = authData.auth_token_secret; - - return client.get("/1.1/account/verify_credentials.json").then((data) => { - if (data && data.id == authData.id) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Twitter auth is invalid for this user.'); - }); -} - -// Returns a promise that fulfills iff this app id is valid. -function validateAppId() { - return Promise.resolve(); -} - -function handleMultipleConfigurations(authData, options) { - if (Array.isArray(options)) { - let consumer_key = authData.consumer_key; - if (!consumer_key) { - logger.error('Twitter Auth', 'Multiple twitter configurations are available, by no consumer_key was sent by the client.'); - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Twitter auth is invalid for this user.'); - } - options = options.filter((option) => { - return option.consumer_key == consumer_key; - }); - - if (options.length == 0) { - logger.error('Twitter Auth','Cannot find a configuration for the provided consumer_key'); - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Twitter auth is invalid for this user.'); - } - options = options[0]; - } - return options; -} - -module.exports = { - validateAppId, - validateAuthData, - handleMultipleConfigurations -}; diff --git a/src/batch.js b/src/batch.js index bf61b9e999..00740c9cda 100644 --- a/src/batch.js +++ b/src/batch.js @@ -1,21 +1,83 @@ -var Parse = require('parse/node').Parse; - +const Parse = require('parse/node').Parse; +const path = require('path'); // These methods handle batch requests. -var batchPath = '/batch'; +const batchPath = '/batch'; // Mounts a batch-handler onto a PromiseRouter. function mountOnto(router) { - router.route('POST', batchPath, (req) => { + router.route('POST', batchPath, req => { return handleBatch(router, req); }); } +function parseURL(urlString) { + try { + return new URL(urlString); + } catch { + return undefined; + } +} + +function makeBatchRoutingPathFunction(originalUrl, serverURL, publicServerURL) { + serverURL = serverURL ? parseURL(serverURL) : undefined; + publicServerURL = publicServerURL ? parseURL(publicServerURL) : undefined; + + const apiPrefixLength = originalUrl.length - batchPath.length; + let apiPrefix = originalUrl.slice(0, apiPrefixLength); + + const makeRoutablePath = function (requestPath) { + // The routablePath is the path minus the api prefix + if (requestPath.slice(0, apiPrefix.length) != apiPrefix) { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'cannot route batch path ' + requestPath); + } + return path.posix.join('/', requestPath.slice(apiPrefix.length)); + }; + + if (serverURL && publicServerURL && serverURL.pathname != publicServerURL.pathname) { + const localPath = serverURL.pathname; + const publicPath = publicServerURL.pathname; + + // Override the api prefix + apiPrefix = localPath; + return function (requestPath) { + // Figure out which server url was used by figuring out which + // path more closely matches requestPath + const startsWithLocal = requestPath.startsWith(localPath); + const startsWithPublic = requestPath.startsWith(publicPath); + const pathLengthToUse = + startsWithLocal && startsWithPublic + ? Math.max(localPath.length, publicPath.length) + : startsWithLocal + ? localPath.length + : publicPath.length; + + const newPath = path.posix.join('/', localPath, '/', requestPath.slice(pathLengthToUse)); + + // Use the method for local routing + return makeRoutablePath(newPath); + }; + } + + return makeRoutablePath; +} + // Returns a promise for a {response} object. // TODO: pass along auth correctly -function handleBatch(router, req) { - if (!req.body.requests instanceof Array) { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'requests must be an array'); +async function handleBatch(router, req) { + if (!Array.isArray(req.body?.requests)) { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'requests must be an array'); + } + const batchRequestLimit = req.config?.requestComplexity?.batchRequestLimit ?? -1; + if (batchRequestLimit > -1 && !req.auth?.isMaster && !req.auth?.isMaintenance && req.body.requests.length > batchRequestLimit) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `Batch request contains ${req.body.requests.length} sub-requests, which exceeds the limit of ${batchRequestLimit}.` + ); + } + for (const restRequest of req.body.requests) { + if (!restRequest || typeof restRequest !== 'object' || typeof restRequest.path !== 'string') { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'batch request path must be a string'); + } } // The batch paths are all from the root of our domain. @@ -26,48 +88,118 @@ function handleBatch(router, req) { if (!req.originalUrl.endsWith(batchPath)) { throw 'internal routing problem - expected url to end with batch'; } - var apiPrefixLength = req.originalUrl.length - batchPath.length; - var apiPrefix = req.originalUrl.slice(0, apiPrefixLength); - var promises = []; - for (var restRequest of req.body.requests) { - // The routablePath is the path minus the api prefix - if (restRequest.path.slice(0, apiPrefixLength) != apiPrefix) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - 'cannot route batch path ' + restRequest.path); + const makeRoutablePath = makeBatchRoutingPathFunction( + req.originalUrl, + req.config.serverURL, + req.config.publicServerURL + ); + + // Enforce rate limits for each batch sub-request by invoking the + // rate limit handler. This ensures sub-requests consume tokens from + // the same window state as direct requests. + const rateLimits = req.config.rateLimits || []; + for (const restRequest of req.body.requests) { + const routablePath = makeRoutablePath(restRequest.path); + if ((restRequest.method || 'GET').toUpperCase() === 'POST' && routablePath === batchPath) { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'nested batch requests are not allowed'); } - var routablePath = restRequest.path.slice(apiPrefixLength); - - // Use the router to figure out what handler to use - var match = router.match(restRequest.method, routablePath); - if (!match) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - 'cannot route ' + restRequest.method + ' ' + routablePath); + for (const limit of rateLimits) { + const pathExp = limit.path.regexp || limit.path; + if (!pathExp.test(routablePath)) { + continue; + } + const info = { ...req.info }; + if (routablePath === '/login') { + delete info.sessionToken; + } + const fakeReq = { + ip: req.ip || req.config?.ip || '127.0.0.1', + method: (restRequest.method || 'GET').toUpperCase(), + _batchOriginalMethod: 'POST', + config: req.config, + auth: req.auth, + info, + }; + const fakeRes = { setHeader() {} }; + try { + await limit.handler(fakeReq, fakeRes, err => { + if (err) { + throw err; + } + }); + } catch { + throw new Parse.Error( + Parse.Error.CONNECTION_FAILED, + limit.errorResponseMessage || 'Too many requests' + ); + } } + } - // Construct a request that we can send to a handler - var request = { - body: restRequest.body, - params: match.params, - config: req.config, - auth: req.auth, - info: req.info - }; + const batch = transactionRetries => { + let initialPromise = Promise.resolve(); + if (req.body?.transaction === true) { + initialPromise = req.config.database.createTransactionalSession(); + } - promises.push(match.handler(request).then((response) => { - return {success: response.response}; - }, (error) => { - return {error: {code: error.code, error: error.message}}; - })); - } + return initialPromise.then(() => { + const promises = req.body?.requests.map(restRequest => { + const routablePath = makeRoutablePath(restRequest.path); - return Promise.all(promises).then((results) => { - return {response: results}; - }); + // Construct a request that we can send to a handler + const request = { + body: restRequest.body, + config: req.config, + auth: req.auth, + info: req.info, + }; + + return router.tryRouteRequest(restRequest.method, routablePath, request).then( + response => { + return { success: response.response }; + }, + error => { + return { error: { code: error.code, error: error.message } }; + } + ); + }); + + return Promise.all(promises) + .then(results => { + if (req.body?.transaction === true) { + if (results.find(result => typeof result.error === 'object')) { + return req.config.database.abortTransactionalSession().then(() => { + return Promise.reject({ response: results }); + }); + } else { + return req.config.database.commitTransactionalSession().then(() => { + return { response: results }; + }); + } + } else { + return { response: results }; + } + }) + .catch(error => { + if ( + error && + error.response && + error.response.find( + errorItem => typeof errorItem.error === 'object' && errorItem.error.code === 251 + ) && + transactionRetries > 0 + ) { + return batch(transactionRetries - 1); + } + throw error; + }); + }); + }; + return batch(5); } module.exports = { - mountOnto: mountOnto + mountOnto, + makeBatchRoutingPathFunction, }; diff --git a/src/cache.js b/src/cache.js index 96b00b4534..71c01f4a14 100644 --- a/src/cache.js +++ b/src/cache.js @@ -1,4 +1,4 @@ -import {InMemoryCache} from './Adapters/Cache/InMemoryCache'; +import { InMemoryCache } from './Adapters/Cache/InMemoryCache'; -export var AppCache = new InMemoryCache({ttl: NaN}); +export var AppCache = new InMemoryCache({ ttl: NaN }); export default AppCache; diff --git a/src/cli/cli-definitions.js b/src/cli/cli-definitions.js deleted file mode 100644 index f0b7d0deee..0000000000 --- a/src/cli/cli-definitions.js +++ /dev/null @@ -1,206 +0,0 @@ -function numberParser(key) { - return function(opt) { - opt = parseInt(opt); - if (!Number.isInteger(opt)) { - throw new Error(`The ${key} is invalid`); - } - return opt; - } -} - -function objectParser(opt) { - if (typeof opt == 'object') { - return opt; - } - return JSON.parse(opt) -} - -function moduleOrObjectParser(opt) { - if (typeof opt == 'object') { - return opt; - } - try { - return JSON.parse(opt); - } catch(e) {} - return opt; -} - -function booleanParser(opt) { - if (opt == true || opt == "true" || opt == "1") { - return true; - } - return false; -} - -export default { - "appId": { - env: "PARSE_SERVER_APPLICATION_ID", - help: "Your Parse Application ID", - required: true - }, - "masterKey": { - env: "PARSE_SERVER_MASTER_KEY", - help: "Your Parse Master Key", - required: true - }, - "port": { - env: "PORT", - help: "The port to run the ParseServer. defaults to 1337.", - default: 1337, - action: numberParser("port") - }, - "databaseURI": { - env: "PARSE_SERVER_DATABASE_URI", - help: "The full URI to your mongodb database" - }, - "databaseOptions": { - env: "PARSE_SERVER_DATABASE_OPTIONS", - help: "Options to pass to the mongodb client", - action: objectParser - }, - "collectionPrefix": { - env: "PARSE_SERVER_COLLECTION_PREFIX", - help: 'A collection prefix for the classes' - }, - "serverURL": { - env: "PARSE_SERVER_URL", - help: "URL to your parse server with http:// or https://.", - }, - "publicServerURL": { - env: "PARSE_PUBLIC_SERVER_URL", - help: "Public URL to your parse server with http:// or https://.", - }, - "clientKey": { - env: "PARSE_SERVER_CLIENT_KEY", - help: "Key for iOS, MacOS, tvOS clients" - }, - "javascriptKey": { - env: "PARSE_SERVER_JAVASCRIPT_KEY", - help: "Key for the Javascript SDK" - }, - "restAPIKey": { - env: "PARSE_SERVER_REST_API_KEY", - help: "Key for REST calls" - }, - "dotNetKey": { - env: "PARSE_SERVER_DOT_NET_KEY", - help: "Key for Unity and .Net SDK" - }, - "webhookKey": { - env: "PARSE_SERVER_WEBHOOK_KEY", - help: "Key sent with outgoing webhook calls" - }, - "cloud": { - env: "PARSE_SERVER_CLOUD_CODE_MAIN", - help: "Full path to your cloud code main.js" - }, - "push": { - env: "PARSE_SERVER_PUSH", - help: "Configuration for push, as stringified JSON. See https://github.com/ParsePlatform/parse-server/wiki/Push", - action: objectParser - }, - "oauth": { - env: "PARSE_SERVER_OAUTH_PROVIDERS", - help: "Configuration for your oAuth providers, as stringified JSON. See https://github.com/ParsePlatform/parse-server/wiki/Parse-Server-Guide#oauth", - action: objectParser - }, - "fileKey": { - env: "PARSE_SERVER_FILE_KEY", - help: "Key for your files", - }, - "facebookAppIds": { - env: "PARSE_SERVER_FACEBOOK_APP_IDS", - help: "Comma separated list for your facebook app Ids", - type: "list", - action: function(opt) { - return opt.split(",") - } - }, - "enableAnonymousUsers": { - env: "PARSE_SERVER_ENABLE_ANON_USERS", - help: "Enable (or disable) anon users, defaults to true", - action: booleanParser - }, - "allowClientClassCreation": { - env: "PARSE_SERVER_ALLOW_CLIENT_CLASS_CREATION", - help: "Enable (or disable) client class creation, defaults to true", - action: booleanParser - }, - "mountPath": { - env: "PARSE_SERVER_MOUNT_PATH", - help: "Mount path for the server, defaults to /parse", - default: "/parse" - }, - "filesAdapter": { - env: "PARSE_SERVER_FILES_ADAPTER", - help: "Adapter module for the files sub-system", - action: moduleOrObjectParser - }, - "emailAdapter": { - env: "PARSE_SERVER_EMAIL_ADAPTER", - help: "Adapter module for the email sending", - action: moduleOrObjectParser - }, - "verifyUserEmails": { - env: "PARSE_SERVER_VERIFY_USER_EMAILS", - help: "Enable (or disable) user email validation, defaults to false", - action: booleanParser - }, - "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: booleanParser - }, - "emailVerifyTokenValidityDuration": { - env: "PARSE_SERVER_EMAIL_VERIFY_TOKEN_VALIDITY_DURATION", - help: "Email verification token validity duration", - action: numberParser("emailVerifyTokenValidityDuration") - }, - "appName": { - env: "PARSE_SERVER_APP_NAME", - help: "Sets the app name" - }, - "loggerAdapter": { - env: "PARSE_SERVER_LOGGER_ADAPTER", - help: "Adapter module for the logging sub-system", - action: moduleOrObjectParser - }, - "liveQuery": { - env: "PARSE_SERVER_LIVE_QUERY_OPTIONS", - help: "liveQuery options", - action: objectParser - }, - "customPages": { - env: "PARSE_SERVER_CUSTOM_PAGES", - help: "custom pages for pasword validation and reset", - action: objectParser - }, - "maxUploadSize": { - env: "PARSE_SERVER_MAX_UPLOAD_SIZE", - help: "Max file size for uploads.", - default: "20mb" - }, - "sessionLength": { - env: "PARSE_SERVER_SESSION_LENGTH", - help: "Session duration, defaults to 1 year", - action: numberParser("sessionLength") - }, - "verbose": { - env: "VERBOSE", - help: "Set the logging to verbose" - }, - "jsonLogs": { - env: "JSON_LOGS", - help: "Log as structured JSON objects" - }, - "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: booleanParser - }, - "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 0; disabled.", - action: numberParser("schemaCacheTTL"), - } -}; diff --git a/src/cli/definitions/parse-live-query-server.js b/src/cli/definitions/parse-live-query-server.js new file mode 100644 index 0000000000..0fd2fca6c9 --- /dev/null +++ b/src/cli/definitions/parse-live-query-server.js @@ -0,0 +1,2 @@ +const LiveQueryServerOptions = require('../../Options/Definitions').LiveQueryServerOptions; +export default LiveQueryServerOptions; diff --git a/src/cli/definitions/parse-server.js b/src/cli/definitions/parse-server.js new file mode 100644 index 0000000000..d19dcc5d8a --- /dev/null +++ b/src/cli/definitions/parse-server.js @@ -0,0 +1,2 @@ +const ParseServerDefinitions = require('../../Options/Definitions').ParseServerOptions; +export default ParseServerDefinitions; diff --git a/src/cli/parse-live-query-server.js b/src/cli/parse-live-query-server.js new file mode 100644 index 0000000000..525a202a26 --- /dev/null +++ b/src/cli/parse-live-query-server.js @@ -0,0 +1,11 @@ +import definitions from './definitions/parse-live-query-server'; +import runner from './utils/runner'; +import { ParseServer } from '../index'; + +runner({ + definitions, + start: function (program, options, logOptions) { + logOptions(); + ParseServer.createLiveQueryServer(undefined, options); + }, +}); diff --git a/src/cli/parse-server.js b/src/cli/parse-server.js index 1ab5403b92..7c7639b497 100755 --- a/src/cli/parse-server.js +++ b/src/cli/parse-server.js @@ -1,21 +1,15 @@ -import path from 'path'; -import express from 'express'; -import { ParseServer } from '../index'; -import definitions from './cli-definitions'; -import program from './utils/commander'; -import { mergeWithOptions } from './utils/commander'; -import colors from 'colors'; +/* eslint-disable no-console */ +import ParseServer from '../index'; +import definitions from './definitions/parse-server'; +import cluster from 'cluster'; +import os from 'os'; +import runner from './utils/runner'; -program.loadDefinitions(definitions); - -program - .usage('[options] '); - -program.on('--help', function(){ +const help = function () { console.log(' Get Started guide:'); console.log(''); - console.log(' Please have a look at the get started guide!') - console.log(' https://github.com/ParsePlatform/parse-server/wiki/Parse-Server-Guide'); + console.log(' Please have a look at the get started guide!'); + console.log(' http://docs.parseplatform.org/parse-server/guide/'); console.log(''); console.log(''); console.log(' Usage with npm start'); @@ -31,46 +25,94 @@ program.on('--help', function(){ console.log(' $ parse-server -- --appId APP_ID --masterKey MASTER_KEY --serverURL serverURL'); console.log(' $ parse-server -- --appId APP_ID --masterKey MASTER_KEY --serverURL serverURL'); console.log(''); -}); - -program.parse(process.argv, process.env); - -let options = program.getOptions(); +}; -if (!options.serverURL) { - options.serverURL = `http://localhost:${options.port}${options.mountPath}`; -} +runner({ + definitions, + help, + usage: '[options] ', + start: function (program, options, logOptions) { -if (!options.appId || !options.masterKey || !options.serverURL) { - program.outputHelp(); - console.error(""); - console.error(colors.red("ERROR: appId and masterKey are required")); - console.error(""); - process.exit(1); -} + if (!options.appId || !options.masterKey) { + program.outputHelp(); + console.error(''); + console.error('\u001b[31mERROR: appId and masterKey are required\u001b[0m'); + console.error(''); + process.exit(1); + } -const app = express(); -const api = new ParseServer(options); -app.use(options.mountPath, api); + if (options['liveQuery.classNames']) { + options.liveQuery = options.liveQuery || {}; + options.liveQuery.classNames = options['liveQuery.classNames']; + delete options['liveQuery.classNames']; + } + if (options['liveQuery.redisURL']) { + options.liveQuery = options.liveQuery || {}; + options.liveQuery.redisURL = options['liveQuery.redisURL']; + delete options['liveQuery.redisURL']; + } + if (options['liveQuery.redisOptions']) { + options.liveQuery = options.liveQuery || {}; + options.liveQuery.redisOptions = options['liveQuery.redisOptions']; + delete options['liveQuery.redisOptions']; + } -var server = app.listen(options.port, function() { + if (options.cluster) { + const numCPUs = typeof options.cluster === 'number' ? options.cluster : os.cpus().length; + if (cluster.isMaster) { + logOptions(); + for (let i = 0; i < numCPUs; i++) { + cluster.fork(); + } + cluster.on('exit', (worker, code) => { + console.log(`worker ${worker.process.pid} died (${code})... Restarting`); + cluster.fork(); + }); + } else { + ParseServer.startApp(options) + .then(() => { + printSuccessMessage(); + }) + .catch(e => { + console.error(e); + process.exit(1); + }); + } + } else { + ParseServer.startApp(options) + .then(() => { + logOptions(); + console.log(''); + printSuccessMessage(); + }) + .catch(e => { + console.error(e); + process.exit(1); + }); + } - for (let key in options) { - let value = options[key]; - if (key == "masterKey") { - value = "***REDACTED***"; + function printSuccessMessage() { + console.log('[' + process.pid + '] parse-server running on ' + options.serverURL); + if (options.mountGraphQL) { + console.log( + '[' + + process.pid + + '] GraphQL running on http://localhost:' + + options.port + + options.graphQLPath + ); + } + if (options.mountPlayground) { + console.log( + '[' + + process.pid + + '] Playground running on http://localhost:' + + options.port + + options.playgroundPath + ); + } } - console.log(`${key}: ${value}`); - } - console.log(''); - console.log('parse-server running on '+options.serverURL); + }, }); -var handleShutdown = function() { - console.log('Termination signal received. Shutting down.'); - server.close(function () { - process.exit(0); - }); -}; -process.on('SIGTERM', handleShutdown); -process.on('SIGINT', handleShutdown); +/* eslint-enable no-console */ diff --git a/src/cli/utils/commander.js b/src/cli/utils/commander.js index 12ea5ea794..e2e06e0550 100644 --- a/src/cli/utils/commander.js +++ b/src/cli/utils/commander.js @@ -1,59 +1,70 @@ +/* eslint-disable no-console */ import { Command } from 'commander'; import path from 'path'; +import Deprecator from '../../Deprecator/Deprecator'; + let _definitions; let _reverseDefinitions; let _defaults; -Command.prototype.loadDefinitions = function(definitions) { +Command.prototype.loadDefinitions = function (definitions) { _definitions = definitions; Object.keys(definitions).reduce((program, opt) => { - if (typeof definitions[opt] == "object") { + if (typeof definitions[opt] == 'object') { const additionalOptions = definitions[opt]; if (additionalOptions.required === true) { - return program.option(`--${opt} <${opt}>`, additionalOptions.help, additionalOptions.action); + return program.option( + `--${opt} <${opt}>`, + additionalOptions.help, + additionalOptions.action + ); } else { - return program.option(`--${opt} [${opt}]`, additionalOptions.help, additionalOptions.action); + return program.option( + `--${opt} [${opt}]`, + additionalOptions.help, + additionalOptions.action + ); } } return program.option(`--${opt} [${opt}]`); }, this); + _reverseDefinitions = Object.keys(definitions).reduce((object, key) => { + let value = definitions[key]; + if (typeof value == 'object') { + value = value.env; + } + if (value) { + object[value] = key; + } + return object; + }, {}); + _defaults = Object.keys(definitions).reduce((defs, opt) => { - if(_definitions[opt].default) { + if (_definitions[opt].default !== undefined) { defs[opt] = _definitions[opt].default; } return defs; }, {}); - _reverseDefinitions = Object.keys(definitions).reduce((object, key) => { - let value = definitions[key]; - if (typeof value == "object") { - value = value.env; - } - if (value) { - object[value] = key; - } - return object; - }, {}); - - /* istanbul ignore next */ - this.on('--help', function(){ + /* istanbul ignore next */ + this.on('--help', function () { console.log(' Configure From Environment:'); console.log(''); - Object.keys(_reverseDefinitions).forEach((key) => { + Object.keys(_reverseDefinitions).forEach(key => { console.log(` $ ${key}='${_reverseDefinitions[key]}'`); }); console.log(''); }); -} +}; function parseEnvironment(env = {}) { return Object.keys(_reverseDefinitions).reduce((options, key) => { if (env[key]) { const originalKey = _reverseDefinitions[key]; - let action = (option) => (option); - if (typeof _definitions[originalKey] === "object") { + let action = option => option; + if (typeof _definitions[originalKey] === 'object') { action = _definitions[originalKey].action || action; } options[_reverseDefinitions[key]] = action(env[key]); @@ -67,7 +78,7 @@ function parseConfigFile(program) { if (program.args.length > 0) { let jsonPath = program.args[0]; jsonPath = path.resolve(jsonPath); - let jsonConfig = require(jsonPath); + const jsonConfig = require(jsonPath); if (jsonConfig.apps) { if (jsonConfig.apps.length > 1) { throw 'Multiple apps are not supported'; @@ -76,32 +87,32 @@ function parseConfigFile(program) { } else { options = jsonConfig; } - Object.keys(options).forEach((key) => { - let value = options[key]; + Object.keys(options).forEach(key => { + const value = options[key]; if (!_definitions[key]) { throw `error: unknown option ${key}`; } - let action = _definitions[key].action; + const action = _definitions[key].action; if (action) { options[key] = action(value); } - }) - console.log(`Configuration loaded from ${jsonPath}`) + }); + console.log(`Configuration loaded from ${jsonPath}`); } return options; } -Command.prototype.setValuesIfNeeded = function(options) { - Object.keys(options).forEach((key) => { - if (!this[key]) { - this[key] = options[key]; - } +Command.prototype.setValuesIfNeeded = function (options) { + Object.keys(options).forEach(key => { + if (!Object.prototype.hasOwnProperty.call(this, key)) { + this[key] = options[key]; + } }); -} +}; Command.prototype._parse = Command.prototype.parse; -Command.prototype.parse = function(args, env) { +Command.prototype.parse = function (args, env) { this._parse(args); // Parse the environment first const envOptions = parseEnvironment(env); @@ -110,17 +121,23 @@ Command.prototype.parse = function(args, env) { this.setValuesIfNeeded(envOptions); // Load from file to override this.setValuesIfNeeded(fromFile); + // Scan for deprecated Parse Server options + Deprecator.scanParseServerOptions(this); // Last set the defaults this.setValuesIfNeeded(_defaults); -} +}; -Command.prototype.getOptions = function() { +Command.prototype.getOptions = function () { return Object.keys(_definitions).reduce((options, key) => { if (typeof this[key] !== 'undefined') { options[key] = this[key]; } return options; }, {}); -} +}; -export default new Command(); +const commander = new Command() +commander.storeOptionsAsProperties(); +commander.allowExcessArguments(); +export default commander; +/* eslint-enable no-console */ diff --git a/src/cli/utils/runner.js b/src/cli/utils/runner.js new file mode 100644 index 0000000000..6b18012af5 --- /dev/null +++ b/src/cli/utils/runner.js @@ -0,0 +1,49 @@ +import program from './commander'; + +function logStartupOptions(options) { + if (!options.verbose) { + return; + } + // Keys that may include sensitive information that will be redacted in logs + const keysToRedact = [ + 'databaseAdapter', + 'databaseURI', + 'masterKey', + 'maintenanceKey', + 'push', + ]; + for (const key in options) { + let value = options[key]; + if (keysToRedact.includes(key)) { + value = ''; + } + if (typeof value === 'object') { + try { + value = JSON.stringify(value); + } catch { + if (value && value.constructor && value.constructor.name) { + value = value.constructor.name; + } + } + } + /* eslint-disable no-console */ + console.log(`${key}: ${value}`); + /* eslint-enable no-console */ + } +} + +export default function ({ definitions, help, usage, start }) { + program.loadDefinitions(definitions); + if (usage) { + program.usage(usage); + } + if (help) { + program.on('--help', help); + } + program.parse(process.argv, process.env); + + const options = program.getOptions(); + start(program, options, function () { + logStartupOptions(options); + }); +} diff --git a/src/cloud-code/HTTPResponse.js b/src/cloud-code/HTTPResponse.js deleted file mode 100644 index 8359860ee6..0000000000 --- a/src/cloud-code/HTTPResponse.js +++ /dev/null @@ -1,49 +0,0 @@ - -export default class HTTPResponse { - constructor(response, body) { - let _text, _data; - this.status = response.statusCode; - this.headers = response.headers || {}; - this.cookies = this.headers["set-cookie"]; - - if (typeof body == 'string') { - _text = body; - } else if (Buffer.isBuffer(body)) { - this.buffer = body; - } else if (typeof body == 'object') { - _data = body; - } - - let getText = () => { - if (!_text && this.buffer) { - _text = this.buffer.toString('utf-8'); - } else if (!_text && _data) { - _text = JSON.stringify(_data); - } - return _text; - } - - let getData = () => { - if (!_data) { - try { - _data = JSON.parse(getText()); - } catch (e) {} - } - return _data; - } - - Object.defineProperty(this, 'body', { - get: () => { return body } - }); - - Object.defineProperty(this, 'text', { - enumerable: true, - get: getText - }); - - Object.defineProperty(this, 'data', { - enumerable: true, - get: getData - }); - } -} diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index ca323f0ac5..e829a492e0 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -1,55 +1,872 @@ -import { Parse } from 'parse/node'; +import { Parse } from 'parse/node'; import * as triggers from '../triggers'; +import { addRateLimit } from '../middlewares'; +const Config = require('../Config'); -function validateClassNameForTriggers(className) { - const restrictedClassNames = [ '_Session' ]; - if (restrictedClassNames.indexOf(className) != -1) { - throw `Triggers are not supported for ${className} class.`; - } - return className; +function isParseObjectConstructor(object) { + return typeof object === 'function' && Object.prototype.hasOwnProperty.call(object, 'className'); } -function getClassName(parseClass) { - if (parseClass && parseClass.className) { - return validateClassNameForTriggers(parseClass.className); +function validateValidator(validator) { + if (!validator || typeof validator === 'function') { + return; + } + const fieldOptions = { + type: ['Any'], + constant: [Boolean], + default: ['Any'], + options: [Array, 'function', 'Any'], + required: [Boolean], + error: [String], + }; + const allowedKeys = { + requireUser: [Boolean], + requireAnyUserRoles: [Array, 'function'], + requireAllUserRoles: [Array, 'function'], + requireMaster: [Boolean], + validateMasterKey: [Boolean], + skipWithMasterKey: [Boolean], + requireUserKeys: [Array, Object], + fields: [Array, Object], + rateLimit: [Object], + }; + const getType = fn => { + if (Array.isArray(fn)) { + return 'array'; + } + if (fn === 'Any' || fn === 'function') { + return fn; + } + const type = typeof fn; + if (typeof fn === 'function') { + const match = fn && fn.toString().match(/^\s*function (\w+)/); + return (match ? match[1] : 'function').toLowerCase(); + } + return type; + }; + const checkKey = (key, data, validatorParam) => { + const parameter = data[key]; + if (!parameter) { + throw `${key} is not a supported parameter for Cloud Function validations.`; + } + const types = parameter.map(type => getType(type)); + const type = getType(validatorParam); + if (!types.includes(type) && !types.includes('Any')) { + throw `Invalid type for Cloud Function validation key ${key}. Expected ${types.join( + '|' + )}, actual ${type}`; + } + }; + for (const key in validator) { + checkKey(key, allowedKeys, validator[key]); + if (key === 'fields' || key === 'requireUserKeys') { + const values = validator[key]; + if (Array.isArray(values)) { + continue; + } + for (const value in values) { + const data = values[value]; + for (const subKey in data) { + checkKey(subKey, fieldOptions, data[subKey]); + } + } + } } - return validateClassNameForTriggers(parseClass); } +const getRoute = parseClass => { + const route = + { + _User: 'users', + _Session: 'sessions', + '@File': 'files', + '@Config' : 'config', + }[parseClass] || 'classes'; + if (parseClass === '@File') { + return `/${route}{/*id}`; + } + if (parseClass === '@Config') { + return `/${route}`; + } + return `/${route}/${parseClass}{/*id}`; +}; +/** @namespace + * @name Parse + * @description The Parse SDK. + * see [api docs](https://docs.parseplatform.org/js/api) and [guide](https://docs.parseplatform.org/js/guide) + */ + +/** @namespace + * @name Parse.Cloud + * @memberof Parse + * @description The Parse Cloud Code SDK. + */ var ParseCloud = {}; -ParseCloud.define = function(functionName, handler, validationHandler) { +/** + * Defines a Cloud Function. + * + * **Available in Cloud Code only.** + * + * **Traditional Style:** + * ``` + * Parse.Cloud.define('functionName', (request) => { + * // code here + * return result; + * }, (request) => { + * // validation code here + * }); + * + * Parse.Cloud.define('functionName', (request) => { + * // code here + * return result; + * }, { ...validationObject }); + * ``` + * + * **Express Style with Custom HTTP Status Codes:** + * ``` + * Parse.Cloud.define('functionName', (request, response) => { + * // Set custom HTTP status code and send response + * response.status(201).success({ message: 'Created' }); + * }); + * + * Parse.Cloud.define('unauthorizedFunction', (request, response) => { + * if (!request.user) { + * response.status(401).error('Unauthorized'); + * } else { + * response.success({ data: 'OK' }); + * } + * }); + * + * Parse.Cloud.define('withCustomHeaders', (request, response) => { + * response.header('X-Custom-Header', 'value').success({ data: 'OK' }); + * }); + * + * Parse.Cloud.define('errorFunction', (request, response) => { + * response.error('Something went wrong'); + * }); + * ``` + * + * @static + * @memberof Parse.Cloud + * @param {String} name The name of the Cloud Function + * @param {Function} data The Cloud Function to register. This function can be an async function and should take one parameter a {@link Parse.Cloud.FunctionRequest}, or two parameters (request, response) for Express-style functions where response is a {@link Parse.Cloud.FunctionResponse}. + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.FunctionRequest}, or a {@link Parse.Cloud.ValidatorObject}. + */ +ParseCloud.define = function (functionName, handler, validationHandler) { + validateValidator(validationHandler); triggers.addFunction(functionName, handler, validationHandler, Parse.applicationId); + if (validationHandler && validationHandler.rateLimit) { + addRateLimit( + { requestPath: `/functions/${functionName}`, ...validationHandler.rateLimit }, + Parse.applicationId, + true + ); + } +}; + +/** + * Defines a Background Job. + * + * **Available in Cloud Code only.** + * + * @method job + * @name Parse.Cloud.job + * @param {String} name The name of the Background Job + * @param {Function} func The Background Job to register. This function can be async should take a single parameters a {@link Parse.Cloud.JobRequest} + * + */ +ParseCloud.job = function (functionName, handler) { + triggers.addJob(functionName, handler, Parse.applicationId); +}; + +/** + * + * Registers a before save function. + * + * **Available in Cloud Code only.** + * + * If you want to use beforeSave for a predefined class in the Parse JavaScript SDK (e.g. {@link Parse.User} or {@link Parse.File}), you should pass the class itself and not the String for arg1. + * + * ``` + * Parse.Cloud.beforeSave('MyCustomClass', (request) => { + * // code here + * }, (request) => { + * // validation code here + * }); + * + * Parse.Cloud.beforeSave(Parse.User, (request) => { + * // code here + * }, { ...validationObject }) + * ``` + * + * @method beforeSave + * @name Parse.Cloud.beforeSave + * @param {(String|Parse.Object)} arg1 The Parse.Object subclass to register the after save function for. This can instead be a String that is the className of the subclass. + * @param {Function} func The function to run before a save. This function can be async and should take one parameter a {@link Parse.Cloud.TriggerRequest}; + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.TriggerRequest}, or a {@link Parse.Cloud.ValidatorObject}. + */ +ParseCloud.beforeSave = function (parseClass, handler, validationHandler) { + const className = triggers.getClassName(parseClass); + validateValidator(validationHandler); + triggers.addTrigger( + triggers.Types.beforeSave, + className, + handler, + Parse.applicationId, + validationHandler + ); + if (validationHandler && validationHandler.rateLimit) { + addRateLimit( + { + requestPath: getRoute(className), + requestMethods: ['POST', 'PUT'], + ...validationHandler.rateLimit, + }, + Parse.applicationId, + true + ); + } +}; + +/** + * Registers a before delete function. + * + * **Available in Cloud Code only.** + * + * If you want to use beforeDelete for a predefined class in the Parse JavaScript SDK (e.g. {@link Parse.User} or {@link Parse.File}), you should pass the class itself and not the String for arg1. + * ``` + * Parse.Cloud.beforeDelete('MyCustomClass', (request) => { + * // code here + * }, (request) => { + * // validation code here + * }); + * + * Parse.Cloud.beforeDelete(Parse.User, (request) => { + * // code here + * }, { ...validationObject }) + *``` + * + * @method beforeDelete + * @name Parse.Cloud.beforeDelete + * @param {(String|Parse.Object)} arg1 The Parse.Object subclass to register the before delete function for. This can instead be a String that is the className of the subclass. + * @param {Function} func The function to run before a delete. This function can be async and should take one parameter, a {@link Parse.Cloud.TriggerRequest}. + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.TriggerRequest}, or a {@link Parse.Cloud.ValidatorObject}. + */ +ParseCloud.beforeDelete = function (parseClass, handler, validationHandler) { + const className = triggers.getClassName(parseClass); + validateValidator(validationHandler); + triggers.addTrigger( + triggers.Types.beforeDelete, + className, + handler, + Parse.applicationId, + validationHandler + ); + if (validationHandler && validationHandler.rateLimit) { + addRateLimit( + { + requestPath: getRoute(className), + requestMethods: 'DELETE', + ...validationHandler.rateLimit, + }, + Parse.applicationId, + true + ); + } +}; + +/** + * + * Registers the before login function. + * + * **Available in Cloud Code only.** + * + * This function provides further control + * in validating a login attempt. Specifically, + * it is triggered after a user enters + * correct credentials (or other valid authData), + * but prior to a session being generated. + * + * ``` + * Parse.Cloud.beforeLogin((request) => { + * // code here + * }) + * + * ``` + * + * @method beforeLogin + * @name Parse.Cloud.beforeLogin + * @param {Function} func The function to run before a login. This function can be async and should take one parameter a {@link Parse.Cloud.TriggerRequest}; + */ +ParseCloud.beforeLogin = function (handler, validationHandler) { + let className = '_User'; + if (typeof handler === 'string' || isParseObjectConstructor(handler)) { + // validation will occur downstream, this is to maintain internal + // code consistency with the other hook types. + className = triggers.getClassName(handler); + handler = arguments[1]; + validationHandler = arguments.length >= 2 ? arguments[2] : null; + } + triggers.addTrigger(triggers.Types.beforeLogin, className, handler, Parse.applicationId); + if (validationHandler && validationHandler.rateLimit) { + addRateLimit( + { requestPath: `/login`, requestMethods: ['POST', 'GET'], ...validationHandler.rateLimit }, + Parse.applicationId, + true + ); + } +}; + +/** + * + * Registers the after login function. + * + * **Available in Cloud Code only.** + * + * This function is triggered after a user logs in successfully, + * and after a _Session object has been created. + * + * ``` + * Parse.Cloud.afterLogin((request) => { + * // code here + * }); + * ``` + * + * @method afterLogin + * @name Parse.Cloud.afterLogin + * @param {Function} func The function to run after a login. This function can be async and should take one parameter a {@link Parse.Cloud.TriggerRequest}; + */ +ParseCloud.afterLogin = function (handler) { + let className = '_User'; + if (typeof handler === 'string' || isParseObjectConstructor(handler)) { + // validation will occur downstream, this is to maintain internal + // code consistency with the other hook types. + className = triggers.getClassName(handler); + handler = arguments[1]; + } + triggers.addTrigger(triggers.Types.afterLogin, className, handler, Parse.applicationId); +}; + +/** + * + * Registers the after logout function. + * + * **Available in Cloud Code only.** + * + * This function is triggered after a user logs out. + * + * ``` + * Parse.Cloud.afterLogout((request) => { + * // code here + * }); + * ``` + * + * @method afterLogout + * @name Parse.Cloud.afterLogout + * @param {Function} func The function to run after a logout. This function can be async and should take one parameter a {@link Parse.Cloud.TriggerRequest}; + */ +ParseCloud.afterLogout = function (handler) { + let className = '_Session'; + if (typeof handler === 'string' || isParseObjectConstructor(handler)) { + // validation will occur downstream, this is to maintain internal + // code consistency with the other hook types. + className = triggers.getClassName(handler); + handler = arguments[1]; + } + triggers.addTrigger(triggers.Types.afterLogout, className, handler, Parse.applicationId); +}; + +/** + * Registers the before password reset request function. + * + * **Available in Cloud Code only.** + * + * This function provides control in validating a password reset request + * before the reset email is sent. It is triggered after the user is found + * by email, but before the reset token is generated and the email is sent. + * + * Code example: + * + * ``` + * Parse.Cloud.beforePasswordResetRequest(request => { + * if (request.object.get('banned')) { + * throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User is banned.'); + * } + * }); + * ``` + * + * @method beforePasswordResetRequest + * @name Parse.Cloud.beforePasswordResetRequest + * @param {Function} func The function to run before a password reset request. This function can be async and should take one parameter a {@link Parse.Cloud.TriggerRequest}; + */ +ParseCloud.beforePasswordResetRequest = function (handler, validationHandler) { + let className = '_User'; + if (typeof handler === 'string' || isParseObjectConstructor(handler)) { + // validation will occur downstream, this is to maintain internal + // code consistency with the other hook types. + className = triggers.getClassName(handler); + handler = arguments[1]; + validationHandler = arguments.length >= 2 ? arguments[2] : null; + } + triggers.addTrigger(triggers.Types.beforePasswordResetRequest, className, handler, Parse.applicationId); + if (validationHandler && validationHandler.rateLimit) { + addRateLimit( + { requestPath: `/requestPasswordReset`, requestMethods: 'POST', ...validationHandler.rateLimit }, + Parse.applicationId, + true + ); + } }; -ParseCloud.beforeSave = function(parseClass, handler) { - var className = getClassName(parseClass); - triggers.addTrigger(triggers.Types.beforeSave, className, handler, Parse.applicationId); +/** + * Registers an after save function. + * + * **Available in Cloud Code only.** + * + * If you want to use afterSave for a predefined class in the Parse JavaScript SDK (e.g. {@link Parse.User} or {@link Parse.File}), you should pass the class itself and not the String for arg1. + * + * ``` + * Parse.Cloud.afterSave('MyCustomClass', async function(request) { + * // code here + * }, (request) => { + * // validation code here + * }); + * + * Parse.Cloud.afterSave(Parse.User, async function(request) { + * // code here + * }, { ...validationObject }); + * ``` + * + * @method afterSave + * @name Parse.Cloud.afterSave + * @param {(String|Parse.Object)} arg1 The Parse.Object subclass to register the after save function for. This can instead be a String that is the className of the subclass. + * @param {Function} func The function to run after a save. This function can be an async function and should take just one parameter, {@link Parse.Cloud.TriggerRequest}. + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.TriggerRequest}, or a {@link Parse.Cloud.ValidatorObject}. + */ +ParseCloud.afterSave = function (parseClass, handler, validationHandler) { + const className = triggers.getClassName(parseClass); + validateValidator(validationHandler); + triggers.addTrigger( + triggers.Types.afterSave, + className, + handler, + Parse.applicationId, + validationHandler + ); }; -ParseCloud.beforeDelete = function(parseClass, handler) { - var className = getClassName(parseClass); - triggers.addTrigger(triggers.Types.beforeDelete, className, handler, Parse.applicationId); +/** + * Registers an after delete function. + * + * **Available in Cloud Code only.** + * + * If you want to use afterDelete for a predefined class in the Parse JavaScript SDK (e.g. {@link Parse.User} or {@link Parse.File}), you should pass the class itself and not the String for arg1. + * ``` + * Parse.Cloud.afterDelete('MyCustomClass', async (request) => { + * // code here + * }, (request) => { + * // validation code here + * }); + * + * Parse.Cloud.afterDelete(Parse.User, async (request) => { + * // code here + * }, { ...validationObject }); + *``` + * + * @method afterDelete + * @name Parse.Cloud.afterDelete + * @param {(String|Parse.Object)} arg1 The Parse.Object subclass to register the after delete function for. This can instead be a String that is the className of the subclass. + * @param {Function} func The function to run after a delete. This function can be async and should take just one parameter, {@link Parse.Cloud.TriggerRequest}. + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.TriggerRequest}, or a {@link Parse.Cloud.ValidatorObject}. + */ +ParseCloud.afterDelete = function (parseClass, handler, validationHandler) { + const className = triggers.getClassName(parseClass); + validateValidator(validationHandler); + triggers.addTrigger( + triggers.Types.afterDelete, + className, + handler, + Parse.applicationId, + validationHandler + ); +}; + +/** + * Registers a before find function. + * + * **Available in Cloud Code only.** + * + * If you want to use beforeFind for a predefined class in the Parse JavaScript SDK (e.g. {@link Parse.User} or {@link Parse.File}), you should pass the class itself and not the String for arg1. + * ``` + * Parse.Cloud.beforeFind('MyCustomClass', async (request) => { + * // code here + * }, (request) => { + * // validation code here + * }); + * + * Parse.Cloud.beforeFind(Parse.User, async (request) => { + * // code here + * }, { ...validationObject }); + *``` + * + * @method beforeFind + * @name Parse.Cloud.beforeFind + * @param {(String|Parse.Object)} arg1 The Parse.Object subclass to register the before find function for. This can instead be a String that is the className of the subclass. + * @param {Function} func The function to run before a find. This function can be async and should take just one parameter, {@link Parse.Cloud.BeforeFindRequest}. + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.BeforeFindRequest}, or a {@link Parse.Cloud.ValidatorObject}. + */ +ParseCloud.beforeFind = function (parseClass, handler, validationHandler) { + const className = triggers.getClassName(parseClass); + validateValidator(validationHandler); + triggers.addTrigger( + triggers.Types.beforeFind, + className, + handler, + Parse.applicationId, + validationHandler + ); + if (validationHandler && validationHandler.rateLimit) { + addRateLimit( + { + requestPath: getRoute(className), + requestMethods: 'GET', + ...validationHandler.rateLimit, + }, + Parse.applicationId, + true + ); + } +}; + +/** + * Registers an after find function. + * + * **Available in Cloud Code only.** + * + * If you want to use afterFind for a predefined class in the Parse JavaScript SDK (e.g. {@link Parse.User} or {@link Parse.File}), you should pass the class itself and not the String for arg1. + * ``` + * Parse.Cloud.afterFind('MyCustomClass', async (request) => { + * // code here + * }, (request) => { + * // validation code here + * }); + * + * Parse.Cloud.afterFind(Parse.User, async (request) => { + * // code here + * }, { ...validationObject }); + *``` + * + * @method afterFind + * @name Parse.Cloud.afterFind + * @param {(String|Parse.Object)} arg1 The Parse.Object subclass to register the after find function for. This can instead be a String that is the className of the subclass. + * @param {Function} func The function to run before a find. This function can be async and should take just one parameter, {@link Parse.Cloud.AfterFindRequest}. + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.AfterFindRequest}, or a {@link Parse.Cloud.ValidatorObject}. + */ +ParseCloud.afterFind = function (parseClass, handler, validationHandler) { + const className = triggers.getClassName(parseClass); + validateValidator(validationHandler); + triggers.addTrigger( + triggers.Types.afterFind, + className, + handler, + Parse.applicationId, + validationHandler + ); +}; + +/** + * Registers a before live query server connect function. + * + * **Available in Cloud Code only.** + * + * ``` + * Parse.Cloud.beforeConnect(async (request) => { + * // code here + * }, (request) => { + * // validation code here + * }); + * + * Parse.Cloud.beforeConnect(async (request) => { + * // code here + * }, { ...validationObject }); + *``` + * + * @method beforeConnect + * @name Parse.Cloud.beforeConnect + * @param {Function} func The function to before connection is made. This function can be async and should take just one parameter, {@link Parse.Cloud.ConnectTriggerRequest}. + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.ConnectTriggerRequest}, or a {@link Parse.Cloud.ValidatorObject}. + */ +ParseCloud.beforeConnect = function (handler, validationHandler) { + validateValidator(validationHandler); + triggers.addConnectTrigger( + triggers.Types.beforeConnect, + handler, + Parse.applicationId, + validationHandler + ); +}; + +/** + * Sends an email through the Parse Server mail adapter. + * + * **Available in Cloud Code only.** + * **Requires a mail adapter to be configured for Parse Server.** + * + * ``` + * Parse.Cloud.sendEmail({ + * from: 'Example ', + * to: 'contact@example.com', + * subject: 'Test email', + * text: 'This email is a test.' + * }); + *``` + * + * @method sendEmail + * @name Parse.Cloud.sendEmail + * @param {Object} data The object of the mail data to send. + */ +ParseCloud.sendEmail = function (data) { + const config = Config.get(Parse.applicationId); + const emailAdapter = config.userController.adapter; + if (!emailAdapter) { + config.loggerController.error( + 'Failed to send email because no mail adapter is configured for Parse Server.' + ); + return; + } + return emailAdapter.sendMail(data); }; -ParseCloud.afterSave = function(parseClass, handler) { - var className = getClassName(parseClass); - triggers.addTrigger(triggers.Types.afterSave, className, handler, Parse.applicationId); +/** + * Registers a before live query subscription function. + * + * **Available in Cloud Code only.** + * + * If you want to use beforeSubscribe for a predefined class in the Parse JavaScript SDK (e.g. {@link Parse.User} or {@link Parse.File}), you should pass the class itself and not the String for arg1. + * ``` + * Parse.Cloud.beforeSubscribe('MyCustomClass', (request) => { + * // code here + * }, (request) => { + * // validation code here + * }); + * + * Parse.Cloud.beforeSubscribe(Parse.User, (request) => { + * // code here + * }, { ...validationObject }); + *``` + * + * @method beforeSubscribe + * @name Parse.Cloud.beforeSubscribe + * @param {(String|Parse.Object)} arg1 The Parse.Object subclass to register the before subscription function for. This can instead be a String that is the className of the subclass. + * @param {Function} func The function to run before a subscription. This function can be async and should take one parameter, a {@link Parse.Cloud.TriggerRequest}. + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.TriggerRequest}, or a {@link Parse.Cloud.ValidatorObject}. + */ +ParseCloud.beforeSubscribe = function (parseClass, handler, validationHandler) { + validateValidator(validationHandler); + const className = triggers.getClassName(parseClass); + triggers.addTrigger( + triggers.Types.beforeSubscribe, + className, + handler, + Parse.applicationId, + validationHandler + ); }; -ParseCloud.afterDelete = function(parseClass, handler) { - var className = getClassName(parseClass); - triggers.addTrigger(triggers.Types.afterDelete, className, handler, Parse.applicationId); +ParseCloud.onLiveQueryEvent = function (handler) { + triggers.addLiveQueryEventHandler(handler, Parse.applicationId); }; -ParseCloud._removeHook = function(category, name, type, applicationId) { - applicationId = applicationId || Parse.applicationId; - triggers._unregister(applicationId, category, name, type); +/** + * Registers an after live query server event function. + * + * **Available in Cloud Code only.** + * + * ``` + * Parse.Cloud.afterLiveQueryEvent('MyCustomClass', (request) => { + * // code here + * }, (request) => { + * // validation code here + * }); + * + * Parse.Cloud.afterLiveQueryEvent('MyCustomClass', (request) => { + * // code here + * }, { ...validationObject }); + *``` + * + * @method afterLiveQueryEvent + * @name Parse.Cloud.afterLiveQueryEvent + * @param {(String|Parse.Object)} arg1 The Parse.Object subclass to register the after live query event function for. This can instead be a String that is the className of the subclass. + * @param {Function} func The function to run after a live query event. This function can be async and should take one parameter, a {@link Parse.Cloud.LiveQueryEventTrigger}. + * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.LiveQueryEventTrigger}, or a {@link Parse.Cloud.ValidatorObject}. + */ +ParseCloud.afterLiveQueryEvent = function (parseClass, handler, validationHandler) { + const className = triggers.getClassName(parseClass); + validateValidator(validationHandler); + triggers.addTrigger( + triggers.Types.afterEvent, + className, + handler, + Parse.applicationId, + validationHandler + ); }; ParseCloud._removeAllHooks = () => { triggers._unregisterAll(); -} + const config = Config.get(Parse.applicationId); + config?.unregisterRateLimiters(); +}; -ParseCloud.httpRequest = require("./httpRequest"); +ParseCloud.useMasterKey = () => { + // eslint-disable-next-line + console.warn( + 'Parse.Cloud.useMasterKey is deprecated (and has no effect anymore) on parse-server, please refer to the cloud code migration notes: http://docs.parseplatform.org/parse-server/guide/#master-key-must-be-passed-explicitly' + ); +}; module.exports = ParseCloud; + +/** + * @interface Parse.Cloud.TriggerRequest + * @property {String} installationId If set, the installationId triggering the request. + * @property {Boolean} master If true, means the master key or the read-only master key was used. + * @property {Boolean} isReadOnly If true, means the read-only master key was used. This is a subset of `master`, so `master` will also be true. Use `master && !isReadOnly` to check for full master key access. + * @property {Boolean} isChallenge If true, means the current request is originally triggered by an auth challenge. + * @property {Parse.User} user If set, the user that made the request. + * @property {Parse.Object} object The object triggering the hook. + * @property {String} ip The IP address of the client making the request. To ensure retrieving the correct IP address, set the Parse Server option `trustProxy: true` if Parse Server runs behind a proxy server, for example behind a load balancer. + * @property {Object} headers The original HTTP headers for the request. + * @property {String} triggerName The name of the trigger (`beforeSave`, `afterSave`, ...) + * @property {Object} log The current logger inside Parse Server. + * @property {Parse.Object} original If set, the object, as currently stored. + * @property {Object} config The Parse Server config. + */ + +/** + * @interface Parse.Cloud.FileTriggerRequest + * @property {String} installationId If set, the installationId triggering the request. + * @property {Boolean} master If true, means the master key or the read-only master key was used. + * @property {Boolean} isReadOnly If true, means the read-only master key was used. This is a subset of `master`, so `master` will also be true. Use `master && !isReadOnly` to check for full master key access. + * @property {Parse.User} user If set, the user that made the request. + * @property {Parse.File} file The file that triggered the hook. + * @property {Integer} fileSize The size of the file in bytes. + * @property {Integer} contentLength The value from Content-Length header + * @property {String} ip The IP address of the client making the request. + * @property {Object} headers The original HTTP headers for the request. + * @property {String} triggerName The name of the trigger (`beforeSave`, `afterSave`) + * @property {Object} log The current logger inside Parse Server. + * @property {Object} config The Parse Server config. + * @property {Boolean} forceDownload (afterFind only) If set to `true`, the file response will include a `Content-Disposition: attachment` header, prompting the browser to download the file instead of displaying it inline. + * @property {Object} responseHeaders (afterFind only) The headers that will be set on the file response. By default contains `{ 'X-Content-Type-Options': 'nosniff' }`. Modify this object to add, change, or remove response headers. + */ + +/** + * @interface Parse.Cloud.ConnectTriggerRequest + * @property {String} installationId If set, the installationId triggering the request. + * @property {Boolean} useMasterKey If true, means the master key was used. + * @property {Parse.User} user If set, the user that made the request. + * @property {Integer} clients The number of clients connected. + * @property {Integer} subscriptions The number of subscriptions connected. + * @property {String} sessionToken If set, the session of the user that made the request. + */ + +/** + * @interface Parse.Cloud.LiveQueryEventTrigger + * @property {String} installationId If set, the installationId triggering the request. + * @property {Boolean} useMasterKey If true, means the master key was used. + * @property {Parse.User} user If set, the user that made the request. + * @property {String} sessionToken If set, the session of the user that made the request. + * @property {String} event The live query event that triggered the request. + * @property {Parse.Object} object The object triggering the hook. + * @property {Parse.Object} original If set, the object, as currently stored. + * @property {Integer} clients The number of clients connected. + * @property {Integer} subscriptions The number of subscriptions connected. + * @property {Boolean} sendEvent If the LiveQuery event should be sent to the client. Set to false to prevent LiveQuery from pushing to the client. + */ + +/** + * @interface Parse.Cloud.BeforeFindRequest + * @property {String} installationId If set, the installationId triggering the request. + * @property {Boolean} master If true, means the master key or the read-only master key was used. + * @property {Boolean} isReadOnly If true, means the read-only master key was used. This is a subset of `master`, so `master` will also be true. Use `master && !isReadOnly` to check for full master key access. + * @property {Parse.User} user If set, the user that made the request. + * @property {Parse.Query} query The query triggering the hook. + * @property {String} ip The IP address of the client making the request. + * @property {Object} headers The original HTTP headers for the request. + * @property {String} triggerName The name of the trigger (`beforeSave`, `afterSave`, ...) + * @property {Object} log The current logger inside Parse Server. + * @property {Boolean} isGet wether the query a `get` or a `find` + * @property {Object} config The Parse Server config. + */ + +/** + * @interface Parse.Cloud.AfterFindRequest + * @property {String} installationId If set, the installationId triggering the request. + * @property {Boolean} master If true, means the master key or the read-only master key was used. + * @property {Boolean} isReadOnly If true, means the read-only master key was used. This is a subset of `master`, so `master` will also be true. Use `master && !isReadOnly` to check for full master key access. + * @property {Parse.User} user If set, the user that made the request. + * @property {Parse.Query} query The query triggering the hook. + * @property {Array} results The results the query yielded. + * @property {String} ip The IP address of the client making the request. + * @property {Object} headers The original HTTP headers for the request. + * @property {String} triggerName The name of the trigger (`beforeSave`, `afterSave`, ...) + * @property {Object} log The current logger inside Parse Server. + * @property {Object} config The Parse Server config. + */ + +/** + * @interface Parse.Cloud.FunctionRequest + * @property {String} installationId If set, the installationId triggering the request. + * @property {Boolean} master If true, means the master key or the read-only master key was used. + * @property {Boolean} isReadOnly If true, means the read-only master key was used. This is a subset of `master`, so `master` will also be true. Use `master && !isReadOnly` to check for full master key access. + * @property {Parse.User} user If set, the user that made the request. + * @property {Object} params The params passed to the cloud function. For JSON requests, values + * retain their JSON types. For multipart/form-data requests, text fields are strings and file + * fields are objects with `{ filename: string, contentType: string, data: Buffer }`. + * @property {String} ip The IP address of the client making the request. + * @property {Object} headers The original HTTP headers for the request. + * @property {Object} log The current logger inside Parse Server. + * @property {String} functionName The name of the cloud function. + * @property {Object} context The context of the cloud function call. + * @property {Object} config The Parse Server config. + */ + +/** + * @interface Parse.Cloud.FunctionResponse + * @property {function} success Call this function to return a successful response with an optional result. Usage: `response.success(result)` + * @property {function} error Call this function to return an error response with an error message. Usage: `response.error(message)` + * @property {function} status Call this function to set a custom HTTP status code for the response. Returns the response object for chaining. Usage: `response.status(code).success(result)` or `response.status(code).error(message)` + * @property {function} header Call this function to set a custom HTTP header for the response. Returns the response object for chaining. Usage: `response.header('X-Custom-Header', 'value').success(result)` + */ + +/** + * @interface Parse.Cloud.JobRequest + * @property {Object} params The params passed to the background job. + * @property {function} message If message is called with a string argument, will update the current message to be stored in the job status. + * @property {Object} config The Parse Server config. + */ + +/** + * @interface Parse.Cloud.ValidatorObject + * @property {Boolean} requireUser whether the cloud trigger requires a user. + * @property {Boolean} requireMaster whether the cloud trigger requires a master key. + * @property {Boolean} validateMasterKey whether the validator should run if masterKey is provided. Defaults to false. + * @property {Boolean} skipWithMasterKey whether the cloud code function should be ignored using a masterKey. + * + * @property {Array|Object} requireUserKeys If set, keys required on request.user to make the request. + * @property {String} requireUserKeys.field If requireUserKeys is an object, name of field to validate on request user + * @property {Array|function|Any} requireUserKeys.field.options array of options that the field can be, function to validate field, or single value. Throw an error if value is invalid. + * @property {String} requireUserKeys.field.error custom error message if field is invalid. + * + * @property {Array|function}requireAnyUserRoles If set, request.user has to be part of at least one roles name to make the request. If set to a function, function must return role names. + * @property {Array|function}requireAllUserRoles If set, request.user has to be part all roles name to make the request. If set to a function, function must return role names. + * + * @property {Object|Array} fields if an array of strings, validator will look for keys in request.params, and throw if not provided. If Object, fields to validate. If the trigger is a cloud function, `request.params` will be validated, otherwise `request.object`. + * @property {String} fields.field name of field to validate. + * @property {String} fields.field.type expected type of data for field. + * @property {Boolean} fields.field.constant whether the field can be modified on the object. + * @property {Any} fields.field.default default value if field is `null`, or initial value `constant` is `true`. + * @property {Array|function|Any} fields.field.options array of options that the field can be, function to validate field, or single value. Throw an error if value is invalid. + * @property {String} fields.field.error custom error message if field is invalid. + */ diff --git a/src/cloud-code/Parse.Server.js b/src/cloud-code/Parse.Server.js new file mode 100644 index 0000000000..71295618f2 --- /dev/null +++ b/src/cloud-code/Parse.Server.js @@ -0,0 +1,19 @@ +const ParseServer = {}; +/** + * ... + * + * @memberof Parse.Server + * @property {String} global Rate limit based on the number of requests made by all users. + * @property {String} session Rate limit based on the sessionToken. + * @property {String} user Rate limit based on the user ID. + * @property {String} ip Rate limit based on the request ip. + * ... + */ +ParseServer.RateLimitZone = Object.freeze({ + global: 'global', + session: 'session', + user: 'user', + ip: 'ip', +}); + +module.exports = ParseServer; diff --git a/src/cloud-code/httpRequest.js b/src/cloud-code/httpRequest.js deleted file mode 100644 index 820500733d..0000000000 --- a/src/cloud-code/httpRequest.js +++ /dev/null @@ -1,83 +0,0 @@ -import request from 'request'; -import Parse from 'parse/node'; -import HTTPResponse from './HTTPResponse'; -import querystring from 'querystring'; -import log from '../logger'; - -var encodeBody = function({body, headers = {}}) { - if (typeof body !== 'object') { - return {body, headers}; - } - var contentTypeKeys = Object.keys(headers).filter((key) => { - return key.match(/content-type/i) != null; - }); - - if (contentTypeKeys.length == 0) { - // no content type - // As per https://parse.com/docs/cloudcode/guide#cloud-code-advanced-sending-a-post-request the default encoding is supposedly x-www-form-urlencoded - - body = querystring.stringify(body); - headers['Content-Type'] = 'application/x-www-form-urlencoded'; - } else { - /* istanbul ignore next */ - if (contentTypeKeys.length > 1) { - log.error('Parse.Cloud.httpRequest', 'multiple content-type headers are set.'); - } - // There maybe many, we'll just take the 1st one - var contentType = contentTypeKeys[0]; - if (headers[contentType].match(/application\/json/i)) { - body = JSON.stringify(body); - } else if(headers[contentType].match(/application\/x-www-form-urlencoded/i)) { - body = querystring.stringify(body); - } - } - return {body, headers}; -} - -module.exports = function(options) { - var promise = new Parse.Promise(); - var callbacks = { - success: options.success, - error: options.error - }; - delete options.success; - delete options.error; - delete options.uri; // not supported - options = Object.assign(options, encodeBody(options)); - // set follow redirects to false by default - options.followRedirect = options.followRedirects == true; - // support params options - if (typeof options.params === 'object') { - options.qs = options.params; - } else if (typeof options.params === 'string') { - options.qs = querystring.parse(options.params); - } - // force the response as a buffer - options.encoding = null; - - request(options, (error, response, body) => { - if (error) { - if (callbacks.error) { - callbacks.error(error); - } - return promise.reject(error); - } - let httpResponse = new HTTPResponse(response, body); - - // Consider <200 && >= 400 as errors - if (httpResponse.status < 200 || httpResponse.status >= 400) { - if (callbacks.error) { - callbacks.error(httpResponse); - } - return promise.reject(httpResponse); - } else { - if (callbacks.success) { - callbacks.success(httpResponse); - } - return promise.resolve(httpResponse); - } - }); - return promise; -}; - -module.exports.encodeBody = encodeBody; diff --git a/src/cryptoUtils.js b/src/cryptoUtils.js index 4b529293b7..f85b62c349 100644 --- a/src/cryptoUtils.js +++ b/src/cryptoUtils.js @@ -8,9 +8,9 @@ export function randomHexString(size: number): string { throw new Error('Zero-length randomHexString is useless.'); } if (size % 2 !== 0) { - throw new Error('randomHexString size must be divisible by 2.') + throw new Error('randomHexString size must be divisible by 2.'); } - return randomBytes(size/2).toString('hex'); + return randomBytes(size / 2).toString('hex'); } // Returns a new random alphanumeric string of the given size. @@ -23,11 +23,9 @@ export function randomString(size: number): string { if (size === 0) { throw new Error('Zero-length randomString is useless.'); } - let chars = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ' + - 'abcdefghijklmnopqrstuvwxyz' + - '0123456789'); + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + 'abcdefghijklmnopqrstuvwxyz' + '0123456789'; let objectId = ''; - let bytes = randomBytes(size); + const bytes = randomBytes(size); for (let i = 0; i < bytes.length; ++i) { objectId += chars[bytes.readUInt8(i) % chars.length]; } @@ -35,9 +33,8 @@ export function randomString(size: number): string { } // Returns a new random alphanumeric string suitable for object ID. -export function newObjectId(): string { - //TODO: increase length to better protect against collisions. - return randomString(10); +export function newObjectId(size: number = 10): string { + return randomString(size); } // Returns a new random hex string suitable for secure tokens. diff --git a/src/defaults.js b/src/defaults.js new file mode 100644 index 0000000000..b7d05f1550 --- /dev/null +++ b/src/defaults.js @@ -0,0 +1,64 @@ +import { nullParser } from './Options/parsers'; +const { ParseServerOptions, DatabaseOptions } = require('./Options/Definitions'); +const logsFolder = (() => { + let folder = './logs/'; + if (typeof process !== 'undefined' && process.env.TESTING === '1') { + folder = './test_logs/'; + } + if (process.env.PARSE_SERVER_LOGS_FOLDER) { + folder = nullParser(process.env.PARSE_SERVER_LOGS_FOLDER); + } + return folder; +})(); + +const { verbose, level } = (() => { + const verbose = process.env.VERBOSE ? true : false; + return { verbose, level: verbose ? 'verbose' : undefined }; +})(); + +const DefinitionDefaults = Object.keys(ParseServerOptions).reduce((memo, key) => { + const def = ParseServerOptions[key]; + if (Object.prototype.hasOwnProperty.call(def, 'default')) { + memo[key] = def.default; + } + return memo; +}, {}); + +const computedDefaults = { + jsonLogs: process.env.JSON_LOGS || false, + logsFolder, + verbose, + level, +}; + +export default Object.assign({}, DefinitionDefaults, computedDefaults); +export const DefaultMongoURI = DefinitionDefaults.databaseURI; + +export const DatabaseOptionDefaults = Object.keys(DatabaseOptions).reduce((memo, key) => { + const def = DatabaseOptions[key]; + if (Object.prototype.hasOwnProperty.call(def, 'default')) { + memo[key] = def.default; + } + return memo; +}, {}); + +// Parse Server-specific database options that should be filtered out +// before passing to MongoDB client +export const ParseServerDatabaseOptions = [ + 'allowPublicExplain', + 'batchSize', + 'clientMetadata', + 'createIndexAuthDataUniqueness', + 'createIndexRoleName', + 'createIndexUserEmail', + 'createIndexUserEmailCaseInsensitive', + 'createIndexUserEmailVerifyToken', + 'createIndexUserPasswordResetToken', + 'createIndexUserUsername', + 'createIndexUserUsernameCaseInsensitive', + 'disableIndexFieldValidation', + 'enableSchemaHooks', + 'logClientEvents', + 'maxTimeMS', + 'schemaCacheTtl', +]; diff --git a/src/deprecated.js b/src/deprecated.js index 7192336c2c..dd20a73034 100644 --- a/src/deprecated.js +++ b/src/deprecated.js @@ -1,5 +1,5 @@ export function useExternal(name, moduleName) { - return function() { + return function () { throw `${name} is not provided by parse-server anymore; please install ${moduleName}`; - } + }; } diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 82d3c35a19..0000000000 --- a/src/index.js +++ /dev/null @@ -1,20 +0,0 @@ -import ParseServer from './ParseServer'; -import logger from './logger'; -import S3Adapter from 'parse-server-s3-adapter' -import FileSystemAdapter from 'parse-server-fs-adapter' -import InMemoryCacheAdapter from './Adapters/Cache/InMemoryCacheAdapter' -import TestUtils from './TestUtils'; -import { useExternal } from './deprecated' - -// Factory function -let _ParseServer = function(options) { - let server = new ParseServer(options); - return server.app; -} -// Mount the create liveQueryServer -_ParseServer.createLiveQueryServer = ParseServer.createLiveQueryServer; - -let GCSAdapter = useExternal('GCSAdapter', 'parse-server-gcs-adapter'); - -export default ParseServer; -export { S3Adapter, GCSAdapter, FileSystemAdapter, InMemoryCacheAdapter, TestUtils, logger, _ParseServer as ParseServer }; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000000..0c9069d6b5 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,47 @@ +import ParseServer from './ParseServer'; +import FileSystemAdapter from '@parse/fs-files-adapter'; +import InMemoryCacheAdapter from './Adapters/Cache/InMemoryCacheAdapter'; +import NullCacheAdapter from './Adapters/Cache/NullCacheAdapter'; +import RedisCacheAdapter from './Adapters/Cache/RedisCacheAdapter'; +import LRUCacheAdapter from './Adapters/Cache/LRUCache.js'; +import * as TestUtils from './TestUtils'; +import * as SchemaMigrations from './SchemaMigrations/Migrations'; +import AuthAdapter from './Adapters/Auth/AuthAdapter'; +import { useExternal } from './deprecated'; +import { getLogger } from './logger'; +import { PushWorker } from './Push/PushWorker'; +import { ParseServerOptions } from './Options'; +import { ParseGraphQLServer } from './GraphQL/ParseGraphQLServer'; + +// Factory function +const _ParseServer = function (options: ParseServerOptions) { + const server = new ParseServer(options); + return server; +}; +// Mount the create liveQueryServer +_ParseServer.createLiveQueryServer = ParseServer.createLiveQueryServer; +_ParseServer.startApp = ParseServer.startApp; + +const S3Adapter = useExternal('S3Adapter', '@parse/s3-files-adapter'); +const GCSAdapter = useExternal('GCSAdapter', '@parse/gcs-files-adapter'); + +Object.defineProperty(module.exports, 'logger', { + get: getLogger, +}); + +export default ParseServer; +export { + S3Adapter, + GCSAdapter, + FileSystemAdapter, + InMemoryCacheAdapter, + NullCacheAdapter, + RedisCacheAdapter, + LRUCacheAdapter, + TestUtils, + PushWorker, + ParseGraphQLServer, + _ParseServer as ParseServer, + SchemaMigrations, + AuthAdapter, +}; diff --git a/src/logger.js b/src/logger.js deleted file mode 100644 index 0c9308ee58..0000000000 --- a/src/logger.js +++ /dev/null @@ -1,94 +0,0 @@ -import winston from 'winston'; -import fs from 'fs'; -import path from 'path'; -import DailyRotateFile from 'winston-daily-rotate-file'; - -let LOGS_FOLDER = './logs/'; - -if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { - LOGS_FOLDER = './test_logs/' -} - -LOGS_FOLDER = process.env.PARSE_SERVER_LOGS_FOLDER || LOGS_FOLDER; -const JSON_LOGS = process.env.JSON_LOGS || false; - -let currentLogsFolder = LOGS_FOLDER; - -function generateTransports(level, options = {}) { - let transports = [ - new (DailyRotateFile)( - Object.assign({ - filename: 'parse-server.info', - dirname: currentLogsFolder, - name: 'parse-server', - level: level - }, options) - ), - new (DailyRotateFile)( - Object.assign({ - filename: 'parse-server.err', - dirname: currentLogsFolder, - name: 'parse-server-error', - level: 'error' - } - ), options) - ]; - if (!process.env.TESTING || process.env.VERBOSE) { - transports = [ - new (winston.transports.Console)( - Object.assign({ - colorize: true, - level: level - }, options) - ) - ].concat(transports); - } - return transports; -} - -const logger = new winston.Logger(); - -export function configureLogger({ logsFolder, jsonLogs, level = winston.level }) { - winston.level = level; - logsFolder = logsFolder || currentLogsFolder; - - if (!path.isAbsolute(logsFolder)) { - logsFolder = path.resolve(process.cwd(), logsFolder); - } - try { - fs.mkdirSync(logsFolder); - } catch (exception) { - // Ignore, assume the folder already exists - } - currentLogsFolder = logsFolder; - - const options = {}; - if (jsonLogs) { - options.json = true; - options.stringify = true; - } - const transports = generateTransports(level, options); - logger.configure({ - transports: transports - }) -} - -configureLogger({ logsFolder: LOGS_FOLDER, jsonLogs: JSON_LOGS }); - -export function addGroup(groupName) { - let level = winston.level; - let transports = generateTransports().concat(new (DailyRotateFile)({ - filename: groupName, - dirname: currentLogsFolder, - name: groupName, - level: level - })); - - winston.loggers.add(groupName, { - transports: transports - }); - return winston.loggers.get(groupName); -} - -export { logger }; -export default logger; diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000000..97604039c5 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,36 @@ +'use strict'; +import defaults from './defaults'; +import { WinstonLoggerAdapter } from './Adapters/Logger/WinstonLoggerAdapter'; +import { LoggerController } from './Controllers/LoggerController'; + +// Used for Separate Live Query Server +function defaultLogger() { + const options = { + logsFolder: defaults.logsFolder, + jsonLogs: defaults.jsonLogs, + verbose: defaults.verbose, + silent: defaults.silent, + }; + const adapter = new WinstonLoggerAdapter(options); + return new LoggerController(adapter, null, options); +} + +let logger = defaultLogger(); + +export function setLogger(aLogger) { + logger = aLogger; +} + +export function getLogger() { + return logger; +} + +// for: `import logger from './logger'` +Object.defineProperty(module.exports, 'default', { + get: getLogger, +}); + +// for: `import { logger } from './logger'` +Object.defineProperty(module.exports, 'logger', { + get: getLogger, +}); diff --git a/src/middlewares.js b/src/middlewares.js index 4e64c9ee31..3c55278f33 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -1,11 +1,69 @@ import AppCache from './cache'; -import log from './logger'; +import Utils from './Utils'; +import Parse from 'parse/node'; +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'; +import PostgresStorageAdapter from './Adapters/Storage/Postgres/PostgresStorageAdapter'; +import rateLimit from 'express-rate-limit'; +import { RateLimitOptions } from './Options/Definitions'; +import { pathToRegexp } from 'path-to-regexp'; +import RedisStore from 'rate-limit-redis'; +import { createClient } from 'redis'; +import { BlockList, isIPv4 } from 'net'; +import { createSanitizedHttpError, createSanitizedError } from './Error'; + +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, X-Parse-Request-Id, Content-Type, Pragma, Cache-Control'; + +const getMountForRequest = function (req) { + const mountPathLength = req.originalUrl.length - req.url.length; + const mountPath = req.originalUrl.slice(0, mountPathLength); + return req.protocol + '://' + req.get('host') + mountPath; +}; + +const getBlockList = (ipRangeList, store) => { + if (store.get('blockList')) { return store.get('blockList'); } + const blockList = new BlockList(); + ipRangeList.forEach(fullIp => { + if (fullIp === '::/0' || fullIp === '::' || fullIp === '::0') { + store.set('allowAllIpv6', true); + return; + } + if (fullIp === '0.0.0.0/0' || fullIp === '0.0.0.0') { + store.set('allowAllIpv4', true); + return; + } + const [ip, mask] = fullIp.split('/'); + if (!mask) { + blockList.addAddress(ip, isIPv4(ip) ? 'ipv4' : 'ipv6'); + } else { + blockList.addSubnet(ip, Number(mask), isIPv4(ip) ? 'ipv4' : 'ipv6'); + } + }); + store.set('blockList', blockList); + return blockList; +}; + +export const checkIp = (ip, ipRangeList, store) => { + const incomingIpIsV4 = isIPv4(ip); + const blockList = getBlockList(ipRangeList, store); -var Parse = require('parse/node').Parse; + if (store.get(ip)) { return true; } + if (store.get('allowAllIpv4') && incomingIpIsV4) { return true; } + if (store.get('allowAllIpv6') && !incomingIpIsV4) { return true; } + const result = blockList.check(ip, incomingIpIsV4 ? 'ipv4' : 'ipv6'); -var auth = require('./Auth'); -var Config = require('./Config'); -var ClientSDK = require('./ClientSDK'); + // If the ip is in the list, we store the result in the store + // so we have a optimized path for the next request + if (ipRangeList.includes(ip) && result) { + store.set(ip, result); + } + return result; +}; // Checks that the request is authorized for this app and checks user // auth too. @@ -13,29 +71,43 @@ var ClientSDK = require('./ClientSDK'); // Adds info to the request: // req.config - the Config for this app // req.auth - the Auth for this request -function handleParseHeaders(req, res, next) { - var mountPathLength = req.originalUrl.length - req.url.length; - var mountPath = req.originalUrl.slice(0, mountPathLength); - var mount = req.protocol + '://' + req.get('host') + mountPath; - +export async function handleParseHeaders(req, res, next) { + var mount = getMountForRequest(req); + + let context = {}; + if (req.get('X-Parse-Cloud-Context') != null) { + try { + context = JSON.parse(req.get('X-Parse-Cloud-Context')); + if (Object.prototype.toString.call(context) !== '[object Object]') { + throw 'Context is not an object'; + } + } catch { + return malformedContext(req, res); + } + } var info = { appId: req.get('X-Parse-Application-Id'), sessionToken: req.get('X-Parse-Session-Token'), masterKey: req.get('X-Parse-Master-Key'), + maintenanceKey: req.get('X-Parse-Maintenance-Key'), installationId: req.get('X-Parse-Installation-Id'), clientKey: req.get('X-Parse-Client-Key'), javascriptKey: req.get('X-Parse-Javascript-Key'), dotNetKey: req.get('X-Parse-Windows-Key'), restAPIKey: req.get('X-Parse-REST-API-Key'), - clientVersion: req.get('X-Parse-Client-Version') + clientVersion: req.get('X-Parse-Client-Version'), + context: context, }; var basicAuth = httpAuth(req); if (basicAuth) { - info.appId = basicAuth.appId - info.masterKey = basicAuth.masterKey || info.masterKey; - info.javascriptKey = basicAuth.javascriptKey || info.javascriptKey; + var basicAuthAppId = basicAuth.appId; + if (AppCache.get(basicAuthAppId)) { + info.appId = basicAuthAppId; + info.masterKey = basicAuth.masterKey || info.masterKey; + info.javascriptKey = basicAuth.javascriptKey || info.javascriptKey; + } } if (req.body) { @@ -48,10 +120,17 @@ function handleParseHeaders(req, res, next) { if (!info.appId || !AppCache.get(info.appId)) { // See if we can find the app id on the body. - if (req.body instanceof Buffer) { + if (Buffer.isBuffer(req.body)) { // The only chance to find the app id is if this is a file // upload that actually is a JSON body. So try to parse it. - req.body = JSON.parse(req.body); + // https://github.com/parse-community/parse-server/issues/6589 + // It is also possible that the client is trying to upload a file but forgot + // to provide x-parse-app-id in header and parse a binary file will fail + try { + req.body = JSON.parse(req.body); + } catch { + return invalidRequest(req, res); + } fileViaJSON = true; } @@ -59,7 +138,8 @@ function handleParseHeaders(req, res, next) { delete req.body._RevocableSession; } - if (req.body && + if ( + req.body && req.body._ApplicationId && AppCache.get(req.body._ApplicationId) && (!info.masterKey || AppCache.get(req.body._ApplicationId).masterKey === info.masterKey) @@ -71,22 +151,52 @@ function handleParseHeaders(req, res, next) { // TODO: test that the REST API formats generated by the other // SDKs are handled ok if (req.body._ClientVersion) { + if (typeof req.body._ClientVersion !== 'string') { + return invalidRequest(req, res); + } info.clientVersion = req.body._ClientVersion; delete req.body._ClientVersion; } if (req.body._InstallationId) { + if (typeof req.body._InstallationId !== 'string') { + return invalidRequest(req, res); + } info.installationId = req.body._InstallationId; delete req.body._InstallationId; } if (req.body._SessionToken) { + if (typeof req.body._SessionToken !== 'string') { + return invalidRequest(req, res); + } info.sessionToken = req.body._SessionToken; delete req.body._SessionToken; } if (req.body._MasterKey) { + if (typeof req.body._MasterKey !== 'string') { + return invalidRequest(req, res); + } info.masterKey = req.body._MasterKey; delete req.body._MasterKey; } + if (req.body._context) { + if (Utils.isObject(req.body._context)) { + info.context = req.body._context; + } else { + try { + info.context = JSON.parse(req.body._context); + if (Object.prototype.toString.call(info.context) !== '[object Object]') { + throw 'Context is not an object'; + } + } catch { + return malformedContext(req, res); + } + } + delete req.body._context; + } if (req.body._ContentType) { + if (typeof req.body._ContentType !== 'string') { + return invalidRequest(req, res); + } req.headers['content-type'] = req.body._ContentType; delete req.body._ContentType; } @@ -95,80 +205,174 @@ function handleParseHeaders(req, res, next) { } } - if (info.clientVersion) { + if (info.sessionToken && typeof info.sessionToken !== 'string') { + return invalidRequest(req, res); + } + + if (info.clientVersion && typeof info.clientVersion === 'string') { info.clientSDK = ClientSDK.fromString(info.clientVersion); } - if (fileViaJSON) { + if (fileViaJSON && req.body) { + if (req.body.base64 && typeof req.body.base64 !== 'string') { + return invalidRequest(req, res); + } + req.fileData = req.body.fileData; // We need to repopulate req.body with a buffer var base64 = req.body.base64; - req.body = new Buffer(base64, 'base64'); + req.body = Buffer.from(base64, 'base64'); + } + + const clientIp = getClientIp(req); + const config = req.config || Config.get(info.appId, mount); + if (config.state && config.state !== 'ok') { + res.status(500); + res.json({ + code: Parse.Error.INTERNAL_SERVER_ERROR, + error: `Invalid server state: ${config.state}`, + }); + return; + } + if (!req.config) { + await config.loadKeys(); } info.app = AppCache.get(info.appId); - req.config = new Config(info.appId, mount); + req.config = config; + req.config.headers = req.headers || {}; + req.config.ip = clientIp; req.info = info; - var isMaster = (info.masterKey === req.config.masterKey); + // Skip key detection if already resolved by handleParseAuth (header-based). + // Only resolve here for body-based _MasterKey (info.masterKey may come from body). + if (!req.auth || (!req.auth.isMaster && !req.auth.isMaintenance)) { + const resolved = await resolveKeyAuth({ + config: req.config, + keyValue: info.masterKey, + maintenanceKeyValue: info.maintenanceKey, + installationId: info.installationId, + clientIp, + }); + if (resolved) { + req.auth = resolved; + } + } - if (isMaster) { - req.auth = new auth.Auth({ config: req.config, installationId: info.installationId, isMaster: true }); - next(); - return; + if (req.auth && (req.auth.isMaster || req.auth.isMaintenance)) { + return handleRateLimit(req, res, next); } // Client keys are not required in parse-server, but if any have been configured in the server, validate them // to preserve original behavior. - let keys = ["clientKey", "javascriptKey", "dotNetKey", "restAPIKey"]; - - // We do it with mismatching keys to support no-keys config - var keyMismatch = keys.reduce(function(mismatch, key){ - - // check if set in the config and compare - if (req.config[key] && info[key] !== req.config[key]) { - mismatch++; - } - return mismatch; - }, 0); - - // All keys mismatch - if (keyMismatch == keys.length) { + const keys = ['clientKey', 'javascriptKey', 'dotNetKey', 'restAPIKey']; + const oneKeyConfigured = keys.some(function (key) { + return req.config[key] !== undefined; + }); + const oneKeyMatches = keys.some(function (key) { + return req.config[key] !== undefined && info[key] === req.config[key]; + }); + + if (oneKeyConfigured && !oneKeyMatches) { return invalidRequest(req, res); } - if (req.url == "/login") { + if (req.url == '/login') { delete info.sessionToken; } + if (req.userFromJWT) { + req.auth = new auth.Auth({ + config: req.config, + installationId: info.installationId, + isMaster: false, + user: req.userFromJWT, + }); + return handleRateLimit(req, res, next); + } + if (!info.sessionToken) { - req.auth = new auth.Auth({ config: req.config, installationId: info.installationId, isMaster: false }); - next(); + req.auth = new auth.Auth({ + config: req.config, + installationId: info.installationId, + isMaster: false, + }); + } + handleRateLimit(req, res, next); +} + +const handleRateLimit = async (req, res, next) => { + const rateLimits = req.config.rateLimits || []; + try { + await Promise.all( + rateLimits.map(async limit => { + const pathExp = limit.path.regexp || limit.path; + if (pathExp.test(req.url)) { + await limit.handler(req, res, err => { + if (err) { + if (err.code === Parse.Error.CONNECTION_FAILED) { + throw err; + } + req.config.loggerController.error( + 'An unknown error occured when attempting to apply the rate limiter: ', + err + ); + } + }); + } + }) + ); + } catch (error) { + res.status(429); + res.json({ code: Parse.Error.CONNECTION_FAILED, error: error.message }); return; } + next(); +}; - return auth.getAuthForSessionToken({ config: req.config, installationId: info.installationId, sessionToken: info.sessionToken }) - .then((auth) => { - if (auth) { - req.auth = auth; - next(); - } - }) - .catch((error) => { - if(error instanceof Parse.Error) { - next(error); - return; - } - else { - // TODO: Determine the correct error scenario. - log.error('error getting auth for sessionToken', error); - throw new Parse.Error(Parse.Error.UNKNOWN_ERROR, error); - } - }); +export const handleParseSession = async (req, res, next) => { + try { + const info = req.info; + if (req.auth || (req.url === '/sessions/me' && req.method === 'GET')) { + next(); + return; + } + let requestAuth = null; + if ( + info.sessionToken && + req.url === '/upgradeToRevocableSession' && + info.sessionToken.indexOf('r:') != 0 + ) { + requestAuth = await auth.getAuthForLegacySessionToken({ + config: req.config, + installationId: info.installationId, + sessionToken: info.sessionToken, + }); + } else { + requestAuth = await auth.getAuthForSessionToken({ + config: req.config, + installationId: info.installationId, + sessionToken: info.sessionToken, + }); + } + req.auth = requestAuth; + next(); + } catch (error) { + if (error instanceof Parse.Error) { + next(error); + return; + } + // Log full error details internally, but don't expose to client + req.config.loggerController.error('error getting auth for sessionToken', error); + next(new Parse.Error(Parse.Error.UNKNOWN_ERROR, 'Unknown error')); + } +}; + +function getClientIp(req) { + return req.ip; } function httpAuth(req) { - if (!(req.req || req).headers.authorization) - return ; + if (!(req.req || req).headers.authorization) { return; } var header = (req.req || req).headers.authorization; var appId, masterKey, javascriptKey; @@ -188,106 +392,435 @@ function httpAuth(req) { var jsKeyPrefix = 'javascript-key='; - var matchKey = key.indexOf(jsKeyPrefix) + var matchKey = key.indexOf(jsKeyPrefix); if (matchKey == 0) { javascriptKey = key.substring(jsKeyPrefix.length, key.length); - } - else { + } else { masterKey = key; } } } - return {appId: appId, masterKey: masterKey, javascriptKey: javascriptKey}; + return { appId: appId, masterKey: masterKey, javascriptKey: javascriptKey }; } function decodeBase64(str) { - return new Buffer(str, 'base64').toString() + return Buffer.from(str, 'base64').toString(); } -var allowCrossDomain = function(req, res, next) { - res.header('Access-Control-Allow-Origin', '*'); - res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS'); - res.header('Access-Control-Allow-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'); +export function allowCrossDomain(appId) { + return (req, res, next) => { + const config = Config.get(appId, getMountForRequest(req)); + let allowHeaders = DEFAULT_ALLOWED_HEADERS; + if (config && config.allowHeaders) { + allowHeaders += `, ${config.allowHeaders.join(', ')}`; + } - // intercept OPTIONS method - if ('OPTIONS' == req.method) { - res.sendStatus(200); - } - else { - next(); - } -}; + const baseOrigins = + typeof config?.allowOrigin === 'string' ? [config.allowOrigin] : config?.allowOrigin ?? ['*']; + const requestOrigin = req.headers.origin; + const allowOrigins = + requestOrigin && baseOrigins.includes(requestOrigin) ? requestOrigin : baseOrigins[0]; + res.header('Access-Control-Allow-Origin', allowOrigins); + res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS'); + res.header('Access-Control-Allow-Headers', allowHeaders); + res.header('Access-Control-Expose-Headers', 'X-Parse-Job-Status-Id, X-Parse-Push-Status-Id'); + // intercept OPTIONS method + if ('OPTIONS' == req.method) { + res.sendStatus(200); + } else { + next(); + } + }; +} -var allowMethodOverride = function(req, res, next) { - if (req.method === 'POST' && req.body._method) { - req.originalMethod = req.method; - req.method = req.body._method; +export function allowMethodOverride(req, res, next) { + if (req.method === 'POST' && req.body?._method) { + if (typeof req.body._method === 'string') { + req.originalMethod = req.method; + req.method = req.body._method.toUpperCase(); + } delete req.body._method; } next(); -}; +} -var handleParseErrors = function(err, req, res, next) { - // TODO: Add logging as those errors won't make it to the PromiseRouter - if (err instanceof Parse.Error) { - var httpStatus; +async function resolveKeyAuth({ config, keyValue, maintenanceKeyValue, installationId, clientIp }) { + if (maintenanceKeyValue && maintenanceKeyValue === config.maintenanceKey) { + if (checkIp(clientIp, config.maintenanceKeyIps || [], config.maintenanceKeyIpsStore)) { + return new auth.Auth({ config, installationId, isMaintenance: true }); + } + const log = config.loggerController || defaultLogger; + log.error( + `Request using maintenance key rejected as the request IP address '${clientIp}' is not set in Parse Server option 'maintenanceKeyIps'.` + ); + const error = new Error(); + error.status = 403; + error.message = 'unauthorized'; + throw error; + } + const masterKey = await config.loadMasterKey(); + if (keyValue === masterKey) { + if (checkIp(clientIp, config.masterKeyIps || [], config.masterKeyIpsStore)) { + return new auth.Auth({ config, installationId, isMaster: true }); + } + const log = config.loggerController || defaultLogger; + log.error( + `Request using master key rejected as the request IP address '${clientIp}' is not set in Parse Server option 'masterKeyIps'.` + ); + const error = new Error(); + error.status = 403; + error.message = 'unauthorized'; + throw error; + } + if ( + keyValue && + typeof config.readOnlyMasterKey !== 'undefined' && + config.readOnlyMasterKey && + keyValue === config.readOnlyMasterKey + ) { + if (checkIp(clientIp, config.readOnlyMasterKeyIps || [], config.readOnlyMasterKeyIpsStore)) { + return new auth.Auth({ config, installationId, isMaster: true, isReadOnly: true }); + } + const log = config.loggerController || defaultLogger; + log.error( + `Request using read-only master key rejected as the request IP address '${clientIp}' is not set in Parse Server option 'readOnlyMasterKeyIps'.` + ); + const error = new Error(); + error.status = 403; + error.message = 'unauthorized'; + throw error; + } + return null; +} + +export function handleParseAuth(appId) { + return async (req, res, next) => { + const mount = getMountForRequest(req); + const config = Config.get(appId, mount); + if (!config) { + return next(); + } + req.config = config; + const clientIp = getClientIp(req); + req.config.ip = clientIp; + await config.loadKeys(); + const resolved = await resolveKeyAuth({ + config, + keyValue: req.get('X-Parse-Master-Key') || null, + maintenanceKeyValue: req.get('X-Parse-Maintenance-Key') || null, + installationId: req.get('X-Parse-Installation-Id') || 'cloud', + clientIp, + }); + if (resolved) { + req.auth = resolved; + } + return next(); + }; +} +export function handleParseHealth(options) { + return (req, res) => { + res.status(options.state === 'ok' ? 200 : 503); + if (options.state === 'starting') { + res.set('Retry-After', 1); + } + res.json({ + status: options.state, + }); + }; +} + +export function enforceRouteAllowList(req, res, next) { + const config = req.config; + if (!config || config.routeAllowList === undefined || config.routeAllowList === null) { + return next(); + } + if (req.auth && (req.auth.isMaster || req.auth.isMaintenance)) { + return next(); + } + let path = req.originalUrl; + if (config.mount) { + const mountPath = new URL(config.mount).pathname; + if (path.startsWith(mountPath)) { + path = path.substring(mountPath.length); + } + } + if (path.startsWith('/')) { + path = path.substring(1); + } + if (path.endsWith('/')) { + path = path.substring(0, path.length - 1); + } + const queryIndex = path.indexOf('?'); + if (queryIndex !== -1) { + path = path.substring(0, queryIndex); + } + const regexes = config._routeAllowListRegex || []; + for (const regex of regexes) { + if (regex.test(path)) { + return next(); + } + } + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + `Route not allowed by routeAllowList: ${req.method} ${path}`, + config + ); +} + +export function handleParseErrors(err, req, res, next) { + const log = (req.config && req.config.loggerController) || defaultLogger; + if (err instanceof Parse.Error) { + if (req.config && req.config.enableExpressErrorHandler) { + return next(err); + } + const signupUsernameTakenLevel = + req.config?.logLevels?.signupUsernameTaken || 'info'; + let httpStatus; // TODO: fill out this mapping switch (err.code) { - case Parse.Error.INTERNAL_SERVER_ERROR: - httpStatus = 500; - break; - case Parse.Error.OBJECT_NOT_FOUND: - httpStatus = 404; - break; - default: - httpStatus = 400; + case Parse.Error.INTERNAL_SERVER_ERROR: + httpStatus = 500; + break; + case Parse.Error.OBJECT_NOT_FOUND: + httpStatus = 404; + break; + default: + httpStatus = 400; } - res.status(httpStatus); - res.json({code: err.code, error: err.message}); + res.json({ code: err.code, error: err.message }); + if (err.code === Parse.Error.USERNAME_TAKEN) { + if (signupUsernameTakenLevel !== 'silent') { + const loggerMethod = + typeof log[signupUsernameTakenLevel] === 'function' + ? log[signupUsernameTakenLevel].bind(log) + : log.error.bind(log); + loggerMethod('Parse error: ', err); + } + } else { + log.error('Parse error: ', err); + } } else if (err.status && err.message) { res.status(err.status); - res.json({error: err.message}); + res.json({ error: err.message }); + if (!(process && process.env.TESTING)) { + next(err); + } } else { log.error('Uncaught internal server error.', err, err.stack); res.status(500); - res.json({code: Parse.Error.INTERNAL_SERVER_ERROR, - message: 'Internal server error.'}); + res.json({ + code: Parse.Error.INTERNAL_SERVER_ERROR, + message: 'Internal server error.', + }); + if (!(process && process.env.TESTING)) { + next(err); + } } - next(err); -}; +} -function enforceMasterKeyAccess(req, res, next) { +export function enforceMasterKeyAccess(req, res, next) { if (!req.auth.isMaster) { - res.status(403); - res.end('{"error":"unauthorized: master key is required"}'); + const error = createSanitizedHttpError(403, 'unauthorized: master key is required', req.config); + res.status(error.status); + res.end(`{"error":"${error.message}"}`); return; } next(); } -function promiseEnforceMasterKeyAccess(request) { +export function promiseEnforceMasterKeyAccess(request) { if (!request.auth.isMaster) { - let error = new Error(); - error.status = 403; - error.message = "unauthorized: master key is required"; - throw error; + throw createSanitizedHttpError(403, 'unauthorized: master key is required', request.config); } return Promise.resolve(); } +export const addRateLimit = (route, config, cloud) => { + if (typeof config === 'string') { + config = Config.get(config); + } + for (const key in route) { + if (!RateLimitOptions[key]) { + throw `Invalid rate limit option "${key}"`; + } + } + if (!config.rateLimits) { + config.rateLimits = []; + } + const redisStore = { + connectionPromise: Promise.resolve(), + store: null, + }; + if (route.redisUrl) { + const log = config?.loggerController || defaultLogger; + const client = createClient({ + url: route.redisUrl, + }); + client.on('error', err => { log.error('Middlewares addRateLimit Redis client error', { error: err }) }); + client.on('connect', () => { }); + client.on('reconnecting', () => { }); + client.on('ready', () => { }); + redisStore.connectionPromise = async () => { + if (client.isOpen) { + return; + } + try { + await client.connect(); + } catch (e) { + log.error(`Could not connect to redisURL in rate limit: ${e}`); + } + }; + redisStore.connectionPromise(); + redisStore.store = new RedisStore({ + sendCommand: async (...args) => { + await redisStore.connectionPromise(); + return client.sendCommand(args); + }, + }); + } + config.rateLimits.push({ + path: pathToRegexp(route.requestPath), + requestCount: route.requestCount, + requestMethods: route.requestMethods, + includeMasterKey: route.includeMasterKey, + includeInternalRequests: route.includeInternalRequests, + errorResponseMessage: route.errorResponseMessage || RateLimitOptions.errorResponseMessage.default, + handler: rateLimit({ + windowMs: route.requestTimeWindow, + max: route.requestCount, + message: route.errorResponseMessage || RateLimitOptions.errorResponseMessage.default, + handler: (request, response, next, options) => { + throw { + code: Parse.Error.CONNECTION_FAILED, + message: options.message, + }; + }, + skip: request => { + if (request.ip === '127.0.0.1' && !route.includeInternalRequests) { + return true; + } + if (route.includeMasterKey) { + return false; + } + if (route.requestMethods) { + const methodsToCheck = new Set([request.method]); + if (request._batchOriginalMethod) { + methodsToCheck.add(request._batchOriginalMethod); + } + if (Array.isArray(route.requestMethods)) { + if (!route.requestMethods.some(m => methodsToCheck.has(m))) { + return true; + } + } else { + const regExp = new RegExp(route.requestMethods); + if (![...methodsToCheck].some(m => regExp.test(m))) { + return true; + } + } + } + return request.auth?.isMaster; + }, + keyGenerator: async request => { + if (route.zone === Parse.Server.RateLimitZone.global) { + return request.config.appId; + } + const token = request.info.sessionToken; + if (route.zone === Parse.Server.RateLimitZone.session && token) { + return token; + } + if (route.zone === Parse.Server.RateLimitZone.user && token) { + if (!request.auth) { + await new Promise(resolve => handleParseSession(request, null, resolve)); + } + if (request.auth?.user?.id && route.zone === 'user') { + return request.auth.user.id; + } + } + return request.config.ip; + }, + store: redisStore.store, + }), + cloud, + }); + Config.put(config); +}; + +/** + * 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 || + req.config.database.adapter instanceof PostgresStorageAdapter + ) + ) { + 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"}'); } -module.exports = { - allowCrossDomain: allowCrossDomain, - allowMethodOverride: allowMethodOverride, - handleParseErrors: handleParseErrors, - handleParseHeaders: handleParseHeaders, - enforceMasterKeyAccess: enforceMasterKeyAccess, - promiseEnforceMasterKeyAccess, -}; +function malformedContext(req, res) { + res.status(400); + res.json({ code: Parse.Error.INVALID_JSON, error: 'Invalid object for context.' }); +} + +/** + * Express 4 allowed a double forward slash between a route and router. Although + * this should be considered an anti-pattern, we need to support it for backwards + * compatibility. + * + * Technically valid URL with double foroward slash: + * http://localhost:1337/parse//functions/testFunction + */ +export function allowDoubleForwardSlash(req, res, next) { + req.url = req.url.startsWith('//') ? req.url.substring(1) : req.url; + next(); +} diff --git a/src/password.js b/src/password.js index a3eaa4bfb5..fcf83716e7 100644 --- a/src/password.js +++ b/src/password.js @@ -1,39 +1,39 @@ // Tools for encrypting and decrypting passwords. // Basically promise-friendly wrappers for bcrypt. -var bcrypt = require('bcrypt-nodejs'); +var bcrypt = require('bcryptjs'); + +try { + const _bcrypt = require('@node-rs/bcrypt'); + bcrypt = { + hash: _bcrypt.hash, + compare: _bcrypt.verify, + }; +} catch { + /* */ +} // Returns a promise for a hashed password string. function hash(password) { - return new Promise(function(fulfill, reject) { - bcrypt.hash(password, null, null, function(err, hashedPassword) { - if (err) { - reject(err); - } else { - fulfill(hashedPassword); - } - }); - }); + return bcrypt.hash(password, 10); } // Returns a promise for whether this password compares to equal this // hashed password. function compare(password, hashedPassword) { - return new Promise(function(fulfill, reject) { - // Cannot bcrypt compare when one is undefined - if (!password || !hashedPassword) { - return fulfill(false); - } - bcrypt.compare(password, hashedPassword, function(err, success) { - if (err) { - reject(err); - } else { - fulfill(success); - } - }); - }); + // Cannot bcrypt compare when one is undefined + if (!password || !hashedPassword) { + return Promise.resolve(false); + } + return bcrypt.compare(password, hashedPassword); } +// Pre-computed bcrypt hash (cost factor 10) used for timing normalization. +// The actual value is irrelevant; it ensures bcrypt.compare() runs with +// realistic cost even when no real password hash is available. +const dummyHash = '$2b$10$Wd1gvrMYPnQv5pHBbXCwCehxXmJSEzRqNON0ev98L6JJP5296S35i'; + module.exports = { hash: hash, - compare: compare + compare: compare, + dummyHash: dummyHash, }; diff --git a/src/pushStatusHandler.js b/src/pushStatusHandler.js deleted file mode 100644 index f75660879a..0000000000 --- a/src/pushStatusHandler.js +++ /dev/null @@ -1,120 +0,0 @@ -import { md5Hash, newObjectId } from './cryptoUtils'; -import { logger } from './logger'; - -const PUSH_STATUS_COLLECTION = '_PushStatus'; - -export function flatten(array) { - return array.reduce((memo, element) => { - if (Array.isArray(element)) { - memo = memo.concat(flatten(element)); - } else { - memo = memo.concat(element); - } - return memo; - }, []); -} - -export default function pushStatusHandler(config) { - - let initialPromise; - let pushStatus; - let objectId = newObjectId(); - let database = config.database; - let lastPromise; - let setInitial = function(body = {}, where, options = {source: 'rest'}) { - let now = new Date(); - let data = body.data || {}; - let payloadString = JSON.stringify(data); - let object = { - objectId, - createdAt: now, - pushTime: now.toISOString(), - query: JSON.stringify(where), - payload: payloadString, - source: options.source, - title: options.title, - expiry: body.expiration_time, - status: "pending", - numSent: 0, - pushHash: md5Hash(data.alert || ''), - // lockdown! - ACL: {} - } - - lastPromise = database.create(PUSH_STATUS_COLLECTION, object).then(() => { - pushStatus = { - objectId - }; - return Promise.resolve(pushStatus); - }); - return lastPromise; - } - - let setRunning = function(installations) { - logger.verbose('sending push to %d installations', installations.length); - lastPromise = lastPromise.then(() => { - return database.update(PUSH_STATUS_COLLECTION, - {status:"pending", objectId: objectId}, - {status: "running", updatedAt: new Date() }); - }); - return lastPromise; - } - - let complete = function(results) { - let update = { - status: 'succeeded', - updatedAt: new Date(), - numSent: 0, - numFailed: 0, - }; - if (Array.isArray(results)) { - results = flatten(results); - results.reduce((memo, result) => { - // Cannot handle that - if (!result || !result.device || !result.device.deviceType) { - return memo; - } - let deviceType = result.device.deviceType; - if (result.transmitted) - { - memo.numSent++; - memo.sentPerType = memo.sentPerType || {}; - memo.sentPerType[deviceType] = memo.sentPerType[deviceType] || 0; - memo.sentPerType[deviceType]++; - } else { - memo.numFailed++; - memo.failedPerType = memo.failedPerType || {}; - memo.failedPerType[deviceType] = memo.failedPerType[deviceType] || 0; - memo.failedPerType[deviceType]++; - } - return memo; - }, update); - } - logger.verbose('sent push! %d success, %d failures', update.numSent, update.numFailed); - lastPromise = lastPromise.then(() => { - return database.update(PUSH_STATUS_COLLECTION, {status:"running", objectId }, update); - }); - return lastPromise; - } - - let fail = function(err) { - let update = { - errorMessage: JSON.stringify(err), - status: 'failed', - updatedAt: new Date() - } - logger.info('warning: error while sending push', err); - lastPromise = lastPromise.then(() => { - return database.update(PUSH_STATUS_COLLECTION, { objectId }, update); - }); - return lastPromise; - } - - return Object.freeze({ - objectId, - setInitial, - setRunning, - complete, - fail - }) -} diff --git a/src/request.js b/src/request.js new file mode 100644 index 0000000000..d5754d9201 --- /dev/null +++ b/src/request.js @@ -0,0 +1,174 @@ +import querystring from 'querystring'; +import log from './logger'; +import { http, https } from 'follow-redirects'; +import { parse } from 'url'; + +class HTTPResponse { + constructor(response, body) { + let _text, _data; + this.status = response.statusCode; + this.headers = response.headers || {}; + this.cookies = this.headers['set-cookie']; + + if (typeof body == 'string') { + _text = body; + } else if (Buffer.isBuffer(body)) { + this.buffer = body; + } else if (typeof body == 'object') { + _data = body; + } + + const getText = () => { + if (!_text && this.buffer) { + _text = this.buffer.toString('utf-8'); + } else if (!_text && _data) { + _text = JSON.stringify(_data); + } + return _text; + }; + + const getData = () => { + if (!_data) { + try { + _data = JSON.parse(getText()); + } catch { + /* */ + } + } + return _data; + }; + + Object.defineProperty(this, 'body', { + get: () => { + return body; + }, + }); + + Object.defineProperty(this, 'text', { + enumerable: true, + get: getText, + }); + + Object.defineProperty(this, 'data', { + enumerable: true, + get: getData, + }); + } +} + +const clients = { + 'http:': http, + 'https:': https, +}; + +function makeCallback(resolve, reject) { + return function (response) { + const chunks = []; + response.on('data', chunk => { + chunks.push(chunk); + }); + response.on('end', () => { + const body = Buffer.concat(chunks); + const httpResponse = new HTTPResponse(response, body); + + // Consider <200 && >= 400 as errors + if (httpResponse.status < 200 || httpResponse.status >= 400) { + return reject(httpResponse); + } else { + return resolve(httpResponse); + } + }); + response.on('error', reject); + }; +} + +const encodeBody = function ({ body, headers = {} }) { + if (typeof body !== 'object') { + return { body, headers }; + } + var contentTypeKeys = Object.keys(headers).filter(key => { + return key.match(/content-type/i) != null; + }); + + if (contentTypeKeys.length == 0) { + // no content type + // As per https://parse.com/docs/cloudcode/guide#cloud-code-advanced-sending-a-post-request the default encoding is supposedly x-www-form-urlencoded + + body = querystring.stringify(body); + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } else { + /* istanbul ignore next */ + if (contentTypeKeys.length > 1) { + log.error('Parse.Cloud.httpRequest', 'multiple content-type headers are set.'); + } + // There maybe many, we'll just take the 1st one + var contentType = contentTypeKeys[0]; + if (headers[contentType].match(/application\/json/i)) { + body = JSON.stringify(body); + } else if (headers[contentType].match(/application\/x-www-form-urlencoded/i)) { + body = querystring.stringify(body); + } + } + return { body, headers }; +}; + +function httpRequest(options) { + let url; + try { + url = parse(options.url); + } catch (e) { + return Promise.reject(e); + } + options = Object.assign(options, encodeBody(options)); + // support params options + if (typeof options.params === 'object') { + options.qs = options.params; + } else if (typeof options.params === 'string') { + options.qs = querystring.parse(options.params); + } + const client = clients[url.protocol]; + if (!client) { + return Promise.reject(`Unsupported protocol ${url.protocol}`); + } + const requestOptions = { + method: options.method, + port: Number(url.port), + path: url.pathname, + hostname: url.hostname, + headers: options.headers, + encoding: null, + followRedirects: options.followRedirects === true, + }; + if (requestOptions.headers) { + Object.keys(requestOptions.headers).forEach(key => { + if (typeof requestOptions.headers[key] === 'undefined') { + delete requestOptions.headers[key]; + } + }); + } + if (url.search) { + options.qs = Object.assign({}, options.qs, querystring.parse(url.query)); + } + if (url.auth) { + requestOptions.auth = url.auth; + } + if (options.qs) { + requestOptions.path += `?${querystring.stringify(options.qs)}`; + } + if (options.agent) { + requestOptions.agent = options.agent; + } + return new Promise((resolve, reject) => { + const req = client.request(requestOptions, makeCallback(resolve, reject, options)); + if (options.body) { + req.write(options.body); + } + req.on('error', error => { + reject(error); + }); + req.end(); + }); +} +module.exports = httpRequest; +module.exports.encodeBody = encodeBody; +module.exports.HTTPResponse = HTTPResponse; diff --git a/src/requiredParameter.js b/src/requiredParameter.js index f6d5dd4278..eba860dd82 100644 --- a/src/requiredParameter.js +++ b/src/requiredParameter.js @@ -1,2 +1,4 @@ /** @flow */ -export default (errorMessage: string): any => { throw errorMessage } +export default (errorMessage: string): any => { + throw errorMessage; +}; diff --git a/src/rest.js b/src/rest.js index 91cf9ae3a3..ec0bc4ee57 100644 --- a/src/rest.js +++ b/src/rest.js @@ -8,128 +8,332 @@ // things. var Parse = require('parse/node').Parse; -import Auth from './Auth'; var RestQuery = require('./RestQuery'); var RestWrite = require('./RestWrite'); var triggers = require('./triggers'); +const Auth = require('./Auth'); +const { enforceRoleSecurity } = require('./SharedRest'); +const { createSanitizedError } = require('./Error'); -// Returns a promise for an object with optional keys 'results' and 'count'. -function find(config, auth, className, restWhere, restOptions, clientSDK) { - enforceRoleSecurity('find', className, auth); - let query = new RestQuery(config, auth, className, restWhere, restOptions, clientSDK); - return query.execute(); +function checkTriggers(className, config, types) { + return types.some(triggerType => { + return triggers.getTrigger(className, triggers.Types[triggerType], config.applicationId); + }); } -// get is just like find but only queries an objectId. -const get = (config, auth, className, objectId, restOptions, clientSDK) => { - enforceRoleSecurity('get', className, auth); - let query = new RestQuery(config, auth, className, { objectId }, restOptions, clientSDK); +function checkLiveQuery(className, config) { + return config.liveQueryController && config.liveQueryController.hasLiveQuery(className); +} +async function runFindTriggers( + config, + auth, + className, + restWhere, + restOptions, + clientSDK, + context, + options = {} +) { + const { isGet } = options; + + if (restOptions && restOptions.explain && !auth.isMaster) { + const allowPublicExplain = config.databaseOptions?.allowPublicExplain ?? false; + + if (!allowPublicExplain) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + 'Using the explain query parameter requires the master key' + ); + } + } + + // Run beforeFind trigger - may modify query or return objects directly + const result = await triggers.maybeRunQueryTrigger( + triggers.Types.beforeFind, + className, + restWhere, + restOptions, + config, + auth, + context, + isGet + ); + + restWhere = result.restWhere || restWhere; + restOptions = result.restOptions || restOptions; + + // Short-circuit path: beforeFind returned objects directly + // Security risk: These objects may have been fetched with master privileges + if (result?.objects) { + const objectsFromBeforeFind = result.objects; + + let objectsForAfterFind = objectsFromBeforeFind; + + // Security check: Re-filter objects if not master to ensure ACL/CLP compliance + if (!auth?.isMaster && !auth?.isMaintenance) { + const ids = (Array.isArray(objectsFromBeforeFind) ? objectsFromBeforeFind : [objectsFromBeforeFind]) + .map(o => (o && (o.id || o.objectId)) || null) + .filter(Boolean); + + // Objects without IDs are(normally) unsaved objects + // For unsaved objects, the ACL security does not apply, so no need to redo the query. + // For saved objects, we need to re-query to ensure proper ACL/CLP enforcement + if (ids.length > 0) { + const refilterWhere = isGet ? { objectId: ids[0] } : { objectId: { $in: ids } }; + + // Re-query with proper security: no triggers to avoid infinite loops + const refilterQuery = await RestQuery({ + method: isGet ? RestQuery.Method.get : RestQuery.Method.find, + config, + auth, + className, + restWhere: refilterWhere, + restOptions, + clientSDK, + context, + runBeforeFind: false, + runAfterFind: false, + }); + + const refiltered = await refilterQuery.execute(); + objectsForAfterFind = (refiltered && refiltered.results) || []; + } + } + + // Run afterFind trigger on security-filtered objects + const afterFindProcessedObjects = await triggers.maybeRunAfterFindTrigger( + triggers.Types.afterFind, + auth, + className, + objectsForAfterFind, + config, + new Parse.Query(className).withJSON({ where: restWhere, ...restOptions }), + context, + isGet + ); + + return { + results: afterFindProcessedObjects, + }; + } + + // Normal path: execute database query with modified conditions + const query = await RestQuery({ + method: isGet ? RestQuery.Method.get : RestQuery.Method.find, + config, + auth, + className, + restWhere, + restOptions, + clientSDK, + context, + runBeforeFind: false, + }); + return query.execute(); } +// Returns a promise for an object with optional keys 'results' and 'count'. +const find = async (config, auth, className, restWhere, restOptions, clientSDK, context) => { + enforceRoleSecurity('find', className, auth, config); + return runFindTriggers( + config, + auth, + className, + restWhere, + restOptions, + clientSDK, + context, + { isGet: false } + ); +}; + +// get is just like find but only queries an objectId. +const get = async (config, auth, className, objectId, restOptions, clientSDK, context) => { + enforceRoleSecurity('get', className, auth, config); + return runFindTriggers( + config, + auth, + className, + { objectId }, + restOptions, + clientSDK, + context, + { isGet: true } + ); +}; + // Returns a promise that doesn't resolve to any useful value. -function del(config, auth, className, objectId, clientSDK) { +function del(config, auth, className, objectId, context) { if (typeof objectId !== 'string') { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'bad objectId'); + throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad objectId'); } - if (className === '_User' && !auth.couldUpdateUserId(objectId)) { - throw new Parse.Error(Parse.Error.SESSION_MISSING, - 'insufficient auth to delete user'); + if (className === '_User' && auth.isUnauthenticated()) { + throw new Parse.Error(Parse.Error.SESSION_MISSING, 'Insufficient auth to delete user'); } - enforceRoleSecurity('delete', className, auth); - - var inflatedObject; - - return Promise.resolve().then(() => { - if (triggers.getTrigger(className, triggers.Types.beforeDelete, config.applicationId) || - triggers.getTrigger(className, triggers.Types.afterDelete, config.applicationId) || - (config.liveQueryController && config.liveQueryController.hasLiveQuery(className)) || - className == '_Session') { - return find(config, Auth.master(config), className, {objectId: objectId}) - .then((response) => { - if (response && response.results && response.results.length) { - response.results[0].className = className; - - var cacheAdapter = config.cacheController; - cacheAdapter.user.del(response.results[0].sessionToken); - inflatedObject = Parse.Object.fromJSON(response.results[0]); - // Notify LiveQuery server if possible - config.liveQueryController.onAfterDelete(inflatedObject.className, inflatedObject); - return triggers.maybeRunTrigger(triggers.Types.beforeDelete, auth, inflatedObject, null, config); + enforceRoleSecurity('delete', className, auth, config); + + let inflatedObject; + let schemaController; + + return Promise.resolve() + .then(async () => { + const hasTriggers = checkTriggers(className, config, ['beforeDelete', 'afterDelete']); + const hasLiveQuery = checkLiveQuery(className, config); + if (hasTriggers || hasLiveQuery || className == '_Session') { + const query = await RestQuery({ + method: RestQuery.Method.get, + config, + auth, + className, + restWhere: { objectId }, + }); + return query.execute({ op: 'delete' }).then(response => { + if (response && response.results && response.results.length) { + const firstResult = response.results[0]; + firstResult.className = className; + if (className === '_Session' && !auth.isMaster && !auth.isMaintenance) { + if (!auth.user || firstResult.user.objectId !== auth.user.id) { + throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', config); + } + } + var cacheAdapter = config.cacheController; + cacheAdapter.user.del(firstResult.sessionToken); + inflatedObject = Parse.Object.fromJSON(firstResult); + return triggers.maybeRunTrigger( + triggers.Types.beforeDelete, + auth, + inflatedObject, + null, + config, + context + ); + } + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found for delete.'); + }); + } + return Promise.resolve({}); + }) + .then(() => { + if (!auth.isMaster && !auth.isMaintenance) { + return auth.getUserRoles(); + } else { + return; + } + }) + .then(() => config.database.loadSchema()) + .then(s => { + schemaController = s; + const options = {}; + if (!auth.isMaster && !auth.isMaintenance) { + options.acl = ['*']; + if (auth.user) { + options.acl.push(auth.user.id); + options.acl = options.acl.concat(auth.userRoles); } - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found for delete.'); - }); - } - return Promise.resolve({}); - }).then(() => { - if (!auth.isMaster) { - return auth.getUserRoles(); - } else { - return; - } - }).then(() => { - var options = {}; - if (!auth.isMaster) { - options.acl = ['*']; - if (auth.user) { - options.acl.push(auth.user.id); - options.acl = options.acl.concat(auth.userRoles); } - } - return config.database.destroy(className, { - objectId: objectId - }, options); - }).then(() => { - triggers.maybeRunTrigger(triggers.Types.afterDelete, auth, inflatedObject, null, config); - return; - }); + return config.database.destroy( + className, + { + objectId: objectId, + }, + options, + schemaController + ); + }) + .then(() => { + // Notify LiveQuery server if possible + const perms = schemaController.getClassLevelPermissions(className); + config.liveQueryController.onAfterDelete(className, inflatedObject, null, perms); + return triggers.maybeRunTrigger( + triggers.Types.afterDelete, + auth, + inflatedObject, + null, + config, + context + ); + }) + .catch(error => { + handleSessionMissingError(error, className, auth, config); + }); } // Returns a promise for a {response, status, location} object. -function create(config, auth, className, restObject, clientSDK) { - enforceRoleSecurity('create', className, auth); - var write = new RestWrite(config, auth, className, null, restObject, null, clientSDK); +function create(config, auth, className, restObject, clientSDK, context) { + enforceRoleSecurity('create', className, auth, config); + var write = new RestWrite(config, auth, className, null, restObject, null, clientSDK, context); return write.execute(); } // Returns a promise that contains the fields of the update that the // REST API is supposed to return. // Usually, this is just updatedAt. -function update(config, auth, className, objectId, restObject, clientSDK) { - enforceRoleSecurity('update', className, auth); - - return Promise.resolve().then(() => { - if (triggers.getTrigger(className, triggers.Types.beforeSave, config.applicationId) || - triggers.getTrigger(className, triggers.Types.afterSave, config.applicationId) || - (config.liveQueryController && config.liveQueryController.hasLiveQuery(className))) { - return find(config, Auth.master(config), className, {objectId: objectId}); - } - return Promise.resolve({}); - }).then((response) => { - var originalRestObject; - if (response && response.results && response.results.length) { - originalRestObject = response.results[0]; - } +function update(config, auth, className, restWhere, restObject, clientSDK, context) { + enforceRoleSecurity('update', className, auth, config); - var write = new RestWrite(config, auth, className, {objectId: objectId}, restObject, originalRestObject, clientSDK); - return write.execute(); - }); + return Promise.resolve() + .then(async () => { + const hasTriggers = checkTriggers(className, config, ['beforeSave', 'afterSave']); + const hasLiveQuery = checkLiveQuery(className, config); + if (hasTriggers || hasLiveQuery) { + // Do not use find, as it runs the before finds + // Use master auth when protectedFieldsTriggerExempt is true to bypass + // protectedFields filtering, so triggers see the full original object + const queryAuth = config.protectedFieldsTriggerExempt ? Auth.master(config) : auth; + const query = await RestQuery({ + method: RestQuery.Method.get, + config, + auth: queryAuth, + className, + restWhere, + runAfterFind: false, + runBeforeFind: false, + context, + }); + return query.execute({ + op: 'update', + }); + } + return Promise.resolve({}); + }) + .then(({ results }) => { + var originalRestObject; + if (results && results.length) { + originalRestObject = results[0]; + } + return new RestWrite( + config, + auth, + className, + restWhere, + restObject, + originalRestObject, + clientSDK, + context, + 'update' + ).execute(); + }) + .catch(error => { + handleSessionMissingError(error, className, auth, config); + }); } -// Disallowing access to the _Role collection except by master key -function enforceRoleSecurity(method, className, auth) { - if (className === '_Installation' && !auth.isMaster) { - if (method === 'delete' || method === 'find') { - let error = `Clients aren't allowed to perform the ${method} operation on the installation collection.` - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, error); - } +function handleSessionMissingError(error, className, auth, config) { + // If we're trying to update a user without / with bad session token + if ( + className === '_User' && + error.code === Parse.Error.OBJECT_NOT_FOUND && + !auth.isMaster && + !auth.isMaintenance + ) { + throw createSanitizedError(Parse.Error.SESSION_MISSING, 'Insufficient auth.', config); } + throw error; } module.exports = { @@ -137,5 +341,5 @@ module.exports = { del, find, get, - update + update, }; diff --git a/src/testing-routes.js b/src/testing-routes.js deleted file mode 100644 index bcd05a9db6..0000000000 --- a/src/testing-routes.js +++ /dev/null @@ -1,72 +0,0 @@ -// testing-routes.js -import AppCache from './cache'; -import * as middlewares from './middlewares'; -import { ParseServer } from './index'; -import { Parse } from 'parse/node'; - -var express = require('express'), - cryptoUtils = require('./cryptoUtils'); - -var router = express.Router(); - -// creates a unique app in the cache, with a collection prefix -function createApp(req, res) { - var appId = cryptoUtils.randomHexString(32); - - ParseServer({ - databaseURI: 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase', - appId: appId, - masterKey: 'master', - serverURL: Parse.serverURL, - collectionPrefix: appId - }); - var keys = { - 'application_id': appId, - 'client_key' : 'unused', - 'windows_key' : 'unused', - 'javascript_key': 'unused', - 'webhook_key' : 'unused', - 'rest_api_key' : 'unused', - 'master_key' : 'master' - }; - res.status(200).send(keys); -} - -// deletes all collections that belong to the app -function clearApp(req, res) { - if (!req.auth.isMaster) { - return res.status(401).send({ "error": "unauthorized" }); - } - return req.config.database.deleteEverything().then(() => { - res.status(200).send({}); - }); -} - -// deletes all collections and drops the app from cache -function dropApp(req, res) { - if (!req.auth.isMaster) { - return res.status(401).send({ "error": "unauthorized" }); - } - return req.config.database.deleteEverything().then(() => { - AppCache.del(req.config.applicationId); - res.status(200).send({}); - }); -} - -// Lets just return a success response and see what happens. -function notImplementedYet(req, res) { - res.status(200).send({}); -} - -router.post('/rest_clear_app', middlewares.handleParseHeaders, clearApp); -router.post('/rest_block', middlewares.handleParseHeaders, notImplementedYet); -router.post('/rest_mock_v8_client', middlewares.handleParseHeaders, notImplementedYet); -router.post('/rest_unmock_v8_client', middlewares.handleParseHeaders, notImplementedYet); -router.post('/rest_verify_analytics', middlewares.handleParseHeaders, notImplementedYet); -router.post('/rest_create_app', createApp); -router.post('/rest_drop_app', middlewares.handleParseHeaders, dropApp); -router.post('/rest_configure_app', middlewares.handleParseHeaders, notImplementedYet); - -module.exports = { - router: router -}; diff --git a/src/triggers.js b/src/triggers.js index ea1853f99e..f66d96f942 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -1,113 +1,317 @@ // triggers.js -import Parse from 'parse/node'; -import AppCache from './cache'; +import Parse from 'parse/node'; import { logger } from './logger'; +import Utils from './Utils'; export const Types = { + beforeLogin: 'beforeLogin', + afterLogin: 'afterLogin', + afterLogout: 'afterLogout', + beforePasswordResetRequest: 'beforePasswordResetRequest', beforeSave: 'beforeSave', afterSave: 'afterSave', beforeDelete: 'beforeDelete', - afterDelete: 'afterDelete' + afterDelete: 'afterDelete', + beforeFind: 'beforeFind', + afterFind: 'afterFind', + beforeConnect: 'beforeConnect', + beforeSubscribe: 'beforeSubscribe', + afterEvent: 'afterEvent', }; -const baseStore = function() { - let Validators = {}; - let Functions = {}; - let Triggers = Object.keys(Types).reduce(function(base, key){ - base[key] = {}; +const ConnectClassName = '@Connect'; + +/** + * Creates a prototype-free object for use as a lookup store. + * This prevents prototype chain properties (e.g. `constructor`, `toString`) + * from being resolved as registered handlers when using bracket notation + * for lookups. Always use this instead of `{}` for handler stores. + */ +function createStore() { + return Object.create(null); +} + +const baseStore = function () { + const Validators = Object.keys(Types).reduce(function (base, key) { + base[key] = createStore(); + return base; + }, createStore()); + const Functions = createStore(); + const Jobs = createStore(); + const LiveQuery = []; + const Triggers = Object.keys(Types).reduce(function (base, key) { + base[key] = createStore(); return base; - }, {}); + }, createStore()); return Object.freeze({ Functions, + Jobs, Validators, - Triggers + Triggers, + LiveQuery, }); }; -const _triggerStore = {}; +export function getClassName(parseClass) { + if (parseClass && parseClass.className) { + return parseClass.className; + } + if (parseClass && parseClass.name) { + return parseClass.name.replace('Parse', '@'); + } + return parseClass; +} -export function addFunction(functionName, handler, validationHandler, applicationId) { - applicationId = applicationId || Parse.applicationId; - _triggerStore[applicationId] = _triggerStore[applicationId] || baseStore(); - _triggerStore[applicationId].Functions[functionName] = handler; - _triggerStore[applicationId].Validators[functionName] = validationHandler; +function validateClassNameForTriggers(className, type) { + if (type == Types.beforeSave && className === '_PushStatus') { + // _PushStatus uses undocumented nested key increment ops + // allowing beforeSave would mess up the objects big time + // TODO: Allow proper documented way of using nested increment ops + throw 'Only afterSave is allowed on _PushStatus'; + } + if ((type === Types.beforeLogin || type === Types.afterLogin || type === Types.beforePasswordResetRequest) && className !== '_User') { + // TODO: check if upstream code will handle `Error` instance rather + // than this anti-pattern of throwing strings + throw 'Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers'; + } + if (type === Types.afterLogout && className !== '_Session') { + // TODO: check if upstream code will handle `Error` instance rather + // than this anti-pattern of throwing strings + throw 'Only the _Session class is allowed for the afterLogout trigger.'; + } + if (className === '_Session' && type !== Types.afterLogout) { + // TODO: check if upstream code will handle `Error` instance rather + // than this anti-pattern of throwing strings + throw 'Only the afterLogout trigger is allowed for the _Session class.'; + } + return className; } -export function addTrigger(type, className, handler, applicationId) { +const _triggerStore = Object.create(null); + +const Category = { + Functions: 'Functions', + Validators: 'Validators', + Jobs: 'Jobs', + Triggers: 'Triggers', +}; + +function getStore(category, name, applicationId) { + const invalidNameRegex = /['"`]/; + if (invalidNameRegex.test(name)) { + // Prevent a malicious user from injecting properties into the store + return createStore(); + } + + const path = name.split('.'); + path.splice(-1); // remove last component applicationId = applicationId || Parse.applicationId; - _triggerStore[applicationId] = _triggerStore[applicationId] || baseStore(); - _triggerStore[applicationId].Triggers[type][className] = handler; + _triggerStore[applicationId] = _triggerStore[applicationId] || baseStore(); + let store = _triggerStore[applicationId][category]; + for (const component of path) { + if (!Object.prototype.hasOwnProperty.call(store, component)) { + return createStore(); + } + store = store[component]; + if (!store || Object.getPrototypeOf(store) !== null) { + return createStore(); + } + } + return store; } -export function removeFunction(functionName, applicationId) { - applicationId = applicationId || Parse.applicationId; - delete _triggerStore[applicationId].Functions[functionName] +function add(category, name, handler, applicationId) { + const lastComponent = name.split('.').splice(-1); + const store = getStore(category, name, applicationId); + if (store[lastComponent]) { + logger.warn( + `Warning: Duplicate cloud functions exist for ${lastComponent}. Only the last one will be used and the others will be ignored.` + ); + } + store[lastComponent] = handler; } -export function removeTrigger(type, className, applicationId) { - applicationId = applicationId || Parse.applicationId; - delete _triggerStore[applicationId].Triggers[type][className] +function remove(category, name, applicationId) { + const lastComponent = name.split('.').splice(-1); + const store = getStore(category, name, applicationId); + delete store[lastComponent]; } -export function _unregister(appId,category,className,type) { - if (type) { - removeTrigger(className,type,appId); - delete _triggerStore[appId][category][className][type]; - } else { - delete _triggerStore[appId][category][className]; +function get(category, name, applicationId) { + const lastComponent = name.split('.').splice(-1); + const store = getStore(category, name, applicationId); + if (!Object.prototype.hasOwnProperty.call(store, lastComponent)) { + return undefined; } + return store[lastComponent]; +} + +export function addFunction(functionName, handler, validationHandler, applicationId) { + add(Category.Functions, functionName, handler, applicationId); + add(Category.Validators, functionName, validationHandler, applicationId); +} + +export function addJob(jobName, handler, applicationId) { + add(Category.Jobs, jobName, handler, applicationId); +} + +export function addTrigger(type, className, handler, applicationId, validationHandler) { + validateClassNameForTriggers(className, type); + add(Category.Triggers, `${type}.${className}`, handler, applicationId); + add(Category.Validators, `${type}.${className}`, validationHandler, applicationId); +} + +export function addConnectTrigger(type, handler, applicationId, validationHandler) { + add(Category.Triggers, `${type}.${ConnectClassName}`, handler, applicationId); + add(Category.Validators, `${type}.${ConnectClassName}`, validationHandler, applicationId); +} + +export function addLiveQueryEventHandler(handler, applicationId) { + applicationId = applicationId || Parse.applicationId; + _triggerStore[applicationId] = _triggerStore[applicationId] || baseStore(); + _triggerStore[applicationId].LiveQuery.push(handler); +} + +export function removeFunction(functionName, applicationId) { + remove(Category.Functions, functionName, applicationId); +} + +export function removeTrigger(type, className, applicationId) { + remove(Category.Triggers, `${type}.${className}`, applicationId); } export function _unregisterAll() { Object.keys(_triggerStore).forEach(appId => delete _triggerStore[appId]); } +export function toJSONwithObjects(object, className) { + if (!object || !object.toJSON) { + return {}; + } + const toJSON = object.toJSON(); + const stateController = Parse.CoreManager.getObjectStateController(); + const [pending] = stateController.getPendingOps(object._getStateIdentifier()); + for (const key in pending) { + const val = object.get(key); + if (!val || !val._toFullJSON) { + toJSON[key] = val; + continue; + } + toJSON[key] = val._toFullJSON(); + } + // Preserve original object's className if no override className is provided + if (className) { + toJSON.className = className; + } else if (object.className && !toJSON.className) { + toJSON.className = object.className; + } + return toJSON; +} + export function getTrigger(className, triggerType, applicationId) { if (!applicationId) { - throw "Missing ApplicationID"; + throw 'Missing ApplicationID'; + } + return get(Category.Triggers, `${triggerType}.${className}`, applicationId); +} + +export async function runTrigger(trigger, name, request, auth) { + if (!trigger) { + return; } - var manager = _triggerStore[applicationId] - if (manager - && manager.Triggers - && manager.Triggers[triggerType] - && manager.Triggers[triggerType][className]) { - return manager.Triggers[triggerType][className]; + await maybeRunValidator(request, name, auth); + if (request.skipWithMasterKey) { + return; } - return undefined; -}; + return await trigger(request); +} export function triggerExists(className: string, type: string, applicationId: string): boolean { - return (getTrigger(className, type, applicationId) != undefined); + return getTrigger(className, type, applicationId) != undefined; } export function getFunction(functionName, applicationId) { - var manager = _triggerStore[applicationId]; - if (manager && manager.Functions) { - return manager.Functions[functionName]; + return get(Category.Functions, functionName, applicationId); +} + +export function getFunctionNames(applicationId) { + const store = + (_triggerStore[applicationId] && _triggerStore[applicationId][Category.Functions]) || {}; + const functionNames = []; + const extractFunctionNames = (namespace, store) => { + Object.keys(store).forEach(name => { + const value = store[name]; + if (namespace) { + name = `${namespace}.${name}`; + } + if (typeof value === 'function') { + functionNames.push(name); + } else { + extractFunctionNames(name, value); + } + }); }; - return undefined; + extractFunctionNames(null, store); + return functionNames; } -export function getValidator(functionName, applicationId) { +export function getJob(jobName, applicationId) { + return get(Category.Jobs, jobName, applicationId); +} + +export function getJobs(applicationId) { var manager = _triggerStore[applicationId]; - if (manager && manager.Validators) { - return manager.Validators[functionName]; - }; + if (manager && manager.Jobs) { + return manager.Jobs; + } return undefined; } -export function getRequestObject(triggerType, auth, parseObject, originalParseObject, config) { - var request = { +export function getValidator(functionName, applicationId) { + return get(Category.Validators, functionName, applicationId); +} + +export function getRequestObject( + triggerType, + auth, + parseObject, + originalParseObject, + config, + context, + isGet +) { + const request = { triggerName: triggerType, object: parseObject, master: false, - log: config.loggerController && config.loggerController.adapter + isReadOnly: false, + log: config.loggerController, + headers: config.headers, + ip: config.ip, + config, }; + if (isGet !== undefined) { + request.isGet = !!isGet; + } + if (originalParseObject) { request.original = originalParseObject; } + if ( + triggerType === Types.beforeSave || + triggerType === Types.afterSave || + triggerType === Types.beforeDelete || + triggerType === Types.afterDelete || + triggerType === Types.beforeLogin || + triggerType === Types.afterLogin || + triggerType === Types.beforePasswordResetRequest || + triggerType === Types.afterFind + ) { + // Set a copy of the context on the request object. + request.context = Object.assign(Object.create(null), context); + } if (!auth) { return request; @@ -115,6 +319,44 @@ export function getRequestObject(triggerType, auth, parseObject, originalParseOb if (auth.isMaster) { request['master'] = true; } + if (auth.isReadOnly) { + request['isReadOnly'] = true; + } + if (auth.user) { + request['user'] = auth.user; + } + if (auth.installationId) { + request['installationId'] = auth.installationId; + } + return request; +} + +export function getRequestQueryObject(triggerType, auth, query, count, config, context, isGet) { + isGet = !!isGet; + + var request = { + triggerName: triggerType, + query, + master: false, + isReadOnly: false, + count, + log: config.loggerController, + isGet, + headers: config.headers, + ip: config.ip, + context: context || {}, + config, + }; + + if (!auth) { + return request; + } + if (auth.isMaster) { + request['master'] = true; + } + if (auth.isReadOnly) { + request['isReadOnly'] = true; + } if (auth.user) { request['user'] = auth.user; } @@ -130,95 +372,775 @@ export function getRequestObject(triggerType, auth, parseObject, originalParseOb // Any changes made to the object in a beforeSave will be included. export function getResponseObject(request, resolve, reject) { return { - success: function(response) { + success: function (response) { + if (request.triggerName === Types.afterFind) { + if (!response) { + response = request.objects; + } + response = response.map(object => { + return toJSONwithObjects(object); + }); + return resolve(response); + } // Use the JSON response - if (response && !request.object.equals(response) - && request.triggerName === Types.beforeSave) { + if ( + response && + typeof response === 'object' && + !request.object.equals(response) && + request.triggerName === Types.beforeSave + ) { return resolve(response); } + if (response && typeof response === 'object' && request.triggerName === Types.afterSave) { + return resolve(response); + } + if (request.triggerName === Types.afterSave) { + return resolve(); + } response = {}; if (request.triggerName === Types.beforeSave) { response['object'] = request.object._getSaveJSON(); + response['object']['objectId'] = request.object.id; } return resolve(response); }, - error: function(code, message) { - if (!message) { - message = code; - code = Parse.Error.SCRIPT_FAILED; - } - var scriptError = new Parse.Error(code, message); - return reject(scriptError); + error: function (error) { + const e = resolveError(error, { + code: Parse.Error.SCRIPT_FAILED, + message: 'Script failed. Unknown error.', + }); + reject(e); + }, + }; +} + +function userIdForLog(auth) { + return auth && auth.user ? auth.user.id : undefined; +} + +function logTriggerAfterHook(triggerType, className, input, auth, logLevel) { + if (logLevel === 'silent') { + return; + } + const cleanInput = logger.truncateLogMessage(JSON.stringify(input)); + logger[logLevel]( + `${triggerType} triggered for ${className} for user ${userIdForLog( + auth + )}:\n Input: ${cleanInput}`, + { + className, + triggerType, + user: userIdForLog(auth), } + ); +} + +function logTriggerSuccessBeforeHook(triggerType, className, input, result, auth, logLevel) { + if (logLevel === 'silent') { + return; } -}; + const cleanInput = logger.truncateLogMessage(JSON.stringify(input)); + const cleanResult = logger.truncateLogMessage(JSON.stringify(result)); + logger[logLevel]( + `${triggerType} triggered for ${className} for user ${userIdForLog( + auth + )}:\n Input: ${cleanInput}\n Result: ${cleanResult}`, + { + className, + triggerType, + user: userIdForLog(auth), + } + ); +} -function logTrigger(triggerType, className, input) { - if (triggerType.indexOf('after') != 0) { +function logTriggerErrorBeforeHook(triggerType, className, input, auth, error, logLevel) { + if (logLevel === 'silent') { return; } - logger.info(`${triggerType} triggered for ${className}\nInput: ${JSON.stringify(input)}`, { - className, - triggerType, - input - }); + const cleanInput = logger.truncateLogMessage(JSON.stringify(input)); + logger[logLevel]( + `${triggerType} failed for ${className} for user ${userIdForLog( + auth + )}:\n Input: ${cleanInput}\n Error: ${JSON.stringify(error)}`, + { + className, + triggerType, + error, + user: userIdForLog(auth), + } + ); } -function logTriggerSuccess(triggerType, className, input, result) { - logger.info(`${triggerType} triggered for ${className}\nInput: ${JSON.stringify(input)}\nResult: ${JSON.stringify(result)}`, { - className, - triggerType, - input, - result +export function maybeRunAfterFindTrigger( + triggerType, + auth, + classNameQuery, + objectsInput, + config, + query, + context, + isGet +) { + return new Promise((resolve, reject) => { + const trigger = getTrigger(classNameQuery, triggerType, config.applicationId); + + if (!trigger) { + if (objectsInput && objectsInput.length > 0 && objectsInput[0] instanceof Parse.Object) { + return resolve(objectsInput.map(obj => toJSONwithObjects(obj))); + } + return resolve(objectsInput || []); + } + + const request = getRequestObject(triggerType, auth, null, null, config, context, isGet); + // Convert query parameter to Parse.Query instance + if (query instanceof Parse.Query) { + request.query = query; + } else if (typeof query === 'object' && query !== null) { + const parseQueryInstance = new Parse.Query(classNameQuery); + if (query.where) { + parseQueryInstance.withJSON(query); + } + request.query = parseQueryInstance; + } else { + request.query = new Parse.Query(classNameQuery); + } + + const { success, error } = getResponseObject( + request, + processedObjectsJSON => { + resolve(processedObjectsJSON); + }, + errorData => { + reject(errorData); + } + ); + logTriggerSuccessBeforeHook( + triggerType, + classNameQuery, + 'AfterFind Input (Pre-Transform)', + JSON.stringify( + objectsInput.map(o => (o instanceof Parse.Object ? o.id + ':' + o.className : o)) + ), + auth, + config.logLevels.triggerBeforeSuccess + ); + + // Convert plain objects to Parse.Object instances for trigger + request.objects = objectsInput.map(currentObject => { + if (currentObject instanceof Parse.Object) { + return currentObject; + } + // Preserve the original className if it exists, otherwise use the query className + const originalClassName = currentObject.className || classNameQuery; + const tempObjectWithClassName = { ...currentObject, className: originalClassName }; + return Parse.Object.fromJSON(tempObjectWithClassName); + }); + return Promise.resolve() + .then(() => { + return maybeRunValidator(request, `${triggerType}.${classNameQuery}`, auth); + }) + .then(() => { + if (request.skipWithMasterKey) { + return request.objects; + } + const responseFromTrigger = trigger(request); + if (responseFromTrigger && typeof responseFromTrigger.then === 'function') { + return responseFromTrigger.then(results => { + return results; + }); + } + return responseFromTrigger; + }) + .then(success, error); + }).then(resultsAsJSON => { + logTriggerAfterHook( + triggerType, + classNameQuery, + JSON.stringify(resultsAsJSON), + auth, + config.logLevels.triggerAfter + ); + return resultsAsJSON; }); } -function logTriggerError(triggerType, className, input, error) { - logger.error(`${triggerType} failed for ${className}\nInput: ${JSON.stringify(input)}\Error: ${JSON.stringify(error)}`, { - className, +export function maybeRunQueryTrigger( + triggerType, + className, + restWhere, + restOptions, + config, + auth, + context, + isGet +) { + const trigger = getTrigger(className, triggerType, config.applicationId); + if (!trigger) { + return Promise.resolve({ + restWhere, + restOptions, + }); + } + const json = Object.assign({}, restOptions); + json.where = restWhere; + + const parseQuery = new Parse.Query(className); + parseQuery.withJSON(json); + + let count = false; + if (restOptions) { + count = !!restOptions.count; + } + const requestObject = getRequestQueryObject( triggerType, - input, - error + auth, + parseQuery, + count, + config, + context, + isGet + ); + return Promise.resolve() + .then(() => { + return maybeRunValidator(requestObject, `${triggerType}.${className}`, auth); + }) + .then(() => { + if (requestObject.skipWithMasterKey) { + return requestObject.query; + } + return trigger(requestObject); + }) + .then( + result => { + let queryResult = parseQuery; + if (result && result instanceof Parse.Query) { + queryResult = result; + } + const jsonQuery = queryResult.toJSON(); + if (jsonQuery.where) { + restWhere = jsonQuery.where; + } + if (jsonQuery.limit) { + restOptions = restOptions || {}; + restOptions.limit = jsonQuery.limit; + } + if (jsonQuery.skip) { + restOptions = restOptions || {}; + restOptions.skip = jsonQuery.skip; + } + if (jsonQuery.include) { + restOptions = restOptions || {}; + restOptions.include = jsonQuery.include; + } + if (jsonQuery.excludeKeys) { + restOptions = restOptions || {}; + restOptions.excludeKeys = jsonQuery.excludeKeys; + } + if (jsonQuery.explain) { + restOptions = restOptions || {}; + restOptions.explain = jsonQuery.explain; + } + if (jsonQuery.keys) { + restOptions = restOptions || {}; + restOptions.keys = jsonQuery.keys; + } + if (jsonQuery.order) { + restOptions = restOptions || {}; + restOptions.order = jsonQuery.order; + } + if (jsonQuery.hint) { + restOptions = restOptions || {}; + restOptions.hint = jsonQuery.hint; + } + if (jsonQuery.comment) { + restOptions = restOptions || {}; + restOptions.comment = jsonQuery.comment; + } + if (requestObject.readPreference) { + restOptions = restOptions || {}; + restOptions.readPreference = requestObject.readPreference; + } + if (requestObject.includeReadPreference) { + restOptions = restOptions || {}; + restOptions.includeReadPreference = requestObject.includeReadPreference; + } + if (requestObject.subqueryReadPreference) { + restOptions = restOptions || {}; + restOptions.subqueryReadPreference = requestObject.subqueryReadPreference; + } + let objects = undefined; + if (result instanceof Parse.Object) { + objects = [result]; + } else if ( + Array.isArray(result) && + (!result.length || result.every(obj => obj instanceof Parse.Object)) + ) { + objects = result; + } + return { + restWhere, + restOptions, + objects, + }; + }, + err => { + const error = resolveError(err, { + code: Parse.Error.SCRIPT_FAILED, + message: 'Script failed. Unknown error.', + }); + throw error; + } + ); +} + +export function resolveError(message, defaultOpts) { + if (!defaultOpts) { + defaultOpts = {}; + } + if (!message) { + return new Parse.Error( + defaultOpts.code || Parse.Error.SCRIPT_FAILED, + defaultOpts.message || 'Script failed.' + ); + } + if (message instanceof Parse.Error) { + return message; + } + + const code = defaultOpts.code || Parse.Error.SCRIPT_FAILED; + // If it's an error, mark it as a script failed + if (typeof message === 'string') { + return new Parse.Error(code, message); + } + const error = new Parse.Error(code, message.message || message); + if (Utils.isNativeError(message)) { + error.stack = message.stack; + } + return error; +} +export function maybeRunValidator(request, functionName, auth) { + const theValidator = getValidator(functionName, Parse.applicationId); + if (!theValidator) { + return; + } + if (typeof theValidator === 'object' && theValidator.skipWithMasterKey && request.master) { + request.skipWithMasterKey = true; + } + return new Promise((resolve, reject) => { + return Promise.resolve() + .then(() => { + return typeof theValidator === 'object' + ? builtInTriggerValidator(theValidator, request, auth) + : theValidator(request); + }) + .then(() => { + resolve(); + }) + .catch(e => { + const error = resolveError(e, { + code: Parse.Error.VALIDATION_ERROR, + message: 'Validation failed.', + }); + reject(error); + }); }); } +async function builtInTriggerValidator(options, request, auth) { + if (request.master && !options.validateMasterKey) { + return; + } + let reqUser = request.user; + if ( + !reqUser && + request.object && + request.object.className === '_User' && + !request.object.existed() + ) { + reqUser = request.object; + } + if ( + (options.requireUser || options.requireAnyUserRoles || options.requireAllUserRoles) && + !reqUser + ) { + throw 'Validation failed. Please login to continue.'; + } + if (options.requireMaster && !request.master) { + throw 'Validation failed. Master key is required to complete this request.'; + } + let params = request.params || {}; + if (request.object) { + params = request.object.toJSON(); + } + const requiredParam = key => { + const value = params[key]; + if (value == null) { + throw `Validation failed. Please specify data for ${key}.`; + } + }; + + const validateOptions = async (opt, key, val) => { + let opts = opt.options; + if (typeof opts === 'function') { + try { + const result = await opts(val); + if (!result && result != null) { + throw opt.error || `Validation failed. Invalid value for ${key}.`; + } + } catch (e) { + if (!e) { + throw opt.error || `Validation failed. Invalid value for ${key}.`; + } + + throw opt.error || e.message || e; + } + return; + } + if (!Array.isArray(opts)) { + opts = [opt.options]; + } + + if (!opts.includes(val)) { + throw ( + opt.error || `Validation failed. Invalid option for ${key}. Expected: ${opts.join(', ')}` + ); + } + }; + + const getType = fn => { + const match = fn && fn.toString().match(/^\s*function (\w+)/); + return (match ? match[1] : '').toLowerCase(); + }; + if (Array.isArray(options.fields)) { + for (const key of options.fields) { + requiredParam(key); + } + } else { + const optionPromises = []; + for (const key in options.fields) { + const opt = options.fields[key]; + let val = params[key]; + if (typeof opt === 'string') { + requiredParam(opt); + } + if (typeof opt === 'object') { + if (opt.default != null && val == null) { + val = opt.default; + params[key] = val; + if (request.object) { + request.object.set(key, val); + } + } + if (opt.constant && request.object) { + if (request.original) { + request.object.revert(key); + } else if (opt.default != null) { + request.object.set(key, opt.default); + } + } + if (opt.required) { + requiredParam(key); + } + const optional = !opt.required && val === undefined; + if (!optional) { + if (opt.type) { + const type = getType(opt.type); + const valType = Array.isArray(val) ? 'array' : typeof val; + if (valType !== type) { + throw `Validation failed. Invalid type for ${key}. Expected: ${type}`; + } + } + if (opt.options) { + optionPromises.push(validateOptions(opt, key, val)); + } + } + } + } + await Promise.all(optionPromises); + } + let userRoles = options.requireAnyUserRoles; + let requireAllRoles = options.requireAllUserRoles; + const promises = [Promise.resolve(), Promise.resolve(), Promise.resolve()]; + if (userRoles || requireAllRoles) { + promises[0] = auth.getUserRoles(); + } + if (typeof userRoles === 'function') { + promises[1] = userRoles(); + } + if (typeof requireAllRoles === 'function') { + promises[2] = requireAllRoles(); + } + const [roles, resolvedUserRoles, resolvedRequireAll] = await Promise.all(promises); + if (resolvedUserRoles && Array.isArray(resolvedUserRoles)) { + userRoles = resolvedUserRoles; + } + if (resolvedRequireAll && Array.isArray(resolvedRequireAll)) { + requireAllRoles = resolvedRequireAll; + } + if (userRoles) { + const hasRole = userRoles.some(requiredRole => roles.includes(`role:${requiredRole}`)); + if (!hasRole) { + throw `Validation failed. User does not match the required roles.`; + } + } + if (requireAllRoles) { + for (const requiredRole of requireAllRoles) { + if (!roles.includes(`role:${requiredRole}`)) { + throw `Validation failed. User does not match all the required roles.`; + } + } + } + const userKeys = options.requireUserKeys || []; + if (Array.isArray(userKeys)) { + for (const key of userKeys) { + if (!reqUser) { + throw 'Please login to make this request.'; + } + if (reqUser.get(key) == null) { + throw `Validation failed. Please set data for ${key} on your account.`; + } + } + } else if (typeof userKeys === 'object') { + const optionPromises = []; + for (const key in options.requireUserKeys) { + const opt = options.requireUserKeys[key]; + if (opt.options) { + optionPromises.push(validateOptions(opt, key, reqUser.get(key))); + } + } + await Promise.all(optionPromises); + } +} // To be used as part of the promise chain when saving/deleting an object // Will resolve successfully if no trigger is configured // Resolves to an object, empty or containing an object key. A beforeSave // trigger will set the object key to the rest format object to save. -// originalParseObject is optional, we only need that for befote/afterSave functions -export function maybeRunTrigger(triggerType, auth, parseObject, originalParseObject, config) { +// originalParseObject is optional, we only need that for before/afterSave functions +export function maybeRunTrigger( + triggerType, + auth, + parseObject, + originalParseObject, + config, + context +) { if (!parseObject) { return Promise.resolve({}); } return new Promise(function (resolve, reject) { var trigger = getTrigger(parseObject.className, triggerType, config.applicationId); - if (!trigger) return resolve(); - var request = getRequestObject(triggerType, auth, parseObject, originalParseObject, config); - var response = getResponseObject(request, (object) => { - logTriggerSuccess(triggerType, parseObject.className, parseObject.toJSON(), object); - resolve(object); - }, (error) => { - logTriggerError(triggerType, parseObject.className, parseObject.toJSON(), error); - reject(error); - }); - // Force the current Parse app before the trigger - Parse.applicationId = config.applicationId; - Parse.javascriptKey = config.javascriptKey || ''; - Parse.masterKey = config.masterKey; - // For the afterSuccess / afterDelete - logTrigger(triggerType, parseObject.className, parseObject.toJSON()); - trigger(request, response); + if (!trigger) { return resolve(); } + var request = getRequestObject( + triggerType, + auth, + parseObject, + originalParseObject, + config, + context + ); + var { success, error } = getResponseObject( + request, + object => { + logTriggerSuccessBeforeHook( + triggerType, + parseObject.className, + parseObject.toJSON(), + object, + auth, + triggerType.startsWith('after') + ? config.logLevels.triggerAfter + : config.logLevels.triggerBeforeSuccess + ); + if ( + triggerType === Types.beforeSave || + triggerType === Types.afterSave || + triggerType === Types.beforeDelete || + triggerType === Types.afterDelete + ) { + Object.assign(context, request.context); + } + resolve(object); + }, + error => { + logTriggerErrorBeforeHook( + triggerType, + parseObject.className, + parseObject.toJSON(), + auth, + error, + config.logLevels.triggerBeforeError + ); + reject(error); + } + ); + + // AfterSave and afterDelete triggers can return a promise, which if they + // do, needs to be resolved before this promise is resolved, + // so trigger execution is synced with RestWrite.execute() call. + // If triggers do not return a promise, they can run async code parallel + // to the RestWrite.execute() call. + return Promise.resolve() + .then(() => { + return maybeRunValidator(request, `${triggerType}.${parseObject.className}`, auth); + }) + .then(() => { + if (request.skipWithMasterKey) { + return Promise.resolve(); + } + const promise = trigger(request); + if ( + triggerType === Types.afterSave || + triggerType === Types.afterDelete || + triggerType === Types.afterLogin + ) { + logTriggerAfterHook( + triggerType, + parseObject.className, + parseObject.toJSON(), + auth, + config.logLevels.triggerAfter + ); + } + // beforeSave is expected to return null (nothing) + if (triggerType === Types.beforeSave) { + if (promise && typeof promise.then === 'function') { + return promise.then(response => { + // response.object may come from express routing before hook + if (response && response.object) { + return response; + } + return null; + }); + } + return null; + } + + return promise; + }) + .then(success, error); }); -}; +} // Converts a REST-format object to a Parse.Object // data is either className or an object export function inflate(data, restObject) { - var copy = typeof data == 'object' ? data : {className: data}; + var copy = typeof data == 'object' ? data : { className: data }; for (var key in restObject) { copy[key] = restObject[key]; } return Parse.Object.fromJSON(copy); } + +export function runLiveQueryEventHandlers(data, applicationId = Parse.applicationId) { + if (!_triggerStore || !_triggerStore[applicationId] || !_triggerStore[applicationId].LiveQuery) { + return; + } + _triggerStore[applicationId].LiveQuery.forEach(handler => handler(data)); +} + +export function getRequestFileObject(triggerType, auth, fileObject, config) { + const request = { + ...fileObject, + triggerName: triggerType, + master: false, + isReadOnly: false, + log: config.loggerController, + headers: config.headers, + ip: config.ip, + config, + }; + + if (!auth) { + return request; + } + if (auth.isMaster) { + request['master'] = true; + } + if (auth.isReadOnly) { + request['isReadOnly'] = true; + } + if (auth.user) { + request['user'] = auth.user; + } + if (auth.installationId) { + request['installationId'] = auth.installationId; + } + return request; +} + +export async function maybeRunFileTrigger(triggerType, fileObject, config, auth) { + const FileClassName = getClassName(Parse.File); + const fileTrigger = getTrigger(FileClassName, triggerType, config.applicationId); + if (typeof fileTrigger === 'function') { + try { + const request = getRequestFileObject(triggerType, auth, fileObject, config); + await maybeRunValidator(request, `${triggerType}.${FileClassName}`, auth); + if (request.skipWithMasterKey) { + return fileObject; + } + const result = await fileTrigger(request); + if (request.forceDownload) { + fileObject.forceDownload = true; + } + if (request.responseHeaders) { + fileObject.responseHeaders = request.responseHeaders; + } + logTriggerSuccessBeforeHook( + triggerType, + 'Parse.File', + { ...fileObject.file.toJSON(), fileSize: fileObject.fileSize }, + result, + auth, + config.logLevels.triggerBeforeSuccess + ); + return result || fileObject; + } catch (error) { + logTriggerErrorBeforeHook( + triggerType, + 'Parse.File', + { ...fileObject.file.toJSON(), fileSize: fileObject.fileSize }, + auth, + error, + config.logLevels.triggerBeforeError + ); + throw error; + } + } + return fileObject; +} + +export async function maybeRunGlobalConfigTrigger(triggerType, auth, configObject, originalConfigObject, config, context) { + const GlobalConfigClassName = getClassName(Parse.Config); + const configTrigger = getTrigger(GlobalConfigClassName, triggerType, config.applicationId); + if (typeof configTrigger === 'function') { + try { + const request = getRequestObject(triggerType, auth, configObject, originalConfigObject, config, context); + await maybeRunValidator(request, `${triggerType}.${GlobalConfigClassName}`, auth); + if (request.skipWithMasterKey) { + return configObject; + } + const result = await configTrigger(request); + logTriggerSuccessBeforeHook( + triggerType, + 'Parse.Config', + configObject, + result, + auth, + config.logLevels.triggerBeforeSuccess + ); + return result || configObject; + } catch (error) { + logTriggerErrorBeforeHook( + triggerType, + 'Parse.Config', + configObject, + auth, + error, + config.logLevels.triggerBeforeError + ); + throw error; + } + } + return configObject; +} diff --git a/src/vendor/README.md b/src/vendor/README.md index d51e8ea4ec..04e3256f72 100644 --- a/src/vendor/README.md +++ b/src/vendor/README.md @@ -1,8 +1,8 @@ # mongoUrl -A fork of node's `url` module, with the modification that commas and colons are -allowed in hostnames. While this results in a slightly incorrect parsed result, -as the hostname field for a mongodb should be an array of replica sets, it's +A fork of node's `url` module, with the modification that commas and colons are +allowed in hostnames. While this results in a slightly incorrect parsed result, +as the hostname field for a mongodb should be an array of replica sets, it's good enough to let us pull out and escape the auth portion of the URL. -See also: https://github.com/ParsePlatform/parse-server/pull/986 +https://github.com/parse-community/parse-server/pull/986 diff --git a/src/vendor/mongodbUrl.js b/src/vendor/mongodbUrl.js index f2711bf6d2..eaa25add02 100644 --- a/src/vendor/mongodbUrl.js +++ b/src/vendor/mongodbUrl.js @@ -1,12 +1,11 @@ -// A slightly patched version of node's url module, with support for mongodb:// -// uris. -// -// See https://github.com/nodejs/node/blob/master/LICENSE for licensing -// information +/* + * A slightly patched version of node's URL module, with support for `mongodb://` URIs. + * See https://github.com/nodejs/node for licensing information. + */ 'use strict'; -const punycode = require('punycode'); +import punycode from 'punycode/punycode.js'; exports.parse = urlParse; exports.resolve = urlResolve; @@ -40,35 +39,34 @@ const portPattern = /:[0-9]*$/; // Special case for a simple path URL const simplePathPattern = /^(\/\/?(?!\/)[^\?\s]*)(\?[^\s]*)?$/; -const hostnameMaxLen = 255; // protocols that can allow "unsafe" and "unwise" chars. const unsafeProtocol = { - 'javascript': true, - 'javascript:': true + javascript: true, + 'javascript:': true, }; // protocols that never have a hostname. const hostlessProtocol = { - 'javascript': true, - 'javascript:': true + javascript: true, + 'javascript:': true, }; // protocols that always contain a // bit. const slashedProtocol = { - 'http': true, + http: true, 'http:': true, - 'https': true, + https: true, 'https:': true, - 'ftp': true, + ftp: true, 'ftp:': true, - 'gopher': true, + gopher: true, 'gopher:': true, - 'file': true, - 'file:': true + file: true, + 'file:': true, }; const querystring = require('querystring'); /* istanbul ignore next: improve coverage */ function urlParse(url, parseQueryString, slashesDenoteHost) { - if (url instanceof Url) return url; + if (url instanceof Url) { return url; } var u = new Url(); u.parse(url, parseQueryString, slashesDenoteHost); @@ -76,7 +74,7 @@ function urlParse(url, parseQueryString, slashesDenoteHost) { } /* istanbul ignore next: improve coverage */ -Url.prototype.parse = function(url, parseQueryString, slashesDenoteHost) { +Url.prototype.parse = function (url, parseQueryString, slashesDenoteHost) { if (typeof url !== 'string') { throw new TypeError('Parameter "url" must be a string, not ' + typeof url); } @@ -94,16 +92,16 @@ Url.prototype.parse = function(url, parseQueryString, slashesDenoteHost) { const code = url.charCodeAt(i); // Find first and last non-whitespace characters for trimming - const isWs = code === 32/* */ || - code === 9/*\t*/ || - code === 13/*\r*/ || - code === 10/*\n*/ || - code === 12/*\f*/ || - code === 160/*\u00A0*/ || - code === 65279/*\uFEFF*/; + const isWs = + code === 32 /* */ || + code === 9 /*\t*/ || + code === 13 /*\r*/ || + code === 10 /*\n*/ || + code === 12 /*\f*/ || + code === 160 /*\u00A0*/ || + code === 65279; /*\uFEFF*/ if (start === -1) { - if (isWs) - continue; + if (isWs) { continue; } lastPos = start = i; } else { if (inWs) { @@ -127,13 +125,12 @@ Url.prototype.parse = function(url, parseQueryString, slashesDenoteHost) { split = true; break; case 92: // '\\' - if (i - lastPos > 0) - rest += url.slice(lastPos, i); + if (i - lastPos > 0) { rest += url.slice(lastPos, i); } rest += '/'; lastPos = i + 1; break; } - } else if (!hasHash && code === 35/*#*/) { + } else if (!hasHash && code === 35 /*#*/) { hasHash = true; } } @@ -144,10 +141,8 @@ Url.prototype.parse = function(url, parseQueryString, slashesDenoteHost) { // We didn't convert any backslashes if (end === -1) { - if (start === 0) - rest = url; - else - rest = url.slice(start); + if (start === 0) { rest = url; } + else { rest = url.slice(start); } } else { rest = url.slice(start, end); } @@ -195,17 +190,14 @@ Url.prototype.parse = function(url, parseQueryString, slashesDenoteHost) { // resolution will treat //foo/bar as host=foo,path=bar because that's // how the browser resolves relative URLs. if (slashesDenoteHost || proto || /^\/\/[^@\/]+@[^@\/]+/.test(rest)) { - var slashes = rest.charCodeAt(0) === 47/*/*/ && - rest.charCodeAt(1) === 47/*/*/; + var slashes = rest.charCodeAt(0) === 47 /*/*/ && rest.charCodeAt(1) === 47; /*/*/ if (slashes && !(proto && hostlessProtocol[proto])) { rest = rest.slice(2); this.slashes = true; } } - if (!hostlessProtocol[proto] && - (slashes || (proto && !slashedProtocol[proto]))) { - + if (!hostlessProtocol[proto] && (slashes || (proto && !slashedProtocol[proto]))) { // there's a hostname. // the first instance of /, ?, ;, or # ends the host. // @@ -226,32 +218,30 @@ Url.prototype.parse = function(url, parseQueryString, slashesDenoteHost) { var nonHost = -1; for (i = 0; i < rest.length; ++i) { switch (rest.charCodeAt(i)) { - case 9: // '\t' - case 10: // '\n' - case 13: // '\r' - case 32: // ' ' - case 34: // '"' - case 37: // '%' - case 39: // '\'' - case 59: // ';' - case 60: // '<' - case 62: // '>' - case 92: // '\\' - case 94: // '^' - case 96: // '`' + case 9: // '\t' + case 10: // '\n' + case 13: // '\r' + case 32: // ' ' + case 34: // '"' + case 37: // '%' + case 39: // '\'' + case 59: // ';' + case 60: // '<' + case 62: // '>' + case 92: // '\\' + case 94: // '^' + case 96: // '`' case 123: // '{' case 124: // '|' case 125: // '}' // Characters that are never ever allowed in a hostname from RFC 2396 - if (nonHost === -1) - nonHost = i; + if (nonHost === -1) { nonHost = i; } break; case 35: // '#' case 47: // '/' case 63: // '?' // Find the first instance of any host-ending characters - if (nonHost === -1) - nonHost = i; + if (nonHost === -1) { nonHost = i; } hostEnd = i; break; case 64: // '@' @@ -261,8 +251,7 @@ Url.prototype.parse = function(url, parseQueryString, slashesDenoteHost) { nonHost = -1; break; } - if (hostEnd !== -1) - break; + if (hostEnd !== -1) { break; } } start = 0; if (atSign !== -1) { @@ -282,29 +271,23 @@ Url.prototype.parse = function(url, parseQueryString, slashesDenoteHost) { // we've indicated that there is a hostname, // so even if it's empty, it has to be present. - if (typeof this.hostname !== 'string') - this.hostname = ''; + if (typeof this.hostname !== 'string') { this.hostname = ''; } var hostname = this.hostname; // if hostname begins with [ and ends with ] // assume that it's an IPv6 address. - var ipv6Hostname = hostname.charCodeAt(0) === 91/*[*/ && - hostname.charCodeAt(hostname.length - 1) === 93/*]*/; + var ipv6Hostname = + hostname.charCodeAt(0) === 91 /*[*/ && hostname.charCodeAt(hostname.length - 1) === 93; /*]*/ // validate a little. if (!ipv6Hostname) { const result = validateHostname(this, rest, hostname); - if (result !== undefined) - rest = result; + if (result !== undefined) { rest = result; } } - if (this.hostname.length > hostnameMaxLen) { - this.hostname = ''; - } else { - // hostnames are always lower case. - this.hostname = this.hostname.toLowerCase(); - } + // hostnames are always lower case. + this.hostname = this.hostname.toLowerCase(); if (!ipv6Hostname) { // IDNA Support: Returns a punycoded representation of "domain". @@ -335,19 +318,18 @@ Url.prototype.parse = function(url, parseQueryString, slashesDenoteHost) { // escaped, even if encodeURIComponent doesn't think they // need to be. const result = autoEscapeStr(rest); - if (result !== undefined) - rest = result; + if (result !== undefined) { rest = result; } } var questionIdx = -1; var hashIdx = -1; for (i = 0; i < rest.length; ++i) { const code = rest.charCodeAt(i); - if (code === 35/*#*/) { + if (code === 35 /*#*/) { this.hash = rest.slice(i); hashIdx = i; break; - } else if (code === 63/*?*/ && questionIdx === -1) { + } else if (code === 63 /*?*/ && questionIdx === -1) { questionIdx = i; } } @@ -369,18 +351,14 @@ Url.prototype.parse = function(url, parseQueryString, slashesDenoteHost) { this.query = {}; } - var firstIdx = (questionIdx !== -1 && - (hashIdx === -1 || questionIdx < hashIdx) - ? questionIdx - : hashIdx); + var firstIdx = + questionIdx !== -1 && (hashIdx === -1 || questionIdx < hashIdx) ? questionIdx : hashIdx; if (firstIdx === -1) { - if (rest.length > 0) - this.pathname = rest; + if (rest.length > 0) { this.pathname = rest; } } else if (firstIdx > 0) { this.pathname = rest.slice(0, firstIdx); } - if (slashedProtocol[lowerProto] && - this.hostname && !this.pathname) { + if (slashedProtocol[lowerProto] && this.hostname && !this.pathname) { this.pathname = '/'; } @@ -400,9 +378,8 @@ Url.prototype.parse = function(url, parseQueryString, slashesDenoteHost) { function validateHostname(self, rest, hostname) { for (var i = 0, lastPos; i <= hostname.length; ++i) { var code; - if (i < hostname.length) - code = hostname.charCodeAt(i); - if (code === 46/*.*/ || i === hostname.length) { + if (i < hostname.length) { code = hostname.charCodeAt(i); } + if (code === 46 /*.*/ || i === hostname.length) { if (i - lastPos > 0) { if (i - lastPos > 63) { self.hostname = hostname.slice(0, lastPos + 63); @@ -411,23 +388,24 @@ function validateHostname(self, rest, hostname) { } lastPos = i + 1; continue; - } else if ((code >= 48/*0*/ && code <= 57/*9*/) || - (code >= 97/*a*/ && code <= 122/*z*/) || - code === 45/*-*/ || - (code >= 65/*A*/ && code <= 90/*Z*/) || - code === 43/*+*/ || - code === 95/*_*/ || - /* BEGIN MONGO URI PATCH */ - code === 44/*,*/ || - code === 58/*:*/ || - /* END MONGO URI PATCH */ - code > 127) { + } else if ( + (code >= 48 /*0*/ && code <= 57) /*9*/ || + (code >= 97 /*a*/ && code <= 122) /*z*/ || + code === 45 /*-*/ || + (code >= 65 /*A*/ && code <= 90) /*Z*/ || + code === 43 /*+*/ || + code === 95 /*_*/ || + /* BEGIN MONGO URI PATCH */ + code === 44 /*,*/ || + code === 58 /*:*/ || + /* END MONGO URI PATCH */ + code > 127 + ) { continue; } // Invalid host character self.hostname = hostname.slice(0, i); - if (i < hostname.length) - return '/' + hostname.slice(i) + rest; + if (i < hostname.length) { return '/' + hostname.slice(i) + rest; } break; } } @@ -440,98 +418,81 @@ function autoEscapeStr(rest) { // Automatically escape all delimiters and unwise characters from RFC 2396 // Also escape single quotes in case of an XSS attack switch (rest.charCodeAt(i)) { - case 9: // '\t' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); + case 9: // '\t' + if (i - lastPos > 0) { newRest += rest.slice(lastPos, i); } newRest += '%09'; lastPos = i + 1; break; - case 10: // '\n' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); + case 10: // '\n' + if (i - lastPos > 0) { newRest += rest.slice(lastPos, i); } newRest += '%0A'; lastPos = i + 1; break; - case 13: // '\r' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); + case 13: // '\r' + if (i - lastPos > 0) { newRest += rest.slice(lastPos, i); } newRest += '%0D'; lastPos = i + 1; break; - case 32: // ' ' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); + case 32: // ' ' + if (i - lastPos > 0) { newRest += rest.slice(lastPos, i); } newRest += '%20'; lastPos = i + 1; break; - case 34: // '"' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); + case 34: // '"' + if (i - lastPos > 0) { newRest += rest.slice(lastPos, i); } newRest += '%22'; lastPos = i + 1; break; - case 39: // '\'' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); + case 39: // '\'' + if (i - lastPos > 0) { newRest += rest.slice(lastPos, i); } newRest += '%27'; lastPos = i + 1; break; - case 60: // '<' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); + case 60: // '<' + if (i - lastPos > 0) { newRest += rest.slice(lastPos, i); } newRest += '%3C'; lastPos = i + 1; break; - case 62: // '>' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); + case 62: // '>' + if (i - lastPos > 0) { newRest += rest.slice(lastPos, i); } newRest += '%3E'; lastPos = i + 1; break; - case 92: // '\\' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); + case 92: // '\\' + if (i - lastPos > 0) { newRest += rest.slice(lastPos, i); } newRest += '%5C'; lastPos = i + 1; break; - case 94: // '^' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); + case 94: // '^' + if (i - lastPos > 0) { newRest += rest.slice(lastPos, i); } newRest += '%5E'; lastPos = i + 1; break; - case 96: // '`' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); + case 96: // '`' + if (i - lastPos > 0) { newRest += rest.slice(lastPos, i); } newRest += '%60'; lastPos = i + 1; break; case 123: // '{' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); + if (i - lastPos > 0) { newRest += rest.slice(lastPos, i); } newRest += '%7B'; lastPos = i + 1; break; case 124: // '|' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); + if (i - lastPos > 0) { newRest += rest.slice(lastPos, i); } newRest += '%7C'; lastPos = i + 1; break; case 125: // '}' - if (i - lastPos > 0) - newRest += rest.slice(lastPos, i); + if (i - lastPos > 0) { newRest += rest.slice(lastPos, i); } newRest += '%7D'; lastPos = i + 1; break; } } - if (lastPos === 0) - return; - if (lastPos < rest.length) - return newRest + rest.slice(lastPos); - else - return newRest; + if (lastPos === 0) { return; } + if (lastPos < rest.length) { return newRest + rest.slice(lastPos); } + else { return newRest; } } // format a parsed object into a url string @@ -541,19 +502,18 @@ function urlFormat(obj) { // If it's an obj, this is a no-op. // this way, you can call url_format() on strings // to clean up potentially wonky urls. - if (typeof obj === 'string') obj = urlParse(obj); - + if (typeof obj === 'string') { obj = urlParse(obj); } else if (typeof obj !== 'object' || obj === null) - throw new TypeError('Parameter "urlObj" must be an object, not ' + - obj === null ? 'null' : typeof obj); - - else if (!(obj instanceof Url)) return Url.prototype.format.call(obj); + { throw new TypeError( + 'Parameter "urlObj" must be an object, not ' + (obj === null ? 'null' : typeof obj) + ); } + else if (!(obj instanceof Url)) { return Url.prototype.format.call(obj); } return obj.format(); } /* istanbul ignore next: improve coverage */ -Url.prototype.format = function() { +Url.prototype.format = function () { var auth = this.auth || ''; if (auth) { auth = encodeAuth(auth); @@ -569,62 +529,53 @@ Url.prototype.format = function() { if (this.host) { host = auth + this.host; } else if (this.hostname) { - host = auth + (this.hostname.indexOf(':') === -1 ? - this.hostname : - '[' + this.hostname + ']'); + host = auth + (this.hostname.indexOf(':') === -1 ? this.hostname : '[' + this.hostname + ']'); if (this.port) { host += ':' + this.port; } } if (this.query !== null && typeof this.query === 'object') - query = querystring.stringify(this.query); + { query = querystring.stringify(this.query); } - var search = this.search || (query && ('?' + query)) || ''; + var search = this.search || (query && '?' + query) || ''; - if (protocol && protocol.charCodeAt(protocol.length - 1) !== 58/*:*/) - protocol += ':'; + if (protocol && protocol.charCodeAt(protocol.length - 1) !== 58 /*:*/) { protocol += ':'; } var newPathname = ''; var lastPos = 0; for (var i = 0; i < pathname.length; ++i) { switch (pathname.charCodeAt(i)) { case 35: // '#' - if (i - lastPos > 0) - newPathname += pathname.slice(lastPos, i); + if (i - lastPos > 0) { newPathname += pathname.slice(lastPos, i); } newPathname += '%23'; lastPos = i + 1; break; case 63: // '?' - if (i - lastPos > 0) - newPathname += pathname.slice(lastPos, i); + if (i - lastPos > 0) { newPathname += pathname.slice(lastPos, i); } newPathname += '%3F'; lastPos = i + 1; break; } } if (lastPos > 0) { - if (lastPos !== pathname.length) - pathname = newPathname + pathname.slice(lastPos); - else - pathname = newPathname; + if (lastPos !== pathname.length) { pathname = newPathname + pathname.slice(lastPos); } + else { pathname = newPathname; } } // only the slashedProtocols get the //. Not mailto:, xmpp:, etc. // unless they had them to begin with. - if (this.slashes || - (!protocol || slashedProtocol[protocol]) && host !== false) { + if (this.slashes || ((!protocol || slashedProtocol[protocol]) && host !== false)) { host = '//' + (host || ''); - if (pathname && pathname.charCodeAt(0) !== 47/*/*/) - pathname = '/' + pathname; + if (pathname && pathname.charCodeAt(0) !== 47 /*/*/) { pathname = '/' + pathname; } } else if (!host) { host = ''; } search = search.replace('#', '%23'); - if (hash && hash.charCodeAt(0) !== 35/*#*/) hash = '#' + hash; - if (search && search.charCodeAt(0) !== 63/*?*/) search = '?' + search; + if (hash && hash.charCodeAt(0) !== 35 /*#*/) { hash = '#' + hash; } + if (search && search.charCodeAt(0) !== 63 /*?*/) { search = '?' + search; } return protocol + host + pathname + search + hash; }; @@ -635,18 +586,18 @@ function urlResolve(source, relative) { } /* istanbul ignore next: improve coverage */ -Url.prototype.resolve = function(relative) { +Url.prototype.resolve = function (relative) { return this.resolveObject(urlParse(relative, false, true)).format(); }; /* istanbul ignore next: improve coverage */ function urlResolveObject(source, relative) { - if (!source) return relative; + if (!source) { return relative; } return urlParse(source, false, true).resolveObject(relative); } /* istanbul ignore next: improve coverage */ -Url.prototype.resolveObject = function(relative) { +Url.prototype.resolveObject = function (relative) { if (typeof relative === 'string') { var rel = new Url(); rel.parse(relative, false, true); @@ -676,13 +627,11 @@ Url.prototype.resolveObject = function(relative) { var rkeys = Object.keys(relative); for (var rk = 0; rk < rkeys.length; rk++) { var rkey = rkeys[rk]; - if (rkey !== 'protocol') - result[rkey] = relative[rkey]; + if (rkey !== 'protocol') { result[rkey] = relative[rkey]; } } //urlParse appends trailing / to urls like http://www.example.com - if (slashedProtocol[result.protocol] && - result.hostname && !result.pathname) { + if (slashedProtocol[result.protocol] && result.hostname && !result.pathname) { result.path = result.pathname = '/'; } @@ -710,15 +659,23 @@ Url.prototype.resolveObject = function(relative) { } result.protocol = relative.protocol; - if (!relative.host && - !/^file:?$/.test(relative.protocol) && - !hostlessProtocol[relative.protocol]) { + if ( + !relative.host && + !/^file:?$/.test(relative.protocol) && + !hostlessProtocol[relative.protocol] + ) { const relPath = (relative.pathname || '').split('/'); - while (relPath.length && !(relative.host = relPath.shift())); - if (!relative.host) relative.host = ''; - if (!relative.hostname) relative.hostname = ''; - if (relPath[0] !== '') relPath.unshift(''); - if (relPath.length < 2) relPath.unshift(''); + while (relPath.length) { + const shifted = relPath.shift(); + if (shifted) { + relative.host = shifted; + break; + } + } + if (!relative.host) { relative.host = ''; } + if (!relative.hostname) { relative.hostname = ''; } + if (relPath[0] !== '') { relPath.unshift(''); } + if (relPath.length < 2) { relPath.unshift(''); } result.pathname = relPath.join('/'); } else { result.pathname = relative.pathname; @@ -740,16 +697,12 @@ Url.prototype.resolveObject = function(relative) { return result; } - var isSourceAbs = (result.pathname && result.pathname.charAt(0) === '/'); - var isRelAbs = ( - relative.host || - relative.pathname && relative.pathname.charAt(0) === '/' - ); - var mustEndAbs = (isRelAbs || isSourceAbs || - (result.host && relative.pathname)); + var isSourceAbs = result.pathname && result.pathname.charAt(0) === '/'; + var isRelAbs = relative.host || (relative.pathname && relative.pathname.charAt(0) === '/'); + var mustEndAbs = isRelAbs || isSourceAbs || (result.host && relative.pathname); var removeAllDots = mustEndAbs; - var srcPath = result.pathname && result.pathname.split('/') || []; - var relPath = relative.pathname && relative.pathname.split('/') || []; + var srcPath = (result.pathname && result.pathname.split('/')) || []; + var relPath = (relative.pathname && relative.pathname.split('/')) || []; var psychotic = result.protocol && !slashedProtocol[result.protocol]; // if the url is a non-slashed url, then relative @@ -761,16 +714,16 @@ Url.prototype.resolveObject = function(relative) { result.hostname = ''; result.port = null; if (result.host) { - if (srcPath[0] === '') srcPath[0] = result.host; - else srcPath.unshift(result.host); + if (srcPath[0] === '') { srcPath[0] = result.host; } + else { srcPath.unshift(result.host); } } result.host = ''; if (relative.protocol) { relative.hostname = null; relative.port = null; if (relative.host) { - if (relPath[0] === '') relPath[0] = relative.host; - else relPath.unshift(relative.host); + if (relPath[0] === '') { relPath[0] = relative.host; } + else { relPath.unshift(relative.host); } } relative.host = null; } @@ -779,10 +732,9 @@ Url.prototype.resolveObject = function(relative) { if (isRelAbs) { // it's absolute. - result.host = (relative.host || relative.host === '') ? - relative.host : result.host; - result.hostname = (relative.hostname || relative.hostname === '') ? - relative.hostname : result.hostname; + result.host = relative.host || relative.host === '' ? relative.host : result.host; + result.hostname = + relative.hostname || relative.hostname === '' ? relative.hostname : result.hostname; result.search = relative.search; result.query = relative.query; srcPath = relPath; @@ -790,7 +742,7 @@ Url.prototype.resolveObject = function(relative) { } else if (relPath.length) { // it's relative // throw away the existing file, and take the new path instead. - if (!srcPath) srcPath = []; + if (!srcPath) { srcPath = []; } srcPath.pop(); srcPath = srcPath.concat(relPath); result.search = relative.search; @@ -804,8 +756,8 @@ Url.prototype.resolveObject = function(relative) { //occasionally the auth can get stuck only in host //this especially happens in cases like //url.resolveObject('mailto:local1@domain1', 'local2@domain2') - const authInHost = result.host && result.host.indexOf('@') > 0 ? - result.host.split('@') : false; + const authInHost = + result.host && result.host.indexOf('@') > 0 ? result.host.split('@') : false; if (authInHost) { result.auth = authInHost.shift(); result.host = result.hostname = authInHost.shift(); @@ -815,8 +767,7 @@ Url.prototype.resolveObject = function(relative) { result.query = relative.query; //to support http.request if (result.pathname !== null || result.search !== null) { - result.path = (result.pathname ? result.pathname : '') + - (result.search ? result.search : ''); + result.path = (result.pathname ? result.pathname : '') + (result.search ? result.search : ''); } result.href = result.format(); return result; @@ -840,9 +791,9 @@ Url.prototype.resolveObject = function(relative) { // however, if it ends in anything else non-slashy, // then it must NOT get a trailing slash. var last = srcPath.slice(-1)[0]; - var hasTrailingSlash = ( - (result.host || relative.host || srcPath.length > 1) && - (last === '.' || last === '..') || last === ''); + var hasTrailingSlash = + ((result.host || relative.host || srcPath.length > 1) && (last === '.' || last === '..')) || + last === ''; // strip single dots, resolve double dots to parent dir // if the path tries to go above the root, `up` ends up > 0 @@ -867,27 +818,27 @@ Url.prototype.resolveObject = function(relative) { } } - if (mustEndAbs && srcPath[0] !== '' && - (!srcPath[0] || srcPath[0].charAt(0) !== '/')) { + if (mustEndAbs && srcPath[0] !== '' && (!srcPath[0] || srcPath[0].charAt(0) !== '/')) { srcPath.unshift(''); } - if (hasTrailingSlash && (srcPath.join('/').substr(-1) !== '/')) { + if (hasTrailingSlash && srcPath.join('/').substr(-1) !== '/') { srcPath.push(''); } - var isAbsolute = srcPath[0] === '' || - (srcPath[0] && srcPath[0].charAt(0) === '/'); + var isAbsolute = srcPath[0] === '' || (srcPath[0] && srcPath[0].charAt(0) === '/'); // put the host back if (psychotic) { - result.hostname = result.host = isAbsolute ? '' : - srcPath.length ? srcPath.shift() : ''; + if (isAbsolute) { + result.hostname = result.host = ''; + } else { + result.hostname = result.host = srcPath.length ? srcPath.shift() : ''; + } //occasionally the auth can get stuck only in host //this especially happens in cases like //url.resolveObject('mailto:local1@domain1', 'local2@domain2') - const authInHost = result.host && result.host.indexOf('@') > 0 ? - result.host.split('@') : false; + const authInHost = result.host && result.host.indexOf('@') > 0 ? result.host.split('@') : false; if (authInHost) { result.auth = authInHost.shift(); result.host = result.hostname = authInHost.shift(); @@ -909,8 +860,7 @@ Url.prototype.resolveObject = function(relative) { //to support request.http if (result.pathname !== null || result.search !== null) { - result.path = (result.pathname ? result.pathname : '') + - (result.search ? result.search : ''); + result.path = (result.pathname ? result.pathname : '') + (result.search ? result.search : ''); } result.auth = relative.auth || result.auth; result.slashes = result.slashes || relative.slashes; @@ -919,7 +869,7 @@ Url.prototype.resolveObject = function(relative) { }; /* istanbul ignore next: improve coverage */ -Url.prototype.parseHost = function() { +Url.prototype.parseHost = function () { var host = this.host; var port = portPattern.exec(host); if (port) { @@ -929,20 +879,19 @@ Url.prototype.parseHost = function() { } host = host.slice(0, host.length - port.length); } - if (host) this.hostname = host; + if (host) { this.hostname = host; } }; // About 1.5x faster than the two-arg version of Array#splice(). /* istanbul ignore next: improve coverage */ function spliceOne(list, index) { - for (var i = index, k = i + 1, n = list.length; k < n; i += 1, k += 1) - list[i] = list[k]; + for (var i = index, k = i + 1, n = list.length; k < n; i += 1, k += 1) { list[i] = list[k]; } list.pop(); } var hexTable = new Array(256); for (var i = 0; i < 256; ++i) - hexTable[i] = '%' + ((i < 16 ? '0' : '') + i.toString(16)).toUpperCase(); +{ hexTable[i] = '%' + ((i < 16 ? '0' : '') + i.toString(16)).toUpperCase(); } /* istanbul ignore next: improve coverage */ function encodeAuth(str) { // faster encodeURIComponent alternative for encoding auth uri components @@ -957,16 +906,21 @@ function encodeAuth(str) { // digits // alpha (uppercase) // alpha (lowercase) - if (c === 0x21 || c === 0x2D || c === 0x2E || c === 0x5F || c === 0x7E || - (c >= 0x27 && c <= 0x2A) || - (c >= 0x30 && c <= 0x3A) || - (c >= 0x41 && c <= 0x5A) || - (c >= 0x61 && c <= 0x7A)) { + if ( + c === 0x21 || + c === 0x2d || + c === 0x2e || + c === 0x5f || + c === 0x7e || + (c >= 0x27 && c <= 0x2a) || + (c >= 0x30 && c <= 0x3a) || + (c >= 0x41 && c <= 0x5a) || + (c >= 0x61 && c <= 0x7a) + ) { continue; } - if (i - lastPos > 0) - out += str.slice(lastPos, i); + if (i - lastPos > 0) { out += str.slice(lastPos, i); } lastPos = i + 1; @@ -978,31 +932,29 @@ function encodeAuth(str) { // Multi-byte characters ... if (c < 0x800) { - out += hexTable[0xC0 | (c >> 6)] + hexTable[0x80 | (c & 0x3F)]; + out += hexTable[0xc0 | (c >> 6)] + hexTable[0x80 | (c & 0x3f)]; continue; } - if (c < 0xD800 || c >= 0xE000) { - out += hexTable[0xE0 | (c >> 12)] + - hexTable[0x80 | ((c >> 6) & 0x3F)] + - hexTable[0x80 | (c & 0x3F)]; + if (c < 0xd800 || c >= 0xe000) { + out += + hexTable[0xe0 | (c >> 12)] + + hexTable[0x80 | ((c >> 6) & 0x3f)] + + hexTable[0x80 | (c & 0x3f)]; continue; } // Surrogate pair ++i; var c2; - if (i < str.length) - c2 = str.charCodeAt(i) & 0x3FF; - else - c2 = 0; - c = 0x10000 + (((c & 0x3FF) << 10) | c2); - out += hexTable[0xF0 | (c >> 18)] + - hexTable[0x80 | ((c >> 12) & 0x3F)] + - hexTable[0x80 | ((c >> 6) & 0x3F)] + - hexTable[0x80 | (c & 0x3F)]; - } - if (lastPos === 0) - return str; - if (lastPos < str.length) - return out + str.slice(lastPos); + if (i < str.length) { c2 = str.charCodeAt(i) & 0x3ff; } + else { c2 = 0; } + c = 0x10000 + (((c & 0x3ff) << 10) | c2); + out += + hexTable[0xf0 | (c >> 18)] + + hexTable[0x80 | ((c >> 12) & 0x3f)] + + hexTable[0x80 | ((c >> 6) & 0x3f)] + + hexTable[0x80 | (c & 0x3f)]; + } + if (lastPos === 0) { return str; } + if (lastPos < str.length) { return out + str.slice(lastPos); } return out; } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000..22836c54d2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2015", + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "types", + "noImplicitAny": false, + "allowJs": false, + "skipLibCheck": true, + "paths": { + "deepcopy": ["./types/@types/deepcopy"], + } + }, + "include": [ + "src/*.ts" + ] +} diff --git a/types/@types/@parse/fs-files-adapter/index.d.ts b/types/@types/@parse/fs-files-adapter/index.d.ts new file mode 100644 index 0000000000..ffab73832d --- /dev/null +++ b/types/@types/@parse/fs-files-adapter/index.d.ts @@ -0,0 +1,5 @@ +// TODO: Remove when @parse/fs-files-adapter is typed +declare module '@parse/fs-files-adapter' { + const FileSystemAdapter: any; + export default FileSystemAdapter; +} diff --git a/types/@types/deepcopy/index.d.ts b/types/@types/deepcopy/index.d.ts new file mode 100644 index 0000000000..e5da2a3238 --- /dev/null +++ b/types/@types/deepcopy/index.d.ts @@ -0,0 +1,5 @@ +// TODO: Remove when https://github.com/sasaplus1/deepcopy.js/issues/278 is fixed +declare type Customizer = (value: any, valueType: string) => unknown; +declare type Options = Customizer | { customizer: Customizer }; +declare function deepcopy(value: T, options?: Options): T; +export default deepcopy; diff --git a/types/LiveQuery/ParseLiveQueryServer.d.ts b/types/LiveQuery/ParseLiveQueryServer.d.ts new file mode 100644 index 0000000000..d5966144b5 --- /dev/null +++ b/types/LiveQuery/ParseLiveQueryServer.d.ts @@ -0,0 +1,40 @@ +import { Auth } from '../Auth'; +declare class ParseLiveQueryServer { + server: any; + config: any; + clients: Map; + subscriptions: Map; + parseWebSocketServer: any; + keyPairs: any; + subscriber: any; + authCache: any; + cacheController: any; + constructor(server: any, config?: any, parseServerConfig?: any); + connect(): Promise; + shutdown(): Promise; + _createSubscribers(): void; + _inflateParseObject(message: any): void; + _onAfterDelete(message: any): Promise; + _onAfterSave(message: any): Promise; + _onConnect(parseWebsocket: any): void; + _matchesSubscription(parseObject: any, subscription: any): boolean; + _clearCachedRoles(userId: string): Promise; + getAuthForSessionToken(sessionToken?: string): Promise<{ + auth?: Auth; + userId?: string; + }>; + _matchesCLP(classLevelPermissions?: any, object?: any, client?: any, requestId?: number, op?: string): Promise; + _filterSensitiveData(classLevelPermissions?: any, res?: any, client?: any, requestId?: number, op?: string, query?: any): Promise; + _getCLPOperation(query: any): "get" | "find"; + _verifyACL(acl: any, token: string): Promise; + getAuthFromClient(client: any, requestId: number, sessionToken?: string): Promise; + _checkWatchFields(client: any, requestId: any, message: any): any; + _matchesACL(acl: any, client: any, requestId: number): Promise; + _handleConnect(parseWebsocket: any, request: any): Promise; + _hasMasterKey(request: any, validKeyPairs: any): boolean; + _validateKeys(request: any, validKeyPairs: any): boolean; + _handleSubscribe(parseWebsocket: any, request: any): Promise; + _handleUpdateSubscription(parseWebsocket: any, request: any): any; + _handleUnsubscribe(parseWebsocket: any, request: any, notifyClient?: boolean): any; +} +export { ParseLiveQueryServer }; diff --git a/types/Options/index.d.ts b/types/Options/index.d.ts new file mode 100644 index 0000000000..6a8b1494ac --- /dev/null +++ b/types/Options/index.d.ts @@ -0,0 +1,319 @@ +// This file is manually updated to match src/Options/index.js until typed +import { AnalyticsAdapter } from '../Adapters/Analytics/AnalyticsAdapter'; +import { CacheAdapter } from '../Adapters/Cache/CacheAdapter'; +import { MailAdapter } from '../Adapters/Email/MailAdapter'; +import { FilesAdapter } from '../Adapters/Files/FilesAdapter'; +import { LoggerAdapter } from '../Adapters/Logger/LoggerAdapter'; +import { PubSubAdapter } from '../Adapters/PubSub/PubSubAdapter'; +import { StorageAdapter } from '../Adapters/Storage/StorageAdapter'; +import { WSSAdapter } from '../Adapters/WebSocketServer/WSSAdapter'; +import { CheckGroup } from '../Security/CheckGroup'; +export interface SchemaOptions { + definitions: any; + strict?: boolean; + deleteExtraFields?: boolean; + recreateModifiedFields?: boolean; + lockSchemas?: boolean; + beforeMigration?: () => void | Promise; + afterMigration?: () => void | Promise; +} +type Adapter = string | T; +type NumberOrBoolean = number | boolean; +type NumberOrString = number | string; +type ProtectedFields = any; +type StringOrStringArray = string | string[]; +type RequestKeywordDenylist = { + key: string; + value: any; +}; +export interface EmailVerificationRequest { + original?: any; + object: any; + master?: boolean; + ip?: string; + installationId?: string; + createdWith?: { + action: 'login' | 'signup'; + authProvider: string; + }; + resendRequest?: boolean; +} +export interface SendEmailVerificationRequest { + user: any; + master?: boolean; +} +export interface ParseServerOptions { + appId: string; + masterKey: (() => void) | string; + masterKeyTtl?: number; + maintenanceKey: string; + serverURL: string; + masterKeyIps?: (string[]); + maintenanceKeyIps?: (string[]); + appName?: string; + allowHeaders?: (string[]); + allowOrigin?: StringOrStringArray; + analyticsAdapter?: Adapter; + filesAdapter?: Adapter; + push?: any; + scheduledPush?: boolean; + loggerAdapter?: Adapter; + jsonLogs?: boolean; + logsFolder?: string; + verbose?: boolean; + logLevel?: string; + logLevels?: LogLevels; + maxLogFiles?: NumberOrString; + silent?: boolean; + databaseURI: string; + databaseOptions?: DatabaseOptions; + databaseAdapter?: Adapter; + enableCollationCaseComparison?: boolean; + convertEmailToLowercase?: boolean; + convertUsernameToLowercase?: boolean; + cloud?: string; + collectionPrefix?: string; + clientKey?: string; + javascriptKey?: string; + dotNetKey?: string; + encryptionKey?: string; + restAPIKey?: string; + readOnlyMasterKey?: string; + webhookKey?: string; + fileKey?: string; + preserveFileName?: boolean; + userSensitiveFields?: (string[]); + protectedFields?: ProtectedFields; + enableAnonymousUsers?: boolean; + allowClientClassCreation?: boolean; + allowCustomObjectId?: boolean; + auth?: Record; + enableInsecureAuthAdapters?: boolean; + maxUploadSize?: string; + verifyUserEmails?: boolean | ((params: EmailVerificationRequest) => boolean | Promise); + preventLoginWithUnverifiedEmail?: boolean | ((params: EmailVerificationRequest) => boolean | Promise); + preventSignupWithUnverifiedEmail?: boolean; + emailVerifyTokenValidityDuration?: number; + emailVerifyTokenReuseIfValid?: boolean; + emailVerifySuccessOnInvalidEmail?: boolean; + sendUserEmailVerification?: boolean | ((params: SendEmailVerificationRequest) => boolean | Promise); + accountLockout?: AccountLockoutOptions; + passwordPolicy?: PasswordPolicyOptions; + cacheAdapter?: Adapter; + emailAdapter?: Adapter; + publicServerURL?: string | (() => string) | (() => Promise); + pages?: PagesOptions; + customPages?: CustomPagesOptions; + liveQuery?: LiveQueryOptions; + sessionLength?: number; + extendSessionOnUse?: boolean; + defaultLimit?: number; + maxLimit?: number; + expireInactiveSessions?: boolean; + revokeSessionOnPasswordReset?: boolean; + cacheTTL?: number; + cacheMaxSize?: number; + directAccess?: boolean; + enableExpressErrorHandler?: boolean; + objectIdSize?: number; + port?: number; + host?: string; + mountPath?: string; + cluster?: NumberOrBoolean; + middleware?: ((() => void) | string); + trustProxy?: any; + startLiveQueryServer?: boolean; + liveQueryServerOptions?: LiveQueryServerOptions; + idempotencyOptions?: IdempotencyOptions; + fileUpload?: FileUploadOptions; + graphQLSchema?: string; + mountGraphQL?: boolean; + graphQLPath?: string; + mountPlayground?: boolean; + playgroundPath?: string; + schema?: SchemaOptions; + serverCloseComplete?: () => void; + security?: SecurityOptions; + enforcePrivateUsers?: boolean; + allowExpiredAuthDataToken?: boolean; + requestKeywordDenylist?: (RequestKeywordDenylist[]); + rateLimit?: (RateLimitOptions[]); + verifyServerUrl?: boolean; +} +export interface RateLimitOptions { + requestPath: string; + requestTimeWindow?: number; + requestCount?: number; + errorResponseMessage?: string; + requestMethods?: (string[]); + includeMasterKey?: boolean; + includeInternalRequests?: boolean; + redisUrl?: string; + zone?: string; +} +export interface SecurityOptions { + enableCheck?: boolean; + enableCheckLog?: boolean; + checkGroups?: (CheckGroup[]); +} +export interface PagesOptions { + enableLocalization?: boolean; + localizationJsonPath?: string; + localizationFallbackLocale?: string; + placeholders?: any; + forceRedirect?: boolean; + pagesPath?: string; + pagesEndpoint?: string; + customUrls?: PagesCustomUrlsOptions; + customRoutes?: (PagesRoute[]); +} +export interface PagesRoute { + path: string; + method: string; + handler: () => void; +} +export interface PagesCustomUrlsOptions { + passwordReset?: string; + passwordResetLinkInvalid?: string; + passwordResetSuccess?: string; + emailVerificationSuccess?: string; + emailVerificationSendFail?: string; + emailVerificationSendSuccess?: string; + emailVerificationLinkInvalid?: string; + emailVerificationLinkExpired?: string; +} +export interface CustomPagesOptions { + invalidLink?: string; + linkSendFail?: string; + choosePassword?: string; + linkSendSuccess?: string; + verifyEmailSuccess?: string; + passwordResetSuccess?: string; + invalidVerificationLink?: string; + expiredVerificationLink?: string; + invalidPasswordResetLink?: string; + parseFrameURL?: string; +} +export interface LiveQueryOptions { + classNames?: (string[]); + redisOptions?: any; + redisURL?: string; + pubSubAdapter?: Adapter; + wssAdapter?: Adapter; +} +export interface LiveQueryServerOptions { + appId?: string; + masterKey?: string; + serverURL?: string; + keyPairs?: any; + websocketTimeout?: number; + cacheTimeout?: number; + logLevel?: string; + port?: number; + redisOptions?: any; + redisURL?: string; + pubSubAdapter?: Adapter; + wssAdapter?: Adapter; +} +export interface IdempotencyOptions { + paths?: (string[]); + ttl?: number; +} +export interface AccountLockoutOptions { + duration?: number; + threshold?: number; + unlockOnPasswordReset?: boolean; +} +export interface PasswordPolicyOptions { + validatorPattern?: string; + validatorCallback?: () => void; + validationError?: string; + doNotAllowUsername?: boolean; + maxPasswordAge?: number; + maxPasswordHistory?: number; + resetTokenValidityDuration?: number; + resetTokenReuseIfValid?: boolean; + resetPasswordSuccessOnInvalidEmail?: boolean; +} +export interface FileUploadOptions { + allowedFileUrlDomains?: string[]; + fileExtensions?: (string[]); + enableForAnonymousUser?: boolean; + enableForAuthenticatedUser?: boolean; + enableForPublic?: boolean; +} +export interface DatabaseOptions { + // Parse Server custom options + allowPublicExplain?: boolean; + batchSize?: number; + createIndexRoleName?: boolean; + createIndexUserEmail?: boolean; + createIndexUserEmailCaseInsensitive?: boolean; + createIndexUserEmailVerifyToken?: boolean; + createIndexUserPasswordResetToken?: boolean; + createIndexUserUsername?: boolean; + createIndexUserUsernameCaseInsensitive?: boolean; + disableIndexFieldValidation?: boolean; + enableSchemaHooks?: boolean; + logClientEvents?: any[]; + // maxTimeMS is a MongoDB option but Parse Server applies it per-operation, not as a global client option + maxTimeMS?: number; + schemaCacheTtl?: number; + + // MongoDB driver options + appName?: string; + authMechanism?: string; + authMechanismProperties?: any; + authSource?: string; + autoSelectFamily?: boolean; + autoSelectFamilyAttemptTimeout?: number; + compressors?: string[] | string; + connectTimeoutMS?: number; + directConnection?: boolean; + forceServerObjectId?: boolean; + heartbeatFrequencyMS?: number; + loadBalanced?: boolean; + localThresholdMS?: number; + maxConnecting?: number; + maxIdleTimeMS?: number; + maxPoolSize?: number; + maxStalenessSeconds?: number; + minPoolSize?: number; + proxyHost?: string; + proxyPassword?: string; + proxyPort?: number; + proxyUsername?: string; + readConcernLevel?: string; + readPreference?: string; + readPreferenceTags?: any[]; + replicaSet?: string; + retryReads?: boolean; + retryWrites?: boolean; + serverMonitoringMode?: string; + serverSelectionTimeoutMS?: number; + socketTimeoutMS?: number; + srvMaxHosts?: number; + srvServiceName?: string; + ssl?: boolean; + tls?: boolean; + tlsAllowInvalidCertificates?: boolean; + tlsAllowInvalidHostnames?: boolean; + tlsCAFile?: string; + tlsCertificateKeyFile?: string; + tlsCertificateKeyFilePassword?: string; + tlsInsecure?: boolean; + waitQueueTimeoutMS?: number; + zlibCompressionLevel?: number; +} +export interface AuthAdapter { + enabled?: boolean; +} +export interface LogLevels { + triggerAfter?: string; + triggerBeforeSuccess?: string; + triggerBeforeError?: string; + cloudFunctionSuccess?: string; + cloudFunctionError?: string; + signupUsernameTaken?: string; +} +export {}; diff --git a/types/ParseServer.d.ts b/types/ParseServer.d.ts new file mode 100644 index 0000000000..9570f0cf16 --- /dev/null +++ b/types/ParseServer.d.ts @@ -0,0 +1,65 @@ +import { ParseServerOptions, LiveQueryServerOptions } from './Options'; +import { ParseLiveQueryServer } from './LiveQuery/ParseLiveQueryServer'; +declare class ParseServer { + _app: any; + config: any; + server: any; + expressApp: any; + liveQueryServer: any; + /** + * @constructor + * @param {ParseServerOptions} options the parse server initialization options + */ + constructor(options: ParseServerOptions); + /** + * Starts Parse Server as an express app; this promise resolves when Parse Server is ready to accept requests. + */ + start(): Promise; + get app(): any; + /** + * Stops the parse server, cancels any ongoing requests and closes all connections. + * + * Currently, express doesn't shut down immediately after receiving SIGINT/SIGTERM + * if it has client connections that haven't timed out. + * (This is a known issue with node - https://github.com/nodejs/node/issues/2642) + * + * @returns {Promise} a promise that resolves when the server is stopped + */ + handleShutdown(): Promise; + /** + * @static + * Allow developers to customize each request with inversion of control/dependency injection + */ + static applyRequestContextMiddleware(api: any, options: any): void; + /** + * @static + * Create an express app for the parse server + * @param {Object} options let you specify the maxUploadSize when creating the express app */ + static app(options: any): any; + static promiseRouter({ appId }: { + appId: any; + }): any; + /** + * starts the parse server's express app + * @param {ParseServerOptions} options to use to start the server + * @returns {ParseServer} the parse server instance + */ + startApp(options: ParseServerOptions): Promise; + /** + * Creates a new ParseServer and starts it. + * @param {ParseServerOptions} options used to start the server + * @returns {ParseServer} the parse server instance + */ + static startApp(options: ParseServerOptions): Promise; + /** + * Helper method to create a liveQuery server + * @static + * @param {Server} httpServer an optional http server to pass + * @param {LiveQueryServerOptions} config options for the liveQueryServer + * @param {ParseServerOptions} options options for the ParseServer + * @returns {Promise} the live query server instance + */ + static createLiveQueryServer(httpServer: any, config: LiveQueryServerOptions, options: ParseServerOptions): Promise; + static verifyServerUrl(): any; +} +export default ParseServer; diff --git a/types/eslint.config.mjs b/types/eslint.config.mjs new file mode 100644 index 0000000000..f2375e596f --- /dev/null +++ b/types/eslint.config.mjs @@ -0,0 +1,30 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import expectType from 'eslint-plugin-expect-type/configs/recommended'; + +export default tseslint.config({ + files: ['**/*.js', '**/*.ts'], + extends: [ + expectType, + eslint.configs.recommended, + ...tseslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + ], + plugins: { + '@typescript-eslint': tseslint.plugin, + }, + rules: { + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-unused-expressions': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unsafe-return": "off", + }, + languageOptions: { + parser: tseslint.parser, + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, +}); diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 0000000000..591044c272 --- /dev/null +++ b/types/index.d.ts @@ -0,0 +1,21 @@ +import ParseServer from './ParseServer'; +import FileSystemAdapter from '@parse/fs-files-adapter'; +import InMemoryCacheAdapter from './Adapters/Cache/InMemoryCacheAdapter'; +import NullCacheAdapter from './Adapters/Cache/NullCacheAdapter'; +import RedisCacheAdapter from './Adapters/Cache/RedisCacheAdapter'; +import LRUCacheAdapter from './Adapters/Cache/LRUCache.js'; +import * as TestUtils from './TestUtils'; +import * as SchemaMigrations from './SchemaMigrations/Migrations'; +import AuthAdapter from './Adapters/Auth/AuthAdapter'; +import { PushWorker } from './Push/PushWorker'; +import { ParseServerOptions } from './Options'; +import { ParseGraphQLServer } from './GraphQL/ParseGraphQLServer'; +declare const _ParseServer: { + (options: ParseServerOptions): ParseServer; + createLiveQueryServer: typeof ParseServer.createLiveQueryServer; + startApp: typeof ParseServer.startApp; +}; +declare const S3Adapter: any; +declare const GCSAdapter: any; +export default ParseServer; +export { S3Adapter, GCSAdapter, FileSystemAdapter, InMemoryCacheAdapter, NullCacheAdapter, RedisCacheAdapter, LRUCacheAdapter, TestUtils, PushWorker, ParseGraphQLServer, _ParseServer as ParseServer, SchemaMigrations, AuthAdapter, }; diff --git a/types/logger.d.ts b/types/logger.d.ts new file mode 100644 index 0000000000..14b33350d6 --- /dev/null +++ b/types/logger.d.ts @@ -0,0 +1,2 @@ +export declare function setLogger(aLogger: any): void; +export declare function getLogger(): any; diff --git a/types/tests.ts b/types/tests.ts new file mode 100644 index 0000000000..15593963f8 --- /dev/null +++ b/types/tests.ts @@ -0,0 +1,44 @@ +import ParseServer, { FileSystemAdapter } from 'parse-server'; + +async function server() { + // $ExpectType ParseServer + const parseServer = await ParseServer.startApp({}); + + // $ExpectType void + await parseServer.handleShutdown(); + + // $ExpectType any + parseServer.app; + + // $ExpectType any + ParseServer.app({}); + + // $ExpectType any + ParseServer.promiseRouter({ appId: 'appId' }); + + // $ExpectType ParseLiveQueryServer + await ParseServer.createLiveQueryServer({}, {}, {}); + + // $ExpectType any + ParseServer.verifyServerUrl(); + + // $ExpectError + await ParseServer.startApp(); + + // $ExpectError + ParseServer.promiseRouter(); + + // $ExpectError + await ParseServer.createLiveQueryServer(); + + // $ExpectType ParseServer + const parseServer2 = new ParseServer({}); + + // $ExpectType ParseServer + await parseServer2.start(); +} + +function exports() { + // $ExpectType any + FileSystemAdapter; +} diff --git a/types/tsconfig.json b/types/tsconfig.json new file mode 100644 index 0000000000..0880cb0a81 --- /dev/null +++ b/types/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "module": "commonjs", + "lib": ["es6"], + "noImplicitAny": true, + "noImplicitThis": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "types": [], + "noEmit": true, + "forceConsistentCasingInFileNames": true, + + // If the library is an external module (uses `export`), this allows your test file to import "mylib" instead of "./index". + // If the library is global (cannot be imported via `import` or `require`), leave this out. + "baseUrl": ".", + "paths": { + "parse-server": ["."], + "@parse/fs-files-adapter": ["./@types/@parse/fs-files-adapter"], + } + }, + "include": [ + "tests.ts" + ] +} diff --git a/views/choose_password b/views/choose_password index 097cbd2077..8919818cf3 100644 --- a/views/choose_password +++ b/views/choose_password @@ -108,7 +108,12 @@ background-image: -ms-linear-gradient(#00395E,#005891); background-image: linear-gradient(#00395E,#005891); } - + + button:disabled, + button[disabled] { + opacity: 0.5; + } + input { color: black; cursor: auto; @@ -126,6 +131,12 @@ word-spacing: 0px; } + #password_match_info { + margin-top: 0px; + font-size: 13px; + color: red; + } + @@ -134,11 +145,20 @@
- + + New Password +
+ + Confirm New Password + + + - + + +