diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f5af7d..9aeb1f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,14 @@ name: CI -on: [push, pull_request] +on: + push: + pull_request: + # For the `mutation` job below, which is too slow to run on a push. Everything + # else runs on the schedule too, which costs nothing and catches the class of + # breakage that comes from a dependency rather than from a commit. + schedule: + - cron: '0 4 * * 1' + workflow_dispatch: permissions: contents: read @@ -75,6 +83,40 @@ jobs: if-no-files-found: ignore retention-days: 7 + mutation: + # Mutation testing on the authorization and validation surface. Not a gate: + # it reports, it never fails the build (`break: null` in stryker.config.json), + # for the same reason there is no coverage threshold. See TESTING.md + # section 11. + # + # Weekly and on demand only. A full run is minutes rather than seconds, + # because Stryker reruns the suite once per mutant, and nobody waits that + # long on a pull request. The findings it produces are not the kind that + # need catching within the hour: it finds tests that cannot fail, which is a + # slow leak rather than a regression. + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # node, not bun: Stryker's instrumenter and its vitest runner both expect + # a node runtime, and @stryker-mutator/core declares `engines.node >= 20`. + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: oven-sh/setup-bun@v2 + # bun for the install (it owns bun.lock), node for the run. + - run: bun install + - name: Mutation testing + run: node node_modules/@stryker-mutator/core/bin/stryker.js run + - name: Upload the mutation report + if: always() + uses: actions/upload-artifact@v4 + with: + name: mutation-report + path: reports/mutation/ + if-no-files-found: ignore + retention-days: 30 + e2e: runs-on: ubuntu-latest needs: [check] diff --git a/.gitignore b/.gitignore index 2545768..65d6110 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ /playwright-report /test-results /.playwright +/reports +/.stryker-tmp # next.js /.next/ diff --git a/.prettierignore b/.prettierignore index 353d080..471f71a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,5 @@ bun.lock coverage/ playwright-report/ test-results/ +reports/ +.stryker-tmp/ diff --git a/AGENTS.md b/AGENTS.md index e8e30e8..48359d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,8 @@ - Run `bun run verify` (this is `bun run check` plus the unit and component tests). - If you touched an API route, also run `bun run test:api`. It needs the test database: `bun run test:db:up` first. +- If you added a batch of tests, hand them to a second reviewer before calling the work + done. See "A batch of new tests gets an adversarial review, by somebody else" below. ## Testing @@ -56,6 +58,45 @@ Both have been found in this repo, so they are worth naming. function looks up means deleting an entry from that constant also deletes its own test case. Write expected values by hand as literals. +A third variant is specific to `tests/api/auth-matrix.test.ts`: a route that refuses a +malformed request before it reaches its access check produces an entry that passes whether +or not the guard exists. The suite catches it by requiring a 401 or a 403 rather than +merely a non-2xx, and a 404 counts as suspicious rather than as a refusal, since a fixture +id that stops resolving would otherwise pass forever. `NON_AUTHORIZATION_REFUSALS` and +`NOT_FOUND_IS_THE_GUARD` in that file explain the whole trap; both are empty, and adding to +either is meant to be a visible diff. + +`bun run test:mutation` automates case 1 across the authorization and validation modules +listed in `stryker.config.json`. It is slow, so it is not part of `bun run check`, and CI +runs it weekly rather than on a push. Reach for it when you have written a batch of tests +and want to know which of them are decorative. + +### A batch of new tests gets an adversarial review, by somebody else + +**Rule: whoever wrote a batch of tests does not get to be the one who signs it off.** When +a change adds a meaningful number of tests (a new suite, or a set of them), a second +reviewer goes over them with one question in mind: _do these tests deliver what they claim +to?_ If the work is being done by agents, that reviewer is a separate agent with no stake +in the code it is reading. + +This is not a style pass. The reviewer's job is to find: + +- Tests that pass whether or not the production code works. Verify by mutation, do not take + the author's word for it, and prefer a mutation the author did not already try. +- Assertions weak enough to survive the bug they were written for: `toBeTruthy()` on an + object, a status code checked without checking the database row, a `403` with no `2xx` + beside it, a `not.toThrow()` standing in for a real expectation. +- A test whose subject is the mock rather than the code. If every dependency is stubbed, + ask what is left to be wrong. +- Coverage that reads as complete but is not: the happy path tested five ways and the + rollback, the concurrent call and the failure branch tested not at all. +- Names that promise more than the body checks. The name is what the next person trusts. +- Setup so elaborate that the test no longer describes a situation the app can reach. + +Two rounds of this have already been run on this suite and both found real problems, so it +is worth the cost. Findings go back to the author to fix; the reviewer does not quietly +rewrite the tests. + ## Repo-specific conventions - Use `auth()` from `@/lib/auth` for server-side session reads. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1ec33e4..e523ba1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,6 +40,8 @@ The testing stack, layout, and conventions live in [TESTING.md](TESTING.md). Rea | `bun run test:e2e` | Playwright end-to-end specs | yes | | `bun run verify` | `bun run check` plus the unit and component suites | no | +`scripts/test.sh mutation` is the other one worth knowing about. It runs StrykerJS over the authorization and validation modules, breaking one line at a time to find tests that pass either way. It takes minutes rather than seconds, so it is not in `all` and CI runs it weekly; reach for it after writing a batch of tests. It needs node rather than bun, which the script handles. + The test database is a disposable Postgres defined in `docker-compose.test.yml`, on port `55432` so it cannot collide with your dev stack. ```bash diff --git a/TESTING.md b/TESTING.md index 7df924d..3cad380 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,15 +1,16 @@ # Testing Plan -Status: **all six phases delivered**. What actually landed, and where reality differed -from the plan, is in Section 12. The sections below are kept as written so the reasoning -behind each decision stays readable. +Status: **all six phases delivered**, and a second round has since closed the coverage gaps +the first one left. What actually landed, and where reality differed from the plan, is in +Section 12; the gap-closing round is Section 13. The sections below are kept as written so +the reasoning behind each decision stays readable. | Suite | Command | Tests | Runtime | | ---------------- | ------------------ | -------- | ------- | -| Unit + component | `bun run test` | 1160 | 6s | -| API integration | `bun run test:api` | 537 | 45s | -| End to end | `bun run test:e2e` | 18 | 40s | -| **Total** | `bun run test:all` | **1715** | | +| Unit + component | `bun run test` | 2079 | 12s | +| API integration | `bun run test:api` | 1015 | 92s | +| End to end | `bun run test:e2e` | 29 | 66s | +| **Total** | `bun run test:all` | **3123** | | OpenFrame is ~56k lines across 60 API route handlers, ~90 components and ~50 `lib/` modules. Before this, every change was verified by hand. This document defines the stack, @@ -731,3 +732,85 @@ in mind, and so nobody "fixes" a deliberate deviation back. stage, not from source files. - A coverage PR comment needs `pull-requests: write`, which conflicts with keeping `permissions: contents: read`, so CI uploads an artifact instead. + +--- + +## 13. Closing the gaps + +The first round left an inventory of what it had not covered. This section records what +the second round did about it, so the inventory is not read as still-current. + +Where it ended up: + +| Suite | Before | After | +| ---------------- | ------ | ----- | +| Unit | 1191 | 1702 | +| Component + hook | 167 | 377 | +| API integration | 647 | 1015 | +| End to end | 18 | 29 | + +**The page-level authorization layer.** `lib/route-access.ts` had zero coverage, which +meant the API routes were guarded by tests and the pages were not. It now has 47, with +`next/navigation` mocked so that `redirect()` and `notFound()` throw the way they really +do. Every redirect target was verified against a second source rather than read off the +function under test, and one of the three the plan assumed turned out to be wrong: the +project paths have no billing branch at all and reach `/settings` in two hops through +`/dashboard`. + +**The media proxies.** Five routes served user media with only anonymous coverage, and the +reason they had stayed that way was the positive control: no 2xx is reachable without R2 +configured, and a 403 with nothing green beside it can pass for the wrong reason. The way +in was to stub `r2Client.send()` and leave `lib/r2-media-proxy.ts` itself real, so the +object keys, content types and range handling are production code paths. Every one of the +five now has a genuine 2xx in the same file as its 403. + +**Tests that could not fail.** The auth matrix used to assert only "not 2xx" for an +anonymous caller. Two entries satisfied that without their guard existing at all, because +the route refused a malformed body one line further down, and Section 12's predecessor +recorded them as unfixable. They were fixable: requiring an authorization status (401, 403 +or 404) rather than merely a non-2xx one makes both load-bearing, and all 60 routes pass +the stricter form, so `NON_AUTHORIZATION_REFUSALS` is empty and exists only as a drift +guard. + +**A stub with the wrong shape is worse than no stub.** `tests/setup/api.ts` declared +`readVideoObjectBytes` as returning an object wrapping a `Uint8Array` when it really +returns the array. The object was truthy but had no `.length`, so `hasKnownVideoMagicBytes()` +saw zero bytes and every route reaching `finalizeR2VideoUpload` took the "not a valid +video" branch, cancelled the session and deleted both objects. Nothing failed. No test +drove that path to success until this round, and the whole api suite had been green over +it for weeks. + +**Parallel suites need parallel databases.** Eight agents wrote suites at once, and the +api project empties every table between tests, so they cannot share one database. Each run +got its own, created by hand in the same container and named `openframe_test_`. +`tests/api/infrastructure.test.ts` now accepts that shape instead of the exact name; the +guard that matters, that the dev database is called `openframe` and does not match, is +untouched. + +**Mutation testing.** `bun run test:mutation` runs StrykerJS over the authorization and +input-validation modules listed in `stryker.config.json`, against +`vitest.mutation.config.ts`, which is the `unit` project alone. Not a merge gate, for the +same reason there is no coverage threshold, and CI runs it weekly and on demand rather +than on a push, because a full run is minutes. The module list is explicit rather than a +`lib/**` glob: a file whose only coverage is an API integration test would report every +mutant as survived and bury the real findings. + +**Safari.** `playwright.config.ts` gains a `webkit-player` project behind `E2E_WEBKIT=1`, +scoped to `player.spec.ts`. Playback is where a video review tool's Safari risk actually +lives; running all fourteen specs under WebKit would mostly re-test React. + +**Reviewed by somebody else.** Every suite in this round was read by a separate agent whose +only question was whether the tests deliver what they claim. That is now a standing rule in +`AGENTS.md` rather than a one-off. + +What was deliberately left, and why: + +- **OAuth sign-in, Stripe checkout, and email verification end to end.** All three leave + the app or need a provider stub. The gate each one guards is covered at the API layer. +- **Version comparison end to end.** Two real uploads per test against 51 KB of its own + client logic. Three solid specs beat five thin ones. +- **`components/ui/*`, the marketing pages, and the three large panes.** Unchanged from + Section 11. The panes' real logic is reachable by extraction, which is what + `video-player-utils.ts` and `upload-chunking.ts` demonstrate. +- **A coverage threshold gate.** Still a non-goal. Mutation testing answers the question a + threshold was a proxy for. diff --git a/bun.lock b/bun.lock index 0c2a965..7ee37c6 100644 --- a/bun.lock +++ b/bun.lock @@ -38,6 +38,8 @@ "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", "@playwright/test": "1.61.1", + "@stryker-mutator/core": "^9.6.1", + "@stryker-mutator/vitest-runner": "^9.6.1", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", @@ -195,10 +197,18 @@ "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + "@babel/plugin-proposal-decorators": ["@babel/plugin-proposal-decorators@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-decorators": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg=="], + + "@babel/plugin-syntax-decorators": ["@babel/plugin-syntax-decorators@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg=="], + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], + "@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg=="], + + "@babel/plugin-transform-explicit-resource-management": ["@babel/plugin-transform-explicit-resource-management@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw=="], + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.28.6", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA=="], "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "", { "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", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="], @@ -379,15 +389,37 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="], - "@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="], + "@inquirer/ansi": ["@inquirer/ansi@2.0.7", "", {}, "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q=="], - "@inquirer/confirm": ["@inquirer/confirm@5.1.21", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ=="], + "@inquirer/checkbox": ["@inquirer/checkbox@5.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^11.2.1", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw=="], - "@inquirer/core": ["@inquirer/core@10.3.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A=="], + "@inquirer/confirm": ["@inquirer/confirm@6.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ=="], - "@inquirer/figures": ["@inquirer/figures@1.0.15", "", {}, "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g=="], + "@inquirer/core": ["@inquirer/core@11.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA=="], - "@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="], + "@inquirer/editor": ["@inquirer/editor@5.2.2", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/external-editor": "^3.0.3", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg=="], + + "@inquirer/expand": ["@inquirer/expand@5.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g=="], + + "@inquirer/external-editor": ["@inquirer/external-editor@3.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA=="], + + "@inquirer/figures": ["@inquirer/figures@2.0.7", "", {}, "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw=="], + + "@inquirer/input": ["@inquirer/input@5.1.2", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg=="], + + "@inquirer/number": ["@inquirer/number@4.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA=="], + + "@inquirer/password": ["@inquirer/password@5.1.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg=="], + + "@inquirer/prompts": ["@inquirer/prompts@8.5.2", "", { "dependencies": { "@inquirer/checkbox": "^5.2.1", "@inquirer/confirm": "^6.1.1", "@inquirer/editor": "^5.2.2", "@inquirer/expand": "^5.1.1", "@inquirer/input": "^5.1.2", "@inquirer/number": "^4.1.1", "@inquirer/password": "^5.1.1", "@inquirer/rawlist": "^5.3.1", "@inquirer/search": "^4.2.1", "@inquirer/select": "^5.2.1" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g=="], + + "@inquirer/rawlist": ["@inquirer/rawlist@5.3.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og=="], + + "@inquirer/search": ["@inquirer/search@4.2.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g=="], + + "@inquirer/select": ["@inquirer/select@5.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/core": "^11.2.1", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw=="], + + "@inquirer/type": ["@inquirer/type@4.0.7", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g=="], "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], @@ -663,6 +695,16 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@stryker-mutator/api": ["@stryker-mutator/api@9.6.1", "", { "dependencies": { "mutation-testing-metrics": "3.7.3", "mutation-testing-report-schema": "3.7.3", "tslib": "~2.8.0", "typed-inject": "~5.0.0" } }, "sha512-g8VNoFWQWbx0pdal3Vt8jVCZW+v3sc3gi94iI0GVtVgUGTqphAjJF6EAruPTx0lqvtonsaAxn5TD36hcG1d6Wg=="], + + "@stryker-mutator/core": ["@stryker-mutator/core@9.6.1", "", { "dependencies": { "@inquirer/prompts": "^8.0.0", "@stryker-mutator/api": "9.6.1", "@stryker-mutator/instrumenter": "9.6.1", "@stryker-mutator/util": "9.6.1", "ajv": "~8.18.0", "chalk": "~5.6.0", "commander": "~14.0.0", "diff-match-patch": "1.0.5", "emoji-regex": "~10.6.0", "execa": "~9.6.0", "json-rpc-2.0": "^1.7.0", "lodash.groupby": "~4.6.0", "minimatch": "~10.2.4", "mutation-server-protocol": "~0.4.0", "mutation-testing-elements": "3.7.3", "mutation-testing-metrics": "3.7.3", "mutation-testing-report-schema": "3.7.3", "npm-run-path": "~6.0.0", "progress": "~2.0.3", "rxjs": "~7.8.1", "semver": "^7.6.3", "source-map": "~0.7.4", "tree-kill": "~1.2.2", "tslib": "2.8.1", "typed-inject": "~5.0.0", "typed-rest-client": "~2.3.0" }, "bin": { "stryker": "bin/stryker.js" } }, "sha512-WMgnvf+Wyh/yiruhNZwc8w8DlzmmjXhPjSn5MR8RhAXzlnWji8TQrUYgBUkHk9bEgSaIlB3KZHm37iiU5Q2cLQ=="], + + "@stryker-mutator/instrumenter": ["@stryker-mutator/instrumenter@9.6.1", "", { "dependencies": { "@babel/core": "~7.29.0", "@babel/generator": "~7.29.0", "@babel/parser": "~7.29.0", "@babel/plugin-proposal-decorators": "~7.29.0", "@babel/plugin-transform-explicit-resource-management": "^7.28.0", "@babel/preset-typescript": "~7.28.0", "@stryker-mutator/api": "9.6.1", "@stryker-mutator/util": "9.6.1", "angular-html-parser": "~10.4.0", "semver": "~7.7.0", "tslib": "2.8.1", "weapon-regex": "~1.3.2" } }, "sha512-5K8wH4Pthly25c2uKKik4Dfcoeou7sbJdFS6u3QIYHlulgFVDJwtEMWTZGkZfs7IiUEXIDNa0keRACq5jn5AvA=="], + + "@stryker-mutator/util": ["@stryker-mutator/util@9.6.1", "", {}, "sha512-Lk/ALVctJjFv1vvwR+CFoKzDCWvsBlq7flDUnmnpuwTrGbm156EdZD1Jjq4o8KdOap0ezUZqQNE9OAI1m2+pUQ=="], + + "@stryker-mutator/vitest-runner": ["@stryker-mutator/vitest-runner@9.6.1", "", { "dependencies": { "@stryker-mutator/api": "9.6.1", "@stryker-mutator/util": "9.6.1", "semver": "^7.7.4", "tslib": "~2.8.0" }, "peerDependencies": { "@stryker-mutator/core": "9.6.1", "vitest": ">=2.0.0" } }, "sha512-eyUHTCf3Ui+SUn/tpFJwzw6MV391kyBLZk/cDHFUfKFELqKMLbvd7e81axArlApKqO6cOnLfrxlwED+2SRN0ow=="], + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], @@ -823,10 +865,12 @@ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "ajv": ["ajv@6.12.6", "", { "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" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "angular-html-parser": ["angular-html-parser@10.4.0", "", {}, "sha512-++nLNyZwRfHqFh7akH5Gw/JYizoFlMRz0KRigfwfsLqV8ZqlcVRb1LkPEWdYvEKDnbktknM2J4BXaYUGrQZPww=="], + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -877,7 +921,7 @@ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], @@ -889,7 +933,7 @@ "bowser": ["bowser@2.13.1", "", {}, "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw=="], - "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -915,7 +959,9 @@ "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "chardet": ["chardet@2.2.0", "", {}, "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA=="], "chevrotain": ["chevrotain@10.5.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "10.5.0", "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "@chevrotain/utils": "10.5.0", "lodash": "4.17.21", "regexp-to-ast": "0.5.0" } }, "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A=="], @@ -949,7 +995,7 @@ "combine-errors": ["combine-errors@3.0.3", "", { "dependencies": { "custom-error-instance": "2.1.1", "lodash.uniqby": "4.5.0" } }, "sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q=="], - "commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "compare-func": ["compare-func@2.0.0", "", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="], @@ -1039,6 +1085,8 @@ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "des.js": ["des.js@1.1.0", "", { "dependencies": { "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0" } }, "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg=="], + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -1047,6 +1095,8 @@ "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + "diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="], + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], @@ -1065,7 +1115,7 @@ "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="], - "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], @@ -1151,7 +1201,7 @@ "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], - "execa": ["execa@8.0.1", "", { "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" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], + "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], @@ -1171,8 +1221,14 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], "fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], @@ -1241,7 +1297,7 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - "get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="], + "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], @@ -1307,7 +1363,7 @@ "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="], + "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], @@ -1437,6 +1493,8 @@ "js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], + "js-md4": ["js-md4@0.3.2", "", {}, "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA=="], + "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -1449,7 +1507,9 @@ "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], - "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "json-rpc-2.0": ["json-rpc-2.0@1.7.1", "", {}, "sha512-JqZjhjAanbpkXIzFE7u8mE/iFblawwlXtONaCvRqI+pyABVz7B4M1EUNpyVW+dZjqgQ2L5HFmZCmOCgUKm00hg=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], @@ -1523,6 +1583,8 @@ "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], + "lodash.groupby": ["lodash.groupby@4.6.0", "", {}, "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw=="], + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], "lodash.kebabcase": ["lodash.kebabcase@4.1.1", "", {}, "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g=="], @@ -1591,7 +1653,9 @@ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], - "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -1599,7 +1663,15 @@ "msw": ["msw@2.12.8", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.0", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-KOriJUhjefCO+liF7Ie1KlSXcBAQEzuLhPZ4EKuEUSEmAR4YhuuzT9YuGxTipjqDrg6eWQ6oMoGVhvEnqukFGg=="], - "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], + "mutation-server-protocol": ["mutation-server-protocol@0.4.1", "", { "dependencies": { "zod": "^4.1.12" } }, "sha512-SBGK0j8hLDne7bktgThKI8kGvGTx3rY3LAeQTmOKZ5bVnL/7TorLMvcVF7dIPJCu5RNUWhkkuF53kurygYVt3g=="], + + "mutation-testing-elements": ["mutation-testing-elements@3.7.3", "", {}, "sha512-SMeIPxngJpfjfNYctFpYQQtlBlZaVO0aoB3FKdwrI8Ee/2bkyUuCZzAOCLv1U9fnmfA37dPFq0Owduoxs2XgGQ=="], + + "mutation-testing-metrics": ["mutation-testing-metrics@3.7.3", "", { "dependencies": { "mutation-testing-report-schema": "3.7.3" } }, "sha512-B8QrP0ZomErzTPNlhrzKWPNBln+3afwBZPHv0Q7N8wZZTYxMptzb/Gdm3ExXVmioVYrtZAtsDs7W/T/b2AixOQ=="], + + "mutation-testing-report-schema": ["mutation-testing-report-schema@3.7.3", "", {}, "sha512-BHm3MYq+ckO+t5CtlG8zpqxc75rdJCkxVlE+fGuGJM3F7tNCQ/OW2N+TQVHN3BHsYa84+BFc6g3AwDYkUsw2MA=="], + + "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], "mysql2": ["mysql2@3.15.3", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg=="], @@ -1629,7 +1701,7 @@ "nodemailer": ["nodemailer@9.0.1", "", {}, "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw=="], - "npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="], + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], "nypm": ["nypm@0.6.5", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="], @@ -1695,7 +1767,7 @@ "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], @@ -1767,6 +1839,8 @@ "prisma": ["prisma@7.3.0", "", { "dependencies": { "@prisma/config": "7.3.0", "@prisma/dev": "0.20.0", "@prisma/engines": "7.3.0", "@prisma/studio-core": "0.13.1", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3", "typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-ApYSOLHfMN8WftJA+vL6XwAPOh/aZ0BgUyyKPwUFgjARmG6EBI9LzDPf6SWULQMSAxydV9qn5gLj037nPNlg2w=="], + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], @@ -1779,7 +1853,7 @@ "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], - "qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="], + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], "querystringify": ["querystringify@2.2.0", "", {}, "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="], @@ -1851,6 +1925,8 @@ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], @@ -1905,7 +1981,7 @@ "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -1949,7 +2025,7 @@ "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], - "strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="], + "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], @@ -2001,6 +2077,8 @@ "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], "ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="], @@ -2011,6 +2089,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], + "tus-js-client": ["tus-js-client@4.3.1", "", { "dependencies": { "buffer-from": "^1.1.2", "combine-errors": "^3.0.3", "is-stream": "^2.0.0", "js-base64": "^3.7.2", "lodash.throttle": "^4.1.1", "proper-lockfile": "^4.1.2", "url-parse": "^1.5.7" } }, "sha512-ZLeYmjrkaU1fUsKbIi8JML52uAocjEZtBx4DKjRrqzrZa0O4MYwT6db+oqePlspV+FxXJAyFBc/L5gwUi2OFsg=="], "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], @@ -2029,12 +2109,18 @@ "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], + "typed-inject": ["typed-inject@5.0.0", "", {}, "sha512-0Ql2ORqBORLMdAW89TQKZsb1PQkFGImFfVmncXWe7a+AA3+7dh7Se9exxZowH4kbnlvKEFkMxUYdHUpjYWFJaA=="], + + "typed-rest-client": ["typed-rest-client@2.3.1", "", { "dependencies": { "des.js": "^1.1.0", "js-md4": "^0.3.2", "qs": "6.15.1", "tunnel": "0.0.6", "underscore": "^1.13.8" } }, "sha512-k4kX5Up6qA68D0Cby2AK+6+vM5k3qTxe+/3FqhnHRExjY5cfbOnzjQZbP/LXleF8hVoDvDqxlgk9KK83HoBZlQ=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript-eslint": ["typescript-eslint@8.54.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.54.0", "@typescript-eslint/parser": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/utils": "8.54.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ=="], "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + "underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="], + "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -2077,6 +2163,8 @@ "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + "weapon-regex": ["weapon-regex@1.3.6", "", {}, "sha512-wsf1m1jmMrso5nhwVFJJHSubEBf3+pereGd7+nBKtYJ18KoB/PWJOHS3WRkwS04VrOU0iJr2bZU+l1QaTJ+9nA=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], @@ -2159,18 +2247,24 @@ "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@commitlint/config-validator/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="], - "@commitlint/format/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "@babel/plugin-proposal-decorators/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/plugin-syntax-decorators/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/plugin-transform-destructuring/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/plugin-transform-destructuring/@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/plugin-transform-explicit-resource-management/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@commitlint/config-validator/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "@commitlint/is-ignored/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "@commitlint/load/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "@commitlint/top-level/find-up": ["find-up@7.0.0", "", { "dependencies": { "locate-path": "^7.2.0", "path-exists": "^5.0.0", "unicorn-magic": "^0.1.0" } }, "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g=="], - "@commitlint/types/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "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" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -2179,9 +2273,13 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@eslint/config-array/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "@eslint/eslintrc/ajv": ["ajv@6.12.6", "", { "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" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], - "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "@eslint/eslintrc/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "@modelcontextprotocol/sdk/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], @@ -2203,6 +2301,10 @@ "@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + "@stryker-mutator/instrumenter/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@stryker-mutator/instrumenter/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], @@ -2231,6 +2333,8 @@ "ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + "body-parser/qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="], + "c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "cli-truncate/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], @@ -2239,37 +2343,57 @@ "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "cross-spawn/path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], + "eslint/ajv": ["ajv@6.12.6", "", { "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" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "eslint/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + "eslint-plugin-import/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "eslint-plugin-import/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "eslint-plugin-import/tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], + "eslint-plugin-jsx-a11y/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "eslint-plugin-jsx-a11y/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "eslint-plugin-react/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="], "eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "eslint-plugin-react-hooks/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], + "execa/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "express/qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "get-stream/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "is-bun-module/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "lint-staged/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "lint-staged/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], - "log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "lint-staged/execa": ["execa@8.0.1", "", { "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" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], @@ -2281,18 +2405,18 @@ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "msw/@inquirer/confirm": ["@inquirer/confirm@5.1.21", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ=="], + "msw/tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], + "mutation-server-protocol/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "next-auth/@auth/core": ["@auth/core@0.41.0", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^6.8.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ=="], - "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], - "nypm/citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="], - "ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "ora/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], @@ -2305,14 +2429,12 @@ "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], - "shadcn/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], - - "shadcn/execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], - "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], @@ -2349,7 +2471,31 @@ "@aws-crypto/util/@aws-sdk/types/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="], - "@commitlint/config-validator/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/plugin-transform-destructuring/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/plugin-transform-destructuring/@babel/traverse/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/plugin-transform-destructuring/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/plugin-transform-destructuring/@babel/traverse/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-transform-destructuring/@babel/traverse/@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/plugin-transform-destructuring/@babel/traverse/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], "@commitlint/top-level/find-up/locate-path": ["locate-path@7.2.0", "", { "dependencies": { "p-locate": "^6.0.0" } }, "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA=="], @@ -2371,12 +2517,16 @@ "@dotenvx/dotenvx/which/isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="], - "@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "@next/eslint-plugin-next/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "@stryker-mutator/instrumenter/@babel/parser/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], @@ -2385,29 +2535,37 @@ "@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], - "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "cli-truncate/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "eslint-plugin-import/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "eslint-plugin-import/tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], + "eslint-plugin-jsx-a11y/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "eslint-plugin-react/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "eslint/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "lint-staged/execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="], + + "lint-staged/execa/human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="], + + "lint-staged/execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], + + "lint-staged/execa/npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="], + + "lint-staged/execa/strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="], + "log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "magicast/@babel/parser/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - "ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "msw/@inquirer/confirm/@inquirer/core": ["@inquirer/core@10.3.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A=="], - "shadcn/execa/get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], - - "shadcn/execa/human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], - - "shadcn/execa/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], - - "shadcn/execa/npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], - - "shadcn/execa/strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + "msw/@inquirer/confirm/@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="], "vite/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], @@ -2433,22 +2591,100 @@ "vite/postcss/nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], - "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/helper-annotate-as-pure/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/helper-member-expression-to-functions/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/helper-optimise-call-expression/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/helper-skip-transparent-expression-wrappers/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/traverse/@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/traverse/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/traverse/@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/plugin-transform-destructuring/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/plugin-transform-destructuring/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "@babel/plugin-transform-destructuring/@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/plugin-transform-destructuring/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], "@commitlint/top-level/find-up/locate-path/p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="], + "@dotenvx/dotenvx/execa/npm-run-path/path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "@dotenvx/dotenvx/execa/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "@eslint/config-array/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "@eslint/eslintrc/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "@stryker-mutator/instrumenter/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@stryker-mutator/instrumenter/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + "eslint-plugin-import/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "eslint-plugin-jsx-a11y/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "eslint-plugin-react/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "eslint/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "magicast/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], "magicast/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], - "shadcn/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + "msw/@inquirer/confirm/@inquirer/core/@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="], + + "msw/@inquirer/confirm/@inquirer/core/@inquirer/figures": ["@inquirer/figures@1.0.15", "", {}, "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g=="], + + "msw/@inquirer/confirm/@inquirer/core/mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], + + "msw/@inquirer/confirm/@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/helper-annotate-as-pure/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/helper-annotate-as-pure/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/helper-member-expression-to-functions/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/helper-member-expression-to-functions/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/helper-optimise-call-expression/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/helper-optimise-call-expression/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/helper-skip-transparent-expression-wrappers/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/helper-skip-transparent-expression-wrappers/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/plugin-proposal-decorators/@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], "@commitlint/top-level/find-up/locate-path/p-locate/p-limit": ["p-limit@4.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="], + "msw/@inquirer/confirm/@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@commitlint/top-level/find-up/locate-path/p-locate/p-limit/yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], } } diff --git a/eslint.config.mjs b/eslint.config.mjs index 6fbb1e8..a5273d0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,6 +19,8 @@ const eslintConfig = defineConfig([ 'coverage/**', 'playwright-report/**', 'test-results/**', + 'reports/**', + '.stryker-tmp/**', ]), prettier, { diff --git a/lib/auth.ts b/lib/auth.ts index 32915a7..f9e0d1b 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -215,7 +215,69 @@ export type EnrichedProjectForAccess = { }; /** - * Pure access computation — no DB queries. + * The project permission formulas, in one place. + * + * Two functions resolve the same six inputs by different routes: + * `computeProjectAccess` reads them off a project that was fetched with + * `projectAccessInclude()`, and `checkProjectAccess` queries for each relation. + * They then have to agree on what those inputs mean. Both used to carry a + * verbatim copy of the three formulas below, which is a silent-divergence + * hazard rather than a style complaint: change an authorization rule in one + * copy and not the other and a page renders for somebody the API would refuse. + */ +function resolveProjectPermissions(input: { + isOwner: boolean; + isPublic: boolean; + isProjectMember: boolean; + isProjectAdmin: boolean; + workspaceRole: WorkspaceMemberRole | 'OWNER' | null; + ownerBillingActive: boolean; +}) { + const { isOwner, isPublic, isProjectMember, isProjectAdmin, workspaceRole, ownerBillingActive } = + input; + + const isWorkspaceMember = !!workspaceRole; + const isWorkspaceAdmin = workspaceRole === WorkspaceMemberRole.ADMIN || workspaceRole === 'OWNER'; + + return { + isOwner, + isProjectMember, + isProjectAdmin, + isWorkspaceMember, + isWorkspaceAdmin, + hasAccess: ownerBillingActive && (isOwner || isProjectMember || isPublic || isWorkspaceMember), + canEdit: ownerBillingActive && (isOwner || isProjectAdmin || isWorkspaceAdmin), + canDelete: ownerBillingActive && (isOwner || workspaceRole === 'OWNER'), + ownerBillingActive, + }; +} + +/** + * The workspace permission formulas. Only one caller today, but it is kept + * beside its project twin and exported so it can be tested directly rather + * than only through whichever route happens to exercise it. + */ +export function resolveWorkspacePermissions(input: { + isOwner: boolean; + isMember: boolean; + isAdmin: boolean; + ownerBillingActive: boolean; +}) { + const { isOwner, isMember, isAdmin, ownerBillingActive } = input; + + return { + isOwner, + isMember, + isAdmin, + hasAccess: ownerBillingActive && (isOwner || isMember), + canEdit: ownerBillingActive && (isOwner || isAdmin), + canDelete: ownerBillingActive && isOwner, + ownerBillingActive, + }; +} + +/** + * Pure access computation, no DB queries. * Use after fetching a project with `projectAccessInclude(userId)`. */ export function computeProjectAccess( @@ -241,25 +303,14 @@ export function computeProjectAccess( if (wsMember) workspaceRole = wsMember.role; } - const isWorkspaceMember = !!workspaceRole; - const isWorkspaceAdmin = workspaceRole === WorkspaceMemberRole.ADMIN || workspaceRole === 'OWNER'; - - const hasAccess = - workspaceOwnerBillingAccess && (isOwner || isProjectMember || isPublic || isWorkspaceMember); - const canEdit = workspaceOwnerBillingAccess && (isOwner || isProjectAdmin || isWorkspaceAdmin); - const canDelete = workspaceOwnerBillingAccess && (isOwner || workspaceRole === 'OWNER'); - - return { + return resolveProjectPermissions({ isOwner, + isPublic, isProjectMember, isProjectAdmin, - isWorkspaceMember, - isWorkspaceAdmin, - hasAccess, - canEdit, - canDelete, + workspaceRole, ownerBillingActive: workspaceOwnerBillingAccess, - }; + }); } // Helper to check project access including workspace membership @@ -284,8 +335,8 @@ export async function checkProjectAccess( // The workspace role decides `canEdit`/`isWorkspaceMember`, not just whether the viewer // gets in at all, so it has to be resolved for every signed-in non-owner. Skipping it // once access was already granted some other way (public project, or an existing project - // membership) silently downgraded workspace admins to read-only on `intent: 'view'` — - // the intent pages and GET routes use to decide which actions to render. + // membership) silently downgraded workspace admins to read-only on `intent: 'view'`, + // the intent that pages and GET routes use to decide which actions to render. // Owners pass every check on their own; resolve their role only when they mutate. const shouldLoadWorkspaceRole = !!userId && (!isOwner || intent !== 'view'); @@ -338,25 +389,14 @@ export async function checkProjectAccess( }); workspaceOwnerBillingAccess = wsOwner?.owner ? hasBillingAccess(wsOwner.owner) : false; } - const isWorkspaceMember = !!workspaceRole; - const isWorkspaceAdmin = workspaceRole === WorkspaceMemberRole.ADMIN || workspaceRole === 'OWNER'; - - const hasAccess = - workspaceOwnerBillingAccess && (isOwner || isProjectMember || isPublic || isWorkspaceMember); - const canEdit = workspaceOwnerBillingAccess && (isOwner || isProjectAdmin || isWorkspaceAdmin); - const canDelete = workspaceOwnerBillingAccess && (isOwner || workspaceRole === 'OWNER'); - - return { + return resolveProjectPermissions({ isOwner, + isPublic, isProjectMember, isProjectAdmin, - isWorkspaceMember, - isWorkspaceAdmin, - hasAccess, - canEdit, - canDelete, + workspaceRole, ownerBillingActive: workspaceOwnerBillingAccess, - }; + }); } // Helper to check workspace access @@ -386,17 +426,5 @@ export async function checkWorkspaceAccess( }); const ownerBillingActive = owner ? hasBillingAccess(owner) : false; - const hasAccess = ownerBillingActive && (isOwner || isMember); - const canEdit = ownerBillingActive && (isOwner || isAdmin); - const canDelete = ownerBillingActive && isOwner; - - return { - isOwner, - isMember, - isAdmin, - hasAccess, - canEdit, - canDelete, - ownerBillingActive, - }; + return resolveWorkspacePermissions({ isOwner, isMember, isAdmin, ownerBillingActive }); } diff --git a/lib/client/r2-video-upload.ts b/lib/client/r2-video-upload.ts index 153ab18..52a285b 100644 --- a/lib/client/r2-video-upload.ts +++ b/lib/client/r2-video-upload.ts @@ -1,4 +1,11 @@ import { captureVideoThumbnail } from '@/lib/client/video-thumbnail'; +import { + getMultipartProgressPercent, + getPartByteRange, + getRetryDelayMs, + getUploadProgressPercent, + PART_RETRY_DELAYS_MS, +} from '@/lib/client/upload-chunking'; export type R2MultipartPart = { partNumber: number; url: string }; @@ -21,8 +28,6 @@ export type R2VideoInitResponse = { multipart: R2MultipartInit | null; }; -const PART_RETRY_DELAYS = [0, 2000, 5000, 10000]; - export type R2VideoUploadResult = R2VideoInitResponse & { duration: number | null; thumbnailUrl: string | null; @@ -43,7 +48,7 @@ function uploadBytesWithProgress( xhr.upload.onprogress = (event) => { if (!onProgress || !event.lengthComputable) return; - onProgress(Math.round((event.loaded / event.total) * 100)); + onProgress(getUploadProgressPercent(event.loaded, event.total)); }; xhr.onload = () => { @@ -117,7 +122,7 @@ async function withRetry(fn: () => Promise, delays: number[]): Promise let lastError: unknown; for (let attempt = 0; attempt < delays.length; attempt += 1) { if (attempt > 0) { - await new Promise((resolve) => setTimeout(resolve, delays[attempt])); + await new Promise((resolve) => setTimeout(resolve, getRetryDelayMs(attempt, delays))); } try { return await fn(); @@ -160,16 +165,14 @@ async function uploadVideoMultipart( const reportProgress = () => { if (!onProgress) return; - const loaded = loadedPerPart.reduce((sum, value) => sum + value, 0); - onProgress(Math.min(100, Math.round((loaded / totalBytes) * 100))); + onProgress(getMultipartProgressPercent(loadedPerPart, totalBytes)); }; const completedParts: Array<{ partNumber: number; etag: string }> = []; for (let index = 0; index < multipart.parts.length; index += 1) { const part = multipart.parts[index]; - const start = (part.partNumber - 1) * partSize; - const end = Math.min(start + partSize, totalBytes); + const { start, end } = getPartByteRange(part.partNumber, partSize, totalBytes); const blob = file.slice(start, end); const etag = await withRetry( @@ -178,7 +181,7 @@ async function uploadVideoMultipart( loadedPerPart[index] = loadedBytes; reportProgress(); }), - PART_RETRY_DELAYS + PART_RETRY_DELAYS_MS ); loadedPerPart[index] = end - start; diff --git a/lib/client/upload-chunking.ts b/lib/client/upload-chunking.ts new file mode 100644 index 0000000..85cd895 --- /dev/null +++ b/lib/client/upload-chunking.ts @@ -0,0 +1,70 @@ +/** + * Pure arithmetic extracted from `r2-video-upload.ts`. + * + * The uploader itself is XMLHttpRequest wiring, `fetch` calls and timers, so the + * only test that can reach it is the end-to-end upload spec, and that spec only + * ever walks the happy path. The numbers below are the part of the uploader that + * is actually worth pinning down: which bytes each multipart part carries, how + * long a failed part waits before it is retried, and what percentage the UI is + * told. They live here so they can be called directly with fixed inputs. + * + * Nothing in this module touches the network, the DOM or a timer. + */ + +/** + * Wait, in milliseconds, before each attempt at uploading a single multipart + * part, indexed by attempt number. Index 0 is the first try and is never waited + * on, so the schedule is really "try, then retry after 2s, 5s and 10s": four + * attempts and at most 17 seconds of backoff per part. + */ +export const PART_RETRY_DELAYS_MS = [0, 2000, 5000, 10000]; + +/** + * How long attempt `attempt` waits before it runs. The first attempt never + * waits, and an attempt past the end of the schedule is not one the caller + * should be making, so it waits not at all rather than for `undefined` ms. + */ +export function getRetryDelayMs(attempt: number, delays: number[] = PART_RETRY_DELAYS_MS): number { + if (attempt <= 0) return 0; + return delays[attempt] ?? 0; +} + +export type PartByteRange = { start: number; end: number }; + +/** + * The slice of the file that a given part carries. Part numbers are 1-based + * because that is what S3 uses, and the final part is short: it stops at the end + * of the file rather than at a full part boundary. + * + * The part list comes from the server, which sized it from the same file length, + * so `partNumber` is always within range in practice. A part beyond the end of + * the file would produce `end` below `start`, which `Blob.slice` reads as an + * empty range. + */ +export function getPartByteRange( + partNumber: number, + partSizeBytes: number, + totalBytes: number +): PartByteRange { + const start = (partNumber - 1) * partSizeBytes; + const end = Math.min(start + partSizeBytes, totalBytes); + return { start, end }; +} + +/** Whole-percent progress for a single-request upload. */ +export function getUploadProgressPercent(loadedBytes: number, totalBytes: number): number { + return Math.round((loadedBytes / totalBytes) * 100); +} + +/** + * Whole-percent progress across a multipart upload, given the bytes reported so + * far for each part. Clamped at 100: parts report their own progress + * independently and a re-tried part can briefly double-count. + */ +export function getMultipartProgressPercent( + loadedBytesPerPart: number[], + totalBytes: number +): number { + const loaded = loadedBytesPerPart.reduce((sum, value) => sum + value, 0); + return Math.min(100, Math.round((loaded / totalBytes) * 100)); +} diff --git a/package.json b/package.json index 1f6ee90..8358495 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "test:e2e": "playwright test", "test:all": "bun run test && bun run test:api && bun run test:e2e", "test:coverage": "vitest run --project unit --coverage", + "test:mutation": "node node_modules/@stryker-mutator/core/bin/stryker.js run", "verify": "bun run check && bun run test", "test:db:up": "podman compose -f docker-compose.test.yml up -d --wait postgres-test", "test:db:down": "podman compose -f docker-compose.test.yml down -v", @@ -71,6 +72,8 @@ "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", "@playwright/test": "1.61.1", + "@stryker-mutator/core": "^9.6.1", + "@stryker-mutator/vitest-runner": "^9.6.1", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", diff --git a/playwright.config.ts b/playwright.config.ts index e5a6fef..061ce16 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -64,6 +64,12 @@ const APP_ENV: Record = { INVITE_CODE: 'test-invite', TRUSTED_PROXY_MODE: 'none', + // Admin is not a database column. lib/auth.ts:143-148 derives `token.isAdmin` + // on every request by looking the signed-in address up in this list, so + // without it no account in this suite can be an admin and admin.spec.ts can + // only assert the refusals. The address is the one that spec signs in as. + ADMIN_EMAILS: process.env.ADMIN_EMAILS ?? 'e2e-admin@example.com', + // Direct video uploads through the MinIO service in docker-compose.test.yml. // Without these the `Direct Upload` tab does not render at all, because // app/(dashboard)/projects/[projectId]/videos/new/page.tsx passes @@ -138,6 +144,26 @@ export default defineConfig({ use: { ...devices['Pixel 7'] }, testMatch: '**/dashboard-mobile.spec.ts', }, + // Safari, for the one thing that genuinely differs there. + // + // Opt-in, because it is not free: the browser is a separate download and a + // second full pass would roughly double a CI run that is already the longest + // job. Enable it with E2E_WEBKIT=1; the weekly `mutation`-style schedule in + // ci.yml is the intended home for it rather than every push. + // + // Scoped to player.spec.ts on purpose. A video review tool's real Safari + // risk is playback: codec support, whether `currentTime` commits the way + // Chromium's does, and hls.js, none of which the other specs touch. Running + // all fourteen specs under WebKit would mostly re-test React. + ...(process.env.E2E_WEBKIT + ? [ + { + name: 'webkit-player', + use: { ...devices['Desktop Safari'] }, + testMatch: '**/player.spec.ts', + }, + ] + : []), ], webServer: MANAGES_OWN_SERVER diff --git a/scripts/test.sh b/scripts/test.sh index 34e9e4c..fb3bd92 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -1,10 +1,11 @@ #!/usr/bin/env sh # One entry point for the OpenFrame test suites. # -# scripts/test.sh unit vitest unit + component projects -# scripts/test.sh api vitest api project (needs the test database) -# scripts/test.sh e2e playwright specs (needs the test database) -# scripts/test.sh all unit, then api, then e2e +# scripts/test.sh unit vitest unit + component projects +# scripts/test.sh api vitest api project (needs the test database) +# scripts/test.sh e2e playwright specs (needs the test database) +# scripts/test.sh all unit, then api, then e2e +# scripts/test.sh mutation StrykerJS over the authorization modules (slow) # # Run the api and e2e suites one at a time, never side by side. They share one # database, and the api suite empties every table after each of its tests, so a @@ -17,6 +18,11 @@ set -eu bun_image='docker.io/oven/bun:alpine' +# StrykerJS is the one thing here that cannot run under bun: its instrumenter and +# its vitest runner both want a node runtime, and @stryker-mutator/core declares +# `engines.node >= 20`. `bun run test:mutation` would resolve the bin and then +# execute it under bun, so the mutation mode below uses node explicitly. +node_image='docker.io/library/node:22-alpine' # Pinned to the installed @playwright/test version. The image carries the # matching browser build, and Playwright refuses a mismatched pair. Microsoft # publishes the image some time after the npm release, so check the tag exists @@ -47,13 +53,18 @@ fi usage() { cat <<'EOF' -Usage: scripts/test.sh +Usage: scripts/test.sh - unit Unit and component suites. No database, no browser. - api API integration suites. Starts the disposable test Postgres first. - e2e Playwright end-to-end specs. Starts the test Postgres and MinIO first, - then builds and starts the app itself on port 3100. - all unit, then api, then e2e. + unit Unit and component suites. No database, no browser. + api API integration suites. Starts the disposable test Postgres first. + e2e Playwright end-to-end specs. Starts the test Postgres and MinIO + first, then builds and starts the app itself on port 3100. + all unit, then api, then e2e. + mutation StrykerJS over the authorization and validation modules listed in + stryker.config.json. Minutes, not seconds, and not part of `all`: + it answers "which of my tests cannot fail", which is a question you + ask after writing a batch of them, not on every run. The report + lands in reports/mutation/index.html. The containers keep running afterwards so the next run is fast. Stop them with: podman compose -f docker-compose.test.yml --profile e2e down -v @@ -233,6 +244,16 @@ run_unit() { run_in_bun_image '' "$install_step && bun run test" } +run_mutation() { + say 'mutation testing' + # No install step: the node image has npm, and letting it touch node_modules + # that bun installed is a good way to end up with two package managers + # disagreeing. Run `scripts/test.sh unit` once first if the tree is cold. + run_cmd podman run --rm ${tty_flag:+"$tty_flag"} \ + -v "$repo_root:/workspace:z" -w /workspace "$node_image" \ + node node_modules/@stryker-mutator/core/bin/stryker.js run +} + run_api() { say 'api suites' require_compose_file @@ -294,6 +315,9 @@ case "$1" in run_api run_e2e ;; + mutation) + run_mutation + ;; *) printf 'scripts/test.sh: unknown mode "%s"\n\n' "$1" >&2 usage >&2 diff --git a/stryker.config.json b/stryker.config.json new file mode 100644 index 0000000..7459fec --- /dev/null +++ b/stryker.config.json @@ -0,0 +1,61 @@ +{ + "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json", + "_comment": [ + "Mutation testing. Stryker rewrites one line of a source file at a time and reruns", + "the suite; a mutant that survives is a line no test disagrees with, which is the", + "machine-checkable version of 'this test cannot fail'.", + "", + "This suite was built with three tests that could not fail, all found by hand. This", + "config is what finds the fourth. It is diagnostic and it does not gate a merge: see", + "`break: null` below, and TESTING.md section 11 for the same reasoning applied to", + "coverage.", + "", + "Run it with `bun run test:mutation`, which puts it in a node container because", + "Stryker needs node and the report lands in reports/mutation/. CI runs it weekly and", + "on demand, never on a push, because a full run is minutes rather than seconds." + ], + "packageManager": "npm", + "testRunner": "vitest", + "vitest": { + "configFile": "vitest.mutation.config.ts" + }, + "reporters": ["html", "clear-text", "progress"], + "htmlReporter": { + "fileName": "reports/mutation/index.html" + }, + "coverageAnalysis": "perTest", + "timeoutMS": 60000, + "concurrency": 4, + "_mutate_comment": [ + "Listed one by one rather than globbed as `lib/**`, and the reason matters. Stryker", + "reports every mutant in a file with no unit coverage as survived, so globbing the", + "whole of lib/ would bury the real findings under modules whose only coverage is an", + "API integration test that this config deliberately does not run (see", + "vitest.mutation.config.ts). A report that is mostly noise gets ignored.", + "", + "So the list is the authorization and input-validation surface, the places where a", + "test that cannot fail is actually dangerous. Add a module here once it has real", + "unit coverage." + ], + "mutate": [ + "lib/route-access.ts", + "lib/rate-limit.ts", + "lib/share-links.ts", + "lib/r2-upload-token.ts", + "lib/bunny-upload-token.ts", + "lib/request-origin.ts", + "lib/logger.ts", + "lib/validation.ts", + "lib/email-validation.ts", + "lib/image-upload-validation.ts", + "lib/video-upload-validation.ts", + "lib/guest-identity.ts", + "lib/billing.ts", + "lib/content-security-policy.ts" + ], + "thresholds": { + "high": 85, + "low": 70, + "break": null + } +} diff --git a/tests/api/auth-matrix.test.ts b/tests/api/auth-matrix.test.ts index 2b8453c..8152d77 100644 --- a/tests/api/auth-matrix.test.ts +++ b/tests/api/auth-matrix.test.ts @@ -22,10 +22,10 @@ import fs from 'node:fs'; import path from 'node:path'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { db } from '@/lib/db'; import { REPO_ROOT } from '../helpers/env'; -import { apiRequest, callRoute, type RouteHandler } from '../helpers/request'; +import { apiRequest, callRoute, readData, type RouteHandler } from '../helpers/request'; import { signedInAs, signedOut } from '../helpers/session'; import { addProjectMember, @@ -99,6 +99,46 @@ import * as workspaceMemberRoute from '@/app/api/workspaces/[workspaceId]/member import * as workspaceMembersRoute from '@/app/api/workspaces/[workspaceId]/members/route'; import * as workspaceRoute from '@/app/api/workspaces/[workspaceId]/route'; +// --------------------------------------------------------------------------- +// R2 boundary +// --------------------------------------------------------------------------- +// Only the admin half of this file needs it: POST /api/admin/stats/refresh-r2 +// walks the whole bucket through `r2Client`, which tests/setup/api.ts leaves +// real because it only stubs the named helpers in `@/lib/r2`. The recorder below +// is the same seam tests/api/lib-admin-stats.test.ts and +// tests/api/lib-r2-cleanup.test.ts use, and it doubles as the proof that the +// route ran its body rather than merely getting past the guard. +// +// Registering `@/lib/r2` here replaces the setup file's registration for that +// module, so the presigners are the real ones for the rest of this file. That is +// safe precisely because of what this suite asserts: no anonymous caller reaches +// a line that presigns anything, they all stop at 401 or 403. +// +// vi.mock factories are hoisted above every const in the file, so the recorder +// has to be hoisted with them. +const r2 = vi.hoisted(() => ({ + bucket: 'openframe-auth-matrix-test-bucket', + /** Buckets handed to ListObjectsV2, in call order. */ + listedBuckets: [] as string[], +})); + +vi.mock('@/lib/r2', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + R2_BUCKET_NAME: r2.bucket, + r2Client: { + send: async (command: { input?: { Bucket?: string } }) => { + r2.listedBuckets.push(command.input?.Bucket ?? ''); + return { + Contents: [{ Key: 'videos/auth-matrix-fixture.mp4', Size: 2048 }], + IsTruncated: false, + }; + }, + }, + }; +}); + // --------------------------------------------------------------------------- // The count guard // --------------------------------------------------------------------------- @@ -582,12 +622,10 @@ const ROUTE_CASES: readonly RouteCase[] = [ module: assetsBunnyInitRoute, url: (f) => `/api/videos/${f.videoId}/assets/bunny-init`, params: (f) => ({ videoId: f.videoId }), - // This entry cannot be made load-bearing here, and it was verified to hold - // with `if (!context.canUploadAssets)` replaced by `if (false)`: Bunny - // uploads are unconfigured in the test environment, so the route answers 400 - // one line below the guard whether or not the guard is there. The real - // coverage for it is in tests/api/assets-authz.test.ts, which asserts the - // exact 403 for a stranger next to the exact 400 for a member. + // Bunny uploads are unconfigured in the test environment, so this body + // reaches the access check and nothing beyond it. The exact-status coverage + // is in tests/api/assets-authz.test.ts, which asserts the 403 for a stranger + // next to the 400 a member gets one line below the guard. body: { fileName: 'a.mp4' }, }, { @@ -602,13 +640,11 @@ const ROUTE_CASES: readonly RouteCase[] = [ module: assetsRoute, url: (f) => `/api/videos/${f.videoId}/assets`, params: (f) => ({ videoId: f.videoId }), - // The body carries no `provider`, so POST answers 400 "Invalid provider" - // just below the access check. Verified: with - // `if (!context.canUploadAssets)` replaced by `if (false)` this entry still - // passes. Sending a real provider would not fix it, because every branch - // that could reach 201 needs a live R2 or YouTube call. The exact-status - // coverage lives in tests/api/assets-authz.test.ts instead. The GET half of - // this module is genuinely load-bearing here: it 403s on the access check. + // The body deliberately carries no `provider`. Every provider that could + // reach 201 needs a live R2 or YouTube call, so the request is built to stop + // at the access check: POST answers 403 there, and would answer 400 + // "Invalid provider" one line below if the guard were gone. The + // exact-status coverage lives in tests/api/assets-authz.test.ts. body: { kind: 'IMAGE', sourceUrl: `/api/upload/image/${IMAGE_FILENAME}` }, }, { @@ -669,6 +705,68 @@ const ROUTE_CASES: readonly RouteCase[] = [ const HTTP_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; +/** + * The statuses a route reaches by way of its authorization check. + * + * 404 used to be in here and was taken out. Every one of the 55 guarded entries + * was instrumented and logged: all of them answer 401 or 403, none answers 404, + * so the arm was unreachable. Leaving it in was the last way an entry could pass + * without touching the guard it exists to protect. A fixture id that stops + * resolving for one route (a renamed relation, a factory that no longer writes + * the row) makes that route 404 *before* the access check, and with 404 accepted + * the entry would stay green forever while covering nothing. Now it fails and + * says so. + */ +const AUTHORIZATION_REFUSAL_STATUSES = new Set([401, 403]); + +/** + * Entries that answer an anonymous caller with something other than an + * authorization refusal, each with the reason and with where the route is + * really covered. Empty today, and the intent is that it stays that way. + * + * This map and the check that consults it are the mechanised form of a lesson + * this suite learned the hard way. Asserting only "not 2xx" is too weak: a + * route that refuses a malformed request one line below its access check + * satisfies it whether or not the check is there, so the entry proves nothing. + * Two entries here had exactly that shape and were confirmed by replacing their + * `if (!context.canUploadAssets)` with `if (false)` and watching the test stay + * green on the 400 from the line below. + * + * Requiring an authorization status instead of merely a non-2xx one fixes both + * of them without touching the request they send: an anonymous caller reaches + * the guard and gets 403, and with the guard removed the 400 from the next line + * now fails the assertion instead of passing it. + * + * The map remains as a drift guard, in the same spirit as REVIEWED_MIGRATIONS + * in tests/setup/db-global.ts. Add a route that refuses before its access + * check and this suite fails until somebody decides whether the request can be + * fixed to reach the guard (which is what happened for upload/image and + * upload/audio, both of which now send a real multipart body) or whether the + * route needs a suite of its own. It fails in the other direction too: fix an + * entry and the suite tells you to delete it, so nothing here can rot into a + * permanent exemption. + */ +const NON_AUTHORIZATION_REFUSALS = new Map(); + +/** + * Entries whose guard hides the existence of the row instead of refusing, so + * 404 *is* the authorization answer. Keyed the same way as + * NON_AUTHORIZATION_REFUSALS, and empty today because no route in this repo + * does that. + * + * It exists because the 404 arm was taken out of + * AUTHORIZATION_REFUSAL_STATUSES above, and a route that legitimately answers + * "no such thing" to a caller who may not know it exists is a real design, not + * a mistake. Listing it here keeps the decision visible per method rather than + * granting every entry a blanket 404 pass. + * + * Like its neighbour it fails in both directions. A route that 404s without an + * entry fails and points here; an entry whose route now answers 401 or 403 + * fails and tells you to delete it, so nothing can rot into a permanent + * exemption. + */ +const NOT_FOUND_IS_THE_GUARD = new Map(); + function discoverRouteModules(): string[] { const apiDir = path.join(REPO_ROOT, 'app', 'api'); const found: string[] = []; @@ -765,6 +863,39 @@ describe('auth matrix', () => { // A crash is not a rejection. If this trips, the route threw on the // way to its access check instead of refusing cleanly. expect(status, `${method} ${entry.file} crashed instead of refusing`).not.toBe(500); + + // And a validation refusal is not a rejection either. See + // NON_AUTHORIZATION_REFUSALS for why this is worth asserting. + const key = `${method} ${entry.file}`; + const documentedReason = NON_AUTHORIZATION_REFUSALS.get(key); + const hidesExistence = NOT_FOUND_IS_THE_GUARD.get(key); + + if (hidesExistence !== undefined) { + expect( + status, + `${key} is listed in NOT_FOUND_IS_THE_GUARD, which says it hides the row's ` + + `existence rather than refusing, but it answered ${status}. If it now ` + + `refuses with 401 or 403, delete its entry.` + ).toBe(404); + } else if (documentedReason === undefined) { + expect( + AUTHORIZATION_REFUSAL_STATUSES.has(status), + `${key} answered ${status} to an anonymous caller, which is not an ` + + `authorization refusal. The route rejected the request before it reached ` + + `its access check, so this entry passes whether or not the guard exists. ` + + `Fix the request this entry sends so it reaches the guard, or add the ` + + `entry to NON_AUTHORIZATION_REFUSALS with the suite that covers it ` + + `properly. A 404 means either the fixture id no longer resolves, which is ` + + `the same bug wearing a different status, or the route hides existence on ` + + `purpose, in which case it belongs in NOT_FOUND_IS_THE_GUARD.` + ).toBe(true); + } else { + expect( + AUTHORIZATION_REFUSAL_STATUSES.has(status), + `${key} now answers ${status}, which is an authorization refusal, so it no ` + + `longer belongs in NON_AUTHORIZATION_REFUSALS. Delete its entry.` + ).toBe(false); + } } }); } @@ -778,22 +909,39 @@ describe('auth matrix', () => { }); // ------------------------------------------------------------------------- - // Signed in, but not an admin + // Signed in: admin against non-admin // ------------------------------------------------------------------------- // The sweep above only proves that app/api/admin/** refuses a caller with no // session, and `!session?.user?.isAdmin` is true for a null session for the // wrong reason. Nothing else in the suite touches `isAdmin` at all, so - // rewriting that guard as `!session?.user?.id` would leave every one of these - // tests green while handing the admin endpoints to any signed-in user. These - // two cases are what separate "no session" from "not an admin". - describe('admin routes reject a signed-in non-admin', () => { + // rewriting that guard as `!session?.user?.id` would leave every one of those + // tests green while handing the admin endpoints to any signed-in user. The + // refusals below are what separate "no session" from "not an admin". + // + // Each refusal is paired with the admin who must get through, because a + // refusal on its own is only half a guard. Replacing the whole check in + // app/api/admin/stats/refresh-r2/route.ts with an unconditional + // `return apiErrors.forbidden(...)`, which locks every admin out of the + // endpoint permanently, left all 984 api tests green until these two pairs + // existed. tests/e2e/admin.spec.ts does not close it either: it only POSTs as + // a non-admin. + // + // `isAdmin` is not a column. lib/auth.ts derives it in the jwt callback from + // the ADMIN_EMAILS environment variable and the session callback copies it + // onto session.user. The api project mocks `auth()` itself, so neither + // callback runs and stubbing ADMIN_EMAILS here would change nothing; the + // session signedInAs() builds is that derivation's output, which is all a + // route ever sees. The derivation itself is covered end to end by + // tests/e2e/admin.spec.ts. + describe('admin routes', () => { let fixtures: Fixtures; beforeEach(async () => { + r2.listedBuckets.length = 0; fixtures = await seedFixtures(); }); - it('refuses DELETE /api/admin/feedback/[feedbackId] and keeps the row', async () => { + it('refuses DELETE /api/admin/feedback/[feedbackId] to a non-admin and keeps the row', async () => { signedInAs({ id: fixtures.userId, isAdmin: false }); const response = await callRoute( @@ -806,7 +954,20 @@ describe('auth matrix', () => { expect(await db.userFeedback.count({ where: { id: fixtures.feedbackId } })).toBe(1); }); - it('refuses POST /api/admin/stats/refresh-r2', async () => { + it('lets an admin DELETE /api/admin/feedback/[feedbackId], and the row is gone', async () => { + signedInAs({ id: fixtures.userId, isAdmin: true }); + + const response = await callRoute( + adminFeedbackRoute.DELETE as unknown as RouteHandler, + apiRequest(`/api/admin/feedback/${fixtures.feedbackId}`, { method: 'DELETE' }), + { feedbackId: fixtures.feedbackId } + ); + + expect(response.status).toBe(200); + expect(await db.userFeedback.count({ where: { id: fixtures.feedbackId } })).toBe(0); + }); + + it('refuses POST /api/admin/stats/refresh-r2 to a non-admin', async () => { signedInAs({ id: fixtures.userId, isAdmin: false }); const response = await callRoute( @@ -815,6 +976,28 @@ describe('auth matrix', () => { ); expect(response.status).toBe(403); + // The refusal has to happen before the work, not after it. + expect(r2.listedBuckets).toEqual([]); + }); + + it('lets an admin POST /api/admin/stats/refresh-r2, and the bucket is walked', async () => { + signedInAs({ id: fixtures.userId, isAdmin: true }); + + const response = await callRoute( + adminRefreshR2Route.POST as RouteHandler, + apiRequest('/api/admin/stats/refresh-r2', { method: 'POST', body: {} }) + ); + + expect(response.status).toBe(200); + + const data = await readData<{ ok: boolean; refreshedAt: string }>(response); + expect(data.ok).toBe(true); + expect(Number.isNaN(Date.parse(data.refreshedAt))).toBe(false); + + // Getting past the guard is not the same as doing the job. Without this, + // a handler that returned `{ ok: true }` and skipped the refresh would + // still pass. + expect(r2.listedBuckets).toEqual([r2.bucket]); }); }); }); diff --git a/tests/api/comments.test.ts b/tests/api/comments.test.ts index 547683f..88f853f 100644 --- a/tests/api/comments.test.ts +++ b/tests/api/comments.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { db } from '@/lib/db'; +import { notifyProjectOwner } from '@/lib/notifications'; import { createShareSessionValue, getShareSessionCookieName } from '@/lib/share-session'; import { GET as listComments, @@ -283,6 +284,46 @@ describe('POST /api/versions/[versionId]/comments', () => { expect(await db.comment.count()).toBe(0); }); + // The "do not email somebody about their own comment" rule lives in the route + // (`const isOwnProject = session?.user?.id === project.ownerId`), not in + // lib/notifications.ts: notifyUsers() takes no actor argument and has no way + // to know. So it cannot be covered by a unit test of the notification module, + // and until these two it was covered nowhere: deleting the guard turned + // nothing red. They are written as a pair on purpose, because the negative one + // alone would also pass if notifications stopped firing altogether. + it('does not notify the project owner about the owner’s own comment', async () => { + const scenario = await seedVersion(); + signedInAs(scenario.owner); + vi.mocked(notifyProjectOwner).mockClear(); + + const response = await callRoute( + createCommentRoute, + apiRequest(commentsUrl(scenario.version.id), { body: { content: 'hi', timestamp: 1 } }), + { versionId: scenario.version.id } + ); + + expect(response.status).toBe(201); + expect(notifyProjectOwner).not.toHaveBeenCalled(); + }); + + it('notifies the project owner about a collaborator’s comment', async () => { + const scenario = await seedVersion(); + const collaborator = await createUser(); + await addProjectMember({ projectId: scenario.project.id, userId: collaborator.id }); + signedInAs(collaborator); + vi.mocked(notifyProjectOwner).mockClear(); + + const response = await callRoute( + createCommentRoute, + apiRequest(commentsUrl(scenario.version.id), { body: { content: 'hi', timestamp: 1 } }), + { versionId: scenario.version.id } + ); + + expect(response.status).toBe(201); + expect(notifyProjectOwner).toHaveBeenCalledTimes(1); + expect(vi.mocked(notifyProjectOwner).mock.calls[0][0]).toBe(scenario.owner.id); + }); + it('accepts a timestamp exactly equal to the duration', async () => { const scenario = await seedVersion({ duration: 120 }); signedInAs(scenario.owner); diff --git a/tests/api/email-verification.test.ts b/tests/api/email-verification.test.ts new file mode 100644 index 0000000..558d7d5 --- /dev/null +++ b/tests/api/email-verification.test.ts @@ -0,0 +1,392 @@ +// lib/email-verification.ts and the two routes that drive it. +// +// The property the whole module rests on is that the database never holds a +// usable verification link: it stores a SHA-256 digest, and the raw token +// exists only in the mail. Everything below is written so that storing the raw +// token, or dropping the expiry check, or letting a spent token be replayed, +// fails a test rather than a security review. + +import { createHash } from 'node:crypto'; +import { describe, expect, it, vi } from 'vitest'; +import nodemailer from 'nodemailer'; +import { db } from '@/lib/db'; +import { + consumeVerificationToken, + createVerificationToken, + isEmailVerificationEnabled, + sendVerificationEmail, +} from '@/lib/email-verification'; +import { GET as verifyEmail } from '@/app/api/auth/verify-email/route'; +import { POST as resendVerification } from '@/app/api/auth/verify-email/resend/route'; +import { apiRequest, callRoute, readData, readError } from '../helpers/request'; +import { mailTo, sentMail } from '../helpers/mail'; +import { createUser } from '../factories'; + +const TWO_HOURS_MS = 2 * 60 * 60 * 1000; +const MINUTE_MS = 60 * 1000; + +const RESEND_MESSAGE = + 'If that email has an unverified account, a new verification link has been sent.'; + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +/** Backdates the stored token so the expiry branch is reachable without waiting. */ +async function expireToken(tokenHash: string): Promise { + await db.verificationToken.update({ + where: { token: tokenHash }, + data: { expires: new Date(Date.now() - MINUTE_MS) }, + }); +} + +describe('createVerificationToken', () => { + it('hands back a raw token and stores only its digest', async () => { + const token = await createVerificationToken('ada@example.com'); + + const record = await db.verificationToken.findFirstOrThrow(); + expect(token).toMatch(/^[0-9a-f]{64}$/); + expect(record.identifier).toBe('ada@example.com'); + // The load-bearing assertion: a dump of verification_tokens must not be a + // list of working verification links. + expect(record.token).not.toBe(token); + expect(record.token).toBe(sha256(token)); + }); + + it('expires the token two hours out', async () => { + await createVerificationToken('ada@example.com'); + + const record = await db.verificationToken.findFirstOrThrow(); + const ttl = record.expires.getTime() - Date.now(); + expect(ttl).toBeGreaterThan(TWO_HOURS_MS - MINUTE_MS); + expect(ttl).toBeLessThanOrEqual(TWO_HOURS_MS); + }); + + it('replaces the previous token for the address, so the older link stops working', async () => { + const user = await createUser({ email: 'ada@example.com', emailVerified: null }); + const first = await createVerificationToken('ada@example.com'); + const second = await createVerificationToken('ada@example.com'); + + expect(second).not.toBe(first); + expect(await db.verificationToken.count()).toBe(1); + expect(await consumeVerificationToken(first)).toBeNull(); + expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified).toBeNull(); + expect(await consumeVerificationToken(second)).toBe('ada@example.com'); + }); + + it('leaves tokens for other addresses alone', async () => { + const ada = await createVerificationToken('ada@example.com'); + await createVerificationToken('grace@example.com'); + + expect(await db.verificationToken.count()).toBe(2); + expect(await db.verificationToken.findUnique({ where: { token: sha256(ada) } })).not.toBeNull(); + }); +}); + +describe('consumeVerificationToken', () => { + it('verifies the account and clears the token', async () => { + const user = await createUser({ email: 'ada@example.com', emailVerified: null }); + const token = await createVerificationToken('ada@example.com'); + + expect(await consumeVerificationToken(token)).toBe('ada@example.com'); + + expect( + (await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified + ).toBeInstanceOf(Date); + expect(await db.verificationToken.count()).toBe(0); + }); + + it('refuses a replayed token and keeps the original verification timestamp', async () => { + const user = await createUser({ email: 'ada@example.com', emailVerified: null }); + const token = await createVerificationToken('ada@example.com'); + await consumeVerificationToken(token); + const verifiedAt = (await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified; + + expect(await consumeVerificationToken(token)).toBeNull(); + + expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified).toEqual( + verifiedAt + ); + }); + + it('refuses an expired token, verifies nobody, and deletes the row', async () => { + const user = await createUser({ email: 'ada@example.com', emailVerified: null }); + const token = await createVerificationToken('ada@example.com'); + await expireToken(sha256(token)); + + expect(await consumeVerificationToken(token)).toBeNull(); + + expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified).toBeNull(); + expect(await db.verificationToken.count()).toBe(0); + }); + + // Whoever reads the database sees the digest. Presenting it back must not + // verify anything, and must not burn the live token either. + it('refuses the stored digest offered as if it were the token', async () => { + await createUser({ email: 'ada@example.com', emailVerified: null }); + const token = await createVerificationToken('ada@example.com'); + const stored = (await db.verificationToken.findFirstOrThrow()).token; + + expect(await consumeVerificationToken(stored)).toBeNull(); + + expect(await consumeVerificationToken(token)).toBe('ada@example.com'); + }); + + it('refuses a token nobody was ever issued', async () => { + expect(await consumeVerificationToken('f'.repeat(64))).toBeNull(); + }); + + it('refuses a token for an account that is already verified, and clears it', async () => { + const verifiedAt = new Date(Date.now() - 60 * MINUTE_MS); + const user = await createUser({ email: 'ada@example.com', emailVerified: verifiedAt }); + const token = await createVerificationToken('ada@example.com'); + + expect(await consumeVerificationToken(token)).toBeNull(); + + expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified).toEqual( + verifiedAt + ); + expect(await db.verificationToken.count()).toBe(0); + }); + + it('refuses a token whose account no longer exists', async () => { + const token = await createVerificationToken('deleted@example.com'); + + expect(await consumeVerificationToken(token)).toBeNull(); + }); +}); + +describe('isEmailVerificationEnabled', () => { + it('is on with the SMTP trio configured, as .env.test has it', () => { + expect(isEmailVerificationEnabled()).toBe(true); + }); + + // A self-hosted deployment without a mail server has to keep working, so any + // one of the three going missing turns verification off entirely. + it.each(['SMTP_HOST', 'SMTP_USER', 'SMTP_PASSWORD'])('is off without %s', (variable) => { + vi.stubEnv(variable, ''); + + expect(isEmailVerificationEnabled()).toBe(false); + }); +}); + +describe('sendVerificationEmail', () => { + it('mails a link carrying the raw token', async () => { + vi.stubEnv('NEXTAUTH_URL', 'https://app.example.test'); + const token = 'a'.repeat(64); + + await sendVerificationEmail('ada@example.com', token); + + const mails = mailTo('ada@example.com'); + expect(mails).toHaveLength(1); + expect(mails[0].subject).toBe('Verify your OpenFrame email address'); + expect(mails[0].html).toContain( + `https://app.example.test/api/auth/verify-email?token=${token}` + ); + }); + + it('escapes a token that carries query syntax', async () => { + vi.stubEnv('NEXTAUTH_URL', 'https://app.example.test'); + + await sendVerificationEmail('ada@example.com', 'a b&c'); + + expect(mailTo('ada@example.com')[0].html).toContain( + 'https://app.example.test/api/auth/verify-email?token=a%20b%26c' + ); + }); + + // Without an origin the link would be relative and the account unreachable. + // Sending a broken link is worse than sending nothing. + it('sends nothing when NEXTAUTH_URL is missing', async () => { + vi.stubEnv('NEXTAUTH_URL', ''); + + await sendVerificationEmail('ada@example.com', 'a'.repeat(64)); + + expect(sentMail()).toEqual([]); + }); + + it('sends nothing when SMTP is not configured', async () => { + vi.stubEnv('SMTP_HOST', ''); + vi.stubEnv('SMTP_USER', ''); + vi.stubEnv('SMTP_PASSWORD', ''); + + await sendVerificationEmail('ada@example.com', 'a'.repeat(64)); + + expect(sentMail()).toEqual([]); + }); + + // A mail server that is refusing connections must not turn a successful + // registration into a 500, so the rejection is swallowed here. + it('swallows a rejecting transport', async () => { + vi.mocked(nodemailer.createTransport).mockReturnValueOnce({ + sendMail: vi.fn(async () => { + throw new Error('smtp is down'); + }), + } as unknown as ReturnType); + + await expect(sendVerificationEmail('ada@example.com', 'a'.repeat(64))).resolves.toBeUndefined(); + expect(sentMail()).toEqual([]); + }); +}); + +// The route is anonymous by design: the token in the query string is the only +// credential, so there is no forbidden case to test, only good and bad tokens. +describe('GET /api/auth/verify-email', () => { + function verifyRequest(token: string) { + return apiRequest('/api/auth/verify-email', { searchParams: { token } }); + } + + it('verifies the account and sends the visitor to the login page', async () => { + const user = await createUser({ email: 'ada@example.com', emailVerified: null }); + const token = await createVerificationToken('ada@example.com'); + + const response = await callRoute(verifyEmail, verifyRequest(token)); + + expect(response.headers.get('location')).toBe('http://localhost:3000/login?verified=true'); + expect( + (await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified + ).toBeInstanceOf(Date); + expect(await db.verificationToken.count()).toBe(0); + }); + + // A raw token is 64 hex characters. Anything else is rejected before it can + // reach the database, which is what keeps enumeration cheap for us and not + // for the attacker. + it.each([['short'], ['g'.repeat(64)], ['A'.repeat(64)], ['']])( + 'rejects the malformed token %j without touching the stored one', + async (token) => { + await createUser({ email: 'ada@example.com', emailVerified: null }); + await createVerificationToken('ada@example.com'); + + const response = await callRoute(verifyEmail, verifyRequest(token)); + + expect(response.headers.get('location')).toBe( + 'http://localhost:3000/login?error=InvalidVerificationToken' + ); + expect(await db.verificationToken.count()).toBe(1); + } + ); + + it('rejects an expired token and leaves the account unverified', async () => { + const user = await createUser({ email: 'ada@example.com', emailVerified: null }); + const token = await createVerificationToken('ada@example.com'); + await expireToken(sha256(token)); + + const response = await callRoute(verifyEmail, verifyRequest(token)); + + expect(response.headers.get('location')).toBe( + 'http://localhost:3000/login?error=InvalidVerificationToken' + ); + expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified).toBeNull(); + }); + + it('rejects a replayed token', async () => { + await createUser({ email: 'ada@example.com', emailVerified: null }); + const token = await createVerificationToken('ada@example.com'); + await callRoute(verifyEmail, verifyRequest(token)); + + const response = await callRoute(verifyEmail, verifyRequest(token)); + + expect(response.headers.get('location')).toBe( + 'http://localhost:3000/login?error=InvalidVerificationToken' + ); + }); +}); + +describe('POST /api/auth/verify-email/resend', () => { + function resendRequest(body: unknown) { + return apiRequest('/api/auth/verify-email/resend', { body }); + } + + it('issues a fresh token to an unverified account and kills the previous link', async () => { + await createUser({ email: 'ada@example.com', emailVerified: null }); + const firstToken = await createVerificationToken('ada@example.com'); + + const response = await callRoute( + resendVerification, + resendRequest({ email: 'ada@example.com' }) + ); + + expect(response.status).toBe(200); + expect(await db.verificationToken.count()).toBe(1); + const mails = mailTo('ada@example.com'); + expect(mails).toHaveLength(1); + const mailedToken = mails[0].html?.match(/token=([0-9a-f]{64})/)?.[1]; + expect(mailedToken).toBeTruthy(); + expect(mailedToken).not.toBe(firstToken); + // The mailed token is the raw one and the row still holds only a digest. + expect((await db.verificationToken.findFirstOrThrow()).token).toBe(sha256(mailedToken!)); + expect(await consumeVerificationToken(firstToken)).toBeNull(); + }); + + it('normalizes the address before looking the account up', async () => { + await createUser({ email: 'ada@example.com', emailVerified: null }); + + const response = await callRoute( + resendVerification, + resendRequest({ email: ' Ada@Example.COM ' }) + ); + + expect(response.status).toBe(200); + expect(mailTo('ada@example.com')).toHaveLength(1); + }); + + // The endpoint is unauthenticated, so a different answer for a known address + // would turn it into an account-existence oracle. + it('answers an unknown address exactly as it answers a real one, and mails nothing', async () => { + await createUser({ email: 'ada@example.com', emailVerified: null }); + + const known = await callRoute(resendVerification, resendRequest({ email: 'ada@example.com' })); + const unknown = await callRoute( + resendVerification, + resendRequest({ email: 'nobody@example.com' }) + ); + + const knownBody = await readData<{ message: string }>(known); + const unknownBody = await readData<{ message: string }>(unknown); + expect(unknown.status).toBe(known.status); + expect(unknownBody).toEqual(knownBody); + expect(unknownBody.message).toBe(RESEND_MESSAGE); + expect(mailTo('nobody@example.com')).toEqual([]); + }); + + it('mails nothing to an account that is already verified', async () => { + await createUser({ email: 'ada@example.com', emailVerified: new Date() }); + + const response = await callRoute( + resendVerification, + resendRequest({ email: 'ada@example.com' }) + ); + + expect(response.status).toBe(200); + expect(sentMail()).toEqual([]); + expect(await db.verificationToken.count()).toBe(0); + }); + + it.each([[{}], [{ email: 42 }], [{ email: 'no-at-sign' }], [{ email: 'sp ace@example.com' }]])( + 'rejects %j with 400', + async (body) => { + const response = await callRoute(resendVerification, resendRequest(body)); + + expect(response.status).toBe(400); + expect(await db.verificationToken.count()).toBe(0); + expect(sentMail()).toEqual([]); + } + ); + + it('refuses to run at all when SMTP is not configured', async () => { + vi.stubEnv('SMTP_HOST', ''); + vi.stubEnv('SMTP_USER', ''); + vi.stubEnv('SMTP_PASSWORD', ''); + await createUser({ email: 'ada@example.com', emailVerified: null }); + + const response = await callRoute( + resendVerification, + resendRequest({ email: 'ada@example.com' }) + ); + + expect(response.status).toBe(400); + expect(await readError(response)).toBe('Email verification is not enabled'); + expect(await db.verificationToken.count()).toBe(0); + }); +}); diff --git a/tests/api/infrastructure.test.ts b/tests/api/infrastructure.test.ts index 4440a67..e5b1c09 100644 --- a/tests/api/infrastructure.test.ts +++ b/tests/api/infrastructure.test.ts @@ -18,12 +18,21 @@ import { } from '../factories'; describe('api test infrastructure', () => { - it('points at the test database and not at the dev one', async () => { + it('points at a test database and not at the dev one', async () => { const [{ current_database: name }] = await db.$queryRaw< Array<{ current_database: string }> >`SELECT current_database()`; - expect(name).toBe('openframe_test'); + // `openframe_test` is what everything uses by default. The optional suffix + // exists because this suite empties every table after every test, so two + // runs against one database destroy each other: writing several suites in + // parallel means giving each run its own database, created by hand in the + // same container and named `openframe_test_`. + // + // The guard that matters is the one this leaves intact: the dev database is + // called `openframe`, which does not match, so a stray DATABASE_URL still + // cannot get this suite to truncate real data. + expect(name).toMatch(/^openframe_test(_[a-z0-9]+)?$/); }); it('discovers every table from information_schema, so resetDb cannot drift', async () => { diff --git a/tests/api/invitations.test.ts b/tests/api/invitations.test.ts new file mode 100644 index 0000000..28676fc --- /dev/null +++ b/tests/api/invitations.test.ts @@ -0,0 +1,730 @@ +// lib/invitations.ts, exercised directly rather than through the two member +// routes that call it. The routes decide who may invite; this module decides +// what an invitation is worth once it is accepted, which is the part that hands +// out standing access to a workspace or a project. + +import { describe, expect, it, vi } from 'vitest'; +import nodemailer from 'nodemailer'; +import { db } from '@/lib/db'; +import { + acceptInvitationTokenForUser, + acceptPendingInvitationsForUser, + buildInvitationUrl, + createOrRefreshInvitation, + getValidInvitationByToken, + sendInvitationEmail, +} from '@/lib/invitations'; +import { mailTo, sentMail } from '../helpers/mail'; +import { + addProjectMember, + addWorkspaceMember, + createInvitation, + createUser, + seedProject, +} from '../factories'; + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; +const MINUTE_MS = 60 * 1000; + +describe('createOrRefreshInvitation', () => { + it('creates a pending invitation with a normalized address and a 32-byte token', async () => { + const scenario = await seedProject(); + + const invitation = await createOrRefreshInvitation({ + email: ' Invitee@Example.COM ', + scope: 'WORKSPACE', + role: 'COMMENTATOR', + invitedById: scenario.owner.id, + workspaceId: scenario.workspace.id, + }); + + expect(invitation.email).toBe('invitee@example.com'); + expect(invitation.status).toBe('PENDING'); + expect(invitation.role).toBe('COMMENTATOR'); + expect(invitation.invitedById).toBe(scenario.owner.id); + expect(invitation.workspaceId).toBe(scenario.workspace.id); + expect(invitation.projectId).toBeNull(); + expect(invitation.acceptedAt).toBeNull(); + // randomBytes(32).toString('hex'). A short or non-random token here is the + // whole security of the accept link. + expect(invitation.token).toMatch(/^[0-9a-f]{64}$/); + // The TTL is 7 days; allow a minute for the round trip. + const ttl = invitation.expiresAt.getTime() - Date.now(); + expect(ttl).toBeGreaterThan(SEVEN_DAYS_MS - MINUTE_MS); + expect(ttl).toBeLessThanOrEqual(SEVEN_DAYS_MS); + }); + + it('refreshes the live invitation in place and rotates its token', async () => { + const scenario = await seedProject(); + const args = { + email: 'invitee@example.com', + scope: 'PROJECT' as const, + invitedById: scenario.owner.id, + projectId: scenario.project.id, + }; + + const first = await createOrRefreshInvitation({ ...args, role: 'COMMENTATOR' }); + const second = await createOrRefreshInvitation({ ...args, role: 'ADMIN' }); + + expect(second.id).toBe(first.id); + expect(await db.invitation.count()).toBe(1); + // Re-inviting has to invalidate the link already in someone's inbox. + expect(second.token).not.toBe(first.token); + expect(second.role).toBe('ADMIN'); + expect(second.expiresAt.getTime()).toBeGreaterThanOrEqual(first.expiresAt.getTime()); + }); + + it('expires a stale pending invitation rather than reviving it', async () => { + const scenario = await seedProject(); + const stale = await createInvitation({ + invitedById: scenario.owner.id, + email: 'invitee@example.com', + scope: 'WORKSPACE', + workspaceId: scenario.workspace.id, + expiresAt: new Date(Date.now() - MINUTE_MS), + }); + + const fresh = await createOrRefreshInvitation({ + email: 'invitee@example.com', + scope: 'WORKSPACE', + role: 'COMMENTATOR', + invitedById: scenario.owner.id, + workspaceId: scenario.workspace.id, + }); + + expect(fresh.id).not.toBe(stale.id); + expect((await db.invitation.findUniqueOrThrow({ where: { id: stale.id } })).status).toBe( + 'EXPIRED' + ); + expect(fresh.status).toBe('PENDING'); + expect(await db.invitation.count()).toBe(2); + }); + + // Two concurrent invites can leave two live rows for one address. The next + // call has to collapse them, or a cancelled invitation still has a working + // twin in the database. + it('leaves exactly one live invitation when duplicates already exist', async () => { + const scenario = await seedProject(); + for (let i = 0; i < 2; i++) { + await createInvitation({ + invitedById: scenario.owner.id, + email: 'invitee@example.com', + scope: 'WORKSPACE', + workspaceId: scenario.workspace.id, + }); + } + + const refreshed = await createOrRefreshInvitation({ + email: 'invitee@example.com', + scope: 'WORKSPACE', + role: 'ADMIN', + invitedById: scenario.owner.id, + workspaceId: scenario.workspace.id, + }); + + const pending = await db.invitation.findMany({ where: { status: 'PENDING' } }); + expect(pending.map((row) => row.id)).toEqual([refreshed.id]); + expect(await db.invitation.count({ where: { status: 'CANCELED' } })).toBe(1); + expect(await db.invitation.count()).toBe(2); + }); + + it('keeps a workspace invitation and a project invitation for one address apart', async () => { + const scenario = await seedProject(); + + const workspaceInvitation = await createOrRefreshInvitation({ + email: 'invitee@example.com', + scope: 'WORKSPACE', + role: 'ADMIN', + invitedById: scenario.owner.id, + workspaceId: scenario.workspace.id, + }); + const projectInvitation = await createOrRefreshInvitation({ + email: 'invitee@example.com', + scope: 'PROJECT', + role: 'COMMENTATOR', + invitedById: scenario.owner.id, + projectId: scenario.project.id, + }); + + expect(projectInvitation.id).not.toBe(workspaceInvitation.id); + expect(await db.invitation.count({ where: { status: 'PENDING' } })).toBe(2); + }); +}); + +describe('getValidInvitationByToken', () => { + it('returns the pending invitation behind a live token', async () => { + const scenario = await seedProject(); + const invitation = await createInvitation({ + invitedById: scenario.owner.id, + scope: 'PROJECT', + projectId: scenario.project.id, + token: 'live-token', + }); + + expect((await getValidInvitationByToken('live-token'))?.id).toBe(invitation.id); + }); + + it('returns null for an expired token', async () => { + const scenario = await seedProject(); + await createInvitation({ + invitedById: scenario.owner.id, + scope: 'PROJECT', + projectId: scenario.project.id, + token: 'stale-token', + expiresAt: new Date(Date.now() - MINUTE_MS), + }); + + expect(await getValidInvitationByToken('stale-token')).toBeNull(); + }); + + it.each(['ACCEPTED', 'CANCELED', 'EXPIRED'] as const)( + 'returns null for a %s invitation', + async (status) => { + const scenario = await seedProject(); + await createInvitation({ + invitedById: scenario.owner.id, + scope: 'PROJECT', + projectId: scenario.project.id, + token: 'consumed-token', + status, + }); + + expect(await getValidInvitationByToken('consumed-token')).toBeNull(); + } + ); + + it('returns null for a token nobody was ever given', async () => { + expect(await getValidInvitationByToken('not-a-real-token')).toBeNull(); + }); +}); + +describe('acceptInvitationTokenForUser', () => { + // The role carried by the invitation is the only thing that decides the + // membership role. A COMMENTATOR invite that lands as an ADMIN membership is + // a silent privilege escalation, so both scopes are pinned in both roles. + it.each([ + ['COMMENTATOR', 'COMMENTATOR'], + ['ADMIN', 'ADMIN'], + ] as const)('a %s workspace invitation grants exactly %s', async (invitedRole, memberRole) => { + const scenario = await seedProject(); + const invitee = await createUser({ email: 'invitee@example.com' }); + const invitation = await createInvitation({ + invitedById: scenario.owner.id, + email: 'invitee@example.com', + scope: 'WORKSPACE', + workspaceId: scenario.workspace.id, + role: invitedRole, + }); + + const result = await acceptInvitationTokenForUser({ + token: invitation.token, + userId: invitee.id, + email: invitee.email!, + }); + + expect(result).toBe('accepted'); + const membership = await db.workspaceMember.findUniqueOrThrow({ + where: { workspaceId_userId: { workspaceId: scenario.workspace.id, userId: invitee.id } }, + }); + expect(membership.role).toBe(memberRole); + expect(await db.projectMember.count()).toBe(0); + }); + + it.each([ + ['COMMENTATOR', 'COMMENTATOR'], + ['ADMIN', 'ADMIN'], + ] as const)('a %s project invitation grants exactly %s', async (invitedRole, memberRole) => { + const scenario = await seedProject(); + const invitee = await createUser({ email: 'invitee@example.com' }); + const invitation = await createInvitation({ + invitedById: scenario.owner.id, + email: 'invitee@example.com', + scope: 'PROJECT', + projectId: scenario.project.id, + role: invitedRole, + }); + + const result = await acceptInvitationTokenForUser({ + token: invitation.token, + userId: invitee.id, + email: invitee.email!, + }); + + expect(result).toBe('accepted'); + const membership = await db.projectMember.findUniqueOrThrow({ + where: { projectId_userId: { projectId: scenario.project.id, userId: invitee.id } }, + }); + expect(membership.role).toBe(memberRole); + expect(await db.workspaceMember.count()).toBe(0); + }); + + it('consumes the invitation and stamps acceptedAt', async () => { + const scenario = await seedProject(); + const invitee = await createUser({ email: 'invitee@example.com' }); + const invitation = await createInvitation({ + invitedById: scenario.owner.id, + email: 'invitee@example.com', + scope: 'PROJECT', + projectId: scenario.project.id, + }); + + await acceptInvitationTokenForUser({ + token: invitation.token, + userId: invitee.id, + email: invitee.email!, + }); + + const stored = await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } }); + expect(stored.status).toBe('ACCEPTED'); + expect(stored.acceptedAt).toBeInstanceOf(Date); + }); + + it('refuses the same token a second time and leaves the membership as it was', async () => { + const scenario = await seedProject(); + const invitee = await createUser({ email: 'invitee@example.com' }); + const invitation = await createInvitation({ + invitedById: scenario.owner.id, + email: 'invitee@example.com', + scope: 'PROJECT', + projectId: scenario.project.id, + role: 'COMMENTATOR', + }); + const accept = () => + acceptInvitationTokenForUser({ + token: invitation.token, + userId: invitee.id, + email: invitee.email!, + }); + + expect(await accept()).toBe('accepted'); + // Someone with the link should not be able to undo a later demotion by + // replaying it, so the second accept must not reapply the invited role. + await db.projectMember.update({ + where: { projectId_userId: { projectId: scenario.project.id, userId: invitee.id } }, + data: { role: 'ADMIN' }, + }); + + expect(await accept()).toBe('not_found'); + expect(await db.projectMember.count()).toBe(1); + const membership = await db.projectMember.findUniqueOrThrow({ + where: { projectId_userId: { projectId: scenario.project.id, userId: invitee.id } }, + }); + expect(membership.role).toBe('ADMIN'); + }); + + // An invitation is bound to an address. A forwarded link must not let the + // recipient walk into the project on their own account. + it('refuses a token issued to a different address', async () => { + const scenario = await seedProject(); + const bystander = await createUser({ email: 'someone.else@example.com' }); + const invitation = await createInvitation({ + invitedById: scenario.owner.id, + email: 'intended@example.com', + scope: 'PROJECT', + projectId: scenario.project.id, + }); + + const result = await acceptInvitationTokenForUser({ + token: invitation.token, + userId: bystander.id, + email: bystander.email!, + }); + + expect(result).toBe('forbidden'); + expect(await db.projectMember.count()).toBe(0); + expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe( + 'PENDING' + ); + }); + + it('matches the invited address case-insensitively and ignores stray whitespace', async () => { + const scenario = await seedProject(); + const invitee = await createUser({ email: 'invitee@example.com' }); + const invitation = await createInvitation({ + invitedById: scenario.owner.id, + email: 'invitee@example.com', + scope: 'WORKSPACE', + workspaceId: scenario.workspace.id, + }); + + const result = await acceptInvitationTokenForUser({ + token: invitation.token, + userId: invitee.id, + email: ' Invitee@Example.COM ', + }); + + expect(result).toBe('accepted'); + expect(await db.workspaceMember.count()).toBe(1); + }); + + it('reports an expired invitation as expired, flips the row, and grants nothing', async () => { + const scenario = await seedProject(); + const invitee = await createUser({ email: 'invitee@example.com' }); + const invitation = await createInvitation({ + invitedById: scenario.owner.id, + email: 'invitee@example.com', + scope: 'PROJECT', + projectId: scenario.project.id, + expiresAt: new Date(Date.now() - MINUTE_MS), + }); + + const result = await acceptInvitationTokenForUser({ + token: invitation.token, + userId: invitee.id, + email: invitee.email!, + }); + + expect(result).toBe('expired'); + expect(await db.projectMember.count()).toBe(0); + expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe( + 'EXPIRED' + ); + }); + + it.each(['ACCEPTED', 'CANCELED', 'EXPIRED'] as const)( + 'refuses a %s invitation without granting a membership', + async (status) => { + const scenario = await seedProject(); + const invitee = await createUser({ email: 'invitee@example.com' }); + const invitation = await createInvitation({ + invitedById: scenario.owner.id, + email: 'invitee@example.com', + scope: 'PROJECT', + projectId: scenario.project.id, + status, + }); + + const result = await acceptInvitationTokenForUser({ + token: invitation.token, + userId: invitee.id, + email: invitee.email!, + }); + + expect(result).toBe('not_found'); + expect(await db.projectMember.count()).toBe(0); + expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe( + status + ); + } + ); + + it('reports an unknown token as not_found', async () => { + const user = await createUser(); + + const result = await acceptInvitationTokenForUser({ + token: 'not-a-real-token', + userId: user.id, + email: user.email!, + }); + + expect(result).toBe('not_found'); + }); + + it('promotes an existing COMMENTATOR to ADMIN without duplicating the membership', async () => { + const scenario = await seedProject(); + const member = await createUser({ email: 'member@example.com' }); + await addProjectMember({ + projectId: scenario.project.id, + userId: member.id, + role: 'COMMENTATOR', + }); + const invitation = await createInvitation({ + invitedById: scenario.owner.id, + email: 'member@example.com', + scope: 'PROJECT', + projectId: scenario.project.id, + role: 'ADMIN', + }); + + const result = await acceptInvitationTokenForUser({ + token: invitation.token, + userId: member.id, + email: member.email!, + }); + + expect(result).toBe('accepted'); + expect(await db.projectMember.count()).toBe(1); + const membership = await db.projectMember.findUniqueOrThrow({ + where: { projectId_userId: { projectId: scenario.project.id, userId: member.id } }, + }); + expect(membership.role).toBe('ADMIN'); + }); + + // The invited role wins over the role the member already holds, so accepting + // a COMMENTATOR invitation demotes a sitting workspace ADMIN. Pinned because + // it is a privilege change, and a surprising one: the accept link looks like + // it can only ever add access. + it('applies a COMMENTATOR invitation over an existing ADMIN membership', async () => { + const scenario = await seedProject(); + const member = await createUser({ email: 'member@example.com' }); + await addWorkspaceMember({ + workspaceId: scenario.workspace.id, + userId: member.id, + role: 'ADMIN', + }); + const invitation = await createInvitation({ + invitedById: scenario.owner.id, + email: 'member@example.com', + scope: 'WORKSPACE', + workspaceId: scenario.workspace.id, + role: 'COMMENTATOR', + }); + + const result = await acceptInvitationTokenForUser({ + token: invitation.token, + userId: member.id, + email: member.email!, + }); + + expect(result).toBe('accepted'); + const membership = await db.workspaceMember.findUniqueOrThrow({ + where: { workspaceId_userId: { workspaceId: scenario.workspace.id, userId: member.id } }, + }); + expect(membership.role).toBe('COMMENTATOR'); + }); + + // The owner already outranks any membership row. Writing one would put them + // in their own member list and, for a COMMENTATOR invitation, next to a role + // that reads as a demotion. + it('gives the workspace owner no member row yet still consumes the invitation', async () => { + const scenario = await seedProject(); + const invitation = await createInvitation({ + invitedById: scenario.owner.id, + email: scenario.owner.email!, + scope: 'WORKSPACE', + workspaceId: scenario.workspace.id, + role: 'COMMENTATOR', + }); + + const result = await acceptInvitationTokenForUser({ + token: invitation.token, + userId: scenario.owner.id, + email: scenario.owner.email!, + }); + + expect(result).toBe('accepted'); + expect(await db.workspaceMember.count()).toBe(0); + expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe( + 'ACCEPTED' + ); + }); + + it('gives the project owner no member row yet still consumes the invitation', async () => { + const scenario = await seedProject(); + const invitation = await createInvitation({ + invitedById: scenario.owner.id, + email: scenario.owner.email!, + scope: 'PROJECT', + projectId: scenario.project.id, + role: 'ADMIN', + }); + + const result = await acceptInvitationTokenForUser({ + token: invitation.token, + userId: scenario.owner.id, + email: scenario.owner.email!, + }); + + expect(result).toBe('accepted'); + expect(await db.projectMember.count()).toBe(0); + expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe( + 'ACCEPTED' + ); + }); + + // Pins today's behaviour for a malformed row (scope WORKSPACE with no + // workspaceId): the caller is told "accepted" while nothing is granted and + // the invitation stays PENDING, so the accept page shows a success screen. + // See the report accompanying this suite. + it('reports accepted for a scoped invitation that points at nothing', async () => { + const scenario = await seedProject(); + const invitee = await createUser({ email: 'invitee@example.com' }); + const invitation = await createInvitation({ + invitedById: scenario.owner.id, + email: 'invitee@example.com', + scope: 'WORKSPACE', + workspaceId: null, + }); + + const result = await acceptInvitationTokenForUser({ + token: invitation.token, + userId: invitee.id, + email: invitee.email!, + }); + + expect(result).toBe('accepted'); + expect(await db.workspaceMember.count()).toBe(0); + expect((await db.invitation.findUniqueOrThrow({ where: { id: invitation.id } })).status).toBe( + 'PENDING' + ); + }); +}); + +describe('acceptPendingInvitationsForUser', () => { + it('applies every live invitation for the address with the role each one carries', async () => { + const scenario = await seedProject(); + const invitee = await createUser({ email: 'invitee@example.com' }); + const workspaceInvitation = await createInvitation({ + invitedById: scenario.owner.id, + email: 'invitee@example.com', + scope: 'WORKSPACE', + workspaceId: scenario.workspace.id, + role: 'COMMENTATOR', + }); + const projectInvitation = await createInvitation({ + invitedById: scenario.owner.id, + email: 'invitee@example.com', + scope: 'PROJECT', + projectId: scenario.project.id, + role: 'ADMIN', + }); + + await acceptPendingInvitationsForUser(invitee.id, ' Invitee@Example.COM '); + + const workspaceMembership = await db.workspaceMember.findUniqueOrThrow({ + where: { workspaceId_userId: { workspaceId: scenario.workspace.id, userId: invitee.id } }, + }); + const projectMembership = await db.projectMember.findUniqueOrThrow({ + where: { projectId_userId: { projectId: scenario.project.id, userId: invitee.id } }, + }); + expect(workspaceMembership.role).toBe('COMMENTATOR'); + expect(projectMembership.role).toBe('ADMIN'); + for (const id of [workspaceInvitation.id, projectInvitation.id]) { + expect((await db.invitation.findUniqueOrThrow({ where: { id } })).status).toBe('ACCEPTED'); + } + }); + + it('expires the stale invitations instead of granting them', async () => { + const scenario = await seedProject(); + const invitee = await createUser({ email: 'invitee@example.com' }); + const stale = await createInvitation({ + invitedById: scenario.owner.id, + email: 'invitee@example.com', + scope: 'PROJECT', + projectId: scenario.project.id, + expiresAt: new Date(Date.now() - MINUTE_MS), + }); + + await acceptPendingInvitationsForUser(invitee.id, invitee.email!); + + expect(await db.projectMember.count()).toBe(0); + expect((await db.invitation.findUniqueOrThrow({ where: { id: stale.id } })).status).toBe( + 'EXPIRED' + ); + }); + + it('ignores invitations addressed to somebody else', async () => { + const scenario = await seedProject(); + const invitee = await createUser({ email: 'invitee@example.com' }); + const other = await createInvitation({ + invitedById: scenario.owner.id, + email: 'stranger@example.com', + scope: 'PROJECT', + projectId: scenario.project.id, + role: 'ADMIN', + }); + + await acceptPendingInvitationsForUser(invitee.id, invitee.email!); + + expect(await db.projectMember.count()).toBe(0); + expect((await db.invitation.findUniqueOrThrow({ where: { id: other.id } })).status).toBe( + 'PENDING' + ); + }); +}); + +describe('buildInvitationUrl', () => { + it('points at /invitations/accept on the configured origin', () => { + vi.stubEnv('NEXTAUTH_URL', 'https://app.example.test'); + + expect(buildInvitationUrl('abc123')).toBe( + 'https://app.example.test/invitations/accept?token=abc123' + ); + }); + + it('escapes a token that carries query syntax', () => { + vi.stubEnv('NEXTAUTH_URL', 'https://app.example.test'); + + expect(buildInvitationUrl('a b&c=d')).toBe( + 'https://app.example.test/invitations/accept?token=a+b%26c%3Dd' + ); + }); +}); + +describe('sendInvitationEmail', () => { + const invite = { + to: 'invitee@example.com', + inviterName: 'Ada Lovelace', + role: 'COMMENTATOR' as const, + scope: 'WORKSPACE' as const, + targetName: 'Acme', + invitationUrl: 'https://app.example.test/invitations/accept?token=abc123', + }; + + it('mails the invited address a message carrying the accept link', async () => { + vi.stubEnv('SMTP_FROM', 'OpenFrame '); + + expect(await sendInvitationEmail(invite)).toBe(true); + + const mails = mailTo('invitee@example.com'); + expect(mails).toHaveLength(1); + expect(mails[0].from).toBe('OpenFrame '); + expect(mails[0].subject).toBe('[OpenFrame] You were invited to a workspace: Acme'); + expect(mails[0].html).toContain('https://app.example.test/invitations/accept?token=abc123'); + expect(mails[0].html).toContain('Ada Lovelace'); + expect(mails[0].html).toContain('Commentator'); + }); + + it('names the project scope and the Admin role in a project admin invitation', async () => { + expect( + await sendInvitationEmail({ + ...invite, + scope: 'PROJECT', + role: 'ADMIN', + targetName: 'Launch Film', + }) + ).toBe(true); + + const mails = mailTo('invitee@example.com'); + expect(mails[0].subject).toBe('[OpenFrame] You were invited to a project: Launch Film'); + expect(mails[0].html).toContain('Admin'); + expect(mails[0].html).not.toContain('Commentator'); + }); + + // The inviter's display name and the target name are user-supplied and land + // in an HTML mail body. + it('escapes markup in the inviter and target names', async () => { + await sendInvitationEmail({ + ...invite, + inviterName: '', + targetName: '', + }); + + const html = mailTo('invitee@example.com')[0].html!; + expect(html).not.toContain('', + commentText: '', + }); + + const { html } = sentMail(0); + expect(html).not.toContain('