diff --git a/.dockerignore b/.dockerignore
index 8887c82..bbbf819 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -10,6 +10,11 @@ node_modules
coverage
dist
testsprite_tests
+tests
+vitest.config.ts
+playwright.config.ts
+playwright-report
+test-results
tsconfig.tsbuildinfo
README.md
PROGRESS.md
diff --git a/.env.test.example b/.env.test.example
new file mode 100644
index 0000000..9c8a776
--- /dev/null
+++ b/.env.test.example
@@ -0,0 +1,78 @@
+# Environment for the `api` Vitest project. Copy to `.env.test` (gitignored):
+# cp .env.test.example .env.test
+#
+# `tests/setup/db-global.ts` and `tests/setup/api.ts` both load this file (via
+# `tests/helpers/env.ts`) before anything imports `@/lib/db`, which reads
+# DATABASE_URL once at module load and memoizes the pool. An already-exported
+# variable always wins over the file, so CI can override DATABASE_URL without
+# editing anything.
+
+# ---------------------------------------------------------------------------
+# DATABASE
+# ---------------------------------------------------------------------------
+# The default targets the container-to-container hostname, because the test
+# runner itself runs in a container attached to the `openframe-test` network:
+# podman compose -f docker-compose.test.yml up -d
+# podman run --rm --network openframe-test -v "$PWD":/workspace:z -w /workspace \
+# docker.io/oven/bun:alpine sh -c "bun run test:api"
+DATABASE_URL="postgresql://openframe:openframe@postgres-test:5432/openframe_test?schema=public"
+
+# From the host instead (psql, or a runner that is not on that network), use the
+# published port:
+# DATABASE_URL="postgresql://openframe:openframe@127.0.0.1:55432/openframe_test?schema=public"
+#
+# On GitHub Actions, where Postgres is a service container on the job network:
+# DATABASE_URL="postgresql://openframe:openframe@localhost:5432/openframe_test?schema=public"
+
+# ---------------------------------------------------------------------------
+# AUTH
+# ---------------------------------------------------------------------------
+# `auth()` is mocked in tests, so NEXTAUTH_SECRET is only used for real work by
+# lib/share-session.ts, which HMAC-signs the share cookies.
+NEXTAUTH_URL="http://localhost:3000"
+NEXTAUTH_SECRET="test-secret-not-used-for-anything-real"
+NEXT_PUBLIC_APP_URL="http://localhost:3000"
+
+# ---------------------------------------------------------------------------
+# FEATURE FLAGS
+# ---------------------------------------------------------------------------
+# Stripe on, because that is what production runs and because it is what arms
+# every billing gate: hasBillingAccess() short-circuits to `true` when the flag
+# is off, which would silently neuter the whole access-control surface. Tests
+# that want the self-hosted behaviour stub the flag off per test.
+OPENFRAME_ENABLE_STRIPE="true"
+# Dummy credentials so isStripeBillingEnabled() is true and getStripePriceId()
+# does not throw. `@/lib/stripe` is module-mocked, so no request ever leaves.
+STRIPE_SECRET_KEY="sk_test_openframe_dummy"
+STRIPE_PRICE_ID="price_test_openframe_dummy"
+STRIPE_WEBHOOK_SECRET="whsec_test_openframe_dummy"
+
+OPENFRAME_REQUIRE_INVITE_CODE="true"
+INVITE_CODE="test-invite"
+
+# Direct-upload providers are deliberately left unconfigured, so
+# isDirectFileUploadEnabled() is false by default. Suites that exercise the
+# presigned-upload routes stub R2_* / BUNNY_* in per test.
+
+# No proxy in front of the test runner, so getClientIp() returns 127.0.0.1.
+TRUSTED_PROXY_MODE="none"
+
+# Rate limits are DB-backed and keyed on the client IP, which is that same
+# constant for every request in the suite. Left on, one test exhausting a
+# window would make the next test's 429 look like a passing authorization
+# check. tests/api/rate-limit.test.ts re-enables it with vi.stubEnv (the flag
+# is read per call, not at import) and is the only place that asserts on it.
+DISABLE_RATE_LIMIT="true"
+
+# SMTP is configured on purpose: isEmailVerificationEnabled() is derived from
+# these three variables, and with them unset the register/verify routes take a
+# different branch than production does. `nodemailer` is module-mocked in
+# tests/setup/api.ts, so nothing leaves the process; the messages are captured
+# and assertable through tests/helpers/mail.ts.
+SMTP_HOST="localhost"
+SMTP_PORT="1025"
+SMTP_USER="test"
+SMTP_PASSWORD="test"
+SMTP_FROM="OpenFrame Test "
+
+NODE_ENV="test"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ad3814d..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
@@ -13,3 +21,217 @@ jobs:
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun run check
+
+ test:
+ runs-on: ubuntu-latest
+ services:
+ # Postgres for the `api` Vitest project. This job runs directly on the
+ # runner, so the service is reachable on localhost through the published
+ # port, not by service name.
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_USER: openframe
+ POSTGRES_PASSWORD: openframe
+ POSTGRES_DB: openframe_test
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U openframe -d openframe_test"
+ --health-interval 2s
+ --health-timeout 3s
+ --health-retries 30
+ env:
+ DATABASE_URL: postgresql://openframe:openframe@localhost:5432/openframe_test?schema=public
+ steps:
+ - uses: actions/checkout@v4
+ - uses: oven-sh/setup-bun@v2
+ - run: bun install
+ - name: Create .env.test from the committed example
+ # The `api` project's setup files read `.env.test`, which is gitignored.
+ # The example carries every value the suites need; only the database host
+ # differs, because locally it is the compose service and here it is a
+ # service container. The appended line wins inside the file and the
+ # exported job env wins over the file, so the override holds either way.
+ run: |
+ cp .env.test.example .env.test
+ printf '\nDATABASE_URL=%s\n' "$DATABASE_URL" >> .env.test
+ # No migration step here on purpose. The api project's globalSetup
+ # (tests/setup/db-global.ts) builds the schema itself, and it cannot use
+ # `prisma migrate deploy`: prisma/migrations is a stack of patches on top
+ # of a baseline that was never captured, so the second migration alters an
+ # enum that nothing in the history creates. That file explains it in full.
+ - name: Unit and component tests
+ run: bun run test
+ - name: API integration tests
+ run: bun run test:api
+ - name: Coverage report
+ # Diagnostic only. There is no coverage threshold gate on purpose, see
+ # TESTING.md section 11.
+ #
+ # Run under node rather than `bun run test:coverage`: @vitest/coverage-v8
+ # needs the V8 inspector API, which bun does not implement, so under bun
+ # every file reports "Coverage APIs are not supported" and the numbers
+ # come out as zero. The suite itself passes under both runtimes.
+ run: node node_modules/vitest/vitest.mjs run --project unit --coverage
+ - name: Upload coverage report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage
+ path: coverage/
+ 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]
+ # Runs directly on the runner rather than in the Playwright container image.
+ # Two reasons, both learned the hard way. MinIO cannot be a `services:` entry
+ # (see the step that starts it below), and starting it as a step needs a
+ # docker CLI, which a container job does not have. And the Playwright image
+ # ships neither bun nor unzip, so bun had to be installed through npm and the
+ # image tag had to be kept in lockstep with the npm package. Installing the
+ # browser here costs about a minute and removes all of that.
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_USER: openframe
+ POSTGRES_PASSWORD: openframe
+ POSTGRES_DB: openframe_test
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U openframe -d openframe_test"
+ --health-interval 2s
+ --health-timeout 3s
+ --health-retries 30
+ env:
+ DATABASE_URL: postgresql://openframe:openframe@localhost:5432/openframe_test?schema=public
+ # The Playwright web server builds and starts the app, and `next build`
+ # does not run with NODE_ENV=test, so it never picks up `.env.test`. These
+ # values are therefore set on the job itself. Port 3100 matches
+ # playwright.config.ts.
+ NEXTAUTH_URL: http://localhost:3100
+ NEXTAUTH_SECRET: ci-secret-not-used-for-anything-real
+ NEXT_PUBLIC_APP_URL: http://localhost:3100
+ # Required. NextAuth v5 answers every /api/auth/* request with
+ # `UntrustedHost` in a production build unless the host is trusted, which
+ # is why .env.docker.example sets the same variable for real deployments.
+ AUTH_TRUST_HOST: 'true'
+ # Stripe ON, with dummy credentials, and deliberately not 'false'.
+ # hasBillingAccess() short-circuits to `true` when the flag is off and
+ # buildBillingAccessWhereInput() returns `{}`, which disarms the whole
+ # billing gate: billing-gate.spec.ts would then assert nothing. No spec
+ # walks into checkout, so nothing reaches Stripe. This also keeps the
+ # e2e job consistent with .env.test, which the api suite already runs
+ # with the flag on for the same reason.
+ OPENFRAME_ENABLE_STRIPE: 'true'
+ STRIPE_SECRET_KEY: sk_test_openframe_dummy
+ STRIPE_PRICE_ID: price_test_openframe_dummy
+ STRIPE_WEBHOOK_SECRET: whsec_test_openframe_dummy
+ OPENFRAME_REQUIRE_INVITE_CODE: 'true'
+ INVITE_CODE: test-invite
+ TRUSTED_PROXY_MODE: none
+ # Direct video uploads, pointed at the MinIO container started below.
+ # Without these the `Direct Upload` tab is not rendered and
+ # video-upload.spec.ts fails on its first assertion rather than silently
+ # testing nothing. The browser PUTs the file straight at the presigned URL,
+ # so the app and the browser have to agree on this host, and both run on
+ # the runner.
+ OPENFRAME_ENABLE_S3_VIDEO_UPLOADS: 'true'
+ OPENFRAME_ENABLE_BUNNY_UPLOADS: 'false'
+ R2_ENDPOINT: http://localhost:9000
+ R2_ACCESS_KEY_ID: openframe
+ R2_SECRET_ACCESS_KEY: openframe-test-secret
+ R2_BUCKET_NAME: openframe-test
+ steps:
+ - uses: actions/checkout@v4
+ - uses: oven-sh/setup-bun@v2
+ - run: bun install
+ - name: Create .env.test from the committed example
+ run: |
+ cp .env.test.example .env.test
+ printf '\nDATABASE_URL=%s\n' "$DATABASE_URL" >> .env.test
+ - name: Start MinIO
+ # Not a `services:` entry, because a service block cannot pass a command
+ # to the container and the MinIO entrypoint requires `server /data`.
+ # Without arguments the container prints its usage text, exits, and the
+ # job dies at "Failed to initialize container minio/minio:latest".
+ run: |
+ docker run -d --name minio -p 9000:9000 \
+ -e MINIO_ROOT_USER="$R2_ACCESS_KEY_ID" \
+ -e MINIO_ROOT_PASSWORD="$R2_SECRET_ACCESS_KEY" \
+ -e MINIO_REGION_NAME=auto \
+ minio/minio:latest server /data
+ for _ in $(seq 1 60); do
+ if curl -sf http://localhost:9000/minio/health/live >/dev/null; then
+ echo 'minio is ready'
+ exit 0
+ fi
+ sleep 1
+ done
+ echo 'minio did not become ready within 60 seconds' >&2
+ docker logs minio >&2
+ exit 1
+ - name: Create the MinIO bucket
+ # Nothing at runtime creates it: ensureR2BucketExists() lives in
+ # scripts/self-host-bootstrap.ts, not on the request path, so a missing
+ # bucket would surface as a presigned PUT returning NoSuchBucket.
+ run: |
+ curl -sSfL -o "$RUNNER_TEMP/mc" https://dl.min.io/client/mc/release/linux-amd64/mc
+ chmod +x "$RUNNER_TEMP/mc"
+ "$RUNNER_TEMP/mc" alias set ciminio "$R2_ENDPOINT" "$R2_ACCESS_KEY_ID" "$R2_SECRET_ACCESS_KEY"
+ "$RUNNER_TEMP/mc" mb --ignore-existing "ciminio/$R2_BUCKET_NAME"
+ - name: Install the Playwright browser
+ # Only chromium: playwright.config.ts runs a desktop Chromium project and
+ # a Pixel 7 project, and the mobile one is Chromium too.
+ run: bunx playwright install --with-deps chromium
+ # No `bun run test:db:bootstrap` step: tests/e2e/global-setup.ts calls the
+ # same setup function before the web server starts, and also clears the
+ # rate_limits table so a retry does not inherit a spent window.
+ - name: End-to-end tests
+ run: bun run test:e2e
+ - name: Upload the Playwright report
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-report
+ path: playwright-report/
+ if-no-files-found: ignore
+ retention-days: 7
diff --git a/.gitignore b/.gitignore
index 6fa35f5..65d6110 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,11 @@
# testing
/coverage
+/playwright-report
+/test-results
+/.playwright
+/reports
+/.stryker-tmp
# next.js
/.next/
@@ -34,6 +39,7 @@ yarn-error.log*
.env*
!.env.example
!.env.docker.example
+!.env.test.example
# vercel
.vercel
diff --git a/.husky/pre-push b/.husky/pre-push
new file mode 100644
index 0000000..78123ed
--- /dev/null
+++ b/.husky/pre-push
@@ -0,0 +1 @@
+bun run verify
diff --git a/.prettierignore b/.prettierignore
index 67f6b0d..471f71a 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -2,3 +2,8 @@
node_modules/
prisma/migrations/
bun.lock
+coverage/
+playwright-report/
+test-results/
+reports/
+.stryker-tmp/
diff --git a/AGENTS.md b/AGENTS.md
index f10d06c..b264581 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,6 +10,104 @@
## Validation before finishing
- Run `bun run check`.
+- 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.
+- If you added an end-to-end spec, run it under CI conditions too, not only locally. Hosts
+ differ between the two, and a `page.route()` glob that matches nothing passes locally and
+ tests nothing anywhere.
+
+## Testing
+
+- The testing stack, layout and conventions live in `TESTING.md`. Read it before adding a
+ test.
+- Tests live in a top-level `tests/` tree, never colocated with the code.
+- Import test globals explicitly: `import { describe, it, expect, vi } from 'vitest';`.
+- Never write a BigInt literal (`1n`) in any file. `tsconfig.json` targets ES2017, so `tsc`
+ rejects the syntax with TS2737 and `bun run check` fails. Use `BigInt(1)` instead, and
+ compare `BigInt(...)` against `BigInt(...)`.
+
+### When a change needs a test
+
+Match the change to a layer. Most changes need exactly one.
+
+| You changed | Write |
+| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
+| A pure function in `lib/` (validation, a limit, a date rule, a URL or filename check, a permission calculation) | a unit test in `tests/unit/lib/` |
+| An API route, or any authorization, quota or billing rule behind one | an integration test in `tests/api/` |
+| A new API route | classify it in `tests/api/auth-matrix.test.ts`, or the suite fails until you do |
+| Real logic in a React hook (optimistic updates, throttling, retries) | a hook test in `tests/component/hooks/` |
+| A user-visible flow across more than one page | an end-to-end spec in `tests/e2e/` |
+| Presentation only (styling, copy, layout, a `components/ui/` wrapper) | nothing |
+
+Always write a test for a bug fix, at the layer where the bug lived. The test must fail
+before the fix and pass after it. If it passes before the fix, it is testing the wrong
+thing.
+
+For an API route, three cases are the minimum: an unauthenticated caller, a caller who is
+signed in but not authorized, and the happy path. Assert the database row, not only the
+status code: a refused DELETE has to leave the row present.
+
+### Three ways a test can be worthless
+
+All three have been found in this repo, so they are worth naming.
+
+1. **A test that cannot fail.** Before you finish, name the specific mutation of the
+ production code your test would catch. If you cannot name one, delete the test. When it
+ matters, prove it: break the code on purpose, watch the test go red, then revert.
+2. **A test whose input comes from the code under test.** Iterating the same constant the
+ function looks up means deleting an entry from that constant also deletes its own test
+ case. Write expected values by hand as literals.
+
+3. **A selector, route pattern or filter that matches nothing.** This one is specific to
+ `tests/e2e/`, and it fails in the worst direction: an injected failure that never gets
+ injected leaves the feature working, and the test then waits for an error message that
+ was never going to appear. Both halves need to be asserted, so count what you
+ intercepted and assert the count, the way
+ `tests/e2e/failure-recovery.spec.ts` does with `refusedPuts`. Never hardcode a host in a
+ `page.route()` glob either: CI publishes MinIO on localhost and the compose stack calls
+ it `minio-test`, and the pattern that matched locally silently matched nothing on CI.
+
+A fourth 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
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 52fd458..e523ba1 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -29,6 +29,36 @@ bun run db:generate
bun run check
```
+## Running the tests
+
+The testing stack, layout, and conventions live in [TESTING.md](TESTING.md). Read it before adding a test, and see the "When a change needs a test" table in [AGENTS.md](AGENTS.md) for which layer your change belongs in. A bug fix always needs a test that fails before the fix.
+
+| Command | Runs | Needs the test database |
+| ------------------ | -------------------------------------------------- | ----------------------- |
+| `bun run test` | unit and component suites | no |
+| `bun run test:api` | API integration suites | yes |
+| `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
+bun run test:db:up
+bun run test:api
+bun run test:db:down
+```
+
+`scripts/test.sh ` does all of that in one command. It runs each suite inside a container, so no package manager runs on your host, and it starts the test database first when the suite needs one.
+
+```bash
+./scripts/test.sh unit
+./scripts/test.sh all
+```
+
+The `pre-push` hook runs `bun run verify`, so lint, format, typecheck, and the unit and component suites have to pass before a push leaves your machine. `bun run test:api` is deliberately not in the hook, because it needs the database container. Run it yourself when you change an API route.
+
## Contribution workflow
1. Fork and create a branch from `master`.
@@ -56,6 +86,8 @@ type(scope): short summary
## Required checks before opening a PR
- Run `bun run check`.
+- Run `bun run verify`, or let the `pre-push` hook run it for you.
+- If you changed an API route, also run `bun run test:api` with the test database up.
- If `prisma/schema.prisma` changed, run `bun run db:generate`.
- Ensure no unrelated file changes are included.
- Ensure no secrets or private keys are committed.
diff --git a/README.md b/README.md
index 3c1d141..adc1b64 100644
--- a/README.md
+++ b/README.md
@@ -208,6 +208,28 @@ bun run check
Feature flags and self-hosting environment variables are documented in `.env.example` and `.env.docker.example`.
+### Running The Tests
+
+The testing stack, layout, and conventions are documented in [TESTING.md](TESTING.md).
+
+```bash
+bun run test # unit and component suites, no database needed
+bun run test:api # API integration suites, needs the test database
+bun run test:e2e # Playwright end-to-end specs, needs the test database
+bun run verify # bun run check plus the unit and component suites
+```
+
+The API and end-to-end suites need the disposable Postgres defined in `docker-compose.test.yml`, and the end-to-end suite also needs the MinIO service in its `e2e` profile. Start Postgres with `bun run test:db:up` and stop everything with `bun run test:db:down`. Run those two suites one at a time: they share a database, and the API suite empties every table between its tests.
+
+`scripts/test.sh ` is the shortcut: it runs a suite inside a container, so no package manager runs on your host, and it starts the test database first when the suite needs one.
+
+```bash
+./scripts/test.sh unit
+./scripts/test.sh api
+```
+
+The `pre-push` Git hook runs `bun run verify` on every push. It leaves `bun run test:api` out on purpose, because that suite needs the database container.
+
## License
OpenFrame is Fair Source, licensed under the [Functional Source License](https://fsl.software/) (FSL-1.1-ALv2). The full source code is publicly available, you can self-host it, and every release automatically becomes Apache 2.0 open source two years after its publication. See [LICENCE](LICENCE) for the full terms.
diff --git a/TESTING.md b/TESTING.md
new file mode 100644
index 0000000..3cad380
--- /dev/null
+++ b/TESTING.md
@@ -0,0 +1,816 @@
+# Testing Plan
+
+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` | 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,
+the layout, the priority order, and the exact commands so that verification becomes
+`bun run test`.
+
+---
+
+## 0. Primer
+
+Short glossary, because this repo has no testing history:
+
+- **Unit test**: calls one function directly with fixed inputs and asserts the return
+ value. No database, no network, no browser. Runs in milliseconds.
+- **Integration test**: exercises several real pieces together. Here that means calling an
+ API route handler with a real request object against a real (test) Postgres, with only
+ the session faked.
+- **E2E test**: drives a real browser against a running app. Verifies what a user sees.
+- **Mock / stub**: a fake stand-in for a dependency (`auth()`, Stripe, S3).
+- **Factory**: a helper that inserts a realistic row into the test DB
+ (`createProject({ visibility: 'PUBLIC' })`).
+- **Fixture**: a fixed input file or dataset a test reads from.
+- **Flaky test**: passes and fails on the same code. Usually a timing bug in the test.
+ Flaky tests are worse than no tests; fix or delete them, never retry them away.
+- **AAA**: Arrange, Act, Assert. The shape every test in this repo should have.
+
+The rule of thumb we follow: **many unit tests, a solid layer of API integration tests,
+a handful of E2E tests, almost no component tests.** Cost per test rises and stability
+falls as you go up that list.
+
+---
+
+## 1. Stack decisions
+
+| Layer | Tool | Why |
+| ---------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Unit + API integration | **Vitest 4** | Native ESM/TS, resolves the `@/*` alias via `vite-tsconfig-paths`, first-class module mocking (`vi.mock`) which we need for `auth()`, and multi-project config so node and jsdom suites live in one runner. |
+| Component + hooks | **@testing-library/react 16** + **jsdom 29** | Standard for React 19. Gives us `renderHook`, which is what we actually want for the big hooks in `components/video-page/hooks/`. |
+| E2E | **Playwright 1.62** | Real Chromium/Firefox/WebKit, auto-waiting (kills most flakiness), trace viewer for debugging CI failures, official container image so it runs under podman. |
+| Coverage | **@vitest/coverage-v8** | Built in, no extra config. |
+
+### Rejected alternatives, and why
+
+- **`bun test`**: fast and already in the toolchain, but its jsdom/React story and Next.js
+ module-mocking story are still thinner than Vitest's. We run Vitest _with_ bun
+ (`bun run vitest`), so we keep bun as the only package manager and task runner.
+- **Jest**: needs `next/jest`, babel config and ESM workarounds. Strictly more setup for
+ strictly less speed.
+- **Mocked Prisma (`vitest-mock-extended` / `prismock`)**: rejected for API tests. The
+ bugs this repo actually produces are wrong `where` filters and missing `OR` branches
+ (see the `buildBillingAccessWhereInput` usage in `app/api/projects/route.ts`). A mocked
+ client asserts that we called Prisma, not that the query is correct, and the mock setup
+ is more code than the test. A real Postgres in a container costs seconds.
+- **MSW**: not needed yet. External calls (Stripe, Bunny, R2) are reached through thin
+ wrappers in `lib/`, so `vi.mock('@/lib/stripe')` is simpler than intercepting HTTP.
+ Revisit only if we start testing client-side fetch flows in jsdom.
+- **Cypress**: Playwright is faster, has better parallelism and better container support.
+- **Snapshot tests**: deliberately out of scope. They fail on every intentional markup
+ change and assert nothing about behaviour.
+
+### One structural constraint to be aware of
+
+There are no `use server` actions in this repo; all mutations go through
+`app/api/**/route.ts`. That is good news: route handlers are plain exported functions, so
+they can be imported and called directly in a test without a running server.
+
+Conversely, **async Server Components cannot be unit tested** with Testing Library. Every
+`page.tsx` in `app/(dashboard)` is therefore covered by E2E, not by component tests. This
+is the single biggest reason the component layer stays thin.
+
+---
+
+## 2. Directory layout
+
+```
+tests/
+ unit/ # node env, no DB, no mocks
+ lib/
+ billing.test.ts
+ project-access.test.ts
+ validation.test.ts
+ ...
+ api/ # node env, real test Postgres, auth() mocked
+ auth-matrix.test.ts # data-driven: no route returns 2xx unauthenticated
+ projects.test.ts
+ comments.test.ts
+ ...
+ component/ # jsdom env
+ hooks/
+ use-watch-progress.test.ts
+ ...
+ comment-rich-text.test.tsx
+ e2e/ # Playwright
+ auth.spec.ts
+ project-lifecycle.spec.ts
+ ...
+ factories/ # test-DB row builders
+ index.ts
+ user.ts
+ project.ts
+ video.ts
+ helpers/
+ db.ts # truncate + connect
+ request.ts # NextRequest builders, route invocation
+ session.ts # session mock control
+ setup/
+ api.ts # per-file setup for the api project
+ component.ts # jsdom polyfills + jest-dom matchers
+ db-global.ts # global setup: migrate the test DB once
+ fixtures/
+ sample.mp4 # tiny (<100KB) media for upload paths
+ sample.png
+```
+
+Rationale for a top-level `tests/` tree rather than colocated `*.test.ts`: it keeps `app/`
+free of non-route files, makes the Docker build ignore rules trivial, and lets each layer
+have its own environment without per-file pragmas.
+
+**Import style:** no globals. Every test file does
+`import { describe, it, expect, vi } from 'vitest';`. This keeps `tsconfig.json`
+untouched and keeps `bun run typecheck` covering the test files, so a broken test is a
+failed `bun run check`.
+
+**Naming:** `describe('functionName')` / `it('returns X when Y')`. No "should".
+
+---
+
+## 3. Phase 0: Foundation
+
+Goal: `bun run test` runs and reports "no tests found" instead of erroring. Nothing is
+tested yet; the wiring is done.
+
+- [x] Add dev dependencies:
+
+ ```
+ bun add -d vitest@^4.1.10 @vitest/coverage-v8@^4.1.10 \
+ @vitejs/plugin-react@^6.0.4 vite-tsconfig-paths@^6.1.1 \
+ jsdom@^29.1.1 @testing-library/react@^16.3.2 \
+ @testing-library/jest-dom@^7.0.0 @testing-library/user-event@^14.6.1
+ ```
+
+ (Playwright is added in Phase 3 so the browser download does not slow Phase 0.)
+
+- [x] `vitest.config.ts` at the repo root, using Vitest 4 `projects`:
+
+ ```ts
+ import { defineConfig } from 'vitest/config';
+ import react from '@vitejs/plugin-react';
+ import tsconfigPaths from 'vite-tsconfig-paths';
+
+ export default defineConfig({
+ plugins: [tsconfigPaths()],
+ test: {
+ projects: [
+ {
+ extends: true,
+ test: {
+ name: 'unit',
+ environment: 'node',
+ include: ['tests/unit/**/*.test.ts'],
+ },
+ },
+ {
+ extends: true,
+ test: {
+ name: 'api',
+ environment: 'node',
+ include: ['tests/api/**/*.test.ts'],
+ setupFiles: ['tests/setup/api.ts'],
+ globalSetup: ['tests/setup/db-global.ts'],
+ // One shared test database; parallel files would fight over TRUNCATE.
+ fileParallelism: false,
+ testTimeout: 20_000,
+ },
+ },
+ {
+ extends: true,
+ plugins: [tsconfigPaths(), react()],
+ test: {
+ name: 'component',
+ environment: 'jsdom',
+ include: ['tests/component/**/*.test.{ts,tsx}'],
+ setupFiles: ['tests/setup/component.ts'],
+ },
+ },
+ ],
+ },
+ });
+ ```
+
+- [x] `package.json` scripts:
+
+ ```json
+ "test": "vitest run --project unit --project component",
+ "test:watch": "vitest --project unit --project component",
+ "test:api": "vitest run --project api",
+ "test:e2e": "playwright test",
+ "test:all": "bun run test && bun run test:api && bun run test:e2e",
+ "test:coverage": "vitest run --project unit --coverage",
+ "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"
+ ```
+
+ `test` intentionally excludes the `api` project so the default command needs no
+ infrastructure and stays instant. `test:all` is the full sweep.
+
+- [x] `.prettierignore`: add `coverage/`, `playwright-report/`, `test-results/`.
+- [x] `.gitignore`: add `/playwright-report`, `/test-results`, `/.playwright`, and the
+ exception `!.env.test.example` (the existing `.env*` rule would otherwise hide it).
+- [x] `eslint.config.mjs`: append an override for `tests/**` relaxing
+ `@typescript-eslint/no-explicit-any` and any `no-restricted-imports` that fight
+ test helpers. Keep `--max-warnings=0` intact.
+- [x] `.dockerignore`: add `tests/`, `vitest.config.ts`, `playwright.config.ts` so the
+ production image does not grow.
+- [x] `AGENTS.md`: add "run `bun run verify` before finishing" alongside the existing
+ `bun run check` rule, and a line pointing at this file.
+
+**Definition of done:** `bun run test` exits 0.
+
+---
+
+## 4. Phase 1: Pure unit tests
+
+Highest value per hour of work in the whole plan. No DB, no mocks, no async. This is also
+where the authorization logic lives, which is where the repo's real bugs have been.
+
+Target: **~200 tests across 20 files**, total runtime under 2 seconds.
+
+Priority order, most valuable first:
+
+- [x] **`tests/unit/lib/project-access.test.ts`**: `computeProjectAccess()` from
+ `lib/auth.ts`. This is the heart of every permission decision in the product. Build
+ an explicit matrix: anonymous / non-member / project member / project ADMIN /
+ workspace member / workspace ADMIN / workspace OWNER / project owner, crossed with
+ `visibility` PRIVATE|PUBLIC and workspace-owner billing active|expired. Assert all
+ of `hasAccess`, `canEdit`, `canDelete`, `isWorkspaceAdmin`, `ownerBillingActive`.
+ Recent history (`fix/public-project-hides-workspace-admin-actions`) says bugs land
+ exactly here. ~24 tests.
+- [x] **`tests/unit/lib/billing.test.ts`**: `hasActiveTrial`, `hasActiveSubscription`,
+ `hasRecoverableSubscription`, `hasBillingAccess`, `getBillingAccessEndDate`,
+ `getStorageCleanupEligibleAt`, `getDefaultTrialEndsAt`, `mapStripeSubscriptionStatus`
+ (every Stripe status string), `selectAuthoritativeSubscription`,
+ `getBillingStatusLabel`. All take an injectable `now`, so no fake timers needed.
+ Also snapshot-free shape assertions on `buildBillingAccessWhereInput` and
+ `buildExpiredBillingWhereInput`. ~32 tests.
+- [x] **`tests/unit/lib/validation.test.ts`**: `validateAnnotationStrokes` (limits: 500
+ strokes, 2000 points, colour regex, stroke width bounds, prototype-pollution
+ payloads, `__proto__` keys, NaN/Infinity coords), `isValidHttpUrl`
+ (`javascript:`, `data:`, `file:`), `isSafeAppRelativePath` (traversal, wrong UUID
+ shape), `validateOptionalUrlOrAppPath`. Security boundaries that are impossible to
+ test by hand. ~24 tests.
+- [x] **`tests/unit/lib/feature-flags.test.ts`**: env-driven, so use
+ `vi.stubEnv`. Cover `readBooleanEnv` defaults and garbage values, the S3-over-Bunny
+ precedence in `isBunnyUploadsEnabled`, `isDirectFileUploadEnabled`,
+ `getMaxVideoUploadBytes` fallback on invalid/negative input, and the
+ `getR2MultipartPartSizeBytes` 5 MiB clamp. ~20 tests.
+- [x] **`tests/unit/lib/rate-limit.test.ts`**: `getClientIp` under each
+ `TRUSTED_PROXY_MODE`, spoofed `x-forwarded-for` chains, the `IP_PATTERN` reject
+ path, plus `rateLimitHeaders` and a sanity check that every entry in
+ `RATE_LIMIT_CONFIGS` has positive window and max. ~14 tests.
+- [x] **`tests/unit/lib/api-response.test.ts`**: each `apiErrors.*` helper returns the
+ right status and `code`, `successResponse` serialises `meta` and BigInt values,
+ `withCacheControl` sets the header. Guards the contract every route depends on.
+ ~14 tests.
+- [x] **`tests/unit/lib/upload-validation.test.ts`**: `lib/video-upload-validation.ts`
+ and `lib/image-upload-validation.ts`: extension/MIME allowlists, size limits,
+ filename sanitisation. ~16 tests.
+- [x] **`tests/unit/lib/share-links.test.ts`**: token generation shape, expiry logic,
+ permission comparison. ~10 tests.
+- [x] **`tests/unit/lib/guest-identity.test.ts`**: cookie parse/serialise, name
+ sanitisation, invalid payloads. ~8 tests.
+- [x] **`tests/unit/lib/content-security-policy.test.ts`**: `buildContentSecurityPolicy`
+ includes runtime storage endpoints when set, omits them when not, and never emits
+ `unsafe-eval` in production mode. ~8 tests.
+- [x] **`tests/unit/lib/video-providers.test.ts`**: provider resolution in
+ `lib/video-providers/index.ts`, YouTube ID extraction from every URL form
+ (`watch?v=`, `youtu.be`, `shorts/`, with extra params, invalid), `metadata-cache`
+ hit/miss/expiry. ~14 tests.
+- [x] **`tests/unit/lib/comment-export.test.ts`**: timecode formatting, CSV/text escaping
+ of quotes and newlines, ordering. ~10 tests.
+- [x] **`tests/unit/lib/approval-workflow.test.ts`**: status transition rules. ~8 tests.
+- [x] **`tests/unit/lib/async-pool.test.ts`**: concurrency bound is respected, results
+ keep input order, one rejection does not lose the others. ~6 tests.
+- [x] **`tests/unit/lib/json-serialize.test.ts`**: `bigIntReplacer` on nested structures,
+ `0n`, negative values. ~5 tests.
+- [x] **`tests/unit/lib/email-validation.test.ts`**: ~6 tests.
+- [x] **`tests/unit/lib/cleanup-warnings.test.ts`**: warning threshold boundaries. ~6 tests.
+- [x] **`tests/unit/lib/email-brand.test.ts`**: HTML escaping in email templates. ~5 tests.
+- [x] **`tests/unit/lib/seo.test.ts`** + `lib/marketing/metadata.ts`: canonical URLs,
+ title/description length bounds. ~6 tests.
+- [x] **`tests/unit/lib/comment-tags.test.ts`**: `DEFAULT_COMMENT_TAGS` invariants
+ (unique slugs, valid colours). ~4 tests.
+
+**Definition of done:** `bun run test` runs ~200 assertions in under 2 seconds, and
+`bun run test:coverage` reports >85% line coverage on the files listed above.
+
+---
+
+## 5. Phase 2: API integration tests
+
+Goal: for each covered route, prove that an unauthorised caller cannot reach it, that
+malformed input is rejected with 400, and that the happy path writes the right rows.
+
+### Infrastructure
+
+- [x] `docker-compose.test.yml`: Postgres only (no MinIO for now; storage is mocked at
+ the `lib/r2.ts` boundary), on port `55432` so it cannot collide with the dev stack,
+ with `tmpfs` for the data directory to keep it fast and disposable:
+ ```yaml
+ services:
+ postgres-test:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_USER: openframe
+ POSTGRES_PASSWORD: openframe
+ POSTGRES_DB: openframe_test
+ command: ['postgres', '-c', 'fsync=off', '-c', 'full_page_writes=off']
+ tmpfs:
+ - /var/lib/postgresql/data
+ healthcheck:
+ test: ['CMD-SHELL', 'pg_isready -U openframe -d openframe_test']
+ interval: 2s
+ timeout: 3s
+ retries: 30
+ ports:
+ - '127.0.0.1:55432:5432'
+ ```
+- [x] `.env.test.example` committed, `.env.test` gitignored. Minimum set:
+ `DATABASE_URL` (pointing at 55432), `NEXTAUTH_URL`, `NEXTAUTH_SECRET`,
+ `NEXT_PUBLIC_APP_URL`, `OPENFRAME_ENABLE_STRIPE=false`,
+ `OPENFRAME_REQUIRE_INVITE_CODE=true`, `INVITE_CODE=test-invite`,
+ `TRUSTED_PROXY_MODE=none`, `NODE_ENV=test`.
+- [x] `tests/setup/db-global.ts`: global setup, runs once. Loads `.env.test`, waits for
+ Postgres, runs `prisma migrate deploy` against the test DB. Migrations (not
+ `db push`) because `prisma/migrations/*/migration.sql` contains hand-written SQL
+ such as `cleanup_rate_limits()` that the routes depend on.
+- [x] `tests/setup/api.ts`: per-file setup. Loads `.env.test` **before** any `@/lib/db`
+ import, registers `afterEach(resetDb)`, and installs the `auth()` mock.
+- [x] `tests/helpers/db.ts`: `resetDb()` truncates every table except
+ `_prisma_migrations`, discovered dynamically from `information_schema.tables` so it
+ never drifts from the schema:
+ `TRUNCATE TABLE RESTART IDENTITY CASCADE`.
+- [x] `tests/helpers/session.ts`: controls the mock:
+ ```ts
+ vi.mock('@/lib/auth', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, auth: vi.fn() };
+ });
+ ```
+ plus `signedInAs(user)` / `signedOut()` wrappers. Partial mock, so the real
+ `checkProjectAccess` / `checkWorkspaceAccess` still run against the real DB. That
+ is the whole point: the authorization code under test is not the code being faked.
+- [x] `tests/helpers/request.ts`: `apiRequest(url, { method, body, headers, cookies })`
+ returning a `NextRequest`, and `callRoute(handler, request, params)` that wraps
+ params in a resolved promise, matching the `params: Promise<...>` convention from
+ `AGENTS.md`.
+- [x] `tests/factories/`: `createUser({ trialEndsAt, subscriptionStatus })`,
+ `createWorkspace({ ownerId })`, `addWorkspaceMember`, `createProject({ visibility })`,
+ `addProjectMember({ role })`, `createVideo`, `createVersion`, `createComment`,
+ `createShareLink`, `createApprovalRequest`. Unique values from a module-level
+ counter, no faker dependency.
+- [x] Module mocks for external services, in `tests/setup/api.ts`:
+ `@/lib/r2` (presign returns a fake URL), `@/lib/stripe`,
+ `@/lib/bunny-upload-token`, and `nodemailer` (assert on captured mail instead of
+ sending).
+
+### The cheap win: auth matrix
+
+- [x] `tests/api/auth-matrix.test.ts`: a table of all 60 route modules with their
+ exported methods and a sample params object. For each, assert that an
+ unauthenticated call returns 401 or 403, never 2xx. One file, one afternoon,
+ coverage across every route in the app. Routes that are legitimately public
+ (`/api/watch/[videoId]` with a share token, `/api/stripe/webhook`,
+ `/api/auth/*`) go in an explicit allowlist inside the file, so making a route public
+ becomes a visible diff.
+
+### Deep coverage, in priority order
+
+Each of these gets unauthorised / forbidden / invalid-input / happy-path cases:
+
+- [x] `tests/api/projects.test.ts`: `app/api/projects/route.ts` GET pagination guards
+ (page 0, page 1001, limit 101, offset > 10000), the billing filter (projects of an
+ expired-trial workspace owner are invisible), POST validation and default comment
+ tags; `[projectId]` GET/PATCH/DELETE against the `canEdit` / `canDelete` matrix.
+- [x] `tests/api/project-members.test.ts` covering `members/route.ts` and
+ `members/[memberId]`. A project ADMIN cannot promote itself past its scope, a VIEWER
+ cannot invite, the owner cannot be removed.
+- [x] `tests/api/comments.test.ts` covering `versions/[versionId]/comments` POST.
+ Annotation payload validation wired to `validateAnnotationStrokes`, guest identity
+ path, timecode bounds. Plus `comments/[commentId]` DELETE/PATCH, where only the
+ author or an admin may act.
+- [x] `tests/api/approvals.test.ts`: request creation, `decision` route rejecting a
+ non-candidate approver, `cancel` restricted to the requester, terminal-status
+ transitions rejected.
+- [x] `tests/api/share-links.test.ts`: creation permissions, password-protected links,
+ expiry, `SharePermission` levels honoured on read.
+- [x] `tests/api/watch.test.ts` covering `watch/[videoId]` and `progress`. Share-session
+ gate, private video without session, `upload-token` scoping.
+- [x] `tests/api/videos.test.ts`: `videos/route.ts`, `bulk-delete` (cross-project ids
+ rejected), `move` (target project permission check), `r2-init` / `r2-complete`
+ session lifecycle with `lib/r2.ts` mocked.
+- [x] `tests/api/stripe-webhook.test.ts`: invalid signature rejected, each handled event
+ type maps to the right user state via `syncStripeSubscriptionToUser`, replayed
+ events are idempotent. Stripe SDK mocked; event payloads as fixtures.
+- [x] `tests/api/register.test.ts`: invite code required/not required, duplicate email,
+ password hashing (never stored in clear), email normalisation to lowercase,
+ verification-token creation.
+- [x] `tests/api/workspaces.test.ts`: creation eligibility via
+ `getWorkspaceCreationEligibility`, member add/remove roles.
+- [x] `tests/api/storage-quota.test.ts`: `reserveStorageQuota` /
+ `releaseStorageReservation` concurrency: two parallel reservations cannot exceed
+ `PLAN_STORAGE_LIMIT_BYTES`. This exercises the advisory-lock SQL, which is exactly
+ the kind of thing that cannot be verified by clicking.
+- [x] `tests/api/rate-limit.test.ts`: the DB-backed `checkRateLimit` actually blocks
+ after N requests and the window resets.
+
+**Definition of done:** `bun run test:api` green against a fresh
+`bun run test:db:up`, total runtime under 90 seconds.
+
+_Later optimisation, not now:_ give each Vitest worker its own Postgres schema
+(`?schema=test_w${VITEST_WORKER_ID}`) and re-enable `fileParallelism`. Only worth it if
+the suite passes ~2 minutes.
+
+---
+
+## 6. Phase 3: E2E tests
+
+Goal: the UI is verified by a browser, not by hand. Keep this suite small and ruthlessly
+stable. Eight flows, not eighty.
+
+- [x] `bun add -d @playwright/test@^1.62.0`
+- [x] `playwright.config.ts`: Chromium as the default project, one Mobile Chrome project
+ for the dashboard smoke test, `retries: 2` on CI and `0` locally,
+ `trace: 'on-first-retry'`, and a `webServer` running `bun run build && bun run start`
+ with `.env.test` and `OPENFRAME_ENABLE_STRIPE=false`.
+- [x] `tests/e2e/fixtures.ts`: a seeded-user fixture using Playwright `storageState`, so
+ only the auth spec pays the cost of logging in through the form.
+- [x] Add the app + MinIO to `docker-compose.test.yml` as a separate profile, since E2E
+ needs real storage for the upload flow.
+
+Flows, in priority order:
+
+- [x] `auth.spec.ts`: register with invite code, wrong invite code rejected, login,
+ wrong password, logout, protected route redirects to `/login`.
+- [x] `onboarding.spec.ts`: a fresh user completes onboarding and lands with a workspace.
+- [x] `project-lifecycle.spec.ts`: create, rename, change visibility, delete a project;
+ the list reflects each change.
+- [x] `video-upload.spec.ts`: upload `tests/fixtures/sample.mp4` through the drag-drop
+ uploader, wait for the version to appear, add a second version.
+- [x] `comments.spec.ts`: leave a timestamped comment, verify the timecode links back to
+ the right frame, draw an annotation and confirm it persists after reload, reply and
+ resolve.
+- [x] `approvals.spec.ts`: request approval, approve as a second user in a second
+ browser context, verify both users' views.
+- [x] `share-link.spec.ts`: create a share link, open it in a fresh unauthenticated
+ context, verify the guest name gate and the permission level, verify an expired link
+ is refused.
+- [x] `billing-gate.spec.ts`: a seeded expired-trial user is pushed to `/settings` and
+ cannot open a project.
+- [x] `dashboard-mobile.spec.ts`: mobile viewport smoke test. Navigation opens, the project
+ list renders, no horizontal scroll.
+
+**Rules for this suite** (these are what keep E2E from becoming the thing everyone
+disables): locate by role and accessible name or `data-testid`, never by CSS class; never
+`waitForTimeout`; every spec creates its own data and cleans up after itself; nothing
+depends on execution order.
+
+**Definition of done:** `bun run test:e2e` green twice in a row locally and on CI.
+
+---
+
+## 7. Phase 4: Component and hook tests
+
+Deliberately last and deliberately narrow. Most components here are presentational
+wrappers over Radix or are async Server Components (untestable in jsdom, already covered
+by E2E). The real logic sits in hooks.
+
+- [x] `tests/setup/component.ts`: `@testing-library/jest-dom/vitest`, plus the jsdom
+ polyfills Radix and the video player need: `matchMedia`,
+ `Element.prototype.scrollIntoView`, `ResizeObserver`, `PointerEvent` methods,
+ `HTMLMediaElement.prototype.play/pause`, `URL.createObjectURL`.
+
+Worth testing (via `renderHook`):
+
+- [x] `components/video-page/hooks/use-watch-progress.ts`: throttling, resume position,
+ the boundary where progress counts as "watched".
+- [x] `components/video-page/hooks/use-version-duration-sync.ts`: small and pure enough
+ to pin exactly.
+- [x] `components/video-page/hooks/use-comment-export.ts`: pairs with the
+ `lib/comment-export.ts` unit tests.
+- [x] `components/video-page/hooks/use-comment-actions.ts`: 39k of logic. Test optimistic
+ insert, rollback on failed request, reply threading, resolve toggling. Highest-value
+ item in this phase.
+- [x] `components/video-page/hooks/use-video-player.ts`: 48k. Do **not** attempt full
+ coverage in jsdom. Pull the pure parts (timecode parsing/formatting, frame stepping
+ arithmetic, keyboard-shortcut mapping) into a sibling module and unit test those in
+ Phase 1 style; leave playback behaviour to E2E.
+
+Worth testing (via `render`):
+
+- [x] `components/video-page/comment-rich-text.tsx`: URL linkification and
+ `@[name](asset:id)` mention parsing, including the XSS-shaped inputs
+ (`javascript:` hrefs must not render as links).
+- [x] `components/linkify.tsx`: same regex, different component.
+- [x] `components/error-boundary.tsx`: renders the fallback and does not swallow the
+ error.
+- [x] `components/share-link-unlock.tsx` and `components/guest-gate.tsx`: small forms
+ with real validation branches.
+
+Explicitly **not** tested here: everything in `components/ui/` (upstream shadcn/Radix),
+`LandingPage.tsx`, `components/marketing/*`, `assets-pane.tsx`, `comments-pane.tsx`,
+`video-page-content.tsx`. Those are covered by E2E where they are covered at all.
+
+---
+
+## 8. Phase 5: One-click and CI
+
+- [x] `.husky/pre-push` (new hook):
+ ```sh
+ bun run verify
+ ```
+ `pre-commit` stays as-is (`lint-staged`) so committing stays fast. Push is the right
+ gate: it is where work leaves the machine.
+- [x] Rewrite `.github/workflows/ci.yml` into three jobs:
+ ```yaml
+ jobs:
+ check: # existing: lint + format + typecheck
+ test: # unit + component + api, with a postgres:16-alpine service
+ e2e: # playwright, needs: [check], uploads the report on failure
+ ```
+ `test` runs `bun run test && bun run test:api` with `DATABASE_URL` pointing at the
+ service container and `prisma migrate deploy` first. `e2e` uses
+ `mcr.microsoft.com/playwright:v1.62.0-noble` as the job container and uploads
+ `playwright-report/` via `actions/upload-artifact` when it fails.
+- [x] Add a coverage summary comment or a `coverage-summary.json` artifact. No coverage
+ _threshold_ gate initially: a hard gate on a suite this young turns into people
+ writing tests for getters. Revisit once Phase 2 is complete.
+- [x] `README.md` and `CONTRIBUTING.md`: a "Running the tests" section pointing here.
+
+### Local commands, all under podman
+
+Per the project rule, no npm package touches the host filesystem.
+
+```fish
+# Unit + component, the everyday loop
+podman run -it --rm -v "$PWD":/workspace:z -w /workspace docker.io/oven/bun:alpine \
+ sh -c "bun install && bun run test"
+
+# Watch mode while writing code
+podman run -it --rm -v "$PWD":/workspace:z -w /workspace docker.io/oven/bun:alpine \
+ sh -c "bun install && bun run test:watch"
+
+# API integration: start the test DB on the shared network first
+podman network create openframe-test # once
+podman compose -f docker-compose.test.yml up -d --wait postgres-test
+podman run -it --rm --network openframe-test -v "$PWD":/workspace:z -w /workspace \
+ docker.io/oven/bun:alpine sh -c "bun install && bun run test:api"
+
+# E2E: browsers preinstalled in the Playwright image
+podman run -it --rm --network openframe-test -v "$PWD":/workspace:z -w /workspace \
+ mcr.microsoft.com/playwright:v1.62.0-noble sh -c "bun run test:e2e"
+```
+
+- [x] Wrap these in `scripts/test.sh ` so the everyday invocation is one
+ short command instead of a memorised podman line.
+
+---
+
+## 9. Risks and spikes
+
+Each of these gets a 15-minute spike **before** the phase that depends on it. If a spike
+fails, the fallback is listed.
+
+1. **`vi.mock` partial-mocking `@/lib/auth` (Phase 2).** Importing the real module
+ initialises NextAuth v5 beta with `PrismaAdapter(db)` at module load. It should be
+ inert without a request, but beta versions surprise.
+ _Fallback:_ extract `computeProjectAccess`, `checkProjectAccess`,
+ `checkWorkspaceAccess` and `projectAccessInclude` into `lib/access.ts` (a pure
+ re-export from `lib/auth.ts` keeps every call site working). Then tests import
+ `lib/access.ts` and mock `lib/auth.ts` wholesale. This is a better structure anyway.
+
+2. **`lib/db.ts` env timing (Phase 2).** `db` is a module-level singleton that reads
+ `process.env.DATABASE_URL` at import. Setup files must load `.env.test` before the
+ first `@/lib/db` import in the module graph.
+ _Fallback:_ pass env explicitly on the command line
+ (`DATABASE_URL=... vitest run --project api`) instead of relying on a setup file.
+
+3. **`process.on('SIGINT'|'SIGTERM')` in `lib/db.ts` (Phase 2).** Every test file that
+ imports `db` adds listeners. With many files this trips Node's
+ `MaxListenersExceededWarning` and, with `--max-warnings` style strictness, noise.
+ _Fallback:_ guard the registration with `if (process.env.NODE_ENV !== 'test')`, or
+ call `process.setMaxListeners(0)` in the api setup file.
+
+4. **Radix + React 19 under jsdom 29 (Phase 4).** Radix uses pointer-capture APIs jsdom
+ does not implement.
+ _Fallback:_ the polyfill list in `tests/setup/component.ts`; and if a component still
+ resists, it moves to E2E instead. No fighting jsdom for hours.
+
+5. **Playwright `webServer` build time (Phase 3).** `next build` on a 56k-line app is not
+ fast, so a naive config rebuilds on every local run.
+ _Fallback:_ `reuseExistingServer: !process.env.CI` and a cached `.next` between runs;
+ note that per this repo's worktree recipe, a cold `.next` in a worktree causes Prisma
+ 500s, so the E2E setup must seed `.next` or run in the main checkout.
+
+6. **BigInt in assertions (Phases 1-2).** Storage sizes are `bigint`. `expect(x).toBe(1)`
+ fails against `1n`. Establish the convention early: always compare
+ `BigInt(...)` to `BigInt(...)`.
+
+---
+
+## 10. Effort and sequencing
+
+| Phase | Scope | Rough effort | Value |
+| ----------------- | ------------------------------------ | -------------- | ------------- |
+| 0 Foundation | config, scripts, lint/ignore wiring | half a session | enabling |
+| 1 Unit | ~200 tests, 20 files | 1-2 sessions | **very high** |
+| 2 API | infra + auth matrix + 12 deep suites | 3-4 sessions | **very high** |
+| 3 E2E | 9 specs + compose profile | 2-3 sessions | high |
+| 4 Component/hooks | ~10 targets | 1-2 sessions | medium |
+| 5 CI + hooks | 3 jobs, pre-push, docs | half a session | high |
+
+Recommended order of delivery: **0 → 1 → 5 (partial: pre-push + `test` job) → 2 → 3 → 4**.
+Wiring CI right after Phase 1 means the tests start protecting `master` while they are
+still cheap, instead of waiting for the whole pyramid.
+
+---
+
+## 11. Non-goals
+
+Written down so they do not get relitigated:
+
+- No snapshot tests.
+- No tests for `components/ui/*` (upstream shadcn/Radix).
+- No mocked Prisma client.
+- No 100% coverage target. Coverage is a diagnostic, not a goal.
+- No test for a getter, a re-export, or a constant.
+- No visual regression testing (Percy/Chromatic) at this stage.
+- No load or performance testing at this stage.
+
+---
+
+## 12. Where the plan was wrong
+
+Corrections found while implementing it. Recorded so the sections above are read with them
+in mind, and so nobody "fixes" a deliberate deviation back.
+
+**Infrastructure**
+
+- `prisma migrate deploy` **cannot build this database**, which invalidates Section 5's
+ instruction. `prisma/migrations` is a stack of patches on top of a baseline that was
+ never captured, so the second migration runs `ALTER TYPE "VideoAssetKind" ADD VALUE`
+ against a type nothing in the history creates, and dies with P3018 / 42704 on an empty
+ database. The test schema therefore comes from `prisma db push` plus a replay of the
+ hand-written SQL that `schema.prisma` cannot express (`cleanup_rate_limits()`, the
+ `UNLOGGED` rate-limit table, three partial unique indexes on `video_versions`). This is
+ the same approach `scripts/docker-db-bootstrap.ts` already takes in production.
+ `tests/setup/db-global.ts` documents it in full and carries a drift guard that fails the
+ run when a migration is added without review.
+- **Coverage does not work under bun.** `@vitest/coverage-v8` needs the V8 inspector API,
+ which bun does not implement, so `bun run test:coverage` reports zeros. The suites pass
+ under both runtimes, so CI runs the coverage step under node instead. Getting the unit
+ project to run under node needed `server.deps.inline: [/next-auth/]`, because
+ `next-auth/lib/env.js` imports the extensionless `next/server`, which node's ESM
+ resolver cannot resolve and bun can.
+- **`@playwright/test` is pinned to 1.61.1, not the newest release.** The container image
+ is what fixes the ceiling: `mcr.microsoft.com/playwright:v1.62.0-noble` is not published,
+ and Playwright refuses a browser build that does not match the package. Bump the package
+ and the image tag together, and check the tag exists first.
+- **The Playwright image ships no bun**, and `oven-sh/setup-bun` cannot run inside it
+ either, because the image has no `unzip`. Both the CI job and `scripts/test.sh` install
+ bun with `npm install --global bun` first.
+- **eslint keeps its own ignore list**, so a coverage run used to break `bun run lint` on
+ the reporter's vendored JS. `coverage/**`, `playwright-report/**` and `test-results/**`
+ are now in `globalIgnores`.
+- **`tsconfig.json` targets ES2017, so `1n` is a compile error** (TS2737) even though the
+ BigInt type resolves. Always `BigInt(1)`. Section 9 framed this as a runtime assertion
+ mismatch and understated it.
+- **Testing Library's auto-cleanup never installed.** It only registers its own
+ `afterEach(cleanup)` when a global `afterEach` is visible, and Section 2 mandates
+ explicit imports. Components stayed mounted for the rest of each file, with their
+ intervals and listeners live, which produced real cross-test contamination.
+ `tests/setup/component.ts` now calls `cleanup()` itself.
+- **Risk #1 did not materialize.** `@/lib/auth` imports cleanly in a node test environment,
+ so the `lib/access.ts` extraction was not needed and was not done. Risk #3 was real but
+ `process.setMaxListeners(0)` in the api setup was enough, so `lib/db.ts` stays untouched.
+
+**Test environment**
+
+- **`OPENFRAME_ENABLE_STRIPE` must be `true`** in the test environment, not `false` as
+ Sections 5 and 6 suggested. With the flag off, `hasBillingAccess()` short-circuits to
+ `true` and `buildBillingAccessWhereInput()` returns `{}`, which disarms the entire
+ billing gate and makes every access-control assertion meaningless. Dummy Stripe keys are
+ used and no test walks into checkout.
+- `DISABLE_RATE_LIMIT=true` for the api suite, because every request in it shares one
+ client IP and one file exhausting a window would make the next file's 429 look like a
+ passing authorization check. `rate-limit.test.ts` re-enables it per test.
+- `vi.stubEnv` does **not** auto-restore. `tests/setup/api.ts` calls `vi.unstubAllEnvs()`
+ in `afterEach` centrally, after four tests were caught passing for the wrong reason.
+- The E2E suite deliberately does not truncate between tests: several Playwright workers
+ drive one app against one database, so each test seeds uniquely tagged rows and deletes
+ its own users, letting the schema cascade do the rest.
+
+**Assignments in the plan that did not match the code**
+
+- `lib/share-links.ts` generates no tokens; it exports `validateShareLinkAccess`.
+- `lib/approval-workflow.ts` has no status transition rules; it exports
+ `getApprovalCandidatesForProject`.
+- `runWithConcurrency` returns `Promise`, so "results keep input order" is not a
+ property it can have.
+- `lib/rate-limit.ts`'s real off-by-one lives in `checkRateLimit`, which Section 4 omitted.
+- `use-video-player.ts` contains no timecode parser or formatter. `formatTime` is injected
+ as a parameter and is duplicated in four components; hoisting it into `lib/` is a
+ separate change. The extraction covered frame and playhead arithmetic instead.
+- Section 5 lists `/api/watch/[videoId]` as public. It is not: it 403s anonymously on a
+ private project and needs a share-session cookie.
+- Section 8's `.dockerignore` item does not achieve its stated goal. Image size comes from
+ a non-production `bun install` whose `node_modules` is copied wholesale into the runner
+ 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 a9ab07b..7ee37c6 100644
--- a/bun.lock
+++ b/bun.lock
@@ -37,22 +37,33 @@
"devDependencies": {
"@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",
+ "@testing-library/user-event": "^14.6.1",
"@types/bcryptjs": "^3.0.0",
"@types/node": "^20",
"@types/nodemailer": "^7.0.9",
"@types/pg": "^8.16.0",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "@vitejs/plugin-react": "^6.0.4",
+ "@vitest/coverage-v8": "^4.1.10",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"eslint-config-prettier": "^10.1.5",
"husky": "^9.1.7",
+ "jsdom": "^29.1.1",
"lint-staged": "^15.5.1",
"prettier": "^3.5.3",
"shadcn": "^3.8.3",
"tailwindcss": "^4",
"typescript": "^5",
+ "vite-tsconfig-paths": "^6.1.1",
+ "vitest": "^4.1.10",
},
},
},
@@ -64,10 +75,20 @@
"sharp": "^0.35.3",
},
"packages": {
+ "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
+
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@antfu/ni": ["@antfu/ni@25.0.0", "", { "dependencies": { "ansis": "^4.0.0", "fzf": "^0.5.2", "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" }, "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA=="],
+ "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="],
+
+ "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="],
+
+ "@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="],
+
+ "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
+
"@auth/core": ["@auth/core@0.41.1", "", { "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": "^7.0.7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw=="],
"@auth/prisma-adapter": ["@auth/prisma-adapter@2.11.1", "", { "dependencies": { "@auth/core": "0.41.1" }, "peerDependencies": { "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5 || >=6" } }, "sha512-Ke7DXP0Fy0Mlmjz/ZJLXwQash2UkA4621xCM0rMtEczr1kppLc/njCbUkHkIQ/PnmILjqSPEKeTjDPsYruvkug=="],
@@ -176,22 +197,36 @@
"@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=="],
"@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="],
+ "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
+
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
+ "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
+
+ "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
+
"@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@10.5.0", "", { "dependencies": { "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw=="],
"@chevrotain/gast": ["@chevrotain/gast@10.5.0", "", { "dependencies": { "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A=="],
@@ -234,6 +269,18 @@
"@commitlint/types": ["@commitlint/types@19.8.1", "", { "dependencies": { "@types/conventional-commits-parser": "^5.0.0", "chalk": "^5.3.0" } }, "sha512-/yCrWGCoA1SVKOks25EGadP9Pnj0oAIHGpl2wH2M2Y46dPM2ueb8wyCVOD7O3WCTkaJ0IkKvzhl1JY7+uCT2Dw=="],
+ "@csstools/color-helpers": ["@csstools/color-helpers@6.1.0", "", {}, "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg=="],
+
+ "@csstools/css-calc": ["@csstools/css-calc@3.3.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ=="],
+
+ "@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.10", "", { "dependencies": { "@csstools/color-helpers": "^6.1.0", "@csstools/css-calc": "^3.3.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw=="],
+
+ "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
+
+ "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.7", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig=="],
+
+ "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
+
"@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.52.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w=="],
"@ecies/ciphers": ["@ecies/ciphers@0.2.5", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="],
@@ -244,11 +291,11 @@
"@electric-sql/pglite-tools": ["@electric-sql/pglite-tools@0.2.20", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.15" } }, "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A=="],
- "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
+ "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
- "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
+ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
@@ -268,6 +315,8 @@
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
+ "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="],
+
"@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="],
@@ -340,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=="],
@@ -370,7 +441,7 @@
"@mswjs/interceptors": ["@mswjs/interceptors@0.41.0", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-edAo9bW53BLYeSK+UPRr2Iz1Fj9DeGMjytvVM0HXRoo750ElWUgPsZPAOTQa12EUiwgDErH2PsFNTLvk1jBxjQ=="],
- "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
+ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
"@next/env": ["@next/env@16.2.11", "", {}, "sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA=="],
@@ -414,8 +485,12 @@
"@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="],
+ "@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="],
+
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
+ "@playwright/test": ["@playwright/test@1.61.1", "", { "dependencies": { "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" } }, "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig=="],
+
"@prisma/adapter-pg": ["@prisma/adapter-pg@7.3.0", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.3.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-iuYQMbIPO6i9O45Fv8TB7vWu00BXhCaNAShenqF7gLExGDbnGp5BfFB4yz1K59zQ59jF6tQ9YHrg0P6/J3OoLg=="],
"@prisma/client": ["@prisma/client@7.3.0", "", { "dependencies": { "@prisma/client-runtime-utils": "7.3.0" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-FXBIxirqQfdC6b6HnNgxGmU7ydCPEPk7maHMOduJJfnTP+MuOGa15X4omjR/zpPUUpm8ef/mEFQjJudOGkXFcQ=="],
@@ -562,6 +637,38 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
+ "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="],
+
+ "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="],
+
+ "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="],
+
+ "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="],
+
+ "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="],
+
+ "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="],
+
+ "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="],
+
+ "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="],
+
+ "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="],
+
+ "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="],
+
+ "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="],
+
+ "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="],
+
+ "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="],
+
+ "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="],
+
+ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="],
+
+ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
+
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
@@ -588,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=="],
@@ -620,14 +737,28 @@
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="],
+ "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
+
+ "@testing-library/jest-dom": ["@testing-library/jest-dom@7.0.0", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" }, "peerDependencies": { "@testing-library/dom": ">=10 <11" } }, "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg=="],
+
+ "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
+
+ "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="],
+
"@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
- "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
+ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
+
+ "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
"@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
+ "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
+
"@types/conventional-commits-parser": ["@types/conventional-commits-parser@5.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g=="],
+ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
+
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
@@ -706,6 +837,24 @@
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
+ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.4", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg=="],
+
+ "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.10", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.10", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.10", "vitest": "4.1.10" }, "optionalPeers": ["@vitest/browser"] }, "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g=="],
+
+ "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="],
+
+ "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="],
+
+ "@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="],
+
+ "@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="],
+
+ "@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="],
+
+ "@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
+
+ "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="],
+
"JSONStream": ["JSONStream@1.3.5", "", { "dependencies": { "jsonparse": "^1.2.0", "through": ">=2.2.7 <3" }, "bin": { "JSONStream": "./bin.js" } }, "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
@@ -716,13 +865,15 @@
"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@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
+ "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
@@ -752,10 +903,14 @@
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
+ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
+
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
"ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="],
+ "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA=="],
+
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
@@ -766,17 +921,19 @@
"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=="],
"bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
+ "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
+
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"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=="],
@@ -800,7 +957,11 @@
"caniuse-lite": ["caniuse-lite@1.0.30001768", "", {}, "sha512-qY3aDRZC5nWPgHUgIB84WL+nySuo19wk0VJpp/XI9T34lrvkyhRvNVOFJOp2kxClQhiFBu+TaUSudf6oa3vkSA=="],
- "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
+ "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
+
+ "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=="],
@@ -834,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=="],
@@ -868,6 +1029,10 @@
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
+ "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
+
+ "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
+
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
@@ -880,6 +1045,8 @@
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
+ "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
+
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
"data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="],
@@ -890,6 +1057,8 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+ "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
+
"dedent": ["dedent@1.7.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
@@ -914,6 +1083,10 @@
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
+ "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=="],
@@ -922,8 +1095,12 @@
"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=="],
+
"dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="],
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
@@ -938,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=="],
@@ -946,6 +1123,8 @@
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
+ "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
+
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
@@ -960,6 +1139,8 @@
"es-iterator-helpers": ["es-iterator-helpers@1.2.2", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.1", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "safe-array-concat": "^1.1.3" } }, "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w=="],
+ "es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
+
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
@@ -1008,6 +1189,8 @@
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
+ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
+
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
@@ -1018,7 +1201,9 @@
"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=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
@@ -1036,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=="],
@@ -1074,6 +1265,8 @@
"fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="],
+ "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
+
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="],
@@ -1104,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=="],
@@ -1122,6 +1315,8 @@
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
+ "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="],
+
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
@@ -1158,13 +1353,17 @@
"hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
+ "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
+
+ "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
+
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="],
"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=="],
@@ -1178,6 +1377,8 @@
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
+ "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
+
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="],
@@ -1240,6 +1441,8 @@
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
+ "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
+
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
@@ -1276,6 +1479,12 @@
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+ "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
+
+ "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
+
+ "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
+
"iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
@@ -1284,17 +1493,23 @@
"js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="],
- "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
+ "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=="],
+ "jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="],
+
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"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=="],
@@ -1368,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=="],
@@ -1396,16 +1613,24 @@
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
- "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
+ "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
"lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="],
"lucide-react": ["lucide-react@0.563.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="],
+ "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
+
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+ "magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="],
+
+ "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
+
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
+ "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
+
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"meow": ["meow@12.1.1", "", {}, "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw=="],
@@ -1426,7 +1651,11 @@
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
- "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
+ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
+
+ "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=="],
@@ -1434,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=="],
@@ -1464,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=="],
@@ -1488,6 +1725,8 @@
"object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="],
+ "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="],
+
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
@@ -1518,6 +1757,8 @@
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
+ "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
+
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
@@ -1526,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=="],
@@ -1554,7 +1795,7 @@
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
- "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="],
@@ -1562,6 +1803,10 @@
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
+ "playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="],
+
+ "playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="],
+
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
@@ -1588,10 +1833,14 @@
"prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
+ "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
+
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
"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=="],
@@ -1604,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=="],
@@ -1622,7 +1871,7 @@
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
- "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
+ "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
@@ -1636,6 +1885,8 @@
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
+ "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
+
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
"regexp-to-ast": ["regexp-to-ast@0.5.0", "", {}, "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw=="],
@@ -1666,12 +1917,16 @@
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
+ "rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="],
+
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
"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=="],
@@ -1680,6 +1935,8 @@
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
+ "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
+
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
@@ -1714,6 +1971,8 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
+ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
+
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
@@ -1722,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=="],
@@ -1732,9 +1991,11 @@
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
+ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
+
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
- "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
+ "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
"stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
@@ -1764,7 +2025,9 @@
"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=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
@@ -1778,6 +2041,8 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
+ "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
+
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
@@ -1792,10 +2057,14 @@
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
+ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
+
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+ "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
+
"tldts": ["tldts@7.0.22", "", { "dependencies": { "tldts-core": "^7.0.22" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-nqpKFC53CgopKPjT6Wfb6tpIcZXHcI6G37hesvikhx0EmUGPkZrujRyAjgnmp1SHNgpQfKVanZ+KfpANFt2Hxw=="],
"tldts-core": ["tldts-core@7.0.22", "", {}, "sha512-KgbTDC5wzlL6j/x6np6wCnDSMUq4kucHNm00KXPbfNzmllCmtmvtykJHfmgdHntwIeupW04y8s1N/43S1PkQDw=="],
@@ -1804,16 +2073,24 @@
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
- "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="],
+ "tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="],
+
+ "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=="],
+ "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
+
"tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="],
"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=="],
@@ -1832,12 +2109,20 @@
"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=="],
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
@@ -1870,8 +2155,24 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
+ "vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="],
+
+ "vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="],
+
+ "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="],
+
+ "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=="],
+
+ "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
+
+ "whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
+
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
@@ -1882,6 +2183,8 @@
"which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
+ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
+
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
@@ -1890,8 +2193,12 @@
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
+ "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
+
"xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="],
+ "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
+
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
@@ -1930,37 +2237,49 @@
"@aws-crypto/util/@aws-sdk/types": ["@aws-sdk/types@3.973.1", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg=="],
+ "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
+
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+ "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
+
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@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=="],
- "@dotenvx/dotenvx/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
-
"@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
"@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=="],
@@ -1968,18 +2287,24 @@
"@mrleebo/prisma-ast/lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
- "@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
-
"@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
"@prisma/dev/hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="],
+ "@prisma/dev/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
+
"@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.3.0", "", { "dependencies": { "@prisma/debug": "7.3.0" } }, "sha512-N7c6m4/I0Q6JYmWKP2RCD/sM9eWiyCPY98g5c0uEktObNSZnugW2U/PO+pwL0UaqzxqTXt7gTsYsb0FnMnJNbg=="],
"@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.3.0", "", { "dependencies": { "@prisma/debug": "7.3.0" } }, "sha512-N7c6m4/I0Q6JYmWKP2RCD/sM9eWiyCPY98g5c0uEktObNSZnugW2U/PO+pwL0UaqzxqTXt7gTsYsb0FnMnJNbg=="],
"@prisma/get-platform/@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="],
+ "@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=="],
@@ -1992,6 +2317,10 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+ "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
+
+ "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
+
"@ts-morph/common/minimatch": ["minimatch@10.1.2", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.1" } }, "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
@@ -2000,8 +2329,12 @@
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
+ "@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
+
"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=="],
@@ -2010,70 +2343,98 @@
"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=="],
- "fdir/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+ "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=="],
"log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
+ "loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
+
+ "magicast/@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=="],
+
+ "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=="],
+ "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
+
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
+ "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
+
"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=="],
@@ -2082,7 +2443,17 @@
"string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
- "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+ "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
+
+ "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
+
+ "vite/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
+
+ "vite/postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
+
+ "vite/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
@@ -2100,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=="],
@@ -2122,52 +2517,174 @@
"@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=="],
- "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
+ "@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=="],
- "cli-truncate/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
+ "@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
- "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+ "@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=="],
+
+ "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=="],
- "ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
+ "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=="],
- "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=="],
+ "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/human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
+ "msw/@inquirer/confirm/@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="],
- "shadcn/execa/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
+ "vite/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
- "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=="],
+ "vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
- "shadcn/execa/strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
+ "vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
- "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+ "vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
- "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
+ "vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
+
+ "vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
+
+ "vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
+
+ "vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
+
+ "vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
+
+ "vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
+
+ "vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
+
+ "vite/postcss/nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
+
+ "@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=="],
- "@inquirer/core/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+ "@eslint/config-array/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
- "shadcn/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
+ "@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=="],
+
+ "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/components/video-page/hooks/use-video-player.ts b/components/video-page/hooks/use-video-player.ts
index 0128d7a..8248017 100644
--- a/components/video-page/hooks/use-video-player.ts
+++ b/components/video-page/hooks/use-video-player.ts
@@ -20,17 +20,19 @@ import type {
Version,
} from '@/components/video-page/types';
import { validateAnnotationStrokes } from '@/lib/validation';
-
-// A frame number is only meaningful against a stable rate: a raw measurement
-// drifts (29.94, 30.07, ...) and would slide the count by whole frames late in a
-// long video. Snap to the nearest broadcast standard when we are close enough.
-const STANDARD_FRAME_RATES = [23.976, 24, 25, 29.97, 30, 48, 50, 59.94, 60, 120];
-
-function normalizeFrameRate(rate: number | undefined): number | null {
- if (typeof rate !== 'number' || !Number.isFinite(rate) || rate < 12 || rate > 120) return null;
- const standard = STANDARD_FRAME_RATES.find((value) => Math.abs(rate - value) / value < 0.015);
- return standard ?? rate;
-}
+import {
+ clampSeekTime,
+ getAdjacentPlaybackSpeed,
+ getFrameIndexAtTime,
+ getFrameStepLabel,
+ getFrameStepSeconds,
+ getPlayheadPercent,
+ isTypingTarget,
+ normalizeFrameRate,
+ resolvePlayerShortcut,
+ resolveSkipAmount as resolveSkipAmountFor,
+ timeFromClientX as timeFromClientXWithin,
+} from '@/components/video-page/hooks/video-player-utils';
interface UseVideoPlayerParams {
activeVersion: Version | undefined;
@@ -133,19 +135,12 @@ export function useVideoPlayer({
};
}, []);
- const frameStepSeconds = useMemo(() => {
- if (estimatedFrameRate && Number.isFinite(estimatedFrameRate) && estimatedFrameRate > 0) {
- return 1 / estimatedFrameRate;
- }
- return 1;
- }, [estimatedFrameRate]);
+ const frameStepSeconds = useMemo(
+ () => getFrameStepSeconds(estimatedFrameRate),
+ [estimatedFrameRate]
+ );
- const frameStepLabel = useMemo(() => {
- if (estimatedFrameRate && Number.isFinite(estimatedFrameRate) && estimatedFrameRate > 0) {
- return '1f';
- }
- return '1s';
- }, [estimatedFrameRate]);
+ const frameStepLabel = useMemo(() => getFrameStepLabel(estimatedFrameRate), [estimatedFrameRate]);
const stopBunnyFrameTracking = useCallback(() => {
const videoEl = videoRef.current;
@@ -950,7 +945,7 @@ export function useVideoPlayer({
const applyPlayhead = useCallback(
(time: number) => {
const d = durationRef.current;
- const percent = d > 0 ? Math.max(0, Math.min(100, (time / d) * 100)) : 0;
+ const percent = getPlayheadPercent(time, d);
if (progressRef.current) progressRef.current.style.width = `${percent}%`;
if (playheadRef.current) playheadRef.current.style.left = `calc(${percent}% - 2px)`;
@@ -963,11 +958,7 @@ export function useVideoPlayer({
if (rate === null) {
readoutEl.textContent = formatTime(time);
} else {
- // Frame N covers [N/rate, (N+1)/rate); the epsilon keeps a time that
- // lands exactly on a boundary from floating-point-ing down to N-1.
- const lastFrame = d > 0 ? Math.max(0, Math.ceil(d * rate) - 1) : 0;
- const frame = Math.min(Math.floor(time * rate + 1e-6), lastFrame);
- readoutEl.textContent = `${formatTime(time)} · f${frame}`;
+ readoutEl.textContent = `${formatTime(time)} · f${getFrameIndexAtTime(time, rate, d)}`;
}
}
},
@@ -1043,11 +1034,7 @@ export function useVideoPlayer({
}, [currentTime, isPlaying, isDragging, applyPlayhead]);
const resolveSkipAmount = useCallback(
- (seconds: number) => {
- if (!isFrameMode) return seconds;
- const direction = seconds === 0 ? 1 : Math.sign(seconds);
- return frameStepSeconds * direction;
- },
+ (seconds: number) => resolveSkipAmountFor(seconds, { isFrameMode, frameStepSeconds }),
[frameStepSeconds, isFrameMode]
);
@@ -1118,7 +1105,7 @@ export function useVideoPlayer({
const handleSkip = useCallback(
(seconds: number) => {
- const newTime = Math.max(0, Math.min(duration, currentTime + resolveSkipAmount(seconds)));
+ const newTime = clampSeekTime(currentTime + resolveSkipAmount(seconds), duration);
handleSeekToTimestamp(newTime);
flashSeekReadout();
},
@@ -1131,15 +1118,23 @@ export function useVideoPlayer({
return;
}
- const target = e.target as HTMLElement;
- if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
+ if (isTypingTarget(e.target as HTMLElement)) {
return;
}
- switch (e.code) {
- case 'Space':
- case 'KeyK':
- e.preventDefault();
+ const shortcut = resolvePlayerShortcut(e);
+ if (shortcut === null) return;
+ e.preventDefault();
+
+ const stepPlaybackSpeed = (direction: 1 | -1) => {
+ const newSpeed = getAdjacentPlaybackSpeed(speedOptions, playbackSpeed, direction);
+ if (newSpeed === null) return;
+ setPlaybackSpeed(newSpeed);
+ playerRef.current?.setPlaybackRate(newSpeed);
+ };
+
+ switch (shortcut) {
+ case 'toggle-play':
if (playerRef.current) {
if (isPlaying) {
playerRef.current.pauseVideo();
@@ -1148,60 +1143,19 @@ export function useVideoPlayer({
}
}
break;
- case 'ArrowLeft':
- e.preventDefault();
+ case 'skip-back':
handleSkip(-5);
break;
- case 'ArrowRight':
- e.preventDefault();
+ case 'skip-forward':
handleSkip(5);
break;
- case 'ArrowUp':
- e.preventDefault();
- {
- const currentIndex = speedOptions.indexOf(playbackSpeed);
- if (currentIndex < speedOptions.length - 1) {
- const newSpeed = speedOptions[currentIndex + 1];
- setPlaybackSpeed(newSpeed);
- playerRef.current?.setPlaybackRate(newSpeed);
- }
- }
+ case 'speed-up':
+ stepPlaybackSpeed(1);
break;
- case 'ArrowDown':
- e.preventDefault();
- {
- const currentIndex = speedOptions.indexOf(playbackSpeed);
- if (currentIndex > 0) {
- const newSpeed = speedOptions[currentIndex - 1];
- setPlaybackSpeed(newSpeed);
- playerRef.current?.setPlaybackRate(newSpeed);
- }
- }
+ case 'speed-down':
+ stepPlaybackSpeed(-1);
break;
- case 'Comma':
- if (e.shiftKey) {
- e.preventDefault();
- const currentIndex = speedOptions.indexOf(playbackSpeed);
- if (currentIndex > 0) {
- const newSpeed = speedOptions[currentIndex - 1];
- setPlaybackSpeed(newSpeed);
- playerRef.current?.setPlaybackRate(newSpeed);
- }
- }
- break;
- case 'Period':
- if (e.shiftKey) {
- e.preventDefault();
- const currentIndex = speedOptions.indexOf(playbackSpeed);
- if (currentIndex < speedOptions.length - 1) {
- const newSpeed = speedOptions[currentIndex + 1];
- setPlaybackSpeed(newSpeed);
- playerRef.current?.setPlaybackRate(newSpeed);
- }
- }
- break;
- case 'KeyM':
- e.preventDefault();
+ case 'toggle-mute':
if (playerRef.current) {
if (isMuted) {
playerRef.current.unMute();
@@ -1211,8 +1165,7 @@ export function useVideoPlayer({
setIsMuted(!isMuted);
}
break;
- case 'KeyJ':
- e.preventDefault();
+ case 'jump-back':
if (playerRef.current?.seekTo) {
const newTime = Math.max(0, currentTime - 10);
playerRef.current.seekTo(newTime, true);
@@ -1220,8 +1173,7 @@ export function useVideoPlayer({
flashSeekReadout();
}
break;
- case 'KeyL':
- e.preventDefault();
+ case 'jump-forward':
if (playerRef.current?.seekTo) {
const newTime = Math.min(duration, currentTime + 10);
playerRef.current.seekTo(newTime, true);
@@ -1229,8 +1181,7 @@ export function useVideoPlayer({
flashSeekReadout();
}
break;
- case 'KeyF':
- e.preventDefault();
+ case 'toggle-fullscreen':
toggleFullscreen();
break;
}
@@ -1308,10 +1259,7 @@ export function useVideoPlayer({
// Convert a clientX into a time using the timeline rect captured at drag start
// (avoids a layout read on every move).
const timeFromClientX = useCallback((clientX: number) => {
- const rect = dragRectRef.current;
- if (!rect || rect.width === 0) return 0;
- const percentage = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
- return percentage * durationRef.current;
+ return timeFromClientXWithin(clientX, dragRectRef.current, durationRef.current);
}, []);
const handleTimelineMouseDown = useCallback(
diff --git a/components/video-page/hooks/video-player-utils.ts b/components/video-page/hooks/video-player-utils.ts
new file mode 100644
index 0000000..608dd26
--- /dev/null
+++ b/components/video-page/hooks/video-player-utils.ts
@@ -0,0 +1,156 @@
+/**
+ * Pure helpers extracted from `use-video-player.ts`.
+ *
+ * The hook itself is ~1400 lines of hls.js wiring, iframe messaging and
+ * requestAnimationFrame loops that jsdom cannot run. The arithmetic below is the
+ * part that is actually worth pinning down with tests, so it lives here where it
+ * can be called directly with fixed inputs. Nothing in this module touches
+ * React, the DOM or any player SDK.
+ */
+
+// A frame number is only meaningful against a stable rate: a raw measurement
+// drifts (29.94, 30.07, ...) and would slide the count by whole frames late in a
+// long video. Snap to the nearest broadcast standard when we are close enough.
+const STANDARD_FRAME_RATES = [23.976, 24, 25, 29.97, 30, 48, 50, 59.94, 60, 120];
+
+export function normalizeFrameRate(rate: number | undefined): number | null {
+ if (typeof rate !== 'number' || !Number.isFinite(rate) || rate < 12 || rate > 120) return null;
+ const standard = STANDARD_FRAME_RATES.find((value) => Math.abs(rate - value) / value < 0.015);
+ return standard ?? rate;
+}
+
+/**
+ * How far a single frame-mode step moves the playhead. Falls back to one second
+ * when no frame rate has been measured yet, which is also what the label says.
+ */
+export function getFrameStepSeconds(estimatedFrameRate: number | null): number {
+ if (estimatedFrameRate && Number.isFinite(estimatedFrameRate) && estimatedFrameRate > 0) {
+ return 1 / estimatedFrameRate;
+ }
+ return 1;
+}
+
+export function getFrameStepLabel(estimatedFrameRate: number | null): string {
+ if (estimatedFrameRate && Number.isFinite(estimatedFrameRate) && estimatedFrameRate > 0) {
+ return '1f';
+ }
+ return '1s';
+}
+
+/**
+ * In frame mode every skip collapses to exactly one frame, keeping only the
+ * direction of the requested jump. A zero-second request counts as forward.
+ */
+export function resolveSkipAmount(
+ seconds: number,
+ options: { isFrameMode: boolean; frameStepSeconds: number }
+): number {
+ if (!options.isFrameMode) return seconds;
+ const direction = seconds === 0 ? 1 : Math.sign(seconds);
+ return options.frameStepSeconds * direction;
+}
+
+export function clampSeekTime(time: number, duration: number): number {
+ return Math.max(0, Math.min(duration, time));
+}
+
+/** Timeline fill / playhead offset, as a percentage clamped to [0, 100]. */
+export function getPlayheadPercent(time: number, duration: number): number {
+ return duration > 0 ? Math.max(0, Math.min(100, (time / duration) * 100)) : 0;
+}
+
+/**
+ * Frame N covers [N/rate, (N+1)/rate); the epsilon keeps a time that lands
+ * exactly on a boundary from floating-point-ing down to N-1. The result never
+ * exceeds the last frame the duration can hold.
+ */
+export function getFrameIndexAtTime(time: number, frameRate: number, duration: number): number {
+ const lastFrame = duration > 0 ? Math.max(0, Math.ceil(duration * frameRate) - 1) : 0;
+ return Math.min(Math.floor(time * frameRate + 1e-6), lastFrame);
+}
+
+/** Convert a pointer x-coordinate into a time, using a captured timeline rect. */
+export function timeFromClientX(
+ clientX: number,
+ rect: { left: number; width: number } | null,
+ duration: number
+): number {
+ if (!rect || rect.width === 0) return 0;
+ const percentage = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
+ return percentage * duration;
+}
+
+/**
+ * Next or previous entry in the speed ladder, or `null` at either end. An
+ * unknown current speed behaves like index -1, so stepping up lands on the
+ * slowest option and stepping down does nothing.
+ */
+export function getAdjacentPlaybackSpeed(
+ speedOptions: number[],
+ currentSpeed: number,
+ direction: 1 | -1
+): number | null {
+ const currentIndex = speedOptions.indexOf(currentSpeed);
+ if (direction === 1) {
+ if (currentIndex >= speedOptions.length - 1) return null;
+ return speedOptions[currentIndex + 1];
+ }
+ if (currentIndex <= 0) return null;
+ return speedOptions[currentIndex - 1];
+}
+
+export type PlayerShortcut =
+ | 'toggle-play'
+ | 'skip-back'
+ | 'skip-forward'
+ | 'speed-up'
+ | 'speed-down'
+ | 'toggle-mute'
+ | 'jump-back'
+ | 'jump-forward'
+ | 'toggle-fullscreen';
+
+/**
+ * Map a physical key to a player action. `null` means "not a player shortcut",
+ * and the caller must then leave the event alone (no `preventDefault`), so that
+ * an unshifted comma still types a comma.
+ */
+export function resolvePlayerShortcut(event: {
+ code: string;
+ shiftKey?: boolean;
+}): PlayerShortcut | null {
+ switch (event.code) {
+ case 'Space':
+ case 'KeyK':
+ return 'toggle-play';
+ case 'ArrowLeft':
+ return 'skip-back';
+ case 'ArrowRight':
+ return 'skip-forward';
+ case 'ArrowUp':
+ return 'speed-up';
+ case 'ArrowDown':
+ return 'speed-down';
+ case 'Comma':
+ return event.shiftKey ? 'speed-down' : null;
+ case 'Period':
+ return event.shiftKey ? 'speed-up' : null;
+ case 'KeyM':
+ return 'toggle-mute';
+ case 'KeyJ':
+ return 'jump-back';
+ case 'KeyL':
+ return 'jump-forward';
+ case 'KeyF':
+ return 'toggle-fullscreen';
+ default:
+ return null;
+ }
+}
+
+/** True when the keystroke belongs to a text field and must not be hijacked. */
+export function isTypingTarget(target: HTMLElement): boolean {
+ return (
+ target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable === true
+ );
+}
diff --git a/docker-compose.test.yml b/docker-compose.test.yml
new file mode 100644
index 0000000..6c6fdb6
--- /dev/null
+++ b/docker-compose.test.yml
@@ -0,0 +1,159 @@
+# Disposable infrastructure for the automated test suites. Everything here is
+# on non-default ports so it can never collide with the dev stack in
+# docker-compose.yml.
+#
+# The `openframe-test` network is declared external so that the test runner
+# container can reach these services by service name. Create it once with:
+# podman network create openframe-test
+#
+# Two tiers, split by compose profile:
+#
+# (no profile) postgres-test. Needed by the `api` Vitest project and by the
+# Playwright suite. Started by `bun run test:db:up`.
+# e2e minio-test + minio-test-init (real S3-compatible storage for
+# the direct video upload flow) and app-test (the app under
+# test). Started by `scripts/test.sh e2e`.
+#
+# Usage:
+# podman compose -f docker-compose.test.yml up -d --wait postgres-test
+# podman run --rm --network openframe-test -v "$PWD":/workspace:z -w /workspace \
+# docker.io/oven/bun:alpine sh -c "bun run test:api"
+#
+# podman compose -f docker-compose.test.yml --profile e2e up -d --wait \
+# postgres-test minio-test
+# scripts/test.sh e2e
+#
+# app-test is opt-in and only needed when you want the app under test to run as
+# a container instead of being started by Playwright's own `webServer`:
+# podman compose -f docker-compose.test.yml --profile e2e up -d --wait app-test
+# E2E_BASE_URL=http://app-test:3100 scripts/test.sh e2e
+
+services:
+ postgres-test:
+ image: postgres:16-alpine
+ container_name: openframe-postgres-test
+ environment:
+ POSTGRES_USER: openframe
+ POSTGRES_PASSWORD: openframe
+ POSTGRES_DB: openframe_test
+ # fsync off: the data directory is a tmpfs that is thrown away anyway, so
+ # durability buys nothing and costs a lot of wall clock time.
+ command: ['postgres', '-c', 'fsync=off', '-c', 'full_page_writes=off']
+ tmpfs:
+ - /var/lib/postgresql/data
+ healthcheck:
+ test: ['CMD-SHELL', 'pg_isready -U openframe -d openframe_test']
+ interval: 2s
+ timeout: 3s
+ retries: 30
+ ports:
+ - '127.0.0.1:55432:5432'
+ networks:
+ - openframe-test
+
+ # S3-compatible object storage for the direct video upload path. The browser
+ # PUTs the file straight at this endpoint with a presigned URL, so the
+ # hostname the app signs for has to be the hostname the browser resolves:
+ # both the Playwright container and the app under test are on this network,
+ # so both use `minio-test:9000`. MinIO's default CORS policy allows every
+ # origin and exposes ETag, which is exactly what lib/client/r2-video-upload.ts
+ # needs, so no bucket CORS configuration is involved.
+ minio-test:
+ profiles: ['e2e']
+ image: docker.io/minio/minio:latest
+ container_name: openframe-minio-test
+ command: ['server', '/data', '--console-address', ':9001']
+ environment:
+ MINIO_ROOT_USER: openframe
+ MINIO_ROOT_PASSWORD: openframe-test-secret
+ # lib/r2.ts builds its S3 client with `region: 'auto'`, so MinIO has to
+ # accept that region name in the SigV4 signature.
+ MINIO_REGION_NAME: auto
+ # Same reasoning as postgres-test: the data is disposable, so keep it in RAM.
+ tmpfs:
+ - /data
+ healthcheck:
+ test: ['CMD', 'mc', 'ready', 'local']
+ interval: 2s
+ timeout: 3s
+ retries: 30
+ ports:
+ - '127.0.0.1:59000:9000'
+ - '127.0.0.1:59001:9001'
+ networks:
+ - openframe-test
+
+ # Creates the bucket. Nothing at runtime does: ensureR2BucketExists() lives in
+ # scripts/self-host-bootstrap.ts, not on the request path, so a missing bucket
+ # would surface as a presigned PUT returning NoSuchBucket.
+ minio-test-init:
+ profiles: ['e2e']
+ image: docker.io/minio/mc:latest
+ container_name: openframe-minio-test-init
+ depends_on:
+ minio-test:
+ condition: service_healthy
+ entrypoint:
+ - sh
+ - -c
+ - >
+ mc alias set testminio http://minio-test:9000 openframe openframe-test-secret &&
+ mc mb --ignore-existing testminio/openframe-test &&
+ mc ls testminio
+ networks:
+ - openframe-test
+
+ # The app under test, for the case where you do not want Playwright to start
+ # it. This is the same command playwright.config.ts uses for its `webServer`,
+ # run against the mounted working tree so `.next` stays warm between runs.
+ #
+ # NODE_ENV is left to `next build` / `next start` (production). The runtime
+ # values below are duplicated from .env.test.example on purpose: `next build`
+ # never loads `.env.test`, and NEXT_PUBLIC_APP_URL is inlined at build time.
+ app-test:
+ profiles: ['e2e']
+ image: docker.io/oven/bun:alpine
+ container_name: openframe-app-test
+ depends_on:
+ postgres-test:
+ condition: service_healthy
+ minio-test:
+ condition: service_healthy
+ working_dir: /workspace
+ volumes:
+ - .:/workspace:z
+ environment:
+ DATABASE_URL: postgresql://openframe:openframe@postgres-test:5432/openframe_test?schema=public
+ NEXTAUTH_URL: http://app-test:3100
+ NEXTAUTH_SECRET: test-secret-not-used-for-anything-real
+ NEXT_PUBLIC_APP_URL: http://app-test:3100
+ OPENFRAME_ENABLE_STRIPE: 'true'
+ STRIPE_SECRET_KEY: sk_test_openframe_dummy
+ STRIPE_PRICE_ID: price_test_openframe_dummy
+ OPENFRAME_REQUIRE_INVITE_CODE: 'true'
+ INVITE_CODE: test-invite
+ TRUSTED_PROXY_MODE: none
+ OPENFRAME_ENABLE_S3_VIDEO_UPLOADS: 'true'
+ OPENFRAME_ENABLE_BUNNY_UPLOADS: 'false'
+ R2_ENDPOINT: http://minio-test:9000
+ R2_ACCESS_KEY_ID: openframe
+ R2_SECRET_ACCESS_KEY: openframe-test-secret
+ R2_BUCKET_NAME: openframe-test
+ PORT: '3100'
+ command:
+ - sh
+ - -c
+ - './node_modules/.bin/next build && ./node_modules/.bin/next start -p 3100 -H 0.0.0.0'
+ healthcheck:
+ test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1:3100/login']
+ interval: 5s
+ timeout: 5s
+ retries: 120
+ ports:
+ - '127.0.0.1:3100:3100'
+ networks:
+ - openframe-test
+
+networks:
+ openframe-test:
+ external: true
diff --git a/eslint.config.mjs b/eslint.config.mjs
index f5bee78..a5273d0 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -13,8 +13,26 @@ const eslintConfig = defineConfig([
'out/**',
'build/**',
'next-env.d.ts',
+ // Generated test output. These are gitignored, but eslint keeps its own
+ // ignore list, and the v8 coverage reporter ships vendored JS that trips
+ // `--max-warnings=0`, so a coverage run would otherwise break `bun run lint`.
+ 'coverage/**',
+ 'playwright-report/**',
+ 'test-results/**',
+ 'reports/**',
+ '.stryker-tmp/**',
]),
prettier,
+ {
+ // Tests are allowed liberties that production code is not: `any` when
+ // shaping a fixture, and imports that reach past the `@/` aliases into
+ // test helpers. `--max-warnings=0` still applies to everything else.
+ files: ['tests/**/*.{ts,tsx}'],
+ rules: {
+ '@typescript-eslint/no-explicit-any': 'off',
+ 'no-restricted-imports': 'off',
+ },
+ },
]);
export default eslintConfig;
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 b07c2d8..8358495 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,17 @@
"format:check": "prettier --check .",
"typecheck": "tsc --noEmit",
"check": "bun run lint && bun run format:check && bun run typecheck",
+ "test": "vitest run --project unit --project component",
+ "test:watch": "vitest --project unit --project component",
+ "test:api": "vitest run --project api",
+ "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",
+ "test:db:bootstrap": "bun run scripts/test-db-bootstrap.ts",
"prepare": "husky",
"postinstall": "prisma generate",
"db:generate": "prisma generate",
@@ -60,22 +71,33 @@
"devDependencies": {
"@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",
+ "@testing-library/user-event": "^14.6.1",
"@types/bcryptjs": "^3.0.0",
"@types/node": "^20",
"@types/nodemailer": "^7.0.9",
"@types/pg": "^8.16.0",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "@vitejs/plugin-react": "^6.0.4",
+ "@vitest/coverage-v8": "^4.1.10",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"eslint-config-prettier": "^10.1.5",
"husky": "^9.1.7",
+ "jsdom": "^29.1.1",
"lint-staged": "^15.5.1",
"prettier": "^3.5.3",
"shadcn": "^3.8.3",
"tailwindcss": "^4",
- "typescript": "^5"
+ "typescript": "^5",
+ "vite-tsconfig-paths": "^6.1.1",
+ "vitest": "^4.1.10"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..061ce16
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,184 @@
+import { defineConfig, devices } from '@playwright/test';
+
+// ---------------------------------------------------------------------------
+// End-to-end suite. See TESTING.md section 6.
+//
+// Report output: ./playwright-report (HTML), ./test-results (traces, videos).
+// Both are gitignored and both are what the `e2e` job in ci.yml uploads.
+//
+// Port 3100, not 3000. The developer's dev server owns 3000 on this machine,
+// and reuseExistingServer would happily attach the whole suite to it, pointing
+// every test at the development database.
+// ---------------------------------------------------------------------------
+
+const PORT = Number(process.env.E2E_PORT ?? 3100);
+
+/**
+ * Where the tests point their browser.
+ *
+ * Set E2E_BASE_URL to run against an app you started yourself (the `app-test`
+ * service in docker-compose.test.yml, for instance). Leaving it unset is the
+ * normal path: Playwright builds and starts the app itself, below.
+ */
+const BASE_URL = process.env.E2E_BASE_URL ?? `http://localhost:${PORT}`;
+
+const MANAGES_OWN_SERVER = !process.env.E2E_BASE_URL;
+
+/**
+ * Environment for the app under test.
+ *
+ * `.env.test` is deliberately not reused here. Two reasons:
+ *
+ * 1. `next build` runs with NODE_ENV=production and never loads `.env.test`,
+ * and NEXT_PUBLIC_APP_URL is inlined into the client bundle at build time,
+ * so the build needs these values passed in explicitly anyway.
+ * 2. The R2_* variables below must NOT leak into the `api` Vitest project.
+ * `hasR2Config()` is derived from them, so putting them in `.env.test`
+ * would flip `isDirectFileUploadEnabled()` to true for 537 API tests that
+ * currently assert the unconfigured branch.
+ */
+const APP_ENV: Record = {
+ DATABASE_URL:
+ process.env.DATABASE_URL ??
+ 'postgresql://openframe:openframe@postgres-test:5432/openframe_test?schema=public',
+
+ NEXTAUTH_URL: BASE_URL,
+ NEXT_PUBLIC_APP_URL: BASE_URL,
+ NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET ?? 'test-secret-not-used-for-anything-real',
+ // Required. NextAuth v5 refuses every /api/auth/* request with
+ // `UntrustedHost` in production builds unless the host is trusted, which is
+ // why .env.docker.example sets the same variable for real deployments.
+ AUTH_TRUST_HOST: 'true',
+
+ // Stripe stays ON, with dummy credentials. With the flag off,
+ // hasBillingAccess() short-circuits to `true` and
+ // buildBillingAccessWhereInput() returns `{}`, so the billing gate that
+ // billing-gate.spec.ts exists to verify would not be armed at all. No spec
+ // walks into checkout, so no request ever reaches Stripe.
+ OPENFRAME_ENABLE_STRIPE: 'true',
+ STRIPE_SECRET_KEY: 'sk_test_openframe_dummy',
+ STRIPE_PRICE_ID: 'price_test_openframe_dummy',
+ STRIPE_WEBHOOK_SECRET: 'whsec_test_openframe_dummy',
+
+ OPENFRAME_REQUIRE_INVITE_CODE: 'true',
+ 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
+ // isDirectFileUploadEnabled() into the client.
+ //
+ // The endpoint is the container hostname on purpose: the browser PUTs the
+ // file straight at the presigned URL, so the host the app signs for has to be
+ // the host the browser can resolve. Its origin is added to the CSP
+ // connect-src automatically by lib/content-security-policy.ts.
+ OPENFRAME_ENABLE_S3_VIDEO_UPLOADS: 'true',
+ OPENFRAME_ENABLE_BUNNY_UPLOADS: 'false',
+ R2_ENDPOINT: process.env.R2_ENDPOINT ?? 'http://minio-test:9000',
+ R2_ACCESS_KEY_ID: process.env.R2_ACCESS_KEY_ID ?? 'openframe',
+ R2_SECRET_ACCESS_KEY: process.env.R2_SECRET_ACCESS_KEY ?? 'openframe-test-secret',
+ R2_BUCKET_NAME: process.env.R2_BUCKET_NAME ?? 'openframe-test',
+
+ // Email verification must stay off, or a user registered through the form in
+ // auth.spec.ts cannot sign in until a message that nothing delivers has been
+ // clicked. isEmailVerificationEnabled() is derived from SMTP_HOST/USER/
+ // PASSWORD, so leaving those unset is what disables it. .env.test sets them
+ // for the api suite, which mocks nodemailer; nothing mocks it here.
+};
+
+export default defineConfig({
+ testDir: './tests/e2e',
+ outputDir: './test-results',
+
+ // Every spec seeds its own rows and deletes them again, so files are safe to
+ // interleave. What they share is one app process and one database.
+ fullyParallel: true,
+ // Capped rather than left to the core count: the limit is the single Next
+ // server, and the DB-backed rate limiter is keyed on the client IP, which is
+ // the same address for every worker.
+ workers: process.env.CI ? 2 : 4,
+
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+
+ // A cold run has to build the app first, and `next build` on this codebase
+ // takes minutes; the per-test timeout is unrelated to that but the whole-run
+ // one is not.
+ timeout: 90_000,
+ expect: { timeout: 15_000 },
+
+ // `open: 'never'` matters locally too: the report server would otherwise hold
+ // the run open inside a container that has no browser to open it with.
+ reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
+
+ globalSetup: './tests/e2e/global-setup.ts',
+
+ use: {
+ baseURL: BASE_URL,
+ trace: 'on-first-retry',
+ screenshot: 'only-on-failure',
+ video: 'off',
+ // Chromium in a container is slower than on a desktop, and the first
+ // navigation after a cold start pays for the route being compiled.
+ actionTimeout: 20_000,
+ navigationTimeout: 45_000,
+ },
+
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ testIgnore: '**/dashboard-mobile.spec.ts',
+ },
+ {
+ // One mobile project, for one spec. Section 6 asks for a mobile smoke
+ // test, not a second full pass.
+ name: 'mobile-chrome',
+ 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
+ ? {
+ // `bun run build` would re-run `prebuild` (tsc --noEmit) on every cold
+ // start, which `bun run check` already covers. next is invoked through
+ // its bin so this works under both bun and node.
+ command: `./node_modules/.bin/next build && ./node_modules/.bin/next start -p ${PORT}`,
+ url: `${BASE_URL}/login`,
+ reuseExistingServer: !process.env.CI,
+ // A cold `next build` here measured a little over three minutes.
+ timeout: 15 * 60 * 1000,
+ stdout: 'pipe',
+ stderr: 'pipe',
+ env: APP_ENV,
+ }
+ : undefined,
+});
diff --git a/scripts/test-db-bootstrap.ts b/scripts/test-db-bootstrap.ts
new file mode 100644
index 0000000..3c6b641
--- /dev/null
+++ b/scripts/test-db-bootstrap.ts
@@ -0,0 +1,22 @@
+/**
+ * Builds the test database schema outside of Vitest.
+ *
+ * The `api` Vitest project gets this for free through its globalSetup, but the
+ * end-to-end suite runs the real app against the same database and needs the
+ * schema in place before the server starts. Both paths therefore call the same
+ * setup function, so there is exactly one description of how a test database is
+ * built (including why it uses `prisma db push` rather than `migrate deploy`,
+ * which is documented at the top of tests/setup/db-global.ts).
+ *
+ * Usage: bun run test:db:bootstrap
+ */
+import { setup } from '../tests/setup/db-global';
+
+setup()
+ .then(() => {
+ console.log('Test database schema is ready');
+ })
+ .catch((error: unknown) => {
+ console.error(error instanceof Error ? error.message : error);
+ process.exit(1);
+ });
diff --git a/scripts/test.sh b/scripts/test.sh
new file mode 100755
index 0000000..fb3bd92
--- /dev/null
+++ b/scripts/test.sh
@@ -0,0 +1,326 @@
+#!/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 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
+# concurrent e2e run loses the rows it seeded and fails for no real reason.
+# `all` runs them in sequence for exactly this reason.
+#
+# Every suite runs inside a container, so no package manager runs on the host.
+# TESTING.md section 8 documents the raw podman commands this wraps.
+
+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
+# before bumping either half:
+# curl -sI https://mcr.microsoft.com/v2/playwright/manifests/v1.61.1-noble
+playwright_image='mcr.microsoft.com/playwright:v1.61.1-noble'
+# Shared podman network, so the runner container reaches Postgres by service
+# name instead of a published port.
+network='openframe-test'
+
+# Resolve the repo root from this script's own location, so the script behaves
+# the same from any working directory.
+script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
+repo_root=$(CDPATH='' cd -- "$script_dir/.." && pwd)
+
+compose_file="$repo_root/docker-compose.test.yml"
+playwright_config="$repo_root/playwright.config.ts"
+env_test="$repo_root/.env.test"
+env_test_example="$repo_root/.env.test.example"
+
+# Attach a TTY only when there is one, so the script also works from a hook,
+# a pipe, or a CI runner.
+if [ -t 1 ]; then
+ tty_flag='-t'
+else
+ tty_flag=''
+fi
+
+usage() {
+ cat <<'EOF'
+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.
+ 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
+EOF
+}
+
+say() {
+ printf '\n==> %s\n' "$1"
+}
+
+# Each argument is printed on its own line, so a diagnostic can carry the fix
+# right under the problem.
+die() {
+ printf 'scripts/test.sh: %s\n' "$1" >&2
+ shift
+ for line in "$@"; do
+ printf ' %s\n' "$line" >&2
+ done
+ exit 1
+}
+
+show() {
+ printf '+'
+ for word in "$@"; do
+ # Quote the arguments that contain spaces, so the printed line reads like
+ # something you could paste back into a shell.
+ case $word in
+ *' '*) printf " '%s'" "$word" ;;
+ *) printf ' %s' "$word" ;;
+ esac
+ done
+ printf '\n'
+}
+
+# Prints the command, runs it, and exits with the command's own status so the
+# caller (a hook, CI, or a shell) sees the real result.
+run_cmd() {
+ show "$@"
+ set +e
+ "$@"
+ status=$?
+ set -e
+ if [ "$status" -ne 0 ]; then
+ printf '\nscripts/test.sh: %s exited with %s\n' "$1" "$status" >&2
+ exit "$status"
+ fi
+}
+
+# The tty_flag expansion below stays quoted when set and disappears entirely
+# when empty, which a plain "$tty_flag" cannot do (it would pass an empty
+# argument to podman).
+run_in_bun_image() {
+ bun_network=$1
+ bun_command=$2
+ if [ -n "$bun_network" ]; then
+ run_cmd podman run --rm ${tty_flag:+"$tty_flag"} --network "$bun_network" \
+ -v "$repo_root:/workspace:z" -w /workspace "$bun_image" sh -c "$bun_command"
+ else
+ run_cmd podman run --rm ${tty_flag:+"$tty_flag"} \
+ -v "$repo_root:/workspace:z" -w /workspace "$bun_image" sh -c "$bun_command"
+ fi
+}
+
+# --ipc=host is Playwright's documented requirement for Chromium in a
+# container; without it Chromium runs out of shared memory on larger pages.
+run_in_playwright_image() {
+ run_cmd podman run --rm ${tty_flag:+"$tty_flag"} --ipc=host --network "$network" \
+ -v "$repo_root:/workspace:z" -w /workspace "$playwright_image" sh -c "$1"
+}
+
+require_compose_file() {
+ [ -f "$compose_file" ] && return 0
+ die "docker-compose.test.yml not found at $compose_file." \
+ 'The test database ships with Phase 2 of TESTING.md (section 5), so the api' \
+ 'and e2e suites cannot run until that lands.'
+}
+
+require_env_test() {
+ [ -f "$env_test" ] && return 0
+ if [ -f "$env_test_example" ]; then
+ die "$env_test not found." \
+ 'Create it once with: cp .env.test.example .env.test'
+ fi
+ die "Neither $env_test nor $env_test_example exists." \
+ 'Both ship with Phase 2 of TESTING.md (section 5).'
+}
+
+require_playwright_config() {
+ [ -f "$playwright_config" ] && return 0
+ die "playwright.config.ts not found at $playwright_config." \
+ 'The end-to-end suite is Phase 3 of TESTING.md (section 6) and has not' \
+ 'landed yet, so there is nothing for playwright to run.'
+}
+
+ensure_network() {
+ if podman network exists "$network"; then
+ return 0
+ fi
+ say "creating the $network podman network"
+ run_cmd podman network create "$network"
+}
+
+# `podman compose up -d --wait` is not usable here. With podman-compose 1.6.0 as
+# the provider it does not block on the healthcheck at all when the service is
+# starting, and worse, it never returns when the service is already up and
+# healthy: an e2e run was observed wedged on it for 22 minutes with nothing to
+# show for it. So every service is started without --wait and its readiness is
+# polled here instead.
+wait_for_test_db() {
+ say 'waiting for postgres-test to accept connections'
+ attempt=0
+ while [ "$attempt" -lt 60 ]; do
+ if podman compose -f "$compose_file" exec -T postgres-test \
+ pg_isready -U openframe -d openframe_test >/dev/null 2>&1; then
+ printf 'postgres-test is ready\n'
+ return 0
+ fi
+ attempt=$((attempt + 1))
+ sleep 1
+ done
+ die 'postgres-test did not become ready within 60 seconds.' \
+ "Inspect it with: podman compose -f $compose_file logs postgres-test"
+}
+
+wait_for_test_storage() {
+ say 'waiting for minio-test to report healthy'
+ attempt=0
+ while [ "$attempt" -lt 60 ]; do
+ if podman exec openframe-minio-test mc ready local >/dev/null 2>&1; then
+ printf 'minio-test is ready\n'
+ return 0
+ fi
+ attempt=$((attempt + 1))
+ sleep 1
+ done
+ die 'minio-test did not become ready within 60 seconds.' \
+ "Inspect it with: podman compose -f $compose_file logs minio-test"
+}
+
+start_test_db() {
+ ensure_network
+ say 'starting the test database'
+ run_cmd podman compose -f "$compose_file" up -d postgres-test
+ wait_for_test_db
+}
+
+# Object storage for the direct video upload flow. The browser PUTs the file
+# straight at the presigned URL, so this has to be real; there is nothing to
+# mock at that boundary from inside a browser.
+#
+# minio-test-init is a one-shot container that creates the bucket. Nothing at
+# runtime does: ensureR2BucketExists() lives in scripts/self-host-bootstrap.ts,
+# not on the request path.
+start_test_storage() {
+ say 'starting object storage for the upload flow'
+ # Asking compose to start a container that is already up prints a red
+ # `cannot start an already running container` error and keeps going, which
+ # reads like a failure in the log of an otherwise clean run. Skip the call
+ # instead.
+ if podman exec openframe-minio-test mc ready local >/dev/null 2>&1; then
+ printf 'minio-test is already running\n'
+ else
+ run_cmd podman compose -f "$compose_file" --profile e2e up -d minio-test
+ wait_for_test_storage
+ fi
+ # --no-deps: the init container declares depends_on minio-test, and without
+ # this compose tries to start that dependency again and prints the same
+ # spurious `already running` error the guard above exists to avoid.
+ run_cmd podman compose -f "$compose_file" --profile e2e up --no-deps minio-test-init
+}
+
+# --frozen-lockfile keeps a test run from rewriting bun.lock as a side effect.
+install_step='bun install --frozen-lockfile'
+
+run_unit() {
+ say 'unit and component suites'
+ 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
+ require_env_test
+ start_test_db
+ run_in_bun_image "$network" "$install_step && bun run test:api"
+}
+
+run_e2e() {
+ say 'end-to-end specs'
+ require_compose_file
+ require_playwright_config
+ require_env_test
+ start_test_db
+ start_test_storage
+ # The official Playwright image carries node and the browsers but not bun.
+ # bun is needed for `bun run test:e2e`; the web server inside
+ # playwright.config.ts runs next through node_modules/.bin, so it works under
+ # either runtime. bun is installed into the throwaway container, never on the
+ # host, and `oven-sh/setup-bun` cannot be used because the image has no unzip.
+ #
+ # Playwright starts and stops the app itself (`webServer`), on port 3100 so it
+ # cannot attach to a dev server on 3000. The build output lands in the mounted
+ # .next, which is what keeps the second run fast.
+ run_in_playwright_image \
+ "npm install --global --silent bun && $install_step && bun run test:e2e"
+}
+
+case "${1-}" in
+ -h | --help | help)
+ usage
+ exit 0
+ ;;
+esac
+
+if [ "$#" -ne 1 ]; then
+ printf 'scripts/test.sh: exactly one mode is required\n\n' >&2
+ usage >&2
+ exit 64
+fi
+
+if ! command -v podman >/dev/null 2>&1; then
+ die 'podman was not found on PATH.' \
+ 'Every suite runs in a container, so podman is required.'
+fi
+
+case "$1" in
+ unit)
+ run_unit
+ ;;
+ api)
+ run_api
+ ;;
+ e2e)
+ run_e2e
+ ;;
+ all)
+ run_unit
+ run_api
+ run_e2e
+ ;;
+ mutation)
+ run_mutation
+ ;;
+ *)
+ printf 'scripts/test.sh: unknown mode "%s"\n\n' "$1" >&2
+ usage >&2
+ exit 64
+ ;;
+esac
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/approvals.test.ts b/tests/api/approvals.test.ts
new file mode 100644
index 0000000..fde4e76
--- /dev/null
+++ b/tests/api/approvals.test.ts
@@ -0,0 +1,793 @@
+import { describe, expect, it } from 'vitest';
+import { db } from '@/lib/db';
+import {
+ GET as listApprovals,
+ POST as requestApproval,
+} from '@/app/api/versions/[versionId]/approvals/route';
+import { POST as decideApproval } from '@/app/api/approvals/[requestId]/decision/route';
+import { POST as cancelApproval } from '@/app/api/approvals/[requestId]/cancel/route';
+import { GET as listCandidates } from '@/app/api/projects/[projectId]/approval-candidates/route';
+import { apiRequest, callRoute, readData } from '../helpers/request';
+import { signedInAs, signedOut } from '../helpers/session';
+import {
+ addProjectMember,
+ addWorkspaceMember,
+ createApprovalRequest,
+ createUser,
+ seedVersion,
+} from '../factories';
+
+function approvalsUrl(versionId: string): string {
+ return `/api/versions/${versionId}/approvals`;
+}
+
+describe('GET /api/projects/[projectId]/approval-candidates', () => {
+ it('returns 401 without a session', async () => {
+ const scenario = await seedVersion();
+ signedOut();
+
+ const response = await callRoute(
+ listCandidates,
+ apiRequest(`/api/projects/${scenario.project.id}/approval-candidates`),
+ { projectId: scenario.project.id }
+ );
+
+ expect(response.status).toBe(401);
+ });
+
+ it('returns 403 for a COMMENTATOR, who cannot request approvals', async () => {
+ const scenario = await seedVersion();
+ const commentator = await createUser();
+ await addProjectMember({
+ projectId: scenario.project.id,
+ userId: commentator.id,
+ role: 'COMMENTATOR',
+ });
+ signedInAs(commentator);
+
+ const response = await callRoute(
+ listCandidates,
+ apiRequest(`/api/projects/${scenario.project.id}/approval-candidates`),
+ { projectId: scenario.project.id }
+ );
+
+ expect(response.status).toBe(403);
+ });
+
+ it('lists the project owner, project members, workspace owner and workspace members once each', async () => {
+ const scenario = await seedVersion();
+ const projectMember = await createUser({ name: 'Bianca' });
+ const workspaceMember = await createUser({ name: 'Cleo' });
+ const both = await createUser({ name: 'Dana' });
+ await addProjectMember({ projectId: scenario.project.id, userId: projectMember.id });
+ await addWorkspaceMember({ workspaceId: scenario.workspace.id, userId: workspaceMember.id });
+ await addProjectMember({ projectId: scenario.project.id, userId: both.id });
+ await addWorkspaceMember({ workspaceId: scenario.workspace.id, userId: both.id });
+ signedInAs(scenario.owner);
+
+ const payload = await readData<{ candidates: Array<{ id: string }> }>(
+ await callRoute(
+ listCandidates,
+ apiRequest(`/api/projects/${scenario.project.id}/approval-candidates`),
+ { projectId: scenario.project.id }
+ )
+ );
+
+ const ids = payload.candidates.map((entry) => entry.id).sort();
+ expect(ids).toEqual([scenario.owner.id, projectMember.id, workspaceMember.id, both.id].sort());
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+});
+
+describe('POST /api/versions/[versionId]/approvals', () => {
+ it('returns 401 without a session', async () => {
+ const scenario = await seedVersion();
+ signedOut();
+
+ const response = await callRoute(
+ requestApproval,
+ apiRequest(approvalsUrl(scenario.version.id), { body: { approverIds: ['x'] } }),
+ { versionId: scenario.version.id }
+ );
+
+ expect(response.status).toBe(401);
+ expect(await db.approvalRequest.count()).toBe(0);
+ });
+
+ it('returns 404 for an unknown version', async () => {
+ const user = await createUser();
+ signedInAs(user);
+
+ const response = await callRoute(
+ requestApproval,
+ apiRequest(approvalsUrl('nope'), { body: { approverIds: ['x'] } }),
+ { versionId: 'nope' }
+ );
+
+ expect(response.status).toBe(404);
+ });
+
+ it('returns 403 for a COMMENTATOR', async () => {
+ const scenario = await seedVersion();
+ const commentator = await createUser();
+ await addProjectMember({
+ projectId: scenario.project.id,
+ userId: commentator.id,
+ role: 'COMMENTATOR',
+ });
+ signedInAs(commentator);
+
+ const response = await callRoute(
+ requestApproval,
+ apiRequest(approvalsUrl(scenario.version.id), {
+ body: { approverIds: [scenario.owner.id] },
+ }),
+ { versionId: scenario.version.id }
+ );
+
+ expect(response.status).toBe(403);
+ expect(await db.approvalRequest.count()).toBe(0);
+ });
+
+ it.each([
+ [{}, 'no approverIds at all'],
+ [{ approverIds: [] }, 'an empty approver list'],
+ [{ approverIds: 'not-an-array' }, 'a non-array approverIds'],
+ [{ approverIds: ['', ' '] }, 'blank approver ids'],
+ [{ approverIds: [42, null] }, 'non-string approver ids'],
+ ])('rejects %j with 400 (%s)', async (body, label) => {
+ const scenario = await seedVersion();
+ signedInAs(scenario.owner);
+
+ const response = await callRoute(
+ requestApproval,
+ apiRequest(approvalsUrl(scenario.version.id), { body }),
+ { versionId: scenario.version.id }
+ );
+
+ expect(response.status, label).toBe(400);
+ expect(await db.approvalRequest.count()).toBe(0);
+ });
+
+ it('rejects a message longer than 2000 characters', async () => {
+ const scenario = await seedVersion();
+ const approver = await createUser();
+ await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
+ signedInAs(scenario.owner);
+
+ const response = await callRoute(
+ requestApproval,
+ apiRequest(approvalsUrl(scenario.version.id), {
+ body: { approverIds: [approver.id], message: 'x'.repeat(2001) },
+ }),
+ { versionId: scenario.version.id }
+ );
+
+ expect(response.status).toBe(400);
+ expect(await db.approvalRequest.count()).toBe(0);
+ });
+
+ it('refuses to let the requester approve their own request', async () => {
+ const scenario = await seedVersion();
+ signedInAs(scenario.owner);
+
+ const response = await callRoute(
+ requestApproval,
+ apiRequest(approvalsUrl(scenario.version.id), {
+ body: { approverIds: [scenario.owner.id] },
+ }),
+ { versionId: scenario.version.id }
+ );
+
+ expect(response.status).toBe(400);
+ expect(await db.approvalRequest.count()).toBe(0);
+ });
+
+ // The candidate set is derived from project and workspace membership. Anyone
+ // outside it cannot be nominated, which is what stops an arbitrary user id
+ // being written into approval_decisions.
+ it('refuses an approver who is not a candidate for the project', async () => {
+ const scenario = await seedVersion();
+ const outsider = await createUser();
+ signedInAs(scenario.owner);
+
+ const response = await callRoute(
+ requestApproval,
+ apiRequest(approvalsUrl(scenario.version.id), { body: { approverIds: [outsider.id] } }),
+ { versionId: scenario.version.id }
+ );
+
+ expect(response.status).toBe(400);
+ expect(await db.approvalRequest.count()).toBe(0);
+ expect(await db.approvalDecision.count()).toBe(0);
+ });
+
+ it('creates the request with one PENDING decision per de-duplicated approver', async () => {
+ const scenario = await seedVersion();
+ const first = await createUser();
+ const second = await createUser();
+ await addProjectMember({ projectId: scenario.project.id, userId: first.id });
+ await addWorkspaceMember({ workspaceId: scenario.workspace.id, userId: second.id });
+ signedInAs(scenario.owner);
+
+ const response = await callRoute(
+ requestApproval,
+ apiRequest(approvalsUrl(scenario.version.id), {
+ body: {
+ approverIds: [first.id, ` ${first.id} `, second.id],
+ message: ' please review ',
+ },
+ }),
+ { versionId: scenario.version.id }
+ );
+
+ expect(response.status).toBe(201);
+ const stored = await db.approvalRequest.findFirstOrThrow({ include: { decisions: true } });
+ expect(stored.status).toBe('PENDING');
+ expect(stored.requestedById).toBe(scenario.owner.id);
+ expect(stored.message).toBe('please review');
+ expect(stored.resolvedAt).toBeNull();
+ expect(stored.decisions).toHaveLength(2);
+ expect(stored.decisions.map((entry) => entry.approverId).sort()).toEqual(
+ [first.id, second.id].sort()
+ );
+ expect(stored.decisions.every((entry) => entry.status === 'PENDING')).toBe(true);
+ expect(stored.decisions.every((entry) => entry.respondedAt === null)).toBe(true);
+ });
+
+ it('returns 409 when a request is already pending on the version', async () => {
+ const scenario = await seedVersion();
+ const approver = await createUser();
+ await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
+ await createApprovalRequest({
+ versionId: scenario.version.id,
+ requestedById: scenario.owner.id,
+ approverIds: [approver.id],
+ });
+ signedInAs(scenario.owner);
+
+ const response = await callRoute(
+ requestApproval,
+ apiRequest(approvalsUrl(scenario.version.id), { body: { approverIds: [approver.id] } }),
+ { versionId: scenario.version.id }
+ );
+
+ expect(response.status).toBe(409);
+ expect(await db.approvalRequest.count()).toBe(1);
+ });
+
+ it('allows a new request once the previous one is resolved', async () => {
+ const scenario = await seedVersion();
+ const approver = await createUser();
+ await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
+ await createApprovalRequest({
+ versionId: scenario.version.id,
+ requestedById: scenario.owner.id,
+ approverIds: [approver.id],
+ status: 'REJECTED',
+ resolvedAt: new Date(),
+ });
+ signedInAs(scenario.owner);
+
+ const response = await callRoute(
+ requestApproval,
+ apiRequest(approvalsUrl(scenario.version.id), { body: { approverIds: [approver.id] } }),
+ { versionId: scenario.version.id }
+ );
+
+ expect(response.status).toBe(201);
+ expect(await db.approvalRequest.count()).toBe(2);
+ });
+});
+
+describe('GET /api/versions/[versionId]/approvals', () => {
+ it('returns 403 for a signed-in stranger even on a PUBLIC project', async () => {
+ const scenario = await seedVersion({ visibility: 'PUBLIC' });
+ const stranger = await createUser();
+ signedInAs(stranger);
+
+ const response = await callRoute(listApprovals, apiRequest(approvalsUrl(scenario.version.id)), {
+ versionId: scenario.version.id,
+ });
+
+ // hasMembership is required, not just hasAccess, so a public project does
+ // not expose its approval history to passers-by.
+ expect(response.status).toBe(403);
+ });
+
+ it('lists requests newest first for a COMMENTATOR member', async () => {
+ const scenario = await seedVersion();
+ const approver = await createUser();
+ const commentator = await createUser();
+ await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
+ await addProjectMember({
+ projectId: scenario.project.id,
+ userId: commentator.id,
+ role: 'COMMENTATOR',
+ });
+ const older = await createApprovalRequest({
+ versionId: scenario.version.id,
+ requestedById: scenario.owner.id,
+ approverIds: [approver.id],
+ status: 'CANCELED',
+ canceledAt: new Date(),
+ });
+ const newer = await createApprovalRequest({
+ versionId: scenario.version.id,
+ requestedById: scenario.owner.id,
+ approverIds: [approver.id],
+ });
+ signedInAs(commentator);
+
+ const payload = await readData<{ requests: Array<{ id: string }> }>(
+ await callRoute(listApprovals, apiRequest(approvalsUrl(scenario.version.id)), {
+ versionId: scenario.version.id,
+ })
+ );
+
+ expect(payload.requests.map((entry) => entry.id)).toEqual([newer.id, older.id]);
+ });
+});
+
+describe('POST /api/approvals/[requestId]/decision', () => {
+ it('returns 401 without a session', async () => {
+ const scenario = await seedVersion();
+ const approver = await createUser();
+ await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
+ const request = await createApprovalRequest({
+ versionId: scenario.version.id,
+ requestedById: scenario.owner.id,
+ approverIds: [approver.id],
+ });
+ signedOut();
+
+ const response = await callRoute(
+ decideApproval,
+ apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }),
+ { requestId: request.id }
+ );
+
+ expect(response.status).toBe(401);
+ expect((await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } })).status).toBe(
+ 'PENDING'
+ );
+ });
+
+ it.each([['MAYBE'], [''], ['approved'], [null]])(
+ 'returns 400 for the decision %s',
+ async (decision) => {
+ const scenario = await seedVersion();
+ const approver = await createUser();
+ await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
+ const request = await createApprovalRequest({
+ versionId: scenario.version.id,
+ requestedById: scenario.owner.id,
+ approverIds: [approver.id],
+ });
+ signedInAs(approver);
+
+ const response = await callRoute(
+ decideApproval,
+ apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision } }),
+ { requestId: request.id }
+ );
+
+ expect(response.status).toBe(400);
+ expect(
+ (await db.approvalDecision.findFirstOrThrow({ where: { requestId: request.id } })).status
+ ).toBe('PENDING');
+ }
+ );
+
+ // The core negative case for this route: having access to the project is not
+ // the same as being nominated on the request.
+ it('returns 403 for a project member who is not an approver on the request', async () => {
+ const scenario = await seedVersion();
+ const approver = await createUser();
+ const bystander = await createUser();
+ await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
+ await addProjectMember({ projectId: scenario.project.id, userId: bystander.id });
+ const request = await createApprovalRequest({
+ versionId: scenario.version.id,
+ requestedById: scenario.owner.id,
+ approverIds: [approver.id],
+ });
+ signedInAs(bystander);
+
+ const response = await callRoute(
+ decideApproval,
+ apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }),
+ { requestId: request.id }
+ );
+
+ expect(response.status).toBe(403);
+ expect((await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } })).status).toBe(
+ 'PENDING'
+ );
+ expect(await db.approvalDecision.count({ where: { status: 'APPROVED' } })).toBe(0);
+ });
+
+ it('returns 403 for the project owner who requested it but is not an approver', async () => {
+ const scenario = await seedVersion();
+ const approver = await createUser();
+ await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
+ const request = await createApprovalRequest({
+ versionId: scenario.version.id,
+ requestedById: scenario.owner.id,
+ approverIds: [approver.id],
+ });
+ signedInAs(scenario.owner);
+
+ const response = await callRoute(
+ decideApproval,
+ apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }),
+ { requestId: request.id }
+ );
+
+ expect(response.status).toBe(403);
+ });
+
+ it('returns 403 for an approver who has lost project access', async () => {
+ const scenario = await seedVersion();
+ const approver = await createUser();
+ const membership = await addProjectMember({
+ projectId: scenario.project.id,
+ userId: approver.id,
+ });
+ const request = await createApprovalRequest({
+ versionId: scenario.version.id,
+ requestedById: scenario.owner.id,
+ approverIds: [approver.id],
+ });
+ await db.projectMember.delete({ where: { id: membership.id } });
+ signedInAs(approver);
+
+ const response = await callRoute(
+ decideApproval,
+ apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }),
+ { requestId: request.id }
+ );
+
+ expect(response.status).toBe(403);
+ });
+
+ it('keeps the request PENDING while other approvers have not answered', async () => {
+ const scenario = await seedVersion();
+ const first = await createUser();
+ const second = await createUser();
+ await addProjectMember({ projectId: scenario.project.id, userId: first.id });
+ await addProjectMember({ projectId: scenario.project.id, userId: second.id });
+ const request = await createApprovalRequest({
+ versionId: scenario.version.id,
+ requestedById: scenario.owner.id,
+ approverIds: [first.id, second.id],
+ });
+ signedInAs(first);
+
+ const response = await callRoute(
+ decideApproval,
+ apiRequest(`/api/approvals/${request.id}/decision`, {
+ body: { decision: 'APPROVED', note: ' looks good ' },
+ }),
+ { requestId: request.id }
+ );
+
+ expect(response.status).toBe(200);
+ const stored = await db.approvalRequest.findUniqueOrThrow({
+ where: { id: request.id },
+ include: { decisions: true },
+ });
+ expect(stored.status).toBe('PENDING');
+ expect(stored.resolvedAt).toBeNull();
+
+ const mine = stored.decisions.find((entry) => entry.approverId === first.id)!;
+ expect(mine.status).toBe('APPROVED');
+ expect(mine.note).toBe('looks good');
+ expect(mine.respondedAt).toBeInstanceOf(Date);
+ expect(stored.decisions.find((entry) => entry.approverId === second.id)?.status).toBe(
+ 'PENDING'
+ );
+ });
+
+ it('resolves the request as APPROVED once the last approver approves', async () => {
+ const scenario = await seedVersion();
+ const first = await createUser();
+ const second = await createUser();
+ await addProjectMember({ projectId: scenario.project.id, userId: first.id });
+ await addProjectMember({ projectId: scenario.project.id, userId: second.id });
+ const request = await createApprovalRequest({
+ versionId: scenario.version.id,
+ requestedById: scenario.owner.id,
+ approverIds: [first.id, second.id],
+ });
+
+ signedInAs(first);
+ await callRoute(
+ decideApproval,
+ apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }),
+ { requestId: request.id }
+ );
+ signedInAs(second);
+ const response = await callRoute(
+ decideApproval,
+ apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }),
+ { requestId: request.id }
+ );
+
+ expect(response.status).toBe(200);
+ const stored = await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } });
+ expect(stored.status).toBe('APPROVED');
+ expect(stored.resolvedAt).toBeInstanceOf(Date);
+ });
+
+ it('resolves the request as REJECTED on a single rejection', async () => {
+ const scenario = await seedVersion();
+ const first = await createUser();
+ const second = await createUser();
+ await addProjectMember({ projectId: scenario.project.id, userId: first.id });
+ await addProjectMember({ projectId: scenario.project.id, userId: second.id });
+ const request = await createApprovalRequest({
+ versionId: scenario.version.id,
+ requestedById: scenario.owner.id,
+ approverIds: [first.id, second.id],
+ });
+ signedInAs(first);
+
+ const response = await callRoute(
+ decideApproval,
+ apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'REJECTED' } }),
+ { requestId: request.id }
+ );
+
+ expect(response.status).toBe(200);
+ const stored = await db.approvalRequest.findUniqueOrThrow({
+ where: { id: request.id },
+ include: { decisions: true },
+ });
+ expect(stored.status).toBe('REJECTED');
+ expect(stored.resolvedAt).toBeInstanceOf(Date);
+ // The second approver's row is left PENDING; the request is already decided.
+ expect(stored.decisions.find((entry) => entry.approverId === second.id)?.status).toBe(
+ 'PENDING'
+ );
+ });
+
+ it('returns 409 when the same approver answers twice', async () => {
+ const scenario = await seedVersion();
+ const first = await createUser();
+ const second = await createUser();
+ await addProjectMember({ projectId: scenario.project.id, userId: first.id });
+ await addProjectMember({ projectId: scenario.project.id, userId: second.id });
+ const request = await createApprovalRequest({
+ versionId: scenario.version.id,
+ requestedById: scenario.owner.id,
+ approverIds: [first.id, second.id],
+ });
+ signedInAs(first);
+
+ await callRoute(
+ decideApproval,
+ apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }),
+ { requestId: request.id }
+ );
+ const second_attempt = await callRoute(
+ decideApproval,
+ apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'REJECTED' } }),
+ { requestId: request.id }
+ );
+
+ expect(second_attempt.status).toBe(409);
+ expect(
+ (
+ await db.approvalDecision.findFirstOrThrow({
+ where: { requestId: request.id, approverId: first.id },
+ })
+ ).status
+ ).toBe('APPROVED');
+ });
+
+ it.each([['APPROVED'], ['REJECTED'], ['CANCELED']] as const)(
+ 'returns 409 for a request already in the terminal status %s',
+ async (status) => {
+ const scenario = await seedVersion();
+ const approver = await createUser();
+ await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
+ const request = await createApprovalRequest({
+ versionId: scenario.version.id,
+ requestedById: scenario.owner.id,
+ approverIds: [approver.id],
+ status,
+ resolvedAt: new Date(),
+ });
+ signedInAs(approver);
+
+ const response = await callRoute(
+ decideApproval,
+ apiRequest(`/api/approvals/${request.id}/decision`, { body: { decision: 'APPROVED' } }),
+ { requestId: request.id }
+ );
+
+ expect(response.status).toBe(409);
+ expect(
+ (await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } })).status
+ ).toBe(status);
+ }
+ );
+});
+
+describe('POST /api/approvals/[requestId]/cancel', () => {
+ it('returns 401 without a session', async () => {
+ const scenario = await seedVersion();
+ const approver = await createUser();
+ await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
+ const request = await createApprovalRequest({
+ versionId: scenario.version.id,
+ requestedById: scenario.owner.id,
+ approverIds: [approver.id],
+ });
+ signedOut();
+
+ const response = await callRoute(
+ cancelApproval,
+ apiRequest(`/api/approvals/${request.id}/cancel`, { method: 'POST', body: {} }),
+ { requestId: request.id }
+ );
+
+ expect(response.status).toBe(401);
+ expect((await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } })).status).toBe(
+ 'PENDING'
+ );
+ });
+
+ it('returns 404 for an unknown request', async () => {
+ const user = await createUser();
+ signedInAs(user);
+
+ const response = await callRoute(
+ cancelApproval,
+ apiRequest('/api/approvals/nope/cancel', { method: 'POST', body: {} }),
+ { requestId: 'nope' }
+ );
+
+ expect(response.status).toBe(404);
+ });
+
+ // The nominated approver is not the requester and has no canEdit, so it
+ // cannot cancel the request out from under the person who asked for it.
+ it('returns 403 for the nominated approver', async () => {
+ const scenario = await seedVersion();
+ const approver = await createUser();
+ await addProjectMember({
+ projectId: scenario.project.id,
+ userId: approver.id,
+ role: 'COMMENTATOR',
+ });
+ const request = await createApprovalRequest({
+ versionId: scenario.version.id,
+ requestedById: scenario.owner.id,
+ approverIds: [approver.id],
+ });
+ signedInAs(approver);
+
+ const response = await callRoute(
+ cancelApproval,
+ apiRequest(`/api/approvals/${request.id}/cancel`, { method: 'POST', body: {} }),
+ { requestId: request.id }
+ );
+
+ expect(response.status).toBe(403);
+ expect((await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } })).status).toBe(
+ 'PENDING'
+ );
+ });
+
+ it('returns 403 for a signed-in stranger', async () => {
+ const scenario = await seedVersion();
+ const approver = await createUser();
+ await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
+ const request = await createApprovalRequest({
+ versionId: scenario.version.id,
+ requestedById: scenario.owner.id,
+ approverIds: [approver.id],
+ });
+ const stranger = await createUser();
+ signedInAs(stranger);
+
+ const response = await callRoute(
+ cancelApproval,
+ apiRequest(`/api/approvals/${request.id}/cancel`, { method: 'POST', body: {} }),
+ { requestId: request.id }
+ );
+
+ expect(response.status).toBe(403);
+ });
+
+ it('lets the requester cancel and records who did it', async () => {
+ const scenario = await seedVersion();
+ const requester = await createUser();
+ const approver = await createUser();
+ await addProjectMember({
+ projectId: scenario.project.id,
+ userId: requester.id,
+ role: 'ADMIN',
+ });
+ await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
+ const request = await createApprovalRequest({
+ versionId: scenario.version.id,
+ requestedById: requester.id,
+ approverIds: [approver.id],
+ });
+ signedInAs(requester);
+
+ const response = await callRoute(
+ cancelApproval,
+ apiRequest(`/api/approvals/${request.id}/cancel`, { method: 'POST', body: {} }),
+ { requestId: request.id }
+ );
+
+ expect(response.status).toBe(200);
+ const stored = await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } });
+ expect(stored.status).toBe('CANCELED');
+ expect(stored.canceledById).toBe(requester.id);
+ expect(stored.canceledAt).toBeInstanceOf(Date);
+ });
+
+ it('lets a project ADMIN cancel a request somebody else made', async () => {
+ const scenario = await seedVersion();
+ const requester = await createUser();
+ const admin = await createUser();
+ const approver = await createUser();
+ await addProjectMember({
+ projectId: scenario.project.id,
+ userId: requester.id,
+ role: 'ADMIN',
+ });
+ await addProjectMember({ projectId: scenario.project.id, userId: admin.id, role: 'ADMIN' });
+ await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
+ const request = await createApprovalRequest({
+ versionId: scenario.version.id,
+ requestedById: requester.id,
+ approverIds: [approver.id],
+ });
+ signedInAs(admin);
+
+ const response = await callRoute(
+ cancelApproval,
+ apiRequest(`/api/approvals/${request.id}/cancel`, { method: 'POST', body: {} }),
+ { requestId: request.id }
+ );
+
+ expect(response.status).toBe(200);
+ expect(
+ (await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } })).canceledById
+ ).toBe(admin.id);
+ });
+
+ it.each([['APPROVED'], ['REJECTED'], ['CANCELED']] as const)(
+ 'returns 409 for a request already %s',
+ async (status) => {
+ const scenario = await seedVersion();
+ const approver = await createUser();
+ await addProjectMember({ projectId: scenario.project.id, userId: approver.id });
+ const request = await createApprovalRequest({
+ versionId: scenario.version.id,
+ requestedById: scenario.owner.id,
+ approverIds: [approver.id],
+ status,
+ resolvedAt: new Date(),
+ });
+ signedInAs(scenario.owner);
+
+ const response = await callRoute(
+ cancelApproval,
+ apiRequest(`/api/approvals/${request.id}/cancel`, { method: 'POST', body: {} }),
+ { requestId: request.id }
+ );
+
+ expect(response.status).toBe(409);
+ const stored = await db.approvalRequest.findUniqueOrThrow({ where: { id: request.id } });
+ expect(stored.status).toBe(status);
+ expect(stored.canceledById).toBeNull();
+ }
+ );
+});
diff --git a/tests/api/assets-authz.test.ts b/tests/api/assets-authz.test.ts
new file mode 100644
index 0000000..9bc4358
--- /dev/null
+++ b/tests/api/assets-authz.test.ts
@@ -0,0 +1,669 @@
+// Authorization tests for the /api/videos/[videoId]/assets/* family, from callers
+// who are signed in but not entitled.
+//
+// Every route in this family authorizes through one helper,
+// `getVideoAssetAccessContext()` in lib/video-assets.ts, and then reads one of
+// three flags off it: `hasViewAccess` to list, `canUploadAssets` to write, and
+// `canDownloadAssets` to export. Before this file the only thing standing behind
+// those flags was the anonymous sweep in tests/api/auth-matrix.test.ts, so
+// collapsing all three onto `hasViewAccess`, or returning a context that is
+// simply `{ hasViewAccess: true, canUploadAssets: true, ... }` for any signed-in
+// caller, would not have failed a single test in the suite.
+//
+// Two details make these cases land on the guard rather than short of it.
+//
+// - Each route checks the access context *before* it parses the body. So an
+// unauthorized caller gets 403 and an authorized caller sending the same
+// payload gets a 400 from the validation underneath. The positive controls
+// below deliberately stop on that 400: it is a status no unauthorized caller
+// can reach, which is what makes the 403 next to it mean something.
+//
+// - The assets are YOUTUBE-provider rows. Deleting an R2 or Bunny asset sends
+// the handler off to object storage, and downloading one proxies the bytes;
+// a YouTube asset exercises the identical authorization path with no network
+// underneath it.
+
+import { describe, expect, it } from 'vitest';
+import type { Project, User, Video, VideoAsset, Workspace } from '@prisma/client';
+import { db } from '@/lib/db';
+import { GET as listAssets, POST as createAsset } from '@/app/api/videos/[videoId]/assets/route';
+import { DELETE as deleteAsset } from '@/app/api/videos/[videoId]/assets/[assetId]/route';
+import { GET as downloadAsset } from '@/app/api/videos/[videoId]/assets/[assetId]/download/route';
+import { POST as initAssetBunnyUpload } from '@/app/api/videos/[videoId]/assets/bunny-init/route';
+import { POST as initAssetR2Upload } from '@/app/api/videos/[videoId]/assets/r2-init/route';
+import { apiRequest, callRoute, readData, readError } from '../helpers/request';
+import { signedInAs } from '../helpers/session';
+import {
+ addProjectMember,
+ addWorkspaceMember,
+ createExpiredUser,
+ createUser,
+ createVideo,
+ createVideoAsset,
+ nextSeq,
+ seedProject,
+} from '../factories';
+
+const SEEDED_ASSET_NAME = 'Seeded b-roll';
+
+interface AssetFixture {
+ owner: User;
+ workspace: Workspace;
+ project: Project;
+ video: Video;
+ /** Uploaded by the project owner, so a COMMENTATOR is not its author. */
+ asset: VideoAsset;
+}
+
+async function seedAsset(
+ input: { allowDownloads: boolean; ownerUser?: User } = { allowDownloads: false }
+): Promise {
+ const { owner, workspace, project } = await seedProject({
+ ownerUser: input.ownerUser,
+ visibility: 'PRIVATE',
+ allowDownloads: input.allowDownloads,
+ });
+ const video = await createVideo({ projectId: project.id, title: 'Video with assets' });
+ const asset = await createVideoAsset({
+ videoId: video.id,
+ billedUserId: owner.id,
+ kind: 'VIDEO',
+ provider: 'YOUTUBE',
+ displayName: SEEDED_ASSET_NAME,
+ sourceUrl: `https://www.youtube.com/watch?v=asset${nextSeq()}`,
+ providerVideoId: `asset-provider-${nextSeq()}`,
+ uploadedByUserId: owner.id,
+ });
+
+ return { owner, workspace, project, video, asset };
+}
+
+function assetsUrl(videoId: string): string {
+ return `/api/videos/${videoId}/assets`;
+}
+
+function assetUrl(videoId: string, assetId: string): string {
+ return `${assetsUrl(videoId)}/${assetId}`;
+}
+
+// ---------------------------------------------------------------------------
+// GET /api/videos/[videoId]/assets
+// ---------------------------------------------------------------------------
+describe('GET /api/videos/[videoId]/assets', () => {
+ it('returns 403 to a signed-in stranger with their own unrelated workspace', async () => {
+ const fixture = await seedAsset();
+ await seedProject();
+ const stranger = await createUser();
+ signedInAs(stranger);
+
+ const response = await callRoute(listAssets, apiRequest(assetsUrl(fixture.video.id)), {
+ videoId: fixture.video.id,
+ });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('returns 403 to a project COMMENTATOR once the workspace owner loses billing', async () => {
+ const expiredOwner = await createExpiredUser();
+ const fixture = await seedAsset({ allowDownloads: true, ownerUser: expiredOwner });
+ const commentator = await createUser();
+ await addProjectMember({
+ projectId: fixture.project.id,
+ userId: commentator.id,
+ role: 'COMMENTATOR',
+ });
+ signedInAs(commentator);
+
+ const response = await callRoute(listAssets, apiRequest(assetsUrl(fixture.video.id)), {
+ videoId: fixture.video.id,
+ });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('lets a project COMMENTATOR list the assets', async () => {
+ const fixture = await seedAsset();
+ const commentator = await createUser();
+ await addProjectMember({
+ projectId: fixture.project.id,
+ userId: commentator.id,
+ role: 'COMMENTATOR',
+ });
+ signedInAs(commentator);
+
+ const response = await callRoute(listAssets, apiRequest(assetsUrl(fixture.video.id)), {
+ videoId: fixture.video.id,
+ });
+
+ expect(response.status).toBe(200);
+ const payload = await readData<{ assets: Array<{ id: string }> }>(response);
+ expect(payload.assets.map((asset) => asset.id)).toEqual([fixture.asset.id]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// POST /api/videos/[videoId]/assets
+// ---------------------------------------------------------------------------
+// `canUploadAssets` is intentionally generous: a COMMENTATOR is meant to be able
+// to attach a reference clip. Generous is not the same as open, and the cases
+// below are the difference.
+describe('POST /api/videos/[videoId]/assets', () => {
+ it('returns 403 to a signed-in stranger and writes no asset', async () => {
+ const fixture = await seedAsset();
+ await seedProject();
+ const stranger = await createUser();
+ signedInAs(stranger);
+
+ const response = await callRoute(
+ createAsset,
+ apiRequest(assetsUrl(fixture.video.id), {
+ body: { provider: 'YOUTUBE', sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
+ }),
+ { videoId: fixture.video.id }
+ );
+
+ expect(response.status).toBe(403);
+ expect(await db.videoAsset.count()).toBe(1);
+ });
+
+ it('returns 403 to a project COMMENTATOR once the workspace owner loses billing', async () => {
+ const expiredOwner = await createExpiredUser();
+ const fixture = await seedAsset({ allowDownloads: false, ownerUser: expiredOwner });
+ const commentator = await createUser();
+ await addProjectMember({
+ projectId: fixture.project.id,
+ userId: commentator.id,
+ role: 'COMMENTATOR',
+ });
+ signedInAs(commentator);
+
+ const response = await callRoute(
+ createAsset,
+ apiRequest(assetsUrl(fixture.video.id), {
+ body: { provider: 'YOUTUBE', sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
+ }),
+ { videoId: fixture.video.id }
+ );
+
+ expect(response.status).toBe(403);
+ expect(await db.videoAsset.count()).toBe(1);
+ });
+
+ // The IDOR shape: a caller who legitimately uploads assets to their own video,
+ // aiming the same request at a video id out of another workspace.
+ it('returns 403 for a video id belonging to another workspace', async () => {
+ const mine = await seedAsset();
+ const theirs = await seedAsset();
+ signedInAs(mine.owner);
+
+ const response = await callRoute(
+ createAsset,
+ apiRequest(assetsUrl(theirs.video.id), {
+ body: { provider: 'YOUTUBE', sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
+ }),
+ { videoId: theirs.video.id }
+ );
+
+ expect(response.status).toBe(403);
+ expect(await db.videoAsset.count({ where: { videoId: theirs.video.id } })).toBe(1);
+ });
+
+ // The positive control. The access check runs before the body is parsed, so an
+ // authorized COMMENTATOR sending a deliberately bogus provider gets the 400
+ // from the validation underneath. 400 is a status the three refusals above
+ // cannot produce, which is what proves they came from the guard.
+ it('gets a project COMMENTATOR past the access check and onto body validation', async () => {
+ const fixture = await seedAsset();
+ const commentator = await createUser();
+ await addProjectMember({
+ projectId: fixture.project.id,
+ userId: commentator.id,
+ role: 'COMMENTATOR',
+ });
+ signedInAs(commentator);
+
+ const response = await callRoute(
+ createAsset,
+ apiRequest(assetsUrl(fixture.video.id), { body: { provider: 'NOT_A_REAL_PROVIDER' } }),
+ { videoId: fixture.video.id }
+ );
+
+ expect(response.status).toBe(400);
+ expect(await readError(response)).toContain('Invalid provider');
+ expect(await db.videoAsset.count()).toBe(1);
+ });
+
+ // And the same probe from the stranger, to show the ordering is real: identical
+ // body, identical URL, and the guard answers first.
+ it('still returns 403 to a stranger sending the same invalid body', async () => {
+ const fixture = await seedAsset();
+ const stranger = await createUser();
+ signedInAs(stranger);
+
+ const response = await callRoute(
+ createAsset,
+ apiRequest(assetsUrl(fixture.video.id), { body: { provider: 'NOT_A_REAL_PROVIDER' } }),
+ { videoId: fixture.video.id }
+ );
+
+ expect(response.status).toBe(403);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// DELETE /api/videos/[videoId]/assets/[assetId]
+// ---------------------------------------------------------------------------
+// Two gates in sequence: `canUploadAssets` to be in the room at all, then
+// `canDeleteAssetForViewer` which lets a COMMENTATOR remove only what they
+// uploaded themselves. Both need their own negative case, because collapsing the
+// second one is invisible from outside unless a test actually seeds an asset that
+// belongs to somebody else.
+describe('DELETE /api/videos/[videoId]/assets/[assetId]', () => {
+ it('returns 403 to a signed-in stranger and keeps the asset', async () => {
+ const fixture = await seedAsset();
+ await seedProject();
+ const stranger = await createUser();
+ signedInAs(stranger);
+
+ const response = await callRoute(
+ deleteAsset,
+ apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }),
+ { videoId: fixture.video.id, assetId: fixture.asset.id }
+ );
+
+ expect(response.status).toBe(403);
+ expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(1);
+ });
+
+ // The second gate. This caller is a legitimate member who may upload assets of
+ // their own; what they may not do is delete the owner's.
+ it("returns 403 when a project COMMENTATOR deletes somebody else's asset", async () => {
+ const fixture = await seedAsset();
+ const commentator = await createUser();
+ await addProjectMember({
+ projectId: fixture.project.id,
+ userId: commentator.id,
+ role: 'COMMENTATOR',
+ });
+ signedInAs(commentator);
+
+ const response = await callRoute(
+ deleteAsset,
+ apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }),
+ { videoId: fixture.video.id, assetId: fixture.asset.id }
+ );
+
+ expect(response.status).toBe(403);
+ expect(await readError(response)).toContain('only delete assets you uploaded');
+ expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(1);
+ });
+
+ it("returns 403 when a workspace COMMENTATOR deletes the owner's asset", async () => {
+ const fixture = await seedAsset();
+ const workspaceCommentator = await createUser();
+ await addWorkspaceMember({
+ workspaceId: fixture.workspace.id,
+ userId: workspaceCommentator.id,
+ role: 'COMMENTATOR',
+ });
+ signedInAs(workspaceCommentator);
+
+ const response = await callRoute(
+ deleteAsset,
+ apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }),
+ { videoId: fixture.video.id, assetId: fixture.asset.id }
+ );
+
+ expect(response.status).toBe(403);
+ expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(1);
+ });
+
+ it('returns 403 to the owner once their own billing access has lapsed', async () => {
+ const expiredOwner = await createExpiredUser();
+ const fixture = await seedAsset({ allowDownloads: false, ownerUser: expiredOwner });
+ signedInAs(expiredOwner);
+
+ const response = await callRoute(
+ deleteAsset,
+ apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }),
+ { videoId: fixture.video.id, assetId: fixture.asset.id }
+ );
+
+ expect(response.status).toBe(403);
+ expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(1);
+ });
+
+ // Identifier substitution against a route the caller does legitimately reach:
+ // their own videoId in the path, somebody else's assetId in the query. The
+ // lookup pairs the two, so it misses.
+ it('returns 404 for a foreign asset id pasted onto my own video', async () => {
+ const mine = await seedAsset();
+ const theirs = await seedAsset();
+ signedInAs(mine.owner);
+
+ const response = await callRoute(
+ deleteAsset,
+ apiRequest(assetUrl(mine.video.id, theirs.asset.id), { method: 'DELETE' }),
+ { videoId: mine.video.id, assetId: theirs.asset.id }
+ );
+
+ expect(response.status).toBe(404);
+ expect(await db.videoAsset.count({ where: { id: theirs.asset.id } })).toBe(1);
+ expect(await db.videoAsset.count({ where: { id: mine.asset.id } })).toBe(1);
+ });
+
+ // The matching pair with both foreign ids, which is the request an attacker who
+ // has read an id out of a shared link would actually send. Here the row is
+ // found, so the refusal has to come from the access context.
+ it('returns 403 for a foreign asset reached through its own foreign video id', async () => {
+ const mine = await seedAsset();
+ const theirs = await seedAsset();
+ signedInAs(mine.owner);
+
+ const response = await callRoute(
+ deleteAsset,
+ apiRequest(assetUrl(theirs.video.id, theirs.asset.id), { method: 'DELETE' }),
+ { videoId: theirs.video.id, assetId: theirs.asset.id }
+ );
+
+ expect(response.status).toBe(403);
+ expect(await db.videoAsset.count({ where: { id: theirs.asset.id } })).toBe(1);
+ });
+
+ it('lets the project owner delete the asset', async () => {
+ const fixture = await seedAsset();
+ signedInAs(fixture.owner);
+
+ const response = await callRoute(
+ deleteAsset,
+ apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }),
+ { videoId: fixture.video.id, assetId: fixture.asset.id }
+ );
+
+ expect(response.status).toBe(200);
+ expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(0);
+ });
+
+ // The positive control for the second gate specifically: same role, same route,
+ // and the only thing that changed is who uploaded the row.
+ it('lets a project COMMENTATOR delete an asset they uploaded themselves', async () => {
+ const fixture = await seedAsset();
+ const commentator = await createUser();
+ await addProjectMember({
+ projectId: fixture.project.id,
+ userId: commentator.id,
+ role: 'COMMENTATOR',
+ });
+ const own = await createVideoAsset({
+ videoId: fixture.video.id,
+ billedUserId: fixture.owner.id,
+ kind: 'VIDEO',
+ provider: 'YOUTUBE',
+ displayName: 'Uploaded by the commentator',
+ sourceUrl: `https://www.youtube.com/watch?v=own${nextSeq()}`,
+ uploadedByUserId: commentator.id,
+ });
+ signedInAs(commentator);
+
+ const response = await callRoute(
+ deleteAsset,
+ apiRequest(assetUrl(fixture.video.id, own.id), { method: 'DELETE' }),
+ { videoId: fixture.video.id, assetId: own.id }
+ );
+
+ expect(response.status).toBe(200);
+ expect(await db.videoAsset.count({ where: { id: own.id } })).toBe(0);
+ // The owner's asset was never in scope and is still there.
+ expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(1);
+ });
+
+ it("lets a project ADMIN delete the owner's asset", async () => {
+ const fixture = await seedAsset();
+ const admin = await createUser();
+ await addProjectMember({ projectId: fixture.project.id, userId: admin.id, role: 'ADMIN' });
+ signedInAs(admin);
+
+ const response = await callRoute(
+ deleteAsset,
+ apiRequest(assetUrl(fixture.video.id, fixture.asset.id), { method: 'DELETE' }),
+ { videoId: fixture.video.id, assetId: fixture.asset.id }
+ );
+
+ expect(response.status).toBe(200);
+ expect(await db.videoAsset.count({ where: { id: fixture.asset.id } })).toBe(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// GET /api/videos/[videoId]/assets/[assetId]/download
+// ---------------------------------------------------------------------------
+// Two refusals with two different messages: `hasViewAccess` for people who should
+// not see the video at all, and `canDownloadAssets` for members of a project whose
+// owner has turned exports off. Both are pinned, because merging them would look
+// like a tidy-up and would quietly hand the files to every viewer.
+describe('GET /api/videos/[videoId]/assets/[assetId]/download', () => {
+ it('returns 403 to a signed-in stranger', async () => {
+ const fixture = await seedAsset({ allowDownloads: true });
+ await seedProject();
+ const stranger = await createUser();
+ signedInAs(stranger);
+
+ const response = await callRoute(
+ downloadAsset,
+ apiRequest(`${assetUrl(fixture.video.id, fixture.asset.id)}/download`),
+ { videoId: fixture.video.id, assetId: fixture.asset.id }
+ );
+
+ expect(response.status).toBe(403);
+ expect(await readError(response)).toContain('Access denied');
+ });
+
+ it('returns 403 to a project COMMENTATOR when downloads are disabled', async () => {
+ const fixture = await seedAsset({ allowDownloads: false });
+ const commentator = await createUser();
+ await addProjectMember({
+ projectId: fixture.project.id,
+ userId: commentator.id,
+ role: 'COMMENTATOR',
+ });
+ signedInAs(commentator);
+
+ const response = await callRoute(
+ downloadAsset,
+ apiRequest(`${assetUrl(fixture.video.id, fixture.asset.id)}/download`),
+ { videoId: fixture.video.id, assetId: fixture.asset.id }
+ );
+
+ expect(response.status).toBe(403);
+ expect(await readError(response)).toContain('Downloads are disabled');
+ });
+
+ // Positive control: same COMMENTATOR, same asset, allowDownloads flipped on.
+ // The request now clears both gates and stops on the provider check, a 400 that
+ // neither refusal above can produce.
+ it('gets the same COMMENTATOR past both gates once allowDownloads is on', async () => {
+ const fixture = await seedAsset({ allowDownloads: true });
+ const commentator = await createUser();
+ await addProjectMember({
+ projectId: fixture.project.id,
+ userId: commentator.id,
+ role: 'COMMENTATOR',
+ });
+ signedInAs(commentator);
+
+ const response = await callRoute(
+ downloadAsset,
+ apiRequest(`${assetUrl(fixture.video.id, fixture.asset.id)}/download`),
+ { videoId: fixture.video.id, assetId: fixture.asset.id }
+ );
+
+ expect(response.status).toBe(400);
+ expect(await readError(response)).toContain('YouTube assets cannot be downloaded');
+ });
+
+ it('gets the owner past both gates even when allowDownloads is off', async () => {
+ const fixture = await seedAsset({ allowDownloads: false });
+ signedInAs(fixture.owner);
+
+ const response = await callRoute(
+ downloadAsset,
+ apiRequest(`${assetUrl(fixture.video.id, fixture.asset.id)}/download`),
+ { videoId: fixture.video.id, assetId: fixture.asset.id }
+ );
+
+ expect(response.status).toBe(400);
+ expect(await readError(response)).toContain('YouTube assets cannot be downloaded');
+ });
+
+ it('returns 404 for a foreign asset id pasted onto my own video', async () => {
+ const mine = await seedAsset({ allowDownloads: true });
+ const theirs = await seedAsset({ allowDownloads: true });
+ signedInAs(mine.owner);
+
+ const response = await callRoute(
+ downloadAsset,
+ apiRequest(`${assetUrl(mine.video.id, theirs.asset.id)}/download`),
+ { videoId: mine.video.id, assetId: theirs.asset.id }
+ );
+
+ expect(response.status).toBe(404);
+ });
+
+ it('returns 403 for a foreign asset reached through its own foreign video id', async () => {
+ const mine = await seedAsset({ allowDownloads: true });
+ const theirs = await seedAsset({ allowDownloads: true });
+ signedInAs(mine.owner);
+
+ const response = await callRoute(
+ downloadAsset,
+ apiRequest(`${assetUrl(theirs.video.id, theirs.asset.id)}/download`),
+ { videoId: theirs.video.id, assetId: theirs.asset.id }
+ );
+
+ expect(response.status).toBe(403);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// The two upload-init routes
+// ---------------------------------------------------------------------------
+// Both hand out an upload credential, so a caller who gets through them can spend
+// the workspace owner's storage quota. Direct uploads are unconfigured in the test
+// environment, which is what gives each of these a positive control that stops one
+// step past the guard without touching a provider.
+describe('POST /api/videos/[videoId]/assets/r2-init', () => {
+ it('returns 403 to a signed-in stranger and reserves nothing', async () => {
+ const fixture = await seedAsset();
+ await seedProject();
+ const stranger = await createUser();
+ signedInAs(stranger);
+
+ const response = await callRoute(
+ initAssetR2Upload,
+ apiRequest(`${assetsUrl(fixture.video.id)}/r2-init`, {
+ body: { fileName: 'clip.mp4', sizeBytes: '1024', contentType: 'video/mp4' },
+ }),
+ { videoId: fixture.video.id }
+ );
+
+ expect(response.status).toBe(403);
+ expect(await db.uploadReservation.count()).toBe(0);
+ });
+
+ it('returns 403 for a video id belonging to another workspace', async () => {
+ const mine = await seedAsset();
+ const theirs = await seedAsset();
+ signedInAs(mine.owner);
+
+ const response = await callRoute(
+ initAssetR2Upload,
+ apiRequest(`${assetsUrl(theirs.video.id)}/r2-init`, {
+ body: { fileName: 'clip.mp4', sizeBytes: '1024', contentType: 'video/mp4' },
+ }),
+ { videoId: theirs.video.id }
+ );
+
+ expect(response.status).toBe(403);
+ expect(await db.uploadReservation.count()).toBe(0);
+ });
+
+ it('gets a project COMMENTATOR past the access check onto the disabled-feature check', async () => {
+ const fixture = await seedAsset();
+ const commentator = await createUser();
+ await addProjectMember({
+ projectId: fixture.project.id,
+ userId: commentator.id,
+ role: 'COMMENTATOR',
+ });
+ signedInAs(commentator);
+
+ const response = await callRoute(
+ initAssetR2Upload,
+ apiRequest(`${assetsUrl(fixture.video.id)}/r2-init`, {
+ body: { fileName: 'clip.mp4', sizeBytes: '1024', contentType: 'video/mp4' },
+ }),
+ { videoId: fixture.video.id }
+ );
+
+ expect(response.status).toBe(400);
+ expect(await readError(response)).toContain('S3 video uploads are disabled');
+ expect(await db.uploadReservation.count()).toBe(0);
+ });
+});
+
+describe('POST /api/videos/[videoId]/assets/bunny-init', () => {
+ it('returns 403 to a signed-in stranger', async () => {
+ const fixture = await seedAsset();
+ await seedProject();
+ const stranger = await createUser();
+ signedInAs(stranger);
+
+ const response = await callRoute(
+ initAssetBunnyUpload,
+ apiRequest(`${assetsUrl(fixture.video.id)}/bunny-init`, { body: { title: 'A clip' } }),
+ { videoId: fixture.video.id }
+ );
+
+ expect(response.status).toBe(403);
+ });
+
+ it('returns 403 to a project COMMENTATOR once the workspace owner loses billing', async () => {
+ const expiredOwner = await createExpiredUser();
+ const fixture = await seedAsset({ allowDownloads: false, ownerUser: expiredOwner });
+ const commentator = await createUser();
+ await addProjectMember({
+ projectId: fixture.project.id,
+ userId: commentator.id,
+ role: 'COMMENTATOR',
+ });
+ signedInAs(commentator);
+
+ const response = await callRoute(
+ initAssetBunnyUpload,
+ apiRequest(`${assetsUrl(fixture.video.id)}/bunny-init`, { body: { title: 'A clip' } }),
+ { videoId: fixture.video.id }
+ );
+
+ expect(response.status).toBe(403);
+ });
+
+ it('gets a project COMMENTATOR past the access check onto the disabled-feature check', async () => {
+ const fixture = await seedAsset();
+ const commentator = await createUser();
+ await addProjectMember({
+ projectId: fixture.project.id,
+ userId: commentator.id,
+ role: 'COMMENTATOR',
+ });
+ signedInAs(commentator);
+
+ const response = await callRoute(
+ initAssetBunnyUpload,
+ apiRequest(`${assetsUrl(fixture.video.id)}/bunny-init`, { body: { title: 'A clip' } }),
+ { videoId: fixture.video.id }
+ );
+
+ expect(response.status).toBe(400);
+ expect(await readError(response)).toContain('Direct uploads are disabled');
+ });
+});
diff --git a/tests/api/auth-matrix.test.ts b/tests/api/auth-matrix.test.ts
new file mode 100644
index 0000000..8152d77
--- /dev/null
+++ b/tests/api/auth-matrix.test.ts
@@ -0,0 +1,1003 @@
+// A sweep over every route module under app/api asserting that an
+// unauthenticated caller can never reach a 2xx.
+//
+// Three properties make this more than a smoke test:
+//
+// 1. The routes are enumerated by walking app/api on disk and cross-checked
+// against the table below. Add a route and this file fails until someone
+// classifies it as guarded or public. That is the point: the classification
+// is a reviewable diff, not an omission nobody notices.
+//
+// 2. Every id in the table is a real row, seeded per test. A matrix built on
+// made-up ids passes even with the authorization deleted, because the route
+// 404s before it ever checks anything. Here the project exists, the video
+// exists, the comment exists, and the only reason the call fails is the
+// access check.
+//
+// 3. A 500 counts as a failure. Rejecting an anonymous caller by crashing is
+// not rejecting it.
+//
+// The project is PRIVATE and no share-session cookie is sent, so nothing here
+// is legitimately reachable without a session.
+
+import fs from 'node:fs';
+import path from 'node:path';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { db } from '@/lib/db';
+import { REPO_ROOT } from '../helpers/env';
+import { apiRequest, callRoute, readData, type RouteHandler } from '../helpers/request';
+import { signedInAs, signedOut } from '../helpers/session';
+import {
+ addProjectMember,
+ addWorkspaceMember,
+ createApprovalRequest,
+ createComment,
+ createCommentTag,
+ createProject,
+ createShareLink,
+ createUser,
+ createVersion,
+ createVideo,
+ createVideoAsset,
+ createWorkspace,
+ createInvitation,
+} from '../factories';
+
+import * as adminFeedbackRoute from '@/app/api/admin/feedback/[feedbackId]/route';
+import * as adminRefreshR2Route from '@/app/api/admin/stats/refresh-r2/route';
+import * as approvalCancelRoute from '@/app/api/approvals/[requestId]/cancel/route';
+import * as approvalDecisionRoute from '@/app/api/approvals/[requestId]/decision/route';
+import * as billingCheckoutRoute from '@/app/api/billing/checkout/route';
+import * as billingPortalRoute from '@/app/api/billing/portal/route';
+import * as billingRoute from '@/app/api/billing/route';
+import * as commentRoute from '@/app/api/comments/[commentId]/route';
+import * as feedbackRoute from '@/app/api/feedback/route';
+import * as feedbackUploadRoute from '@/app/api/feedback/upload/route';
+import * as onboardingCompleteRoute from '@/app/api/onboarding/complete/route';
+import * as approvalCandidatesRoute from '@/app/api/projects/[projectId]/approval-candidates/route';
+import * as projectDownloadRoute from '@/app/api/projects/[projectId]/download/route';
+import * as projectInvitationRoute from '@/app/api/projects/[projectId]/members/invitations/[invitationId]/route';
+import * as projectMemberRoute from '@/app/api/projects/[projectId]/members/[memberId]/route';
+import * as projectMembersRoute from '@/app/api/projects/[projectId]/members/route';
+import * as projectRoute from '@/app/api/projects/[projectId]/route';
+import * as projectTagsRoute from '@/app/api/projects/[projectId]/tags/route';
+import * as projectTagRoute from '@/app/api/projects/[projectId]/tags/[tagId]/route';
+import * as videosBulkDeleteRoute from '@/app/api/projects/[projectId]/videos/bulk-delete/route';
+import * as videosBunnyInitRoute from '@/app/api/projects/[projectId]/videos/bunny-init/route';
+import * as videosMoveRoute from '@/app/api/projects/[projectId]/videos/move/route';
+import * as videosR2CompleteRoute from '@/app/api/projects/[projectId]/videos/r2-complete/route';
+import * as videosR2InitRoute from '@/app/api/projects/[projectId]/videos/r2-init/route';
+import * as projectVideosRoute from '@/app/api/projects/[projectId]/videos/route';
+import * as projectVideoRoute from '@/app/api/projects/[projectId]/videos/[videoId]/route';
+import * as videoShareRoute from '@/app/api/projects/[projectId]/videos/[videoId]/share/route';
+import * as videoVersionsRoute from '@/app/api/projects/[projectId]/videos/[videoId]/versions/route';
+import * as videoVersionRoute from '@/app/api/projects/[projectId]/videos/[videoId]/versions/[versionId]/route';
+import * as projectsRoute from '@/app/api/projects/route';
+import * as searchRoute from '@/app/api/search/route';
+import * as settingsNotificationsRoute from '@/app/api/settings/notifications/route';
+import * as settingsStorageRoute from '@/app/api/settings/storage/route';
+import * as uploadAudioFileRoute from '@/app/api/upload/audio/[filename]/route';
+import * as uploadAudioRoute from '@/app/api/upload/audio/route';
+import * as uploadImageFileRoute from '@/app/api/upload/image/[filename]/route';
+import * as uploadImageRoute from '@/app/api/upload/image/route';
+import * as uploadVideoFileRoute from '@/app/api/upload/video/[filename]/route';
+import * as versionApprovalsRoute from '@/app/api/versions/[versionId]/approvals/route';
+import * as commentsExportRoute from '@/app/api/versions/[versionId]/comments/export/route';
+import * as versionCommentsRoute from '@/app/api/versions/[versionId]/comments/route';
+import * as versionDownloadRoute from '@/app/api/versions/[versionId]/download/route';
+import * as assetDownloadRoute from '@/app/api/videos/[videoId]/assets/[assetId]/download/route';
+import * as assetRoute from '@/app/api/videos/[videoId]/assets/[assetId]/route';
+import * as assetsBunnyInitRoute from '@/app/api/videos/[videoId]/assets/bunny-init/route';
+import * as assetsR2InitRoute from '@/app/api/videos/[videoId]/assets/r2-init/route';
+import * as assetsRoute from '@/app/api/videos/[videoId]/assets/route';
+import * as watchProgressRoute from '@/app/api/watch/[videoId]/progress/route';
+import * as watchRoute from '@/app/api/watch/[videoId]/route';
+import * as watchUploadTokenRoute from '@/app/api/watch/[videoId]/upload-token/route';
+import * as workspacesRoute from '@/app/api/workspaces/route';
+import * as workspaceInvitationRoute from '@/app/api/workspaces/[workspaceId]/members/invitations/[invitationId]/route';
+import * as workspaceMemberRoute from '@/app/api/workspaces/[workspaceId]/members/[memberId]/route';
+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
+// ---------------------------------------------------------------------------
+// Bump this only together with a new entry in ROUTE_CASES or in PUBLIC_ROUTES.
+const EXPECTED_ROUTE_MODULE_COUNT = 60;
+
+/**
+ * Routes that are public by design, and why. Everything else must reject an
+ * anonymous caller. Moving a file into this set is the visible diff that says
+ * "this endpoint is now reachable without a session".
+ */
+const PUBLIC_ROUTES: ReadonlyMap = new Map([
+ [
+ 'auth/[...nextauth]/route.ts',
+ // The NextAuth handler itself: sign-in, callback and CSRF endpoints. It has
+ // to be reachable by a caller who has no session yet, by definition.
+ 'NextAuth sign-in/callback handler',
+ ],
+ [
+ 'auth/register/route.ts',
+ // Account creation. Gated by OPENFRAME_REQUIRE_INVITE_CODE plus an IP rate
+ // limit rather than by a session. Covered in tests/api/register.test.ts.
+ 'account creation, gated by the invite code',
+ ],
+ [
+ 'auth/verify-email/route.ts',
+ // Reached by clicking a link in an email, before the user can sign in.
+ // Authenticated by the one-time token in the query string.
+ 'email verification link, authenticated by a single-use token',
+ ],
+ [
+ 'auth/verify-email/resend/route.ts',
+ // A user who cannot sign in because they are unverified has no session to
+ // present. Rate limited by IP, and answers identically for unknown emails
+ // so it cannot be used to enumerate accounts.
+ 'resend of the verification email, for users who cannot sign in yet',
+ ],
+ [
+ 'stripe/webhook/route.ts',
+ // Called by Stripe, not by a browser. Authenticated by the HMAC signature
+ // in the stripe-signature header. Covered in
+ // tests/api/stripe-webhook.test.ts, including the rejection of a bad one.
+ 'Stripe webhook, authenticated by an HMAC signature',
+ ],
+]);
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+const IMAGE_FILENAME = '11111111-1111-4111-8111-111111111111.png';
+const AUDIO_FILENAME = '22222222-2222-4222-8222-222222222222.webm';
+const VIDEO_FILENAME = '33333333-3333-4333-8333-333333333333.mp4';
+
+interface Fixtures {
+ userId: string;
+ workspaceId: string;
+ workspaceMemberId: string;
+ workspaceInvitationId: string;
+ projectId: string;
+ projectMemberId: string;
+ projectInvitationId: string;
+ tagId: string;
+ videoId: string;
+ versionId: string;
+ commentId: string;
+ assetId: string;
+ approvalRequestId: string;
+ feedbackId: string;
+}
+
+async function seedFixtures(): Promise {
+ const owner = await createUser();
+ const collaborator = await createUser();
+
+ const workspace = await createWorkspace({ ownerId: owner.id });
+ const workspaceMember = await addWorkspaceMember({
+ workspaceId: workspace.id,
+ userId: collaborator.id,
+ });
+ const workspaceInvitation = await createInvitation({
+ invitedById: owner.id,
+ scope: 'WORKSPACE',
+ workspaceId: workspace.id,
+ });
+
+ // PRIVATE on purpose. A PUBLIC project grants anonymous read access through
+ // computeProjectAccess(), which would make several of the GET routes return
+ // 200 for entirely legitimate reasons and hide the ones that should not.
+ const project = await createProject({
+ ownerId: owner.id,
+ workspaceId: workspace.id,
+ visibility: 'PRIVATE',
+ allowDownloads: true,
+ });
+ const projectMember = await addProjectMember({
+ projectId: project.id,
+ userId: collaborator.id,
+ });
+ const projectInvitation = await createInvitation({
+ invitedById: owner.id,
+ scope: 'PROJECT',
+ projectId: project.id,
+ });
+ const tag = await createCommentTag({ projectId: project.id });
+
+ const video = await createVideo({ projectId: project.id });
+ const version = await createVersion({
+ videoParentId: video.id,
+ providerId: 'r2',
+ providerVideoId: `videos/${VIDEO_FILENAME}`,
+ originalUrl: `/api/upload/video/${VIDEO_FILENAME}`,
+ sizeBytes: BigInt(1024),
+ });
+ const comment = await createComment({ versionId: version.id, authorId: owner.id });
+
+ const asset = await createVideoAsset({
+ videoId: video.id,
+ billedUserId: owner.id,
+ sourceUrl: `/api/upload/image/${IMAGE_FILENAME}`,
+ });
+ // A second asset so /api/upload/audio/[filename] resolves to a real row too.
+ await createVideoAsset({
+ videoId: video.id,
+ billedUserId: owner.id,
+ kind: 'AUDIO',
+ provider: 'R2_AUDIO',
+ sourceUrl: `/api/upload/audio/${AUDIO_FILENAME}`,
+ });
+
+ await createShareLink({ projectId: project.id, videoId: video.id, permission: 'COMMENT' });
+
+ const approvalRequest = await createApprovalRequest({
+ versionId: version.id,
+ requestedById: owner.id,
+ approverIds: [collaborator.id],
+ });
+
+ const feedback = await db.userFeedback.create({
+ data: {
+ userId: owner.id,
+ type: 'FEEDBACK',
+ title: 'Matrix fixture feedback',
+ message: 'Seeded so the admin delete route has a real row to refuse.',
+ },
+ });
+
+ return {
+ userId: owner.id,
+ workspaceId: workspace.id,
+ workspaceMemberId: workspaceMember.id,
+ workspaceInvitationId: workspaceInvitation.id,
+ projectId: project.id,
+ projectMemberId: projectMember.id,
+ projectInvitationId: projectInvitation.id,
+ tagId: tag.id,
+ videoId: video.id,
+ versionId: version.id,
+ commentId: comment.id,
+ assetId: asset.id,
+ approvalRequestId: approvalRequest.id,
+ feedbackId: feedback.id,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// The table
+// ---------------------------------------------------------------------------
+
+type ParamRecord = Record;
+
+interface RouteCase {
+ /** Path of the route module relative to app/api. */
+ file: string;
+ module: Record;
+ url: (fixtures: Fixtures) => string;
+ params?: (fixtures: Fixtures) => ParamRecord;
+ /** JSON body for the non-GET methods. A valid `{}` by default, so that a
+ * route which parses before authorizing rejects rather than crashes. */
+ body?: unknown;
+ /** Replaces `body`, for the routes that read request.formData(). */
+ rawBody?: (fixtures: Fixtures) => BodyInit;
+ headers?: Record;
+}
+
+/**
+ * A multipart body that gets past the shape checks in the two upload routes and
+ * reaches their access check.
+ *
+ * This is not decoration. Both routes validate the request before they
+ * authorize: /api/upload/image bails with "Missing Content-Length header" at its
+ * first line, and /api/upload/audio bails with "No audio file provided" before
+ * checkProjectAccess() is ever called. An empty FormData therefore produced a
+ * 400 for an anonymous caller *and* an identical 400 for the workspace owner,
+ * which means the assertion below held with the authorization deleted. Sending a
+ * real file and a real videoId is what makes the 403 come from the access check.
+ */
+function uploadForm(field: 'image' | 'audio', fixtures: Fixtures): FormData {
+ const form = new FormData();
+ form.append(field, new File([new Uint8Array([1, 2, 3, 4])], `anon.${field}`));
+ form.append('videoId', fixtures.videoId);
+ return form;
+}
+
+const ROUTE_CASES: readonly RouteCase[] = [
+ {
+ file: 'admin/feedback/[feedbackId]/route.ts',
+ module: adminFeedbackRoute,
+ url: (f) => `/api/admin/feedback/${f.feedbackId}`,
+ params: (f) => ({ feedbackId: f.feedbackId }),
+ },
+ {
+ file: 'admin/stats/refresh-r2/route.ts',
+ module: adminRefreshR2Route,
+ url: () => '/api/admin/stats/refresh-r2',
+ },
+ {
+ file: 'approvals/[requestId]/cancel/route.ts',
+ module: approvalCancelRoute,
+ url: (f) => `/api/approvals/${f.approvalRequestId}/cancel`,
+ params: (f) => ({ requestId: f.approvalRequestId }),
+ },
+ {
+ file: 'approvals/[requestId]/decision/route.ts',
+ module: approvalDecisionRoute,
+ url: (f) => `/api/approvals/${f.approvalRequestId}/decision`,
+ params: (f) => ({ requestId: f.approvalRequestId }),
+ body: { decision: 'APPROVED' },
+ },
+ {
+ file: 'billing/checkout/route.ts',
+ module: billingCheckoutRoute,
+ url: () => '/api/billing/checkout',
+ headers: { origin: 'http://localhost:3000' },
+ },
+ {
+ file: 'billing/portal/route.ts',
+ module: billingPortalRoute,
+ url: () => '/api/billing/portal',
+ headers: { origin: 'http://localhost:3000' },
+ },
+ { file: 'billing/route.ts', module: billingRoute, url: () => '/api/billing' },
+ {
+ file: 'comments/[commentId]/route.ts',
+ module: commentRoute,
+ url: (f) => `/api/comments/${f.commentId}`,
+ params: (f) => ({ commentId: f.commentId }),
+ body: { content: 'edited by an anonymous caller' },
+ },
+ {
+ file: 'feedback/route.ts',
+ module: feedbackRoute,
+ url: () => '/api/feedback',
+ body: { type: 'FEEDBACK', title: 'anon', message: 'anon' },
+ },
+ {
+ file: 'feedback/upload/route.ts',
+ module: feedbackUploadRoute,
+ url: () => '/api/feedback/upload',
+ rawBody: () => new FormData(),
+ },
+ {
+ file: 'onboarding/complete/route.ts',
+ module: onboardingCompleteRoute,
+ url: () => '/api/onboarding/complete',
+ },
+ {
+ file: 'projects/[projectId]/approval-candidates/route.ts',
+ module: approvalCandidatesRoute,
+ url: (f) => `/api/projects/${f.projectId}/approval-candidates`,
+ params: (f) => ({ projectId: f.projectId }),
+ },
+ {
+ file: 'projects/[projectId]/download/route.ts',
+ module: projectDownloadRoute,
+ url: (f) => `/api/projects/${f.projectId}/download`,
+ params: (f) => ({ projectId: f.projectId }),
+ },
+ {
+ file: 'projects/[projectId]/members/invitations/[invitationId]/route.ts',
+ module: projectInvitationRoute,
+ url: (f) => `/api/projects/${f.projectId}/members/invitations/${f.projectInvitationId}`,
+ params: (f) => ({ projectId: f.projectId, invitationId: f.projectInvitationId }),
+ },
+ {
+ file: 'projects/[projectId]/members/[memberId]/route.ts',
+ module: projectMemberRoute,
+ url: (f) => `/api/projects/${f.projectId}/members/${f.projectMemberId}`,
+ params: (f) => ({ projectId: f.projectId, memberId: f.projectMemberId }),
+ body: { role: 'ADMIN' },
+ },
+ {
+ file: 'projects/[projectId]/members/route.ts',
+ module: projectMembersRoute,
+ url: (f) => `/api/projects/${f.projectId}/members`,
+ params: (f) => ({ projectId: f.projectId }),
+ body: { email: 'anon@example.com', role: 'ADMIN' },
+ },
+ {
+ file: 'projects/[projectId]/route.ts',
+ module: projectRoute,
+ url: (f) => `/api/projects/${f.projectId}`,
+ params: (f) => ({ projectId: f.projectId }),
+ body: { name: 'renamed by an anonymous caller' },
+ },
+ {
+ file: 'projects/[projectId]/tags/route.ts',
+ module: projectTagsRoute,
+ url: (f) => `/api/projects/${f.projectId}/tags`,
+ params: (f) => ({ projectId: f.projectId }),
+ body: { name: 'Anon', color: '#ff0000' },
+ },
+ {
+ file: 'projects/[projectId]/tags/[tagId]/route.ts',
+ module: projectTagRoute,
+ url: (f) => `/api/projects/${f.projectId}/tags/${f.tagId}`,
+ params: (f) => ({ projectId: f.projectId, tagId: f.tagId }),
+ body: { name: 'Anon' },
+ },
+ {
+ file: 'projects/[projectId]/videos/bulk-delete/route.ts',
+ module: videosBulkDeleteRoute,
+ url: (f) => `/api/projects/${f.projectId}/videos/bulk-delete`,
+ params: (f) => ({ projectId: f.projectId }),
+ body: { videoIds: ['does-not-matter'] },
+ },
+ {
+ file: 'projects/[projectId]/videos/bunny-init/route.ts',
+ module: videosBunnyInitRoute,
+ url: (f) => `/api/projects/${f.projectId}/videos/bunny-init`,
+ params: (f) => ({ projectId: f.projectId }),
+ body: { title: 'anon' },
+ },
+ {
+ file: 'projects/[projectId]/videos/move/route.ts',
+ module: videosMoveRoute,
+ url: (f) => `/api/projects/${f.projectId}/videos/move`,
+ params: (f) => ({ projectId: f.projectId }),
+ body: { videoIds: ['x'], targetProjectId: 'y' },
+ },
+ {
+ file: 'projects/[projectId]/videos/r2-complete/route.ts',
+ module: videosR2CompleteRoute,
+ url: (f) => `/api/projects/${f.projectId}/videos/r2-complete`,
+ params: (f) => ({ projectId: f.projectId }),
+ body: { objectKey: 'x', uploadToken: 'y' },
+ },
+ {
+ file: 'projects/[projectId]/videos/r2-init/route.ts',
+ module: videosR2InitRoute,
+ url: (f) => `/api/projects/${f.projectId}/videos/r2-init`,
+ params: (f) => ({ projectId: f.projectId }),
+ body: { fileName: 'a.mp4', sizeBytes: '1024', contentType: 'video/mp4' },
+ },
+ {
+ file: 'projects/[projectId]/videos/route.ts',
+ module: projectVideosRoute,
+ url: (f) => `/api/projects/${f.projectId}/videos`,
+ params: (f) => ({ projectId: f.projectId }),
+ body: { title: 'anon', videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
+ },
+ {
+ file: 'projects/[projectId]/videos/[videoId]/route.ts',
+ module: projectVideoRoute,
+ url: (f) => `/api/projects/${f.projectId}/videos/${f.videoId}`,
+ params: (f) => ({ projectId: f.projectId, videoId: f.videoId }),
+ body: { title: 'renamed by an anonymous caller' },
+ },
+ {
+ file: 'projects/[projectId]/videos/[videoId]/share/route.ts',
+ module: videoShareRoute,
+ url: (f) => `/api/projects/${f.projectId}/videos/${f.videoId}/share`,
+ params: (f) => ({ projectId: f.projectId, videoId: f.videoId }),
+ body: { allowGuests: true },
+ },
+ {
+ file: 'projects/[projectId]/videos/[videoId]/versions/route.ts',
+ module: videoVersionsRoute,
+ url: (f) => `/api/projects/${f.projectId}/videos/${f.videoId}/versions`,
+ params: (f) => ({ projectId: f.projectId, videoId: f.videoId }),
+ body: { videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
+ },
+ {
+ file: 'projects/[projectId]/videos/[videoId]/versions/[versionId]/route.ts',
+ module: videoVersionRoute,
+ url: (f) => `/api/projects/${f.projectId}/videos/${f.videoId}/versions/${f.versionId}`,
+ params: (f) => ({ projectId: f.projectId, videoId: f.videoId, versionId: f.versionId }),
+ body: { versionLabel: 'anon' },
+ },
+ {
+ file: 'projects/route.ts',
+ module: projectsRoute,
+ url: () => '/api/projects',
+ body: { name: 'anon project', workspaceId: 'anything' },
+ },
+ { file: 'search/route.ts', module: searchRoute, url: () => '/api/search?q=test' },
+ {
+ file: 'settings/notifications/route.ts',
+ module: settingsNotificationsRoute,
+ url: () => '/api/settings/notifications',
+ body: { emailEnabled: true },
+ },
+ {
+ file: 'settings/storage/route.ts',
+ module: settingsStorageRoute,
+ url: () => '/api/settings/storage',
+ },
+ {
+ file: 'upload/audio/[filename]/route.ts',
+ module: uploadAudioFileRoute,
+ url: () => `/api/upload/audio/${AUDIO_FILENAME}`,
+ params: () => ({ filename: AUDIO_FILENAME }),
+ },
+ {
+ file: 'upload/audio/route.ts',
+ module: uploadAudioRoute,
+ url: () => '/api/upload/audio',
+ rawBody: (f) => uploadForm('audio', f),
+ },
+ {
+ file: 'upload/image/[filename]/route.ts',
+ module: uploadImageFileRoute,
+ url: () => `/api/upload/image/${IMAGE_FILENAME}`,
+ params: () => ({ filename: IMAGE_FILENAME }),
+ },
+ {
+ file: 'upload/image/route.ts',
+ module: uploadImageRoute,
+ url: () => '/api/upload/image',
+ rawBody: (f) => uploadForm('image', f),
+ // The route rejects a missing Content-Length before it does anything else,
+ // and constructing a Request from a FormData does not set one.
+ headers: { 'content-length': '2048' },
+ },
+ {
+ file: 'upload/video/[filename]/route.ts',
+ module: uploadVideoFileRoute,
+ url: () => `/api/upload/video/${VIDEO_FILENAME}`,
+ params: () => ({ filename: VIDEO_FILENAME }),
+ },
+ {
+ file: 'versions/[versionId]/approvals/route.ts',
+ module: versionApprovalsRoute,
+ url: (f) => `/api/versions/${f.versionId}/approvals`,
+ params: (f) => ({ versionId: f.versionId }),
+ body: { approverIds: ['someone'] },
+ },
+ {
+ file: 'versions/[versionId]/comments/export/route.ts',
+ module: commentsExportRoute,
+ url: (f) => `/api/versions/${f.versionId}/comments/export`,
+ params: (f) => ({ versionId: f.versionId }),
+ },
+ {
+ file: 'versions/[versionId]/comments/route.ts',
+ module: versionCommentsRoute,
+ url: (f) => `/api/versions/${f.versionId}/comments`,
+ params: (f) => ({ versionId: f.versionId }),
+ body: { content: 'anonymous comment', timestamp: 1, guestName: 'Anon' },
+ },
+ {
+ file: 'versions/[versionId]/download/route.ts',
+ module: versionDownloadRoute,
+ url: (f) => `/api/versions/${f.versionId}/download`,
+ params: (f) => ({ versionId: f.versionId }),
+ },
+ {
+ file: 'videos/[videoId]/assets/[assetId]/download/route.ts',
+ module: assetDownloadRoute,
+ url: (f) => `/api/videos/${f.videoId}/assets/${f.assetId}/download`,
+ params: (f) => ({ videoId: f.videoId, assetId: f.assetId }),
+ },
+ {
+ file: 'videos/[videoId]/assets/[assetId]/route.ts',
+ module: assetRoute,
+ url: (f) => `/api/videos/${f.videoId}/assets/${f.assetId}`,
+ params: (f) => ({ videoId: f.videoId, assetId: f.assetId }),
+ },
+ {
+ file: 'videos/[videoId]/assets/bunny-init/route.ts',
+ module: assetsBunnyInitRoute,
+ url: (f) => `/api/videos/${f.videoId}/assets/bunny-init`,
+ params: (f) => ({ videoId: f.videoId }),
+ // 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' },
+ },
+ {
+ file: 'videos/[videoId]/assets/r2-init/route.ts',
+ module: assetsR2InitRoute,
+ url: (f) => `/api/videos/${f.videoId}/assets/r2-init`,
+ params: (f) => ({ videoId: f.videoId }),
+ body: { fileName: 'a.mp4', sizeBytes: '1024', contentType: 'video/mp4' },
+ },
+ {
+ file: 'videos/[videoId]/assets/route.ts',
+ module: assetsRoute,
+ url: (f) => `/api/videos/${f.videoId}/assets`,
+ params: (f) => ({ videoId: f.videoId }),
+ // 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}` },
+ },
+ {
+ file: 'watch/[videoId]/progress/route.ts',
+ module: watchProgressRoute,
+ url: (f) => `/api/watch/${f.videoId}/progress`,
+ params: (f) => ({ videoId: f.videoId }),
+ body: { progress: 10, duration: 100 },
+ },
+ {
+ file: 'watch/[videoId]/route.ts',
+ module: watchRoute,
+ url: (f) => `/api/watch/${f.videoId}`,
+ params: (f) => ({ videoId: f.videoId }),
+ },
+ {
+ file: 'watch/[videoId]/upload-token/route.ts',
+ module: watchUploadTokenRoute,
+ url: (f) => `/api/watch/${f.videoId}/upload-token`,
+ params: (f) => ({ videoId: f.videoId }),
+ body: { intent: 'image' },
+ headers: { origin: 'http://localhost:3000' },
+ },
+ {
+ file: 'workspaces/route.ts',
+ module: workspacesRoute,
+ url: () => '/api/workspaces',
+ body: { name: 'anon workspace' },
+ },
+ {
+ file: 'workspaces/[workspaceId]/members/invitations/[invitationId]/route.ts',
+ module: workspaceInvitationRoute,
+ url: (f) => `/api/workspaces/${f.workspaceId}/members/invitations/${f.workspaceInvitationId}`,
+ params: (f) => ({ workspaceId: f.workspaceId, invitationId: f.workspaceInvitationId }),
+ },
+ {
+ file: 'workspaces/[workspaceId]/members/[memberId]/route.ts',
+ module: workspaceMemberRoute,
+ url: (f) => `/api/workspaces/${f.workspaceId}/members/${f.workspaceMemberId}`,
+ params: (f) => ({ workspaceId: f.workspaceId, memberId: f.workspaceMemberId }),
+ body: { role: 'ADMIN' },
+ },
+ {
+ file: 'workspaces/[workspaceId]/members/route.ts',
+ module: workspaceMembersRoute,
+ url: (f) => `/api/workspaces/${f.workspaceId}/members`,
+ params: (f) => ({ workspaceId: f.workspaceId }),
+ body: { email: 'anon@example.com', role: 'ADMIN' },
+ },
+ {
+ file: 'workspaces/[workspaceId]/route.ts',
+ module: workspaceRoute,
+ url: (f) => `/api/workspaces/${f.workspaceId}`,
+ params: (f) => ({ workspaceId: f.workspaceId }),
+ body: { name: 'renamed by an anonymous caller' },
+ },
+];
+
+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[] = [];
+
+ const walk = (dir: string): void => {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const absolute = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ walk(absolute);
+ } else if (entry.name === 'route.ts') {
+ found.push(path.relative(apiDir, absolute));
+ }
+ }
+ };
+
+ walk(apiDir);
+ return found.sort();
+}
+
+describe('auth matrix', () => {
+ const discovered = discoverRouteModules();
+
+ it('classifies every route module that exists on disk', () => {
+ const classified = new Set([
+ ...ROUTE_CASES.map((entry) => entry.file),
+ ...PUBLIC_ROUTES.keys(),
+ ]);
+
+ const unclassified = discovered.filter((file) => !classified.has(file));
+ const stale = [...classified].filter((file) => !discovered.includes(file)).sort();
+
+ // The failure message is the whole value of this assertion: whoever added
+ // the route needs to know what to do about it.
+ expect(
+ { unclassified, stale },
+ 'A route module under app/api is missing from tests/api/auth-matrix.test.ts. ' +
+ 'Add it to ROUTE_CASES (the normal case: it requires a session), or to ' +
+ 'PUBLIC_ROUTES with a comment saying why anonymous access is intended.'
+ ).toEqual({ unclassified: [], stale: [] });
+ });
+
+ it('still has exactly the expected number of route modules', () => {
+ expect(discovered).toHaveLength(EXPECTED_ROUTE_MODULE_COUNT);
+ expect(ROUTE_CASES.length + PUBLIC_ROUTES.size).toBe(EXPECTED_ROUTE_MODULE_COUNT);
+ });
+
+ it('exports at least one HTTP method from every guarded route module', () => {
+ const withoutHandlers = ROUTE_CASES.filter(
+ (entry) => !HTTP_METHODS.some((method) => typeof entry.module[method] === 'function')
+ ).map((entry) => entry.file);
+
+ expect(withoutHandlers).toEqual([]);
+ });
+
+ describe('unauthenticated callers', () => {
+ let fixtures: Fixtures;
+
+ beforeEach(async () => {
+ signedOut();
+ fixtures = await seedFixtures();
+ });
+
+ for (const entry of ROUTE_CASES) {
+ it(`never returns 2xx for ${entry.file}`, async () => {
+ const methods = HTTP_METHODS.filter((method) => typeof entry.module[method] === 'function');
+ expect(methods.length).toBeGreaterThan(0);
+
+ const observed: Record = {};
+
+ for (const method of methods) {
+ const handler = entry.module[method] as RouteHandler;
+ const sendsBody = method !== 'GET' && method !== 'HEAD';
+
+ const request = apiRequest(entry.url(fixtures), {
+ method,
+ headers: entry.headers,
+ ...(sendsBody
+ ? entry.rawBody
+ ? { rawBody: entry.rawBody(fixtures) }
+ : { body: entry.body ?? {} }
+ : {}),
+ });
+
+ const response = await callRoute(handler, request, entry.params?.(fixtures) ?? {});
+ observed[method] = response.status;
+ }
+
+ for (const [method, status] of Object.entries(observed)) {
+ expect(
+ status >= 200 && status < 300,
+ `${method} ${entry.file} returned ${status} to an anonymous caller`
+ ).toBe(false);
+
+ // 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);
+ }
+ }
+ });
+ }
+ });
+
+ it('documents a reason for every public route, and each one still exists', () => {
+ for (const [file, reason] of PUBLIC_ROUTES) {
+ expect(reason.length, `${file} needs a reason`).toBeGreaterThan(10);
+ expect(fs.existsSync(path.join(REPO_ROOT, 'app', 'api', file))).toBe(true);
+ }
+ });
+
+ // -------------------------------------------------------------------------
+ // 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 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] to a non-admin and keeps the row', async () => {
+ signedInAs({ id: fixtures.userId, isAdmin: false });
+
+ 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(403);
+ expect(await db.userFeedback.count({ where: { id: fixtures.feedbackId } })).toBe(1);
+ });
+
+ 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(
+ adminRefreshR2Route.POST as RouteHandler,
+ apiRequest('/api/admin/stats/refresh-r2', { method: 'POST', body: {} })
+ );
+
+ 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
new file mode 100644
index 0000000..88f853f
--- /dev/null
+++ b/tests/api/comments.test.ts
@@ -0,0 +1,1050 @@
+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,
+ POST as createCommentRoute,
+} from '@/app/api/versions/[versionId]/comments/route';
+import {
+ DELETE as deleteCommentRoute,
+ GET as getCommentRoute,
+ PATCH as patchCommentRoute,
+} from '@/app/api/comments/[commentId]/route';
+import { apiRequest, callRoute, readData, readError } from '../helpers/request';
+import { signedInAs, signedOut } from '../helpers/session';
+import {
+ addProjectMember,
+ addWorkspaceMember,
+ createComment,
+ createCommentTag,
+ createExpiredUser,
+ createProject,
+ createShareLink,
+ createUser,
+ createVersion,
+ createVideo,
+ createWorkspace,
+ seedVersion,
+} from '../factories';
+
+const VALID_STROKE = {
+ points: [
+ { x: 0.1, y: 0.2 },
+ { x: 0.3, y: 0.4 },
+ ],
+ color: '#FF3B30',
+ width: 4,
+};
+
+function commentsUrl(versionId: string): string {
+ return `/api/versions/${versionId}/comments`;
+}
+
+describe('GET /api/versions/[versionId]/comments', () => {
+ it('returns 404 for an unknown version', async () => {
+ const user = await createUser();
+ signedInAs(user);
+
+ const response = await callRoute(listComments, apiRequest(commentsUrl('nope')), {
+ versionId: 'nope',
+ });
+
+ expect(response.status).toBe(404);
+ });
+
+ it('returns 403 to an anonymous caller on a PRIVATE project', async () => {
+ const scenario = await seedVersion({ visibility: 'PRIVATE' });
+ await createComment({ versionId: scenario.version.id, authorId: scenario.owner.id });
+ signedOut();
+
+ const response = await callRoute(listComments, apiRequest(commentsUrl(scenario.version.id)), {
+ versionId: scenario.version.id,
+ });
+
+ expect(response.status).toBe(403);
+ });
+
+ it('returns only top-level comments, with replies nested', async () => {
+ const scenario = await seedVersion();
+ const parent = await createComment({
+ versionId: scenario.version.id,
+ authorId: scenario.owner.id,
+ timestamp: 5,
+ });
+ await createComment({
+ versionId: scenario.version.id,
+ authorId: scenario.owner.id,
+ parentId: parent.id,
+ timestamp: 5,
+ });
+ await createComment({
+ versionId: scenario.version.id,
+ authorId: scenario.owner.id,
+ timestamp: 1,
+ });
+ signedInAs(scenario.owner);
+
+ const payload = await readData<{
+ comments: Array<{ id: string; timestamp: number; replies: Array<{ id: string }> }>;
+ total: number;
+ hasMore: boolean;
+ }>(
+ await callRoute(listComments, apiRequest(commentsUrl(scenario.version.id)), {
+ versionId: scenario.version.id,
+ })
+ );
+
+ expect(payload.comments).toHaveLength(2);
+ expect(payload.comments.map((entry) => entry.timestamp)).toEqual([1, 5]);
+ expect(payload.comments.find((entry) => entry.id === parent.id)?.replies).toHaveLength(1);
+ expect(payload.total).toBe(2);
+ expect(payload.hasMore).toBe(false);
+ });
+
+ it('omits resolved comments when includeResolved=false', async () => {
+ const scenario = await seedVersion();
+ const open = await createComment({
+ versionId: scenario.version.id,
+ authorId: scenario.owner.id,
+ });
+ await createComment({
+ versionId: scenario.version.id,
+ authorId: scenario.owner.id,
+ isResolved: true,
+ resolvedAt: new Date(),
+ });
+ signedInAs(scenario.owner);
+
+ const payload = await readData<{ comments: Array<{ id: string }> }>(
+ await callRoute(
+ listComments,
+ apiRequest(`${commentsUrl(scenario.version.id)}?includeResolved=false`),
+ { versionId: scenario.version.id }
+ )
+ );
+
+ expect(payload.comments.map((entry) => entry.id)).toEqual([open.id]);
+ });
+
+ it('answers 304 when the caller presents the current ETag', async () => {
+ const scenario = await seedVersion();
+ await createComment({ versionId: scenario.version.id, authorId: scenario.owner.id });
+ signedInAs(scenario.owner);
+
+ const first = await callRoute(listComments, apiRequest(commentsUrl(scenario.version.id)), {
+ versionId: scenario.version.id,
+ });
+ const etag = first.headers.get('etag');
+ expect(etag).toBeTruthy();
+
+ const second = await callRoute(
+ listComments,
+ apiRequest(commentsUrl(scenario.version.id), { headers: { 'if-none-match': etag! } }),
+ { versionId: scenario.version.id }
+ );
+
+ expect(second.status).toBe(304);
+ });
+
+ it('lets a guest with a VIEW share session read the comments', async () => {
+ const scenario = await seedVersion({ visibility: 'PRIVATE' });
+ const link = await createShareLink({
+ projectId: scenario.project.id,
+ videoId: scenario.video.id,
+ permission: 'VIEW',
+ });
+ await createComment({ versionId: scenario.version.id, authorId: scenario.owner.id });
+ signedOut();
+
+ const response = await callRoute(
+ listComments,
+ apiRequest(commentsUrl(scenario.version.id), {
+ cookies: {
+ [getShareSessionCookieName(scenario.video.id)]: createShareSessionValue(
+ link.token,
+ scenario.video.id,
+ false
+ ),
+ },
+ }),
+ { versionId: scenario.version.id }
+ );
+
+ expect(response.status).toBe(200);
+ });
+
+ it('refuses a share session signed for a different video', async () => {
+ const scenario = await seedVersion({ visibility: 'PRIVATE' });
+ const otherVideo = await createVideo({ projectId: scenario.project.id });
+ const link = await createShareLink({
+ projectId: scenario.project.id,
+ videoId: otherVideo.id,
+ permission: 'VIEW',
+ });
+ signedOut();
+
+ const response = await callRoute(
+ listComments,
+ apiRequest(commentsUrl(scenario.version.id), {
+ cookies: {
+ [getShareSessionCookieName(scenario.video.id)]: createShareSessionValue(
+ link.token,
+ otherVideo.id,
+ false
+ ),
+ },
+ }),
+ { versionId: scenario.version.id }
+ );
+
+ expect(response.status).toBe(403);
+ });
+});
+
+describe('POST /api/versions/[versionId]/comments', () => {
+ it('returns 403 to an anonymous caller with no share session', async () => {
+ const scenario = await seedVersion();
+ signedOut();
+
+ const response = await callRoute(
+ createCommentRoute,
+ apiRequest(commentsUrl(scenario.version.id), {
+ body: { content: 'hi', timestamp: 1, guestName: 'Anon' },
+ }),
+ { versionId: scenario.version.id }
+ );
+
+ expect(response.status).toBe(403);
+ expect(await db.comment.count()).toBe(0);
+ });
+
+ it('returns 403 to a signed-in stranger', async () => {
+ const scenario = await seedVersion();
+ const stranger = await createUser();
+ signedInAs(stranger);
+
+ const response = await callRoute(
+ createCommentRoute,
+ apiRequest(commentsUrl(scenario.version.id), { body: { content: 'hi', timestamp: 1 } }),
+ { versionId: scenario.version.id }
+ );
+
+ expect(response.status).toBe(403);
+ expect(await db.comment.count()).toBe(0);
+ });
+
+ it('returns 403 once the workspace owner has lost billing access', async () => {
+ const expiredOwner = await createExpiredUser();
+ const workspace = await createWorkspace({ ownerId: expiredOwner.id });
+ const project = await createProject({ ownerId: expiredOwner.id, workspaceId: workspace.id });
+ const video = await createVideo({ projectId: project.id });
+ const version = await createVersion({ videoParentId: video.id });
+ signedInAs(expiredOwner);
+
+ const response = await callRoute(
+ createCommentRoute,
+ apiRequest(commentsUrl(version.id), { body: { content: 'hi', timestamp: 1 } }),
+ { versionId: version.id }
+ );
+
+ expect(response.status).toBe(403);
+ expect(await db.comment.count()).toBe(0);
+ });
+
+ it('returns 400 when the timestamp is missing', async () => {
+ const scenario = await seedVersion();
+ signedInAs(scenario.owner);
+
+ const response = await callRoute(
+ createCommentRoute,
+ apiRequest(commentsUrl(scenario.version.id), { body: { content: 'hi' } }),
+ { versionId: scenario.version.id }
+ );
+
+ expect(response.status).toBe(400);
+ });
+
+ it.each([
+ [-1, 'a negative timestamp'],
+ ['not-a-number', 'an unparseable timestamp'],
+ [Number.POSITIVE_INFINITY, 'a non-finite timestamp'],
+ [121, 'a timestamp past the version duration of 120'],
+ ])('rejects the timestamp %s with 400 (%s)', async (timestamp, label) => {
+ const scenario = await seedVersion({ duration: 120 });
+ signedInAs(scenario.owner);
+
+ const response = await callRoute(
+ createCommentRoute,
+ apiRequest(commentsUrl(scenario.version.id), { body: { content: 'hi', timestamp } }),
+ { versionId: scenario.version.id }
+ );
+
+ expect(response.status, label).toBe(400);
+ 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);
+
+ const response = await callRoute(
+ createCommentRoute,
+ apiRequest(commentsUrl(scenario.version.id), { body: { content: 'hi', timestamp: 120 } }),
+ { versionId: scenario.version.id }
+ );
+
+ expect(response.status).toBe(201);
+ expect((await db.comment.findFirstOrThrow()).timestamp).toBe(120);
+ });
+
+ it('rejects a timestampEnd below the timestamp', async () => {
+ const scenario = await seedVersion({ duration: 120 });
+ signedInAs(scenario.owner);
+
+ const response = await callRoute(
+ createCommentRoute,
+ apiRequest(commentsUrl(scenario.version.id), {
+ body: { content: 'hi', timestamp: 10, timestampEnd: 5 },
+ }),
+ { versionId: scenario.version.id }
+ );
+
+ expect(response.status).toBe(400);
+ expect(await readError(response)).toMatch(/greater than or equal/i);
+ });
+
+ it('rejects a comment with no content, voice, image or annotation', async () => {
+ const scenario = await seedVersion();
+ signedInAs(scenario.owner);
+
+ const response = await callRoute(
+ createCommentRoute,
+ apiRequest(commentsUrl(scenario.version.id), { body: { timestamp: 1 } }),
+ { versionId: scenario.version.id }
+ );
+
+ expect(response.status).toBe(400);
+ });
+
+ it('rejects content longer than 10000 characters', async () => {
+ const scenario = await seedVersion();
+ signedInAs(scenario.owner);
+
+ const response = await callRoute(
+ createCommentRoute,
+ apiRequest(commentsUrl(scenario.version.id), {
+ body: { content: 'x'.repeat(10_001), timestamp: 1 },
+ }),
+ { versionId: scenario.version.id }
+ );
+
+ expect(response.status).toBe(400);
+ expect(await db.comment.count()).toBe(0);
+ });
+
+ it.each([
+ ['a bare object', { color: '#FF3B30', width: 4, points: [] }],
+ ['a stroke with a 3-digit colour', [{ ...VALID_STROKE, color: '#f00' }]],
+ ['a stroke with a named colour', [{ ...VALID_STROKE, color: 'red' }]],
+ ['a stroke with width 0', [{ ...VALID_STROKE, width: 0 }]],
+ ['a stroke with width 21', [{ ...VALID_STROKE, width: 21 }]],
+ ['a stroke with a NaN coordinate', [{ ...VALID_STROKE, points: [{ x: 0, y: null }] }]],
+ ['a stroke whose points are not an array', [{ ...VALID_STROKE, points: 'nope' }]],
+ ['a double-encoded JSON string', JSON.stringify([VALID_STROKE])],
+ ['an array of arrays', [[VALID_STROKE]]],
+ ['an array containing null', [null]],
+ ])('rejects annotationData given as %s', async (_label, annotationData) => {
+ const scenario = await seedVersion();
+ signedInAs(scenario.owner);
+
+ const response = await callRoute(
+ createCommentRoute,
+ apiRequest(commentsUrl(scenario.version.id), {
+ body: { timestamp: 1, annotationData },
+ }),
+ { versionId: scenario.version.id }
+ );
+
+ expect(response.status).toBe(400);
+ expect(await db.comment.count()).toBe(0);
+ });
+
+ // Written as raw JSON on purpose. `{ __proto__: ... }` in an object literal
+ // sets the prototype rather than creating an own property, so JSON.stringify
+ // would silently drop it and the test would prove nothing. JSON.parse, by
+ // contrast, does create a real own "__proto__" property.
+ it('does not let a __proto__ key in annotationData reach the database or Object.prototype', async () => {
+ const scenario = await seedVersion();
+ signedInAs(scenario.owner);
+
+ const rawBody = JSON.stringify({
+ timestamp: 1,
+ annotationData: [
+ JSON.parse(
+ '{"points":[{"x":0,"y":0}],"color":"#FF3B30","width":4,' +
+ '"__proto__":{"polluted":"yes"},"constructor":{"prototype":{"polluted":"yes"}}}'
+ ),
+ ],
+ });
+
+ const response = await callRoute(
+ createCommentRoute,
+ apiRequest(commentsUrl(scenario.version.id), {
+ method: 'POST',
+ rawBody,
+ headers: { 'content-type': 'application/json' },
+ }),
+ { versionId: scenario.version.id }
+ );
+
+ expect(response.status).toBe(201);
+ const stored = await db.comment.findFirstOrThrow();
+ expect(stored.annotationData).toBe(
+ JSON.stringify([{ points: [{ x: 0, y: 0 }], color: '#FF3B30', width: 4 }])
+ );
+ expect(stored.annotationData).not.toContain('polluted');
+ expect(stored.annotationData).not.toContain('__proto__');
+ expect(({} as Record).polluted).toBeUndefined();
+ });
+
+ it('re-serialises accepted annotation strokes into canonical form', async () => {
+ const scenario = await seedVersion();
+ signedInAs(scenario.owner);
+
+ const response = await callRoute(
+ createCommentRoute,
+ apiRequest(commentsUrl(scenario.version.id), {
+ body: {
+ timestamp: 1,
+ annotationData: [{ ...VALID_STROKE, extraneous: 'dropped', tool: '',
+ targetName: '
',
+ });
+
+ const html = mailTo('invitee@example.com')[0].html!;
+ expect(html).not.toContain(''} />
+ );
+
+ expect(container.querySelector('img')).toBeNull();
+ expect(container.querySelector('script')).toBeNull();
+ expect(container).toHaveTextContent('
');
+ });
+
+ it('is case sensitive about the scheme, so HTTPS:// is left as text', () => {
+ // Pinning current behaviour: the regex has no `i` flag, so an uppercase
+ // scheme is not linkified. Harmless, but worth knowing before someone
+ // "fixes" the regex and widens what becomes clickable.
+ const { container } = render();
+
+ expect(container.querySelector('a')).toBeNull();
+ });
+
+ it('swallows trailing punctuation into the href', () => {
+ // Pinning current behaviour: `[^\s]+` is greedy to the next whitespace, so
+ // the sentence-ending period lands inside the link.
+ render();
+
+ expect(screen.getByRole('link')).toHaveAttribute('href', 'https://example.com/pr/12.');
+ });
+});
+
+describe('CommentRichText asset mentions', () => {
+ it('renders a mention as a button labelled with the asset name', () => {
+ render();
+
+ expect(screen.getByRole('button', { name: '@Reference frame.png' })).toBeInTheDocument();
+ });
+
+ it('reports the mentioned asset id when clicked', async () => {
+ const onAssetMentionClick = vi.fn();
+ render(
+
+ );
+
+ await userEvent.click(screen.getByRole('button', { name: '@Reference frame.png' }));
+
+ expect(onAssetMentionClick).toHaveBeenCalledWith('a1b2c3');
+ });
+
+ it('prefers the current asset name over the name stored in the comment', () => {
+ render(
+
+ );
+
+ expect(screen.getByRole('button', { name: '@Renamed.png' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: '@old-name.png' })).not.toBeInTheDocument();
+ });
+
+ it('falls back to the stored name when the asset is gone', () => {
+ render();
+
+ expect(screen.getByRole('button', { name: '@deleted.png' })).toBeInTheDocument();
+ });
+
+ it('does not throw when clicked without a handler', async () => {
+ render();
+
+ await userEvent.click(screen.getByRole('button', { name: '@Reference frame.png' }));
+
+ expect(screen.getByRole('button', { name: '@Reference frame.png' })).toBeInTheDocument();
+ });
+
+ it('renders text, mentions and links together in reading order', () => {
+ const { container } = render(
+
+ );
+
+ expect(container).toHaveTextContent('Before @One middle https://example.com/x after');
+ expect(screen.getByRole('button', { name: '@One' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'https://example.com/x' })).toBeInTheDocument();
+ });
+
+ it('renders several mentions in one comment', () => {
+ render();
+
+ expect(screen.getAllByRole('button').map((b) => b.textContent)).toEqual(['@One', '@Two']);
+ });
+
+ it('accepts an uppercase asset id', () => {
+ const onAssetMentionClick = vi.fn();
+ render(
+
+ );
+
+ expect(screen.getByRole('button', { name: '@One' })).toBeInTheDocument();
+ });
+
+ it('leaves a mention with a non-alphanumeric id as plain text', () => {
+ const { container } = render();
+
+ expect(screen.queryAllByRole('button')).toHaveLength(0);
+ expect(container).toHaveTextContent('@[One](asset:aa-11)');
+ });
+
+ it('leaves a malformed mention as plain text', () => {
+ const { container } = render();
+
+ expect(screen.queryAllByRole('button')).toHaveLength(0);
+ expect(container).toHaveTextContent('@[One](assets:aaa111)');
+ });
+
+ it('does not inject markup through the mention label', () => {
+ const { container } = render(
+ ](asset:aaa111)'} />
+ );
+
+ expect(container.querySelector('img')).toBeNull();
+ expect(container).toHaveTextContent('@
');
+ });
+
+ it('does not linkify a URL used as a mention label', () => {
+ const { container } = render();
+
+ expect(container.querySelector('a')).toBeNull();
+ expect(screen.getByRole('button', { name: '@https://evil.test/x' })).toBeInTheDocument();
+ });
+
+ // KNOWN BUG, pinned rather than fixed. `renderUrls` keys its fragments by the
+ // index within its own slice, and CommentRichText calls it once per gap
+ // between mentions, so the same key ("txt-0") is emitted for several
+ // siblings. React logs "Encountered two children with the same key" and warns
+ // that children may be duplicated or omitted. The output happens to be
+ // correct today; the text assertion locks that in, and the warning assertion
+ // is the thing to delete once the keys are made unique.
+ it('produces duplicate React keys when text surrounds a mention', () => {
+ const { container } = render(
+
+ );
+
+ expect(container).toHaveTextContent('Before @One middle @Two after');
+ expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('same key'), 'txt-0');
+ });
+});
diff --git a/tests/component/error-boundary.test.tsx b/tests/component/error-boundary.test.tsx
new file mode 100644
index 0000000..7aa4685
--- /dev/null
+++ b/tests/component/error-boundary.test.tsx
@@ -0,0 +1,272 @@
+import { describe, it, expect, vi, beforeEach, afterEach, onTestFinished } from 'vitest';
+import { useState } from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { ErrorBoundary, withErrorBoundary } from '@/components/error-boundary';
+
+function Boom({ message = 'render blew up' }: { message?: string }): never {
+ throw new Error(message);
+}
+
+/**
+ * Throws while the shared flag is set. React retries a failed render before it
+ * gives up, so a counter would be consumed by the retry; a flag the test flips
+ * explicitly keeps "Try again" deterministic.
+ */
+function ConditionalBoom({ shouldThrow }: { shouldThrow: { value: boolean } }) {
+ if (shouldThrow.value) {
+ throw new Error('transient');
+ }
+ return Recovered content
;
+}
+
+let consoleError: ReturnType;
+
+beforeEach(() => {
+ // React itself logs every caught error, on top of the boundary's own log.
+ // Silence both; the assertions below check the boundary's log explicitly.
+ consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('ErrorBoundary', () => {
+ it('renders its children while nothing throws', () => {
+ render(
+
+ Healthy content
+
+ );
+
+ expect(screen.getByText('Healthy content')).toBeInTheDocument();
+ expect(consoleError).not.toHaveBeenCalled();
+ });
+
+ it('replaces a crashed subtree with the recovery fallback', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByRole('heading', { name: 'Something went wrong' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Reload page' })).toBeInTheDocument();
+ });
+
+ it('names the crashed area when given a context', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByRole('heading', { name: 'Assets pane crashed' })).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'An unexpected error occurred. Try resetting the component or reload the page.'
+ )
+ ).toBeInTheDocument();
+ });
+
+ it('offers video-specific guidance for a video context', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByRole('heading', { name: 'VideoPlayer crashed' })).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'The video player encountered an error. Try reloading or go back to the project.'
+ )
+ ).toBeInTheDocument();
+ });
+
+ it('does not swallow the error: it reports it to onError', () => {
+ const onError = vi.fn();
+ render(
+
+
+
+ );
+
+ expect(onError).toHaveBeenCalledTimes(1);
+ const [error, errorInfo] = onError.mock.calls[0];
+ expect(error).toBeInstanceOf(Error);
+ expect((error as Error).message).toBe('player adapter missing');
+ expect(errorInfo).toHaveProperty('componentStack');
+ expect(String((errorInfo as { componentStack: string }).componentStack)).toContain('Boom');
+ });
+
+ it('does not swallow the error: it logs it with the context', () => {
+ render(
+
+
+
+ );
+
+ expect(consoleError).toHaveBeenCalledWith(
+ 'ErrorBoundary [VideoPlayer] caught an error:',
+ expect.objectContaining({ message: 'player adapter missing' }),
+ expect.anything()
+ );
+ });
+
+ it('logs without a context prefix when none was given', () => {
+ render(
+
+
+
+ );
+
+ expect(consoleError).toHaveBeenCalledWith(
+ 'ErrorBoundary caught an error:',
+ expect.objectContaining({ message: 'nameless' }),
+ expect.anything()
+ );
+ });
+
+ it('renders a custom fallback instead of the built-in one', () => {
+ const onError = vi.fn();
+ render(
+ Could not load the timeline
} onError={onError}>
+
+
+ );
+
+ expect(screen.getByText('Could not load the timeline')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument();
+ // The error still propagates to the caller even with a custom fallback.
+ expect(onError).toHaveBeenCalledTimes(1);
+ });
+
+ it('re-renders the children when Try again is pressed', async () => {
+ const shouldThrow = { value: true };
+ render(
+
+
+
+ );
+
+ expect(screen.getByRole('heading', { name: 'Something went wrong' })).toBeInTheDocument();
+ expect(screen.queryByText('Recovered content')).not.toBeInTheDocument();
+
+ shouldThrow.value = false;
+ await userEvent.click(screen.getByRole('button', { name: 'Try again' }));
+
+ expect(screen.getByText('Recovered content')).toBeInTheDocument();
+ expect(screen.queryByRole('heading', { name: 'Something went wrong' })).not.toBeInTheDocument();
+ });
+
+ it('shows the fallback again if the retry crashes too', async () => {
+ const shouldThrow = { value: true };
+ render(
+
+
+
+ );
+
+ await userEvent.click(screen.getByRole('button', { name: 'Try again' }));
+
+ expect(screen.getByRole('heading', { name: 'Something went wrong' })).toBeInTheDocument();
+ expect(screen.queryByText('Recovered content')).not.toBeInTheDocument();
+ });
+
+ it('reloads the page when Reload page is pressed', async () => {
+ const reload = vi.fn();
+ // Restored by hand. vi.restoreAllMocks() undoes spies, not a
+ // defineProperty, so without this the whole file runs on a fake
+ // window.location from here on and the next test to touch it would be
+ // reading a stub left behind by this one.
+ const realLocation = Object.getOwnPropertyDescriptor(window, 'location');
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: { ...window.location, reload },
+ });
+ onTestFinished(() => {
+ if (realLocation) {
+ Object.defineProperty(window, 'location', realLocation);
+ }
+ });
+
+ render(
+
+
+
+ );
+ await userEvent.click(screen.getByRole('button', { name: 'Reload page' }));
+
+ expect(reload).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps a healthy sibling boundary mounted when one crashes', () => {
+ render(
+
+
+
+
+
+ Right pane still here
+
+
+ );
+
+ expect(screen.getByRole('heading', { name: 'Left crashed' })).toBeInTheDocument();
+ expect(screen.getByText('Right pane still here')).toBeInTheDocument();
+ });
+
+ it('catches an error thrown from a state updater, not just from render', async () => {
+ function ThrowOnClick() {
+ const [, setState] = useState(0);
+ return (
+
+ );
+ }
+
+ render(
+
+
+
+ );
+ await userEvent.click(screen.getByRole('button', { name: 'Break it' }));
+
+ expect(screen.getByRole('heading', { name: 'Something went wrong' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Break it' })).not.toBeInTheDocument();
+ });
+});
+
+describe('withErrorBoundary', () => {
+ it('wraps a component and forwards its props', () => {
+ function Panel({ label }: { label: string }) {
+ return {label}
;
+ }
+ const Wrapped = withErrorBoundary(Panel);
+
+ render();
+
+ expect(screen.getByText('Timeline')).toBeInTheDocument();
+ });
+
+ it('applies the boundary options to a crash inside the wrapped component', () => {
+ const onError = vi.fn();
+ const Wrapped = withErrorBoundary(Boom, { context: 'VideoPlayer', onError });
+
+ render();
+
+ expect(screen.getByRole('heading', { name: 'VideoPlayer crashed' })).toBeInTheDocument();
+ expect(onError).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/tests/component/guest-gate.test.tsx b/tests/component/guest-gate.test.tsx
new file mode 100644
index 0000000..f9553c8
--- /dev/null
+++ b/tests/component/guest-gate.test.tsx
@@ -0,0 +1,134 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { GuestGate } from '@/components/guest-gate';
+
+// next/link needs the App Router context to mount. The sign-in link is
+// incidental to the validation branches under test, so stub it to an anchor.
+vi.mock('next/link', () => ({
+ default: ({ href, children }: { href: string; children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+const STORAGE_KEY = 'openframe_guest_name';
+
+function renderGate() {
+ return render(
+
+ Gated video page
+
+ );
+}
+
+/**
+ * ACCESSIBILITY FINDING: the name field has no