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..50bcaee 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -13,3 +13,186 @@ 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
+
+ e2e:
+ runs-on: ubuntu-latest
+ needs: [check]
+ container:
+ # Must stay pinned to the installed @playwright/test version, because the
+ # image carries the matching browser build and Playwright refuses a
+ # mismatched pair. Microsoft publishes the image a while after the npm
+ # release, which is why package.json pins 1.61.1 rather than the newer
+ # 1.62.0: no v1.62.0-noble image exists yet. Bump both together, and check
+ # the tag is published first:
+ # curl -sI https://mcr.microsoft.com/v2/playwright/manifests/v1.61.1-noble
+ image: mcr.microsoft.com/playwright:v1.61.1-noble
+ # Chromium needs a real /dev/shm in a container.
+ options: --ipc=host
+ # No --user override on purpose: the image has no bun, and installing one
+ # needs root. The runner is ephemeral, so root-owned files do no harm.
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_USER: openframe
+ POSTGRES_PASSWORD: openframe
+ POSTGRES_DB: openframe_test
+ options: >-
+ --health-cmd "pg_isready -U openframe -d openframe_test"
+ --health-interval 2s
+ --health-timeout 3s
+ --health-retries 30
+ # Object storage for video-upload.spec.ts. The browser PUTs the file
+ # straight at the presigned URL, so there is nothing to mock at that
+ # boundary from inside a browser. Mirrors the minio-test service in
+ # docker-compose.test.yml.
+ minio:
+ image: minio/minio:latest
+ env:
+ MINIO_ROOT_USER: openframe
+ MINIO_ROOT_PASSWORD: openframe-test-secret
+ # lib/r2.ts signs with `region: 'auto'`, so MinIO has to accept it.
+ MINIO_REGION_NAME: auto
+ options: >-
+ --health-cmd "mc ready local"
+ --health-interval 2s
+ --health-timeout 3s
+ --health-retries 30
+ env:
+ # This job runs inside a container, so it shares a network with its
+ # services and reaches them by service name. No published ports are
+ # involved, which is why there are no `ports:` blocks above.
+ DATABASE_URL: postgresql://openframe:openframe@postgres: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 service. Without these the
+ # `Direct Upload` tab is not rendered and video-upload.spec.ts fails on its
+ # first assertion rather than silently testing nothing.
+ OPENFRAME_ENABLE_S3_VIDEO_UPLOADS: 'true'
+ OPENFRAME_ENABLE_BUNNY_UPLOADS: 'false'
+ R2_ENDPOINT: http://minio:9000
+ R2_ACCESS_KEY_ID: openframe
+ R2_SECRET_ACCESS_KEY: openframe-test-secret
+ R2_BUCKET_NAME: openframe-test
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install bun
+ # oven-sh/setup-bun cannot be used inside this container: it unpacks a
+ # zip archive and the Playwright image ships no unzip. It ships node and
+ # npm, so npm installs the bun binary instead. bun is needed both for
+ # `bun run test:e2e` and for the web server command in
+ # playwright.config.ts.
+ run: npm install --global bun
+ - 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: 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. The
+ # Playwright image has no mc, so this goes through the same image the
+ # service container uses.
+ run: |
+ curl -sSfL -o /usr/local/bin/mc https://dl.min.io/client/mc/release/linux-amd64/mc
+ chmod +x /usr/local/bin/mc
+ mc alias set ciminio "$R2_ENDPOINT" "$R2_ACCESS_KEY_ID" "$R2_SECRET_ACCESS_KEY"
+ mc mb --ignore-existing "ciminio/$R2_BUCKET_NAME"
+ # 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..2545768 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,9 @@
# testing
/coverage
+/playwright-report
+/test-results
+/.playwright
# next.js
/.next/
@@ -34,6 +37,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..353d080 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -2,3 +2,6 @@
node_modules/
prisma/migrations/
bun.lock
+coverage/
+playwright-report/
+test-results/
diff --git a/AGENTS.md b/AGENTS.md
index f10d06c..e8e30e8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,6 +10,51 @@
## 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.
+
+## 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.
+
+### Two ways a test can be worthless
+
+Both 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.
## Repo-specific conventions
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 52fd458..1ec33e4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -29,6 +29,34 @@ 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 |
+
+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 +84,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..7df924d
--- /dev/null
+++ b/TESTING.md
@@ -0,0 +1,733 @@
+# Testing Plan
+
+Status: **all six phases delivered**. What actually landed, and where reality differed
+from the plan, is in Section 12. The sections below are kept as written so the reasoning
+behind each decision stays readable.
+
+| Suite | Command | Tests | Runtime |
+| ---------------- | ------------------ | -------- | ------- |
+| Unit + component | `bun run test` | 1160 | 6s |
+| API integration | `bun run test:api` | 537 | 45s |
+| End to end | `bun run test:e2e` | 18 | 40s |
+| **Total** | `bun run test:all` | **1715** | |
+
+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.
diff --git a/bun.lock b/bun.lock
index a9ab07b..0c2a965 100644
--- a/bun.lock
+++ b/bun.lock
@@ -37,22 +37,31 @@
"devDependencies": {
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
+ "@playwright/test": "1.61.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 +73,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=="],
@@ -186,12 +205,18 @@
"@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 +259,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 +281,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 +305,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=="],
@@ -370,7 +409,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 +453,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 +605,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=="],
@@ -620,14 +695,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 +795,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=="],
@@ -722,7 +829,7 @@
"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 +859,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=="],
@@ -772,6 +883,8 @@
"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=="],
@@ -800,6 +913,8 @@
"caniuse-lite": ["caniuse-lite@1.0.30001768", "", {}, "sha512-qY3aDRZC5nWPgHUgIB84WL+nySuo19wk0VJpp/XI9T34lrvkyhRvNVOFJOp2kxClQhiFBu+TaUSudf6oa3vkSA=="],
+ "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
+
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"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=="],
@@ -868,6 +983,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 +999,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 +1011,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 +1037,8 @@
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
+ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
+
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -924,6 +1049,8 @@
"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=="],
@@ -946,6 +1073,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 +1089,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 +1139,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=="],
@@ -1020,6 +1153,8 @@
"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=="],
+ "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=="],
"express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="],
@@ -1074,6 +1209,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=="],
@@ -1122,6 +1259,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,6 +1297,10 @@
"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=="],
@@ -1178,6 +1321,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 +1385,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 +1423,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,10 +1437,12 @@
"js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="],
- "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
+ "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=="],
@@ -1396,16 +1551,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,6 +1589,8 @@
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
+ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
+
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -1488,6 +1653,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 +1685,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=="],
@@ -1554,7 +1723,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 +1731,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,6 +1761,8 @@
"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=="],
@@ -1622,7 +1797,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 +1811,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,6 +1843,8 @@
"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=="],
@@ -1680,6 +1859,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 +1895,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=="],
@@ -1732,9 +1915,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=="],
@@ -1766,6 +1951,8 @@
"strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
+ "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=="],
"stripe": ["stripe@20.4.1", "", { "peerDependencies": { "@types/node": ">=16" }, "optionalPeers": ["@types/node"] }, "sha512-axCguHItc8Sxt0HC6aSkdVRPffjYPV7EQqZRb2GkIa8FzWDycE7nHJM19C6xAIynH1Qp1/BHiopSi96jGBxT0w=="],
@@ -1778,6 +1965,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 +1981,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,12 +1997,16 @@
"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=="],
"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=="],
@@ -1838,6 +2035,8 @@
"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=="],
+ "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 +2069,22 @@
"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=="],
+
"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 +2095,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 +2105,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,8 +2149,12 @@
"@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=="],
@@ -1952,8 +2175,6 @@
"@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=="],
@@ -1968,18 +2189,20 @@
"@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=="],
+
"@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 +2215,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,6 +2227,8 @@
"@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=="],
"c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
@@ -2034,8 +2263,6 @@
"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=="],
-
"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=="],
@@ -2048,6 +2275,14 @@
"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/tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="],
+
"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=="],
@@ -2062,8 +2297,12 @@
"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=="],
"restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
@@ -2082,7 +2321,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=="],
@@ -2130,18 +2379,24 @@
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
+ "@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
+
+ "@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
+
+ "@unrs/resolver-binding-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
+
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"cli-truncate/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
- "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
-
"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=="],
"log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
+ "magicast/@babel/parser/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
+
"ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"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=="],
@@ -2154,7 +2409,29 @@
"shadcn/execa/strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
- "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+ "vite/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
+
+ "vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
+
+ "vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
+
+ "vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
+
+ "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=="],
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
@@ -2162,7 +2439,11 @@
"@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=="],
+ "@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=="],
+
+ "magicast/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
+
+ "magicast/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"shadcn/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
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..6fbb1e8 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -13,8 +13,24 @@ 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/**',
]),
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/package.json b/package.json
index b07c2d8..1f6ee90 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,16 @@
"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",
+ "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 +70,31 @@
"devDependencies": {
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
+ "@playwright/test": "1.61.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..e5a6fef
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,158 @@
+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',
+
+ // 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',
+ },
+ ],
+
+ 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..34e9e4c
--- /dev/null
+++ b/scripts/test.sh
@@ -0,0 +1,302 @@
+#!/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
+#
+# 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'
+# 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.
+
+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_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
+ ;;
+ *)
+ printf 'scripts/test.sh: unknown mode "%s"\n\n' "$1" >&2
+ usage >&2
+ exit 64
+ ;;
+esac
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..2b8453c
--- /dev/null
+++ b/tests/api/auth-matrix.test.ts
@@ -0,0 +1,820 @@
+// 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 } from 'vitest';
+import { db } from '@/lib/db';
+import { REPO_ROOT } from '../helpers/env';
+import { apiRequest, callRoute, 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';
+
+// ---------------------------------------------------------------------------
+// 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 }),
+ // This entry cannot be made load-bearing here, and it was verified to hold
+ // with `if (!context.canUploadAssets)` replaced by `if (false)`: Bunny
+ // uploads are unconfigured in the test environment, so the route answers 400
+ // one line below the guard whether or not the guard is there. The real
+ // coverage for it is in tests/api/assets-authz.test.ts, which asserts the
+ // exact 403 for a stranger next to the exact 400 for a member.
+ 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 carries no `provider`, so POST answers 400 "Invalid provider"
+ // just below the access check. Verified: with
+ // `if (!context.canUploadAssets)` replaced by `if (false)` this entry still
+ // passes. Sending a real provider would not fix it, because every branch
+ // that could reach 201 needs a live R2 or YouTube call. The exact-status
+ // coverage lives in tests/api/assets-authz.test.ts instead. The GET half of
+ // this module is genuinely load-bearing here: it 403s on the access check.
+ 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;
+
+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);
+ }
+ });
+ }
+ });
+
+ 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, but not an admin
+ // -------------------------------------------------------------------------
+ // The sweep above only proves that app/api/admin/** refuses a caller with no
+ // session, and `!session?.user?.isAdmin` is true for a null session for the
+ // wrong reason. Nothing else in the suite touches `isAdmin` at all, so
+ // rewriting that guard as `!session?.user?.id` would leave every one of these
+ // tests green while handing the admin endpoints to any signed-in user. These
+ // two cases are what separate "no session" from "not an admin".
+ describe('admin routes reject a signed-in non-admin', () => {
+ let fixtures: Fixtures;
+
+ beforeEach(async () => {
+ fixtures = await seedFixtures();
+ });
+
+ it('refuses DELETE /api/admin/feedback/[feedbackId] 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('refuses POST /api/admin/stats/refresh-r2', 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);
+ });
+ });
+});
diff --git a/tests/api/comments.test.ts b/tests/api/comments.test.ts
new file mode 100644
index 0000000..547683f
--- /dev/null
+++ b/tests/api/comments.test.ts
@@ -0,0 +1,1009 @@
+import { describe, expect, it } from 'vitest';
+import { db } from '@/lib/db';
+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);
+ });
+
+ 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: ''} />
+ );
+
+ 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