diff --git a/.env.example b/.env.example
index df28cdfa8..55afc52a1 100644
--- a/.env.example
+++ b/.env.example
@@ -4,9 +4,12 @@
# Optional - a release bakes this in, a source checkout runs without it.
# Connect trackers in the setup wizard; tuning knobs are in CLAUDE.md.
-# Optional. Blank means a dev build skips sign-in entirely, so a fresh clone
-# just runs. Set a Clerk dev instance key (pk_test_...) to exercise the real
-# sign-in path locally. Packaged builds always require sign-in regardless.
-CLERK_PUBLISHABLE_KEY=
+# Optional. Blank means the OTP sign-in flow is disabled - the wizard's Email
+# step reports "not configured" on the first attempt and lets you continue
+# without signing in (the same dev-bypass shape the old Clerk key had). Set
+# these to a deployed infra/otp-worker instance (staging or your own) to
+# exercise the real send/verify path locally.
+OTP_API_URL=
+OTP_CLIENT_TOKEN=
diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml
index 4c210fca5..a2eb12d09 100644
--- a/.github/workflows/release-build.yml
+++ b/.github/workflows/release-build.yml
@@ -458,7 +458,6 @@ jobs:
MERIDIAN_GITHUB_OAUTH_CLIENT_ID: ${{ secrets.GH_OAUTH_CLIENT_ID }}
MERIDIAN_POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
MERIDIAN_COUNTER_API_KEY: ${{ secrets.COUNTER_API_KEY }}
- MERIDIAN_CLERK_PUBLISHABLE_KEY: ${{ secrets.CLERK_PUBLISHABLE_KEY_PROD }}
MERIDIAN_OTP_API_URL: ${{ vars.MERIDIAN_OTP_API_URL }}
MERIDIAN_OTP_CLIENT_TOKEN: ${{ secrets.MERIDIAN_OTP_CLIENT_TOKEN }}
MERIDIAN_CHANNEL: ${{ needs.prepare.outputs.channel }}
@@ -521,6 +520,7 @@ jobs:
# for v0-rust-macos-release-aarch64-apple-darwin" — so stale entries
# stopped being reaped too, feeding the same eviction.
- uses: Swatinem/rust-cache@v2
+ id: rust-cache
with:
# shared-key REPLACES rust-cache's automatic job-based key. That
# matters: by default `add-job-id-key` is true, so ci.yml's
@@ -563,6 +563,70 @@ jobs:
# nothing can restore from — which is exactly how the repo's 10 GiB
# quota filled up and began evicting caches other jobs needed.
save-if: ${{ github.ref == 'refs/heads/main' }}
+ # SAVE THE CACHE EVEN WHEN THE JOB FAILS.
+ #
+ # Defaults to false, so a run that compiles for 30 minutes and then
+ # dies in bundling, signing, notarization or upload throws that whole
+ # compile away, and the retry starts cold again - paying twice for one
+ # release. The compile is the expensive half and it SUCCEEDED; a later
+ # step failing says nothing about the artifacts it produced. A partial
+ # `target/` is not a hazard either: cargo fingerprints it correctly,
+ # which is the same property every incremental build already relies on.
+ cache-on-failure: true
+
+ # A COLD BUILD MUST NOT BE SILENT. This is the release pipeline's oldest
+ # and most expensive failure mode, and it has never once announced itself.
+ #
+ # The shape every time: the cache key drifts or the entry is evicted,
+ # `No cache found.` is written into a COLLAPSED log group, the build takes
+ # 20-30 extra minutes, and every check goes green with correct binaries at
+ # the end. It has only ever been caught by someone happening to watch the
+ # clock - most recently 2026-08-27, when the Windows job compiled for 36
+ # minutes while the cache it wanted sat unread on `main` (see the
+ # `runs-on` note in the windows job). The same silence let cache-warm.yml
+ # write an unreadable key for this pipeline's entire history.
+ #
+ # Deliberately an `::error::` ANNOTATION that does NOT fail the step. The
+ # asymmetry matters: a cache miss must never block shipping a release -
+ # the build is slow, not wrong, and failing here would turn a GitHub image
+ # roll into a release outage. But it must be impossible to overlook, so it
+ # lands as a red annotation on the run page and as a section in
+ # `$GITHUB_STEP_SUMMARY`, both of which sit ABOVE the logs rather than
+ # inside a collapsed group.
+ #
+ # The env dump is the other half, and it is what makes the NEXT occurrence
+ # cheap. rust-cache hashes the NAMES AND VALUES of every
+ # CARGO_*/CC*/CFLAGS/CXX/CMAKE*/RUST* variable into the key component that
+ # drifts, and nothing has ever recorded what those were on a run that
+ # missed - so each time the cause has to be re-derived from the cache API
+ # and guesswork. Printing them on a miss turns that into a diff between
+ # two runs. Values that look like credentials are dropped rather than
+ # printed; none of the hashed prefixes should carry one, and a build log
+ # is not the place to find out otherwise.
+ - name: Report the rust-cache outcome
+ run: |
+ if [ "${{ steps.rust-cache.outputs.cache-hit }}" = "true" ]; then
+ echo "rust-cache: HIT - this build starts warm."
+ exit 0
+ fi
+ msg="rust-cache MISSED on ${{ runner.os }}: this build compiles from scratch (~35 min on Windows, ~20 min on macOS). The key drifted or the entry was evicted - compare the environment below against a run that hit."
+ echo "::error title=Cold Rust build (${{ runner.os }})::${msg}"
+ {
+ echo "### rust-cache miss - ${{ runner.os }}"
+ echo
+ echo "${msg}"
+ echo
+ echo "Cache-key environment (rust-cache hashes these names and values)
"
+ echo
+ echo '```'
+ env \
+ | grep -E '^(CARGO|CC|CFLAGS|CXX|CMAKE|RUST)' \
+ | grep -viE '(TOKEN|SECRET|PASSWORD|CREDENTIAL|_KEY)' \
+ | sort || true
+ echo '```'
+ echo
+ echo " "
+ } >> "$GITHUB_STEP_SUMMARY"
- uses: actions/setup-node@v7
with:
@@ -838,7 +902,25 @@ jobs:
windows:
name: Windows x86_64
needs: prepare
- runs-on: windows-latest
+ # PINNED, not `windows-latest`. The macOS job is pinned to `macos-26` and
+ # this one was not, and that asymmetry is not cosmetic: on 2026-08-27 the
+ # release's Windows job asked for
+ # `...-Windows_NT-x64-cd9df261-fb0b6063` while a 1305 MiB entry sat on
+ # `main` under `...-Windows_NT-x64-581b1cd0-fb0b6063`, so it compiled for
+ # 36 minutes with the cache it needed one API call away. The same day's
+ # ci.yml Windows jobs asked for `581b1cd0` and missed a `cd9df261` entry -
+ # the two workflows had SWAPPED hashes overnight. Nothing in the repo
+ # changed (identical Cargo.lock, rust-toolchain, workflow file and env
+ # block), so the drift came from the runner image: the fleet was serving
+ # more than one Windows environment (`windows-2025-vs2026` observed), and
+ # rust-cache hashes the CARGO_*/CC*/CFLAGS/CXX/CMAKE*/RUST* vars, so which
+ # runner you land on decides whether you start warm.
+ #
+ # Pinning the label removes the 2022 -> 2025 major roll as a variable. It
+ # does NOT freeze the weekly image refresh, and it cannot fix a fleet that
+ # is heterogeneous within one label - which is exactly why this line is not
+ # treated as the fix. The cache-outcome report below is.
+ runs-on: windows-2025
timeout-minutes: 60 # same cap as macos — bound a hung build instead of falling back to GitHub's 360-minute default
permissions:
contents: write # uploads assets into the draft
@@ -852,13 +934,12 @@ jobs:
# comment) — these are cross-platform values (OAuth client ids, public
# analytics keys), not macOS-specific ones, so skipping them here would
# silently ship a Windows build with Jira sign-in, GitHub device flow,
- # analytics, or Clerk sign-in missing. Keep this list in sync with the
- # macOS job's, minus the Apple-only signing/notarization secrets.
+ # analytics, or email sign-in (the OTP Worker) missing. Keep this list in
+ # sync with the macOS job's, minus the Apple-only signing/notarization secrets.
MERIDIAN_JIRA_OAUTH_CLIENT_SECRET: ${{ secrets.JIRA_OAUTH_CLIENT_SECRET }}
MERIDIAN_GITHUB_OAUTH_CLIENT_ID: ${{ secrets.GH_OAUTH_CLIENT_ID }}
MERIDIAN_POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
MERIDIAN_COUNTER_API_KEY: ${{ secrets.COUNTER_API_KEY }}
- MERIDIAN_CLERK_PUBLISHABLE_KEY: ${{ secrets.CLERK_PUBLISHABLE_KEY_PROD }}
MERIDIAN_OTP_API_URL: ${{ vars.MERIDIAN_OTP_API_URL }}
MERIDIAN_OTP_CLIENT_TOKEN: ${{ secrets.MERIDIAN_OTP_CLIENT_TOKEN }}
MERIDIAN_CHANNEL: ${{ needs.prepare.outputs.channel }}
@@ -933,6 +1014,7 @@ jobs:
# equivalent step for the measurement (5288 cache entries evicting the
# tarball that actually matters, for a 0.00% Rust hit rate).
- uses: Swatinem/rust-cache@v2
+ id: rust-cache
with:
# Keyed by triple only, same reasoning as the macOS job — one warm
# cache shared across every workflow that compiles this target.
@@ -945,6 +1027,70 @@ jobs:
# compiled OpenSSL-from-source + SQLCipher + the daemon and tray from
# a stone-cold cache, 35.7 min of a 37 min job, every single time.
save-if: ${{ github.ref == 'refs/heads/main' }}
+ # SAVE THE CACHE EVEN WHEN THE JOB FAILS.
+ #
+ # Defaults to false, so a run that compiles for 30 minutes and then
+ # dies in bundling, signing, notarization or upload throws that whole
+ # compile away, and the retry starts cold again - paying twice for one
+ # release. The compile is the expensive half and it SUCCEEDED; a later
+ # step failing says nothing about the artifacts it produced. A partial
+ # `target/` is not a hazard either: cargo fingerprints it correctly,
+ # which is the same property every incremental build already relies on.
+ cache-on-failure: true
+
+ # A COLD BUILD MUST NOT BE SILENT. This is the release pipeline's oldest
+ # and most expensive failure mode, and it has never once announced itself.
+ #
+ # The shape every time: the cache key drifts or the entry is evicted,
+ # `No cache found.` is written into a COLLAPSED log group, the build takes
+ # 20-30 extra minutes, and every check goes green with correct binaries at
+ # the end. It has only ever been caught by someone happening to watch the
+ # clock - most recently 2026-08-27, when the Windows job compiled for 36
+ # minutes while the cache it wanted sat unread on `main` (see the
+ # `runs-on` note in the windows job). The same silence let cache-warm.yml
+ # write an unreadable key for this pipeline's entire history.
+ #
+ # Deliberately an `::error::` ANNOTATION that does NOT fail the step. The
+ # asymmetry matters: a cache miss must never block shipping a release -
+ # the build is slow, not wrong, and failing here would turn a GitHub image
+ # roll into a release outage. But it must be impossible to overlook, so it
+ # lands as a red annotation on the run page and as a section in
+ # `$GITHUB_STEP_SUMMARY`, both of which sit ABOVE the logs rather than
+ # inside a collapsed group.
+ #
+ # The env dump is the other half, and it is what makes the NEXT occurrence
+ # cheap. rust-cache hashes the NAMES AND VALUES of every
+ # CARGO_*/CC*/CFLAGS/CXX/CMAKE*/RUST* variable into the key component that
+ # drifts, and nothing has ever recorded what those were on a run that
+ # missed - so each time the cause has to be re-derived from the cache API
+ # and guesswork. Printing them on a miss turns that into a diff between
+ # two runs. Values that look like credentials are dropped rather than
+ # printed; none of the hashed prefixes should carry one, and a build log
+ # is not the place to find out otherwise.
+ - name: Report the rust-cache outcome
+ run: |
+ if [ "${{ steps.rust-cache.outputs.cache-hit }}" = "true" ]; then
+ echo "rust-cache: HIT - this build starts warm."
+ exit 0
+ fi
+ msg="rust-cache MISSED on ${{ runner.os }}: this build compiles from scratch (~35 min on Windows, ~20 min on macOS). The key drifted or the entry was evicted - compare the environment below against a run that hit."
+ echo "::error title=Cold Rust build (${{ runner.os }})::${msg}"
+ {
+ echo "### rust-cache miss - ${{ runner.os }}"
+ echo
+ echo "${msg}"
+ echo
+ echo "Cache-key environment (rust-cache hashes these names and values)
"
+ echo
+ echo '```'
+ env \
+ | grep -E '^(CARGO|CC|CFLAGS|CXX|CMAKE|RUST)' \
+ | grep -viE '(TOKEN|SECRET|PASSWORD|CREDENTIAL|_KEY)' \
+ | sort || true
+ echo '```'
+ echo
+ echo " "
+ } >> "$GITHUB_STEP_SUMMARY"
- uses: actions/setup-node@v7
with:
diff --git a/.releaserc.json b/.releaserc.json
index 4b9f0f40c..42c46c99f 100644
--- a/.releaserc.json
+++ b/.releaserc.json
@@ -7,7 +7,13 @@
[
"@semantic-release/commit-analyzer",
{
- "preset": "conventionalcommits"
+ "preset": "conventionalcommits",
+ "releaseRules": [
+ {
+ "type": "revert",
+ "release": "patch"
+ }
+ ]
}
],
[
diff --git a/.releaserc.staging.json b/.releaserc.staging.json
index 9f8d2a7c3..3b923ed58 100644
--- a/.releaserc.staging.json
+++ b/.releaserc.staging.json
@@ -11,7 +11,13 @@
[
"@semantic-release/commit-analyzer",
{
- "preset": "conventionalcommits"
+ "preset": "conventionalcommits",
+ "releaseRules": [
+ {
+ "type": "revert",
+ "release": "patch"
+ }
+ ]
}
],
[
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 97acc848d..e7fec4f64 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -65,10 +65,7 @@ listens on no port.
| **MCP server** | `packages/meridian-mcp/` | TypeScript. Exposes the same data to AI clients over the Model Context Protocol. |
| **OAuth** | `meridian-oauth/` | Browser-based OAuth flows for Jira and Trello. |
-The Rust workspace is `[".", "meridian-core", "meridian-oauth", "tray/src-tauri",
-"tray/src-tauri/vendor/tauri-plugin-clerk"]`. That last one is a patched copy of a
-third-party crate, and it is a member rather than excluded precisely so its regression
-tests run - see `tray/src-tauri/vendor/tauri-plugin-clerk/README.md`.
+The Rust workspace is `[".", "meridian-core", "meridian-oauth", "tray/src-tauri"]`.
Because the repo root is itself a package, **`cargo test` and `cargo clippy` must be
run with `--workspace`** or they silently test only the daemon. This is the single
diff --git a/CLAUDE.md b/CLAUDE.md
index 7f42cc669..6137ac74b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -19,7 +19,7 @@ Meridian is a single-process Rust daemon that normalises raw screen-capture fram
- NEVER push directly to `main` or `pre-main` — always create a separate feature branch, commit there, and raise a PR to `pre-main`. **All features, fixes, and other changes target `pre-main`** (the staging branch), not `main` — only a maintainer opens the `pre-main → main` release PR, and only after everything on `pre-main` has been tested end-to-end on staging
- ALWAYS use a separate branch per feature/fix — branch name format: `type/short-description` (e.g. `feat/trello-oauth`, `fix/ui-disconnect`)
- In all **user-facing app text** — window titles, wizard/UI copy, button and menu labels, notification bodies, tray tooltips, any string the user reads — use a plain hyphen `-` only. NEVER an em-dash (`—`), en-dash (`–`), or double hyphen (`--`). Use it spaced (` - `) where a dash separates clauses. (This rule is about displayed strings; code comments and docs are exempt.)
-- **Any publicly reachable service we deploy must authenticate every request, validate its origin, allowlist the paths it serves, and rate-limit — and it gets deleted the day its last caller does.** "Authenticate" is separate from "validate the origin" on purpose: an origin check alone is a header a caller controls, and reading the two as one requirement is what permits an unauthenticated public service. An unauthenticated request must be rejected outright with a 401, and **verifying that is currently a MANUAL step** — `scripts/deploy-gateway.sh` only prints "should 401 without a Bearer token" as a reminder at the end of a deploy; it sends no request and fails on nothing, so a gateway that started answering 200 unauthenticated would deploy green. `infra/hf-proxy` (`hf.meridiona.com`) was an unauthenticated reverse proxy to huggingface.co. Its header carried a thoughtful `SECURITY:` block about cache-key poisoning and auth headers leaking into a shared cache; it never asked *who may call this*. When the MLX stack that used it was deleted it kept running with no callers and a public DNS record — and Cloudflare publishes every hostname to the Certificate Transparency logs the moment it issues the cert, so scanners find it whether or not you advertise it. It reached **173,088 requests in a day** against a 100k/day account-wide cap and took meridiona.com down with Error 1027 for traffic the site did not generate. Assume every hostname you provision is public knowledge immediately.
+- **Any publicly reachable service we deploy must authenticate every request, validate its origin, allowlist the paths it serves, and rate-limit — and it gets deleted the day its last caller does.** "Authenticate" is separate from "validate the origin" on purpose: an origin check alone is a header a caller controls, and reading the two as one requirement is what permits an unauthenticated public service. An unauthenticated request must be rejected outright with a 401, and **`scripts/deploy-gateway.sh` now asserts that rather than printing a reminder about it** — after `up -d` it probes both public hostnames four ways (ingest and OO UI × no credentials and wrong credentials) and exits non-zero unless every one answers 401, so a gateway that started answering 200 unauthenticated fails the deploy instead of going green. The wrong-credential probes are not redundant: an unauthenticated 401 still passes if an authenticator were swapped for one that merely checks a header is *present*. Two properties are load-bearing and easy to regress — a `200` fails immediately rather than being retried, and an exhausted retry budget (`000`/5xx, i.e. the stack never came up) **fails** rather than falling through to success, which is exactly the shape of the bug it replaced. `bash scripts/deploy-gateway.sh --self-test` exercises the status classifier offline; `--verify-only` runs the four probes against the live gateway without deploying. `infra/hf-proxy` (`hf.meridiona.com`) was an unauthenticated reverse proxy to huggingface.co. Its header carried a thoughtful `SECURITY:` block about cache-key poisoning and auth headers leaking into a shared cache; it never asked *who may call this*. When the MLX stack that used it was deleted it kept running with no callers and a public DNS record — and Cloudflare publishes every hostname to the Certificate Transparency logs the moment it issues the cert, so scanners find it whether or not you advertise it. It reached **173,088 requests in a day** against a 100k/day account-wide cap and took meridiona.com down with Error 1027 for traffic the site did not generate. Assume every hostname you provision is public knowledge immediately.
---
@@ -385,7 +385,7 @@ supported way to read logs locally, replacing the old JSONL-tailing UI and the
old bash `meridian logs` (which used to tail launchd-redirected stdout/stderr
text).
-**Three couplings that silently delete error coverage.** Each has bitten at
+**Four couplings that silently delete error coverage - or cause an egress.** Each has bitten at
least once; none fails loudly, and none is visible from the call site.
1. **The `EnvFilter` decides what is captured at all — before the spool, before
@@ -420,7 +420,25 @@ least once; none fails loudly, and none is visible from the call site.
the field was the cheaper half. Likewise a full binary path (`bin`) stays
denied while `bin_source` — a closed set of literals from
`install::bin_source` — ships in its place.
-3. **A `u64` field is not shipped as a number.** `tracing-opentelemetry` 0.28's
+3. **The log BODY is not an attribute, and nothing filters it.** The allowlist
+ governs attribute KEYS; the body *is* the record, so it always ships, having
+ passed only `scrub_text`'s URL/email/blob patterns — which know nothing about
+ ticket keys, window titles, or a tracker's error payload. CLAUDE.md has said
+ "structured fields — never format data values into the message string" from
+ the start, and nothing enforced it, so it drifted at three tray call sites
+ that spliced a `meridian` subprocess's stderr into a WARN body. Issue #872
+ measured the result: a real user's ticket key (`ENG-7041`) in central
+ OpenObserve, from a value the allowlist denies as an attribute. It is now
+ enforced — `errors.rs::no_user_data_interpolated_into_a_log_body` walks every
+ workspace source tree and fails the build on a new interpolated WARN+/ERROR
+ body, with `INTERPOLABLE` as the deliberate, justified exemption list. **The
+ corollary when you fix one: move the value to an UNALLOWLISTED attribute
+ rather than deleting it.** Unallowlisted attributes are captured at full
+ fidelity locally (`meridian logs` renders them; export bundles carry the raw
+ spool) and dropped on the ship leg — that is the two-tier design working, and
+ it is why `stderr_tail` costs the engineer debugging their own machine
+ nothing. Deleting the value instead is #867's mistake wearing #872's clothes.
+4. **A `u64` field is not shipped as a number.** `tracing-opentelemetry` 0.28's
`Visit` impl has no `record_u64`, so `tracing::field::Visit`'s default applies
and forwards to `record_debug` — which emits a **StringValue**. The field then
misses the allowlist (nobody lists `timeout_s` as a *string* key) and is
@@ -603,7 +621,7 @@ the packaged-build test recipe). In short:
The dashboard's "What's New" modal (`ui/components/timeline/WhatsNewModal.tsx`, opened via the toolbar nav pill or auto-opened once per app version by the tray's `poll::whats_new_auto_open`) is **hand-curated**, deliberately separate from the auto-generated `CHANGELOG.md` — that file is commit-level and too internal to show end users (e.g. `hf-proxy: bake MERIDIAN_HF_ENDPOINT into the staging channel`).
1. Edit `tray/src-tauri/resources/whats-new.json` (compiled into the tray binary via `include_str!`, not Tauri resource-bundling — a rebuild always picks up the change).
-2. Add a new object to the front of `releases` (newest-first): `version`, `date`, `highlights` (features, user-facing language), `fixes`. Rewrite each bullet in plain user terms — never paste a commit message verbatim.
+2. Add a new object to the front of `releases` (newest-first): `version`, `date`, and `items` — **1-3**, each a short `title` (≤44 chars) plus a `body` of **one short sentence** (≤160 chars). There is deliberately no highlights/fixes split: which bucket a change came from is our concern, not the reader's, and the split doubled the length of every entry. `release_notes_stay_short` in `whats_new.rs` fails the build if these limits are exceeded — they are the feature, not a style preference, because notes nobody finishes reading are notes nobody reads. Pick the two or three changes a user would actually notice and drop the rest; never paste a commit message verbatim.
3. Update `roadmap` if upcoming plans changed — `status` is `in-progress` | `planned` | `considering`.
4. Every string in this file is user-facing app text — plain hyphen `-` only, no em-dash, per the Hard Rules at the top of this file.
5. `cargo test -p meridian-tray` (from `tray/src-tauri/`) covers `whats_new_json_parses`, which fails the build if the JSON doesn't match the expected shape.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index cc5252720..4b0d09907 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -125,8 +125,7 @@ bare `cd ui` on both would put the second one in `ui/ui`.
> **`--workspace` is not optional.** The repo root is itself a package, so a bare
> `cargo test` or `cargo clippy` runs against the daemon **alone** and silently skips
-> `meridian-core`, `meridian-oauth`, the tray, and the vendored `tauri-plugin-clerk`
-> (a workspace member so its regression tests run). They still compile, which is what
+> `meridian-core`, `meridian-oauth`, and the tray. They still compile, which is what
> makes the omission so convincing - they are simply never tested. CI and the git hooks
> pass `--workspace` for exactly this reason.
diff --git a/Cargo.lock b/Cargo.lock
index 2872bc2b6..44f28986c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1441,28 +1441,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
-[[package]]
-name = "clerk-fapi-rs"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e239d6a45c0bc0725bd254dbdd6f89918ea27c634cc753c173caca2f5b60fd0"
-dependencies = [
- "anyhow",
- "base64 0.22.1",
- "futures",
- "getrandom 0.3.4",
- "http",
- "log",
- "parking_lot",
- "pin-project-lite",
- "reqwest 0.12.28",
- "serde",
- "serde_json",
- "serde_with",
- "url",
- "uuid",
-]
-
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -1498,7 +1476,7 @@ dependencies = [
"cocoa-foundation",
"core-foundation 0.10.1",
"core-graphics 0.24.0",
- "foreign-types 0.5.0",
+ "foreign-types",
"libc",
"objc",
]
@@ -1589,7 +1567,6 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
- "percent-encoding",
"time",
"version_check",
]
@@ -1600,24 +1577,6 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2"
-[[package]]
-name = "cookie_store"
-version = "0.22.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
-dependencies = [
- "cookie",
- "document-features",
- "idna",
- "log",
- "publicsuffix",
- "serde",
- "serde_derive",
- "serde_json",
- "time",
- "url",
-]
-
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -1653,7 +1612,7 @@ dependencies = [
"bitflags 2.11.1",
"core-foundation 0.10.1",
"core-graphics-types",
- "foreign-types 0.5.0",
+ "foreign-types",
"libc",
]
@@ -1666,7 +1625,7 @@ dependencies = [
"bitflags 2.11.1",
"core-foundation 0.10.1",
"core-graphics-types",
- "foreign-types 0.5.0",
+ "foreign-types",
"libc",
]
@@ -2093,12 +2052,6 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
-[[package]]
-name = "data-url"
-version = "0.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
-
[[package]]
name = "dbus"
version = "0.9.11"
@@ -2357,15 +2310,6 @@ dependencies = [
"syn 2.0.117",
]
-[[package]]
-name = "document-features"
-version = "0.2.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
-dependencies = [
- "litrs",
-]
-
[[package]]
name = "dom_query"
version = "0.27.0"
@@ -2612,15 +2556,6 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
-[[package]]
-name = "encoding_rs"
-version = "0.8.35"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
-dependencies = [
- "cfg-if",
-]
-
[[package]]
name = "endi"
version = "1.1.1"
@@ -2985,15 +2920,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
-[[package]]
-name = "foreign-types"
-version = "0.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
-dependencies = [
- "foreign-types-shared 0.1.1",
-]
-
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -3001,7 +2927,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
- "foreign-types-shared 0.3.1",
+ "foreign-types-shared",
]
[[package]]
@@ -3015,12 +2941,6 @@ dependencies = [
"syn 2.0.117",
]
-[[package]]
-name = "foreign-types-shared"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
-
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -3710,25 +3630,6 @@ dependencies = [
"syn 2.0.117",
]
-[[package]]
-name = "h2"
-version = "0.4.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
-dependencies = [
- "atomic-waker",
- "bytes",
- "fnv",
- "futures-core",
- "futures-sink",
- "http",
- "indexmap 2.14.0",
- "slab",
- "tokio",
- "tokio-util",
- "tracing",
-]
-
[[package]]
name = "half"
version = "2.7.1"
@@ -3974,7 +3875,6 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
- "h2",
"http",
"http-body",
"httparse",
@@ -4003,22 +3903,6 @@ dependencies = [
"webpki-roots 1.0.7",
]
-[[package]]
-name = "hyper-tls"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
-dependencies = [
- "bytes",
- "http-body-util",
- "hyper",
- "hyper-util",
- "native-tls",
- "tokio",
- "tokio-native-tls",
- "tower-service",
-]
-
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -4037,11 +3921,9 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
- "system-configuration",
"tokio",
"tower-service",
"tracing",
- "windows-registry",
]
[[package]]
@@ -4943,12 +4825,6 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
-[[package]]
-name = "litrs"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
-
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -5150,6 +5026,7 @@ dependencies = [
"http",
"jsonschema",
"keyring",
+ "libc",
"libsqlite3-sys",
"meridian-core",
"meridian-oauth",
@@ -5180,6 +5057,7 @@ dependencies = [
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5247,14 +5125,11 @@ dependencies = [
"sysinfo",
"tauri",
"tauri-build",
- "tauri-plugin-clerk",
- "tauri-plugin-http",
"tauri-plugin-notifications",
"tauri-plugin-opener",
"tauri-plugin-positioner",
"tauri-plugin-sentry",
"tauri-plugin-single-instance",
- "tauri-plugin-store",
"tauri-plugin-updater",
"tempfile",
"tokio",
@@ -5475,23 +5350,6 @@ dependencies = [
"windows-sys 0.61.2",
]
-[[package]]
-name = "native-tls"
-version = "0.2.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
-dependencies = [
- "libc",
- "log",
- "openssl",
- "openssl-probe",
- "openssl-sys",
- "schannel",
- "security-framework 3.7.0",
- "security-framework-sys",
- "tempfile",
-]
-
[[package]]
name = "ndk"
version = "0.9.0"
@@ -6345,31 +6203,6 @@ dependencies = [
"serde_json",
]
-[[package]]
-name = "openssl"
-version = "0.10.81"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
-dependencies = [
- "bitflags 2.11.1",
- "cfg-if",
- "foreign-types 0.3.2",
- "libc",
- "openssl-macros",
- "openssl-sys",
-]
-
-[[package]]
-name = "openssl-macros"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
[[package]]
name = "openssl-probe"
version = "0.2.1"
@@ -7235,22 +7068,6 @@ dependencies = [
"syn 2.0.117",
]
-[[package]]
-name = "psl-types"
-version = "2.0.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
-
-[[package]]
-name = "publicsuffix"
-version = "2.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
-dependencies = [
- "idna",
- "psl-types",
-]
-
[[package]]
name = "pulp"
version = "0.22.3"
@@ -7729,25 +7546,17 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
- "cookie",
- "cookie_store",
- "encoding_rs",
"futures-channel",
"futures-core",
"futures-util",
- "h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
- "hyper-tls",
"hyper-util",
"js-sys",
"log",
- "mime",
- "mime_guess",
- "native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -7759,7 +7568,6 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
- "tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
@@ -9842,27 +9650,6 @@ dependencies = [
"winapi",
]
-[[package]]
-name = "system-configuration"
-version = "0.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
-dependencies = [
- "bitflags 2.11.1",
- "core-foundation 0.9.4",
- "system-configuration-sys",
-]
-
-[[package]]
-name = "system-configuration-sys"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
-dependencies = [
- "core-foundation-sys",
- "libc",
-]
-
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -10100,73 +9887,6 @@ dependencies = [
"walkdir",
]
-[[package]]
-name = "tauri-plugin-clerk"
-version = "0.1.1"
-dependencies = [
- "clerk-fapi-rs",
- "log",
- "parking_lot",
- "serde",
- "serde_json",
- "serde_path_to_error",
- "tauri",
- "tauri-plugin",
- "tauri-plugin-http",
- "tauri-plugin-store",
- "thiserror 2.0.18",
- "tokio",
- "tracing",
-]
-
-[[package]]
-name = "tauri-plugin-fs"
-version = "2.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
-dependencies = [
- "anyhow",
- "dunce",
- "glob",
- "log",
- "objc2-foundation",
- "percent-encoding",
- "schemars 0.8.22",
- "serde",
- "serde_json",
- "serde_repr",
- "tauri",
- "tauri-plugin",
- "tauri-utils",
- "thiserror 2.0.18",
- "toml 1.1.2+spec-1.1.0",
- "url",
-]
-
-[[package]]
-name = "tauri-plugin-http"
-version = "2.5.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b5bd512048e1985b7ec78f96d99083e2ddaf7e0d906b2b63c44ce5bb8b894067"
-dependencies = [
- "bytes",
- "cookie_store",
- "data-url",
- "http",
- "regex",
- "reqwest 0.12.28",
- "schemars 0.8.22",
- "serde",
- "serde_json",
- "tauri",
- "tauri-plugin",
- "tauri-plugin-fs",
- "thiserror 2.0.18",
- "tokio",
- "url",
- "urlpattern",
-]
-
[[package]]
name = "tauri-plugin-notifications"
version = "0.4.6"
@@ -10257,22 +9977,6 @@ dependencies = [
"zbus",
]
-[[package]]
-name = "tauri-plugin-store"
-version = "2.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6c72dda16786eb4a3f903e43a17b64d8d78dc0f00fe2aa4b757c28f617a8630b"
-dependencies = [
- "dunce",
- "serde",
- "serde_json",
- "tauri",
- "tauri-plugin",
- "thiserror 2.0.18",
- "tokio",
- "tracing",
-]
-
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
@@ -10664,16 +10368,6 @@ dependencies = [
"syn 2.0.117",
]
-[[package]]
-name = "tokio-native-tls"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
-dependencies = [
- "native-tls",
- "tokio",
-]
-
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -11321,19 +11015,9 @@ dependencies = [
"getrandom 0.4.2",
"js-sys",
"serde_core",
- "uuid-rng-internal",
"wasm-bindgen",
]
-[[package]]
-name = "uuid-rng-internal"
-version = "1.23.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13ab16e069e7562ecbdfa2e7858097deff01e13284d62921b6db087d0d66dd76"
-dependencies = [
- "getrandom 0.4.2",
-]
-
[[package]]
name = "uuid-simd"
version = "0.8.0"
@@ -12100,17 +11784,6 @@ dependencies = [
"windows-link 0.2.1",
]
-[[package]]
-name = "windows-registry"
-version = "0.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
-dependencies = [
- "windows-link 0.2.1",
- "windows-result 0.4.1",
- "windows-strings 0.5.1",
-]
-
[[package]]
name = "windows-result"
version = "0.2.0"
diff --git a/Cargo.toml b/Cargo.toml
index 994cc97ac..cef0e37d9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,21 +1,9 @@
[workspace]
-# The last entry is the vendored, patched fork of tauri-plugin-clerk (see
-# tray/src-tauri/Cargo.toml's dependency comment + vendor/tauri-plugin-clerk's
-# module doc). It is a MEMBER, not an `exclude`, on purpose: it is where the
-# session-persistence fix and its regression tests live, and excluding it —
-# the obvious call for vendored third-party code — takes it out of
-# `cargo test --workspace`, `clippy --workspace`, `fmt --check`, CI and the
-# pre-push hook in one move. That is the same shape as the trap documented
-# below (`.` being a package, so a bare `cargo test` skips the rest): every
-# gate green while the tests guarding the fix never ran. It costs nothing —
-# upstream 0.1.1 is already clippy- and fmt-clean — and if a future upstream
-# re-sync isn't, that is worth finding out at the re-sync rather than never.
members = [
".",
"meridian-core",
"meridian-oauth",
"tray/src-tauri",
- "tray/src-tauri/vendor/tauri-plugin-clerk",
]
resolver = "2"
@@ -204,6 +192,25 @@ candle-core = "0.10"
candle-nn = "0.10"
candle-transformers = "0.10"
+# `flock` for the daemon's single-instance lock (src/platform/unix.rs). Three
+# lines of FFI; `libc` is already in the tree transitively, so this adds no
+# build cost. Deliberately not `fs2`/`fd-lock`: both wrap this same call, and
+# neither surfaces the errno, which is the ONE thing this code needs — telling
+# "another daemon holds it" apart from "the lock could not be attempted" is
+# what keeps a failed lock from bricking an install.
+[target.'cfg(unix)'.dependencies]
+libc = "0.2"
+
+# `LockFileEx` for the same lock on Windows (src/platform/windows.rs). Version
+# pinned to 0.61 to match what the tray already resolves, rather than adding a
+# seventh windows-sys in the tree.
+[target.'cfg(windows)'.dependencies]
+windows-sys = { version = "0.61", features = [
+ "Win32_Foundation",
+ "Win32_Storage_FileSystem",
+ "Win32_System_IO",
+] }
+
[dev-dependencies]
tokio = { version = "1.15", features = ["full", "test-util"] }
tempfile = "3"
diff --git a/SECURITY.md b/SECURITY.md
index 3b8ebf6bb..c19f5eb85 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -48,7 +48,7 @@ Meridian is designed to contain blast radius by default:
- **Capture and classification are on-device** — screen content does not leave the machine except via the LLM provider you configure for summarisation
- **The activity database is encrypted at rest** — `~/.meridian/meridian.db` uses SQLCipher, with the key generated on first run and held in the OS keychain (macOS Keychain / Windows Credential Manager)
-- **Credentials are stored locally with restrictive permissions** — OAuth tokens in `~/.meridian/oauth/.json` and tracker credentials in `~/.meridian/.env`, both at mode `0600`. One known exception is tracked publicly: the Clerk session token is persisted as plaintext JSON by the upstream auth plugin ([#727](https://github.com/Meridiona/meridian/issues/727))
+- **Credentials are stored locally with restrictive permissions** — OAuth tokens in `~/.meridian/oauth/.json` and tracker credentials in `~/.meridian/.env`, both at mode `0600`. [#727](https://github.com/Meridiona/meridian/issues/727) (the Clerk session token persisted as plaintext JSON by the upstream auth plugin) no longer applies: Clerk has been removed entirely in favor of a stateless one-time email code, so there is no session token of any kind to persist. (Unrelated and pre-existing: `~/.meridian/account.json` stores the captured sign-in email as plaintext JSON — this predates #727, was never part of it, and is not a session or credential.)
- **Product analytics are minimal and content-free** — three events (an install, a daily "was active" heartbeat, and a daily count of what Meridian did plus an install-health snapshot), sent only after sign-in, identified by account email, and switchable off in Settings. Counts, booleans, and fixed internal codes only: no screen content, window titles, application names, ticket data, notification text, or file paths. These events also carry the Support ID, which links them to that machine's error reports — stated plainly in [docs/privacy.md](docs/privacy.md#product-analytics). A separate payload-free counter ping fires per posted worklog.
- **Error reporting is redacted, error-only, and switchable** — packaged builds send WARN-and-above logs and ERROR-status spans to Meridiana's own observability backend, on by default, with an off switch at Settings → Capture & Privacy. Content-bearing attributes are stripped on-device before the network leg and the hostname is replaced by a one-way pseudonym; source builds never ship at all. Full detail in [docs/privacy.md](docs/privacy.md#error-reporting)
- **Approved ticket updates go directly from your machine** to the trackers you connect (Jira, GitHub, Linear, Trello, Azure DevOps) — never proxied through Meridiana
diff --git a/docs/privacy.md b/docs/privacy.md
index 4e16df438..ab7fd15e8 100644
--- a/docs/privacy.md
+++ b/docs/privacy.md
@@ -78,12 +78,13 @@ For the records that do qualify, every attribute is filtered on your device befo
- **Text values are dropped unless the attribute name is on an explicit allowlist.** Anything path-like is scrubbed of your home directory; the small free-text subset (error messages, stack traces) is additionally scrubbed of URLs, email addresses, and token-shaped strings, then length-clamped.
- **Structured values** (byte blobs, arrays, nested maps) are dropped outright.
- **Span events and links are cleared entirely.**
+- **The message itself is sent** - it is the error, so there is nothing to filter it against. Meridian's own rule is therefore that a message must be a fixed sentence and every runtime value must travel as a named field, where the allowlist above applies to it. That rule is enforced automatically: a build fails if any warning or error message formats a value into its text.
The filter fails closed: a newly added attribute anywhere in the codebase is dropped by default until someone deliberately allowlists it.
### What is therefore never sent
-OCR text, accessibility-tree content, window titles, browser URLs, coding-agent conversation bodies, LLM prompts and completions, ticket contents, file paths, and your local database. These stay on your machine even when error reporting is on. Local logs remain full-fidelity for your own debugging — the stripping applies only to the copy that would be transmitted.
+OCR text, accessibility-tree content, window titles, browser URLs, coding-agent conversation bodies, LLM prompts and completions, ticket keys, ticket contents, file paths, and your local database. These stay on your machine even when error reporting is on. Local logs remain full-fidelity for your own debugging — the stripping applies only to the copy that would be transmitted.
### How reports are identified
@@ -238,7 +239,7 @@ There is no on-device generative model. The only model that runs fully locally i
## Accounts
-Signing in is optional and is currently used to gate the invite-only alpha. Your email address is held by our authentication provider (Clerk); during the alpha window it is also attached directly to your error and crash reports so support can identify which tester and machine an issue came from — see the alpha note under [How reports are identified](#how-reports-are-identified) for the full detail and its end date.
+Signing in is required to use Meridian - it is a one-time step, not an account with a password: you verify a code sent to your email, delivered via AWS SES and checked through a Cloudflare Worker we operate. There is no invite-only alpha it gates. During the alpha window described below, your email is also attached directly to your error and crash reports so support can identify which tester and machine an issue came from — see the alpha note under [How reports are identified](#how-reports-are-identified) for the full detail and its end date.
---
@@ -248,7 +249,7 @@ Signing in is optional and is currently used to gate the invite-only alpha. Your
- **Delete** — `meridian uninstall` removes Meridian's local data and services. Deleting `~/.meridian/` by hand does the same for data alone.
- **Portability** — export your activity data and switch tools; there is no lock-in.
- **Opt out of error reporting or product analytics** — each has its own switch at Settings → Capture & Privacy, and they can be turned off independently. Staying signed out disables product analytics entirely regardless of the switch.
-- **No behavioural tracking** — Meridian does not follow you across websites, does not record sessions, and never sells your data or shares it for advertising. It does reach the service providers named throughout this document to do the job each is described for - PostHog for product analytics, Sentry for crash reports, Clerk for sign-in - and nowhere else. The only usage data collected is the three events described under [Product analytics](#product-analytics).
+- **No behavioural tracking** — Meridian does not follow you across websites, does not record sessions, and never sells your data or shares it for advertising. It does reach the service providers named throughout this document to do the job each is described for - PostHog for product analytics, Sentry for crash reports, AWS SES and a Cloudflare Worker we operate for sign-in - and nowhere else. The only usage data collected is the three events described under [Product analytics](#product-analytics).
---
diff --git a/infra/otp-worker/.gitignore b/infra/otp-worker/.gitignore
new file mode 100644
index 000000000..d633f7e53
--- /dev/null
+++ b/infra/otp-worker/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+.wrangler/
+.dev.vars
+.dev.vars.*
+*.log
diff --git a/infra/otp-worker/README.md b/infra/otp-worker/README.md
new file mode 100644
index 000000000..1b686b528
--- /dev/null
+++ b/infra/otp-worker/README.md
@@ -0,0 +1,377 @@
+# otp-worker
+
+Cloudflare Worker backing Meridian's one-time email+OTP capture step (the
+setup wizard's replacement for Clerk — see the parent plan,
+`giggly-jumping-hopcroft.md`, for the full "why"). Sends a 6-digit code to an
+email address via AWS SES and verifies it. No accounts, no sessions, no
+sign-out — ask once, verify once, store the email locally, never re-check.
+
+This is the **first live Cloudflare Worker in this repo.** Read "Why this
+design" below before changing anything auth- or rate-limit-related — a prior
+Worker (`infra/hf-proxy`, since deleted) shipped unauthenticated with no rate
+limit, got hammered for 173,088 requests in a day against a 100k/day
+account-wide cap, and took `meridiona.com` down. CLAUDE.md's Hard Rules
+section has the full incident writeup; this Worker exists specifically not to
+repeat it.
+
+## Routes
+
+Exactly two exist. Everything else — wrong path, wrong method — gets a plain
+404. Both require `Authorization: Bearer `.
+
+| Route | Body | Purpose |
+|---|---|---|
+| `POST /otp/send` | `{ email, turnstileToken? }` | Generate a code, email it via SES |
+| `POST /otp/verify` | `{ email, code, previousEmail? }` | Check a code against the live record |
+
+`previousEmail` is optional and purely informational — the client's best
+knowledge of the address it had on file before this verify, used only to
+decide what (if anything) to tell `NOTIFY_EMAIL` about (see "Account-event
+notification" below). It is never used for any security decision.
+
+### Status codes
+
+The plan specified the set of codes (400/401/403/410/429/503) but not the
+full route-by-route mapping; this is what was actually implemented, and each
+distinct code is meant to map to a distinct client-side message:
+
+**`/otp/send`**
+
+| Status | Body | Meaning |
+|---|---|---|
+| 200 | `{ ok: true }` (or `{ ok: true, code }` — staging only, see below) | Email queued for delivery |
+| 400 | `{ error: "invalid_email" \| "invalid_json" \| "invalid_turnstile_token" }` | Malformed request |
+| 401 | `{ error: "unauthorized" }` | Missing/wrong bearer token |
+| 403 | `{ error: "turnstile_failed" }` | A `turnstileToken` was sent and failed verification |
+| 429 | `{ error: "rate_limited", scope: "email" \| "ip" \| "global" }` | One of the three send caps tripped |
+| 503 | `{ error: "email_delivery_failed" }` | SES call failed after all gates passed |
+
+**`/otp/verify`**
+
+| Status | Body | Meaning |
+|---|---|---|
+| 200 | `{ ok: true, verified: true }` | Code matched; record consumed |
+| 200 | `{ ok: true, verified: false, attemptsRemaining }` | Wrong code, record still live — not an error, the caller can retry |
+| 400 | `{ error: "invalid_email" \| "invalid_json" \| "invalid_code" }` | Malformed request |
+| 401 | `{ error: "unauthorized" }` | Missing/wrong bearer token |
+| 410 | `{ error: "code_expired_or_not_found" }` | No live record — never sent, naturally expired, or attempts just exhausted |
+
+`exhausted` (5th wrong guess) and `not_found_or_expired` (no record at all)
+are deliberately collapsed into the same 410 — a caller must not be able to
+tell "you used up your attempts" from "there was never a code for this
+email" from the HTTP layer; both just mean "request a new code."
+
+## Why this design (hf-proxy postmortem, applied)
+
+Four things this Worker does that `infra/hf-proxy` didn't, each mapped to a
+line item in CLAUDE.md's Hard Rules:
+
+1. **Authenticates every request.** `auth.ts` checks `Authorization: Bearer`
+ before any body parsing or KV access, on both routes, with no
+ unauthenticated path. Empty/unconfigured secrets never match (guards the
+ "both sides blank" bypass).
+2. **Allowlists the paths it serves.** The router in `index.ts` is an
+ exhaustive `if/if/else 404` — there is no default-allow branch.
+3. **Rate-limits.** Three independent KV-backed caps (per-email, per-IP,
+ global-daily) gate every send, checked before any SES call is attempted.
+4. **Has exactly one caller** (the Meridian tray) and a name that says so.
+ When the tray stops calling this, delete it — don't leave it running with
+ a live DNS record, the way hf-proxy did after the MLX stack that used it
+ was removed. Cloudflare publishes every hostname to Certificate
+ Transparency logs the moment it issues a cert, so an unused endpoint is
+ discoverable whether or not it's advertised.
+
+Bearer-auth honesty note: the token is compiled into the shipped tray binary
+(mirrors `tray/src-tauri/src/counter_ping.rs`'s `DEFAULT_COUNTER_API_KEY`
+pattern) and is therefore extractable by anyone with the binary. It proves
+"a genuine Meridian build sent this," **not** "a human is present." Rate
+limiting is the actual abuse containment; the bearer token only keeps out
+callers who never had a Meridian binary in the first place.
+
+## KV schema (`OTP_KV`)
+
+Keys are built from `sha256(normalizeEmail(email))` — the raw email is never
+used as a KV key. `rl:ip:` is the one exception, keyed on the literal
+`CF-Connecting-IP` value, per the plan.
+
+| Key | Value | Notes |
+|---|---|---|
+| `code:` | `{ codeHash, attempts, expiresAt }` | `codeHash` is `HMAC-SHA256(OTP_CODE_PEPPER, code)` — never the bare code |
+| `rl:email:` | `{ count, expiresAt }` | Rolling 24h window from first send, cap `RL_EMAIL_PER_DAY` |
+| `rl:ip:` | `{ count, expiresAt }` | Rolling 1h window, cap `RL_IP_PER_HOUR` |
+| `global:sends:` | `{ count, expiresAt }` | One key per calendar day, cap `RL_GLOBAL_PER_DAY`, cost/abuse containment |
+
+Every record embeds its own authoritative `expiresAt` (epoch ms) rather than
+relying solely on KV's own TTL — two independent reasons, both documented in
+`kv.ts`/`otp.ts`/`ratelimit.ts`:
+
+- A rolling window's expiry must survive a read-modify-write without being
+ reset on every increment, and Cloudflare KV requires `expirationTtl` to be
+ re-specified on **every** `put` (omitting it clears the expiry entirely).
+- **Cloudflare Workers KV enforces a hard minimum `expirationTtl` of 60
+ seconds.** A code with 20 real seconds left cannot be written back with a
+ 20-second KV TTL. `kv.ts`'s `ttlSecondsFromExpiry` clamps to that floor —
+ this only affects the KV-level storage-cleanup timer; the embedded
+ `expiresAt` check in `otp.ts`/`ratelimit.ts` is what actually enforces
+ expiry, so a clamped KV TTL can never let an expired record be honoured. It
+ can only make a dead key linger in storage slightly longer before KV itself
+ reaps it.
+
+Counters are persisted **before** the SES call is attempted, not after — the
+caps exist for cost/abuse containment against attempted sends, so a run of
+SES failures (an outage, a bad credential) still counts against budget.
+Otherwise, an attacker (or an outage) could drive unlimited send-attempt
+traffic for free by ensuring every attempt "fails" cheaply.
+
+## Code hashing
+
+`OTP_CODE_PEPPER` (a Worker secret) is mixed into every code via
+HMAC-SHA256 before it touches KV — never a bare hash, since a 6-digit code is
+trivially brute-forced offline from a raw KV dump otherwise. Verify caps
+attempts at `MAX_VERIFY_ATTEMPTS` (5) before invalidating the code and
+forcing a fresh send. **A wrong guess never extends the record's remaining
+TTL** — `otp.ts`'s `verifyOtpAttempt` carries the original `expiresAt`
+forward unchanged on every wrong guess; this is pinned by
+`otp.test.ts`'s "never extends the TTL" cases.
+
+## Email delivery (AWS SES)
+
+`ses.ts` calls SES's `SendEmail` action (Query API, `2010-12-01`),
+SigV4-signed via [`aws4fetch`](https://github.com/mhart/aws4fetch) —
+Workers run on a V8 isolate with no Node.js APIs, so the official AWS SDK
+doesn't work here; `aws4fetch` is the established community pattern for
+calling AWS from a Worker.
+
+**Resolved, not specified by the plan: SES's v1 Query API over SESv2's JSON
+API.** Picked because it's the one with a documented, minimal `aws4fetch`
+example (`service: "email"`, form-urlencoded body) — a single `SendEmail`
+call is low-stakes either way and this was the path of least friction.
+
+`from` is `${FROM_NAME} <${FROM_ADDRESS}>` — currently
+`Meridian `, a placeholder verified subdomain of
+`meridiona.com` matching the existing `telemetry.`/`observe.` subdomain
+convention. **DNS verification (SPF/DKIM records in SES, added to the
+existing Cloudflare-managed zone) is a manual step outside this Worker's
+code** — see "Manual steps before first deploy" below.
+
+The email body is plain text, no links: "Your Meridian verification code is:
+``. This code expires in `` minutes...".
+
+SES error responses are never logged verbatim (`extractSesErrorCode`) —
+SES's sandbox-mode "email address is not verified" error echoes the
+destination address back in the response text, which must never reach
+Workers Logs.
+
+## Account-event notification
+
+On a successful `/otp/verify` (the `verified` case only — never on a wrong
+guess), `handleVerify` fires a notification to `NOTIFY_EMAIL` telling the team
+that an install signed up or changed its email. This rides on `ctx.waitUntil`
+exactly like the rate-limit alert below — fire-and-forget, never awaited
+inline, so a failed notification can never affect the verify response the
+caller is waiting on.
+
+**This one email goes via Resend, not SES** (`resend.ts`). The marketing site
+has sent the identical notification since June — `Meridian Sign-ins
+` → `adithya@meridiona.com`, subject `New sign-up:
+` — and routing the desktop app's copy through the same provider keeps
+web and desktop sign-ups in one inbox with one sender identity. It does **not**
+reverse the SES-over-Resend decision below: that decision was about OTP *code*
+delivery, where Resend's 100/day free tier cannot cover hundreds of
+user-facing sends. An internal notification to one address is a couple of
+dozen a day at most.
+
+The body deliberately mirrors the website's format (plain text, no HTML part;
+address on line 1, source, then one status line). Where the web version
+carries `Clerk user id: …`, the desktop app has no equivalent since Clerk was
+removed, so line 2 names the source — which is also what distinguishes a
+desktop notification from a web one at a glance.
+
+`resend.ts`'s `resolveAccountEvent(newEmail, previousEmail)` decides which:
+
+- `previousEmail` absent/null → **sign-up**, subject `New sign-up: `
+ (byte-identical to the website's convention).
+- `previousEmail` present and different → **email changed**, subject
+ `Email changed: -> `. No web equivalent exists for this case.
+- `previousEmail` present and identical to the new email → **no-op**, nothing
+ sent (a "Change email" re-entering the address already on file must not
+ claim something changed).
+
+`previousEmail` is sent by the client (`tray/src-tauri/src/commands/otp.rs`'s
+`confirm_account_otp`, reading `commands::account::read_account_email()`
+before the request) and is purely informational — this Worker has no durable
+account state of its own to derive it from independently, and doesn't need
+one: unlike a routine sign-in, every verify in this app is either a genuine
+one-time capture or a deliberate "Change email" action (there is no
+session/re-login concept), so there's no repeat-noise case to dedup against
+and no once-per-day flag like `ALERT_EMAIL`'s.
+
+## Turnstile (conditional, per the plan)
+
+**Turnstile support is wired but CONDITIONAL: whether the client actually
+sends a token depends on a separate frontend/Tauri feasibility spike this
+Worker does not block on. The rate-limiting layer above is the mechanism
+that holds either way** — a request with no `turnstileToken` at all is
+accepted purely on bearer-auth + rate limits, exactly as if Turnstile didn't
+exist.
+
+Two independent gates in `turnstile.ts`, both must pass for verification to
+actually run:
+
+1. The request body includes a `turnstileToken` at all. If absent, `index.ts`
+ never calls `turnstile.ts` — the send proceeds on bearer-auth +
+ rate-limits alone (the plan's explicit "if absent, proceed without it").
+2. `TURNSTILE_SECRET_KEY` (a Worker secret, **not** one of the plan's
+ originally-listed secrets — added here since Turnstile wasn't in scope
+ when that table was written) is actually configured. If unset — the
+ expected state until/unless the frontend spike succeeds and a Turnstile
+ site is provisioned — verification is a no-op that returns `true` and
+ logs a warning, rather than rejecting every send because a feature
+ nothing has enabled yet looks "misconfigured."
+
+**Resolved, not specified by the plan: a token that IS present but FAILS
+verification is rejected with 403**, rather than silently ignored — fail
+closed on invalid input, consistent with this repo's conventions elsewhere.
+A present-and-valid token verifies as usual; an absent token skips
+verification entirely (gate 1); an unconfigured secret with a present token
+also proceeds (gate 2), since there's nothing to validate against yet.
+
+## Anti-abuse summary
+
+Defense in depth, in the order a request actually passes through them:
+
+1. Bearer token (attestation, not a strong secret — see above)
+2. Turnstile, if/when wired up client-side (optional, see above)
+3. Three independent KV rate limits (per-email/per-IP/global), checked
+ before any code is generated or SES is called
+4. HMAC-peppered code hash + 5-attempt cap on verify
+
+## Secrets / config split
+
+Mirrors the existing `ops/central-observability` convention (public
+`vars.*` vs. `wrangler secret put`-only values) and the plan's own table:
+
+| Name | Where | Notes |
+|---|---|---|
+| `OTP_CLIENT_TOKEN` | `wrangler secret put` (both envs) | Bearer token the tray sends |
+| `OTP_CODE_PEPPER` | `wrangler secret put` (both envs) | HMAC pepper for code hashing |
+| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_REGION` | `wrangler secret put` (both envs) | Scoped to an IAM policy permitting only `ses:SendEmail` on the verified identity |
+| `RESEND_API_KEY` | `wrangler secret put` (both envs) | Sending-access key scoped to the `meridiona.com` domain - the account-event notification only, see above |
+| `TURNSTILE_SECRET_KEY` | `wrangler secret put` (both envs), optional | Unset until/unless the frontend spike lands — see "Turnstile" above |
+| `CI_TEST_TOKEN` | `wrangler secret put --env staging` **only** | Staging-only auth token that also unlocks the `/otp/send` code echo — see below |
+| `ENVIRONMENT`, `FROM_ADDRESS`, `FROM_NAME`, `OTP_TTL_S`, `MAX_VERIFY_ATTEMPTS`, `RL_EMAIL_PER_DAY`, `RL_IP_PER_HOUR`, `RL_GLOBAL_PER_DAY`, `ALERT_THRESHOLD_PCT`, `ALERT_EMAIL`, `NOTIFY_EMAIL`, `NOTIFY_FROM` | `wrangler.jsonc` `vars` | Public, tunable without touching code |
+
+### Staging-only code echo
+
+`scripts/deploy-otp-worker.sh`'s happy-path send→verify smoke test needs to
+read the generated code without a real inbox. `/otp/send` echoes the code in
+its response body (`{ ok: true, code }`) under **two** conditions, both
+checked at the point of response, not just at auth time:
+
+```ts
+if (env.ENVIRONMENT === "staging" && auth.isCiTestToken) {
+ return ok({ code });
+}
+```
+
+`env.ENVIRONMENT !== "staging"` is an explicit runtime guard, not just "the
+secret happens to be unset" — a `CI_TEST_TOKEN` secret mistakenly present on
+production would still never trigger the echo there. And the echo never
+fires for the normal `OTP_CLIENT_TOKEN` bearer, even on staging — only the
+distinct `CI_TEST_TOKEN` unlocks it (see `auth.ts`'s `isCiTestToken` flag).
+The production bearer token can never reach this path, on any environment.
+
+## Testing
+
+Unit tests (`src/__tests__/*.test.ts`) run inside a real Miniflare-simulated
+Workers runtime via **`@cloudflare/vitest-plugin`** — no Cloudflare account
+or network access required, everything runs locally against
+`wrangler.jsonc`'s binding shapes.
+
+**Resolved, not specified by the plan: `@cloudflare/vitest-plugin`, not
+`@cloudflare/vitest-pool-workers`.** There is no existing Workers test
+convention anywhere else in this repo to mirror (`ui/` and
+`packages/meridian-mcp/` both use plain Node-based test runners with no
+bundler-aware pool), so this is a new precedent for the repo, not an
+established one. `@cloudflare/vitest-pool-workers` (the package named in the
+plan's discussion and in most existing docs/tutorials as of this writing) no
+longer exports `defineWorkersConfig` from `/config` as of its `0.22.x`
+line — that API was replaced by a plugin-based config
+(`cloudflareTest()` from `@cloudflare/vitest-plugin`, used in `vitest.config.mts`
+via `defineConfig({ plugins: [cloudflareTest(...)] })`) to match Vitest 4's
+plugin architecture. This was chosen over falling back to hand-rolled fakes
+for everything because it wasn't more than trivial friction to wire up once
+the correct current package was identified, and it gives real coverage of
+`crypto.subtle.timingSafeEqual` and the actual `KVNamespace` binding
+(`kv.test.ts`) rather than a hand-mocked substitute for either.
+
+Coverage priorities, highest first:
+
+- `otp.test.ts` — the TTL-preservation invariant on a wrong guess, the
+ exact-5th-attempt exhaustion boundary, and the "already exhausted, right
+ code doesn't matter" case. This is the module a regression here would be
+ most dangerous in.
+- `auth.test.ts` — the empty-secret-never-passes and
+ CI-token-never-works-off-staging cases.
+- `ratelimit.test.ts` — window-open/preserve/reset boundaries and the
+ three-scope precedence order.
+- `turnstile.test.ts` — both conditional gates, and fail-closed on network
+ error / non-2xx / explicit failure.
+- `ses.test.ts` — never logging a raw SES error body (which can echo the
+ destination email in sandbox mode), and that the code never leaks into
+ the request URL.
+- `kv.test.ts` — a real KV round-trip (not a fake), plus the 60s-floor
+ clamp math.
+
+```bash
+npm install
+npm run typecheck # tsc --noEmit
+npm test # vitest run, inside simulated Workers runtime
+npx wrangler deploy --dry-run # validates wrangler.jsonc, no auth needed
+npx wrangler deploy --dry-run --env staging
+```
+
+All four commands above were run as part of building this Worker and pass
+with no Cloudflare account access — `--dry-run` bundles and validates
+`wrangler.jsonc` fully offline.
+
+## Manual steps before first deploy
+
+None of the following can be done from this code — they need an operator
+with AWS/Cloudflare account access:
+
+1. **KV namespaces.** `npx wrangler kv namespace create OTP_KV` (production)
+ and `npx wrangler kv namespace create OTP_KV --env staging`, then paste
+ the two returned ids into `wrangler.jsonc`'s
+ `REPLACE_ME_KV_NAMESPACE_ID_PRODUCTION` / `_STAGING` placeholders.
+2. **SES sending identity.** Verify `auth.meridiona.com` (or whatever
+ subdomain is chosen) in the SES console — adds DKIM/SPF DNS records to
+ the existing Cloudflare-managed zone, same pattern as `telemetry.`/
+ `observe.`. Update `wrangler.jsonc`'s `FROM_ADDRESS` if the real verified
+ address differs from the `otp@auth.meridiona.com` placeholder.
+3. **SES production access.** New accounts start in a sandbox: 200
+ emails/24h, 1/sec, and can only send to pre-verified recipients —
+ unusable for real users. File an AWS Support case to request production
+ access before PR 2 (the client cut) ships to real users. Confirm the
+ granted quota in the SES console once approved.
+4. **IAM credentials.** Create a narrowly-scoped IAM user/policy granting
+ only `ses:SendEmail` on the verified identity.
+5. **Secrets**, run for both environments (default + `--env staging`) unless
+ noted:
+ ```bash
+ npx wrangler secret put OTP_CLIENT_TOKEN
+ npx wrangler secret put OTP_CODE_PEPPER
+ npx wrangler secret put AWS_ACCESS_KEY_ID
+ npx wrangler secret put AWS_SECRET_ACCESS_KEY
+ npx wrangler secret put AWS_REGION
+ npx wrangler secret put CI_TEST_TOKEN --env staging # staging only
+ # optional, only once the Turnstile frontend spike lands:
+ npx wrangler secret put TURNSTILE_SECRET_KEY
+ ```
+6. **Deploy + verify:**
+ ```bash
+ npm run deploy:staging # wrangler deploy --env staging
+ bash ../../scripts/deploy-otp-worker.sh --verify-only
+ npm run deploy # wrangler deploy (production)
+ bash ../../scripts/deploy-otp-worker.sh --verify-only
+ ```
diff --git a/infra/otp-worker/package-lock.json b/infra/otp-worker/package-lock.json
new file mode 100644
index 000000000..b5756bca7
--- /dev/null
+++ b/infra/otp-worker/package-lock.json
@@ -0,0 +1,2836 @@
+{
+ "name": "otp-worker",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "otp-worker",
+ "version": "1.0.0",
+ "dependencies": {
+ "aws4fetch": "1.0.20"
+ },
+ "devDependencies": {
+ "@cloudflare/vitest-plugin": "1.1.2",
+ "@types/node": "22.20.1",
+ "typescript": "5.9.3",
+ "vitest": "4.1.11",
+ "wrangler": "4.127.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@cloudflare/kv-asset-handler": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz",
+ "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@cloudflare/unenv-preset": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz",
+ "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "peerDependencies": {
+ "unenv": "2.0.0-rc.24",
+ "workerd": ">1.20260305.0 <2.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "workerd": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@cloudflare/vitest-plugin": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@cloudflare/vitest-plugin/-/vitest-plugin-1.1.2.tgz",
+ "integrity": "sha512-TLkPo1JTKk0LyFwQlNWY0MnZ0RW7FsjianjSdrKiPi/NbL7DUnIEe8O8leJAeBboiRyn23g/NG5jLeyZvWlmVA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cjs-module-lexer": "1.2.3",
+ "esbuild": "0.28.1",
+ "miniflare": "5.20260828.0-alpha",
+ "wrangler": "4.127.1",
+ "zod": "4.4.3"
+ },
+ "peerDependencies": {
+ "@vitest/runner": "^4.1.0",
+ "@vitest/snapshot": "^4.1.0",
+ "vitest": "^4.1.0"
+ }
+ },
+ "node_modules/@cloudflare/workerd-darwin-64": {
+ "version": "1.20260828.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260828.1.tgz",
+ "integrity": "sha512-CVd+xPhqUESg8Xhq09TZx0wl4FSirfJGOzvbPz2yHhBIvmNHFFQkSN3rkd7wEwnhQQk37Xi0/aD6ykPLJbmGiQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-darwin-arm64": {
+ "version": "1.20260828.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260828.1.tgz",
+ "integrity": "sha512-5HDPXRM152vU5JveByGFk34X57TVyIsfp4cabepAf45DC0MKvm52ucJqAjW1h8bvW4X+zRw9GU35OHF9FEC9Ww==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-linux-64": {
+ "version": "1.20260828.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260828.1.tgz",
+ "integrity": "sha512-MQ1Ll9P7F72HHUKizbb7BlDfbY8fRoNMpbIpZoU6uKsSkneFICWSKv6UlgU9EQZ+w0i7TMa12iUgJ8l29eRI9A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-linux-arm64": {
+ "version": "1.20260828.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260828.1.tgz",
+ "integrity": "sha512-FBTaUQ1xcU9jcp4OyBPcH8x0QiFvc1iuZL2GkD8zp2q1WyTVHYOptRDQUU+cuHjt0rQ2EIKVPBjahPxfa0joBw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-windows-64": {
+ "version": "1.20260828.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260828.1.tgz",
+ "integrity": "sha512-yvr77hC7dUbvK5K+SCg062kkPq3sx+drV1PcgHslzHDYcJBtT0V3X80qLE49LW1vq2svaeNmsVQS+vHsqWu8cQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cspotcode/source-map-support": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+ "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.9"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz",
+ "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.3.1"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz",
+ "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.3.1"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz",
+ "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.2"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz",
+ "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz",
+ "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz",
+ "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz",
+ "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz",
+ "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz",
+ "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz",
+ "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz",
+ "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz",
+ "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz",
+ "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz",
+ "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.3.1"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz",
+ "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.3.1"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz",
+ "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.3.1"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz",
+ "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.3.1"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz",
+ "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.3.1"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz",
+ "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.3.1"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz",
+ "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.1"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz",
+ "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.1"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz",
+ "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==",
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.11.1"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz",
+ "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.2"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz",
+ "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz",
+ "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz",
+ "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.9",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+ "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.0.3",
+ "@jridgewell/sourcemap-codec": "^1.4.10"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.147.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz",
+ "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@poppinss/colors": {
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
+ "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "kleur": "^4.1.5"
+ }
+ },
+ "node_modules/@poppinss/dumper": {
+ "version": "0.6.5",
+ "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz",
+ "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/colors": "^4.1.5",
+ "@sindresorhus/is": "^7.0.2",
+ "supports-color": "^10.0.0"
+ }
+ },
+ "node_modules/@poppinss/exception": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz",
+ "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz",
+ "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz",
+ "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz",
+ "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz",
+ "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz",
+ "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz",
+ "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz",
+ "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz",
+ "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz",
+ "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz",
+ "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz",
+ "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz",
+ "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz",
+ "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz",
+ "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz",
+ "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@sindresorhus/is": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
+ "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/is?sponsor=1"
+ }
+ },
+ "node_modules/@speed-highlight/core": {
+ "version": "1.2.24",
+ "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz",
+ "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "22.20.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
+ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
+ "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
+ "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.11",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
+ "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
+ "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.11",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
+ "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
+ "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
+ "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.11",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/aws4fetch": {
+ "version": "1.0.20",
+ "resolved": "https://registry.npmjs.org/aws4fetch/-/aws4fetch-1.0.20.tgz",
+ "integrity": "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==",
+ "license": "MIT"
+ },
+ "node_modules/blake3-wasm": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
+ "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/cjs-module-lexer": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz",
+ "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/error-stack-parser-es": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz",
+ "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "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"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/miniflare": {
+ "version": "5.20260828.0-alpha",
+ "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260828.0-alpha.tgz",
+ "integrity": "sha512-6nbxhZEcz/UET3Y1OnYPsrAUjUmuFoib3ynUqteRdn1YnDxsLg8cwgZJZCk9QmtOmGzXwzXzgE/d/C0dJAPtVw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspotcode/source-map-support": "0.8.1",
+ "sharp": "0.35.2",
+ "undici": "7.29.0",
+ "workerd": "1.20260828.1",
+ "ws": "8.21.0",
+ "youch": "4.1.0-beta.10"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.17",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz",
+ "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.147.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.6",
+ "@rolldown/binding-android-arm64": "1.2.6",
+ "@rolldown/binding-darwin-arm64": "1.2.6",
+ "@rolldown/binding-darwin-x64": "1.2.6",
+ "@rolldown/binding-freebsd-x64": "1.2.6",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.6",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.6",
+ "@rolldown/binding-linux-arm64-musl": "1.2.6",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.6",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.6",
+ "@rolldown/binding-linux-x64-gnu": "1.2.6",
+ "@rolldown/binding-linux-x64-musl": "1.2.6",
+ "@rolldown/binding-openharmony-arm64": "1.2.6",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.6",
+ "@rolldown/binding-win32-x64-msvc": "1.2.6"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz",
+ "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.1.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.8.4"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.35.2",
+ "@img/sharp-darwin-x64": "0.35.2",
+ "@img/sharp-freebsd-wasm32": "0.35.2",
+ "@img/sharp-libvips-darwin-arm64": "1.3.1",
+ "@img/sharp-libvips-darwin-x64": "1.3.1",
+ "@img/sharp-libvips-linux-arm": "1.3.1",
+ "@img/sharp-libvips-linux-arm64": "1.3.1",
+ "@img/sharp-libvips-linux-ppc64": "1.3.1",
+ "@img/sharp-libvips-linux-riscv64": "1.3.1",
+ "@img/sharp-libvips-linux-s390x": "1.3.1",
+ "@img/sharp-libvips-linux-x64": "1.3.1",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.1",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.1",
+ "@img/sharp-linux-arm": "0.35.2",
+ "@img/sharp-linux-arm64": "0.35.2",
+ "@img/sharp-linux-ppc64": "0.35.2",
+ "@img/sharp-linux-riscv64": "0.35.2",
+ "@img/sharp-linux-s390x": "0.35.2",
+ "@img/sharp-linux-x64": "0.35.2",
+ "@img/sharp-linuxmusl-arm64": "0.35.2",
+ "@img/sharp-linuxmusl-x64": "0.35.2",
+ "@img/sharp-webcontainers-wasm32": "0.35.2",
+ "@img/sharp-win32-arm64": "0.35.2",
+ "@img/sharp-win32-ia32": "0.35.2",
+ "@img/sharp-win32-x64": "0.35.2"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/supports-color": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
+ "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
+ "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
+ "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/unenv": {
+ "version": "2.0.0-rc.24",
+ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz",
+ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pathe": "^2.0.3"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
+ "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.26",
+ "rolldown": "~1.2.4",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0 || ^0.5.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"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
+ "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.11",
+ "@vitest/mocker": "4.1.11",
+ "@vitest/pretty-format": "4.1.11",
+ "@vitest/runner": "4.1.11",
+ "@vitest/snapshot": "4.1.11",
+ "@vitest/spy": "4.1.11",
+ "@vitest/utils": "4.1.11",
+ "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"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "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.11",
+ "@vitest/browser-preview": "4.1.11",
+ "@vitest/browser-webdriverio": "4.1.11",
+ "@vitest/coverage-istanbul": "4.1.11",
+ "@vitest/coverage-v8": "4.1.11",
+ "@vitest/ui": "4.1.11",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/workerd": {
+ "version": "1.20260828.1",
+ "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260828.1.tgz",
+ "integrity": "sha512-pB9yvt0kkwZDAGZHmpY59r0o3hM0DzdW6BJERqwZOhunZ3ssOyDSgQxOQer2cSZW4YCFeOTIQYN1qwhK5wv/Cw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "workerd": "bin/workerd"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "optionalDependencies": {
+ "@cloudflare/workerd-darwin-64": "1.20260828.1",
+ "@cloudflare/workerd-darwin-arm64": "1.20260828.1",
+ "@cloudflare/workerd-linux-64": "1.20260828.1",
+ "@cloudflare/workerd-linux-arm64": "1.20260828.1",
+ "@cloudflare/workerd-windows-64": "1.20260828.1"
+ }
+ },
+ "node_modules/wrangler": {
+ "version": "4.127.1",
+ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.127.1.tgz",
+ "integrity": "sha512-OzsiNgaI8i681L/+KnAKc+uEZ5D57xK5JuNvCOpRKICF4/5Q3Cu1oTGuUiT/f3GDUqQb3gzXNT0tfOHGMEtknw==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@cloudflare/kv-asset-handler": "0.5.0",
+ "@cloudflare/unenv-preset": "2.16.1",
+ "blake3-wasm": "2.1.5",
+ "esbuild": "0.28.1",
+ "miniflare": "5.20260828.0-alpha",
+ "path-to-regexp": "6.3.0",
+ "unenv": "2.0.0-rc.24",
+ "workerd": "1.20260828.1"
+ },
+ "bin": {
+ "cf-wrangler": "bin/cf-wrangler.js",
+ "wrangler": "bin/wrangler.js",
+ "wrangler2": "bin/wrangler.js"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.3"
+ },
+ "peerDependencies": {
+ "@cloudflare/workers-types": "^5.20260828.1"
+ },
+ "peerDependenciesMeta": {
+ "@cloudflare/workers-types": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/youch": {
+ "version": "4.1.0-beta.10",
+ "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz",
+ "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/colors": "^4.1.5",
+ "@poppinss/dumper": "^0.6.4",
+ "@speed-highlight/core": "^1.2.7",
+ "cookie": "^1.0.2",
+ "youch-core": "^0.3.3"
+ }
+ },
+ "node_modules/youch-core": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz",
+ "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/exception": "^1.2.2",
+ "error-stack-parser-es": "^1.0.5"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/infra/otp-worker/package.json b/infra/otp-worker/package.json
new file mode 100644
index 000000000..6aa5029df
--- /dev/null
+++ b/infra/otp-worker/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "otp-worker",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Cloudflare Worker: one-time email+OTP send/verify, backing Meridian's account-email capture step. Not published to npm.",
+ "main": "src/index.ts",
+ "scripts": {
+ "dev": "wrangler dev",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "deploy": "wrangler deploy",
+ "deploy:staging": "wrangler deploy --env staging",
+ "types": "wrangler types"
+ },
+ "dependencies": {
+ "aws4fetch": "1.0.20"
+ },
+ "devDependencies": {
+ "@cloudflare/vitest-plugin": "1.1.2",
+ "@types/node": "22.20.1",
+ "typescript": "5.9.3",
+ "vitest": "4.1.11",
+ "wrangler": "4.127.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+}
diff --git a/infra/otp-worker/src/__tests__/auth.test.ts b/infra/otp-worker/src/__tests__/auth.test.ts
new file mode 100644
index 000000000..4815bd2f0
--- /dev/null
+++ b/infra/otp-worker/src/__tests__/auth.test.ts
@@ -0,0 +1,59 @@
+//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity
+import { describe, expect, it } from "vitest";
+import { checkBearerAuth } from "../auth";
+
+const PROD_ENV = { OTP_CLIENT_TOKEN: "real-client-token", ENVIRONMENT: "production" };
+const STAGING_ENV = {
+ OTP_CLIENT_TOKEN: "real-client-token",
+ CI_TEST_TOKEN: "real-ci-token",
+ ENVIRONMENT: "staging",
+};
+
+describe("checkBearerAuth", () => {
+ it("accepts the correct client token", () => {
+ const result = checkBearerAuth("Bearer real-client-token", PROD_ENV);
+ expect(result).toEqual({ ok: true, isCiTestToken: false });
+ });
+
+ it("rejects a missing Authorization header", () => {
+ expect(checkBearerAuth(null, PROD_ENV).ok).toBe(false);
+ });
+
+ it("rejects a header with no Bearer prefix", () => {
+ expect(checkBearerAuth("real-client-token", PROD_ENV).ok).toBe(false);
+ });
+
+ it("rejects the wrong token", () => {
+ expect(checkBearerAuth("Bearer wrong-token", PROD_ENV).ok).toBe(false);
+ });
+
+ it("rejects an empty bearer token even against an empty configured secret", () => {
+ // Guards the "both sides blank" bypass: an unconfigured OTP_CLIENT_TOKEN
+ // must never accept a request just because both are empty strings.
+ expect(checkBearerAuth("Bearer ", { OTP_CLIENT_TOKEN: "", ENVIRONMENT: "production" }).ok).toBe(false);
+ expect(checkBearerAuth(null, { OTP_CLIENT_TOKEN: "", ENVIRONMENT: "production" }).ok).toBe(false);
+ });
+
+ it("never accepts the CI test token on a non-staging environment, even if the secret is present", () => {
+ const result = checkBearerAuth("Bearer real-ci-token", {
+ OTP_CLIENT_TOKEN: "real-client-token",
+ CI_TEST_TOKEN: "real-ci-token",
+ ENVIRONMENT: "production",
+ });
+ expect(result.ok).toBe(false);
+ });
+
+ it("accepts the CI test token on staging and flags isCiTestToken", () => {
+ const result = checkBearerAuth("Bearer real-ci-token", STAGING_ENV);
+ expect(result).toEqual({ ok: true, isCiTestToken: true });
+ });
+
+ it("accepts the normal client token on staging without flagging isCiTestToken", () => {
+ const result = checkBearerAuth("Bearer real-client-token", STAGING_ENV);
+ expect(result).toEqual({ ok: true, isCiTestToken: false });
+ });
+
+ it("rejects a wrong token on staging even when a CI token is configured", () => {
+ expect(checkBearerAuth("Bearer neither-token", STAGING_ENV).ok).toBe(false);
+ });
+});
diff --git a/infra/otp-worker/src/__tests__/crypto-utils.test.ts b/infra/otp-worker/src/__tests__/crypto-utils.test.ts
new file mode 100644
index 000000000..1e2b47326
--- /dev/null
+++ b/infra/otp-worker/src/__tests__/crypto-utils.test.ts
@@ -0,0 +1,61 @@
+//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity
+import { describe, expect, it } from "vitest";
+import { hmacSha256Hex, sha256Hex, timingSafeEqualStrings } from "../crypto-utils";
+
+describe("timingSafeEqualStrings", () => {
+ it("returns true for identical strings", () => {
+ expect(timingSafeEqualStrings("abc123", "abc123")).toBe(true);
+ });
+
+ it("returns false for different strings of the same length", () => {
+ expect(timingSafeEqualStrings("abc123", "abc124")).toBe(false);
+ });
+
+ it("returns false for different-length strings without throwing", () => {
+ expect(timingSafeEqualStrings("short", "a-lot-longer-string")).toBe(false);
+ });
+
+ it("returns false comparing against an empty string", () => {
+ expect(timingSafeEqualStrings("nonempty", "")).toBe(false);
+ });
+
+ it("treats two empty strings as equal", () => {
+ expect(timingSafeEqualStrings("", "")).toBe(true);
+ });
+});
+
+describe("sha256Hex", () => {
+ it("is deterministic for the same input", async () => {
+ const a = await sha256Hex("test@example.com");
+ const b = await sha256Hex("test@example.com");
+ expect(a).toBe(b);
+ expect(a).toMatch(/^[0-9a-f]{64}$/);
+ });
+
+ it("differs for different input", async () => {
+ const a = await sha256Hex("a@example.com");
+ const b = await sha256Hex("b@example.com");
+ expect(a).not.toBe(b);
+ });
+});
+
+describe("hmacSha256Hex", () => {
+ it("is deterministic for the same key and message", async () => {
+ const a = await hmacSha256Hex("pepper", "123456");
+ const b = await hmacSha256Hex("pepper", "123456");
+ expect(a).toBe(b);
+ expect(a).toMatch(/^[0-9a-f]{64}$/);
+ });
+
+ it("differs when the pepper differs — a code hash from one pepper must not verify under another", async () => {
+ const a = await hmacSha256Hex("pepper-one", "123456");
+ const b = await hmacSha256Hex("pepper-two", "123456");
+ expect(a).not.toBe(b);
+ });
+
+ it("differs when the code differs", async () => {
+ const a = await hmacSha256Hex("pepper", "123456");
+ const b = await hmacSha256Hex("pepper", "654321");
+ expect(a).not.toBe(b);
+ });
+});
diff --git a/infra/otp-worker/src/__tests__/email.test.ts b/infra/otp-worker/src/__tests__/email.test.ts
new file mode 100644
index 000000000..e713fee67
--- /dev/null
+++ b/infra/otp-worker/src/__tests__/email.test.ts
@@ -0,0 +1,53 @@
+//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity
+import { describe, expect, it } from "vitest";
+import { emailHash, normalizeEmail } from "../email";
+
+describe("normalizeEmail", () => {
+ it("trims and lowercases a valid address", () => {
+ expect(normalizeEmail(" Test@Example.COM ")).toBe("test@example.com");
+ });
+
+ it("rejects non-string input", () => {
+ expect(normalizeEmail(undefined)).toBeNull();
+ expect(normalizeEmail(null)).toBeNull();
+ expect(normalizeEmail(12345)).toBeNull();
+ expect(normalizeEmail({})).toBeNull();
+ });
+
+ it("rejects an empty or whitespace-only string", () => {
+ expect(normalizeEmail("")).toBeNull();
+ expect(normalizeEmail(" ")).toBeNull();
+ });
+
+ it("rejects a string with no @", () => {
+ expect(normalizeEmail("not-an-email")).toBeNull();
+ });
+
+ it("rejects a string with no domain dot", () => {
+ expect(normalizeEmail("a@b")).toBeNull();
+ });
+
+ it("rejects an address longer than the RFC 5321 bound", () => {
+ const longLocal = "a".repeat(310);
+ expect(normalizeEmail(`${longLocal}@example.com`)).toBeNull();
+ });
+
+ it("rejects a string containing whitespace inside the address", () => {
+ expect(normalizeEmail("a b@example.com")).toBeNull();
+ });
+});
+
+describe("emailHash", () => {
+ it("is deterministic and hex-encoded", async () => {
+ const a = await emailHash("test@example.com");
+ const b = await emailHash("test@example.com");
+ expect(a).toBe(b);
+ expect(a).toMatch(/^[0-9a-f]{64}$/);
+ });
+
+ it("differs for a different normalized email", async () => {
+ const a = await emailHash("a@example.com");
+ const b = await emailHash("b@example.com");
+ expect(a).not.toBe(b);
+ });
+});
diff --git a/infra/otp-worker/src/__tests__/index.test.ts b/infra/otp-worker/src/__tests__/index.test.ts
new file mode 100644
index 000000000..73fc422d1
--- /dev/null
+++ b/infra/otp-worker/src/__tests__/index.test.ts
@@ -0,0 +1,88 @@
+//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity
+/**
+ * Wiring-level tests for `index.ts`'s router and auth gate — every gate
+ * module (`auth.ts`, `otp.ts`, `ratelimit.ts`, `turnstile.ts`) has its own
+ * thorough unit tests, but until this file nothing ever exercised the actual
+ * exported `fetch` handler that composes them. That left CLAUDE.md's Hard
+ * Rules #1 ("authenticate every request") and #3 ("allowlist the paths it
+ * serves") — the two properties this Worker exists specifically to get
+ * right, per README.md's "Why this design" — asserted by NOTHING: the
+ * deploy script's mock-server exercise (see scripts/deploy-otp-worker.sh)
+ * only ever tests the SCRIPT, not this Worker.
+ *
+ * Deliberately scoped to what needs no secrets: there is no `.dev.vars` here
+ * (and shouldn't be — that would mean committing a bearer token, even a fake
+ * one, next to code path this repo is specifically careful about), so
+ * `OTP_CLIENT_TOKEN` is unset in this simulated environment. Per `auth.ts`,
+ * an empty/unset configured token never matches ANY provided token — which
+ * means these cases require zero setup AND incidentally re-confirm the
+ * empty-secret-never-passes guarantee from `auth.test.ts` one layer up, at
+ * the real `fetch` handler rather than the isolated `checkBearerAuth` call.
+ *
+ * # Related
+ * - `index.ts` — the handler under test
+ * - `scripts/deploy-otp-worker.sh` — the live-deploy counterpart of these
+ * same 401/404 assertions, run against a real deployed Worker
+ */
+import { SELF } from "cloudflare:test";
+import { describe, expect, it } from "vitest";
+
+describe("router: only POST /otp/send and POST /otp/verify exist", () => {
+ it("404s a GET to /otp/send — a method mismatch is not the allowlisted route", async () => {
+ const res = await SELF.fetch("https://example.com/otp/send");
+ expect(res.status).toBe(404);
+ expect(await res.json()).toEqual({ error: "not_found" });
+ });
+
+ it("404s POST to an unknown path", async () => {
+ const res = await SELF.fetch("https://example.com/otp/unknown", { method: "POST" });
+ expect(res.status).toBe(404);
+ });
+
+ it("404s POST to the bare root", async () => {
+ const res = await SELF.fetch("https://example.com/", { method: "POST" });
+ expect(res.status).toBe(404);
+ });
+});
+
+describe("auth gate: unauthenticated requests never reach KV/SES", () => {
+ it("401s POST /otp/send with no Authorization header", async () => {
+ const res = await SELF.fetch("https://example.com/otp/send", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email: "test@example.com" }),
+ });
+ expect(res.status).toBe(401);
+ expect(await res.json()).toEqual({ error: "unauthorized" });
+ });
+
+ it("401s POST /otp/verify with no Authorization header", async () => {
+ const res = await SELF.fetch("https://example.com/otp/verify", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email: "test@example.com", code: "123456" }),
+ });
+ expect(res.status).toBe(401);
+ });
+
+ it("401s POST /otp/send with a bearer token, since no OTP_CLIENT_TOKEN secret is configured here", async () => {
+ // This is the empty-secret-never-passes guarantee (auth.test.ts) proven
+ // at the real handler: an unconfigured secret must reject EVERY token,
+ // not just requests with none at all.
+ const res = await SELF.fetch("https://example.com/otp/send", {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Authorization: "Bearer anything-at-all" },
+ body: JSON.stringify({ email: "test@example.com" }),
+ });
+ expect(res.status).toBe(401);
+ });
+
+ it("auth is checked before body parsing — malformed JSON with no auth still 401s, not 400", async () => {
+ const res = await SELF.fetch("https://example.com/otp/send", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "not json at all {{{",
+ });
+ expect(res.status).toBe(401);
+ });
+});
diff --git a/infra/otp-worker/src/__tests__/kv.test.ts b/infra/otp-worker/src/__tests__/kv.test.ts
new file mode 100644
index 000000000..26da88805
--- /dev/null
+++ b/infra/otp-worker/src/__tests__/kv.test.ts
@@ -0,0 +1,90 @@
+//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity
+import { env } from "cloudflare:test";
+import { beforeEach, describe, expect, it } from "vitest";
+import {
+ deleteOtpRecord,
+ getCounter,
+ getOtpRecord,
+ hasAlertBeenSent,
+ markAlertSent,
+ putCounter,
+ putOtpRecord,
+ ttlSecondsFromExpiry,
+} from "../kv";
+
+describe("ttlSecondsFromExpiry", () => {
+ it("computes the ceiling of the remaining seconds", () => {
+ expect(ttlSecondsFromExpiry(1_000_000 + 90_500, 1_000_000)).toBe(91);
+ });
+
+ it("clamps to KV's 60s minimum when less than 60s remain", () => {
+ expect(ttlSecondsFromExpiry(1_000_000 + 5_000, 1_000_000)).toBe(60);
+ });
+
+ it("clamps to 60s even when the record is already expired (negative remaining)", () => {
+ expect(ttlSecondsFromExpiry(1_000_000 - 5_000, 1_000_000)).toBe(60);
+ });
+});
+
+// Integration-style tests against the real (Miniflare-simulated) OTP_KV
+// binding declared in wrangler.jsonc — no network, no Cloudflare account,
+// but exercises the actual KVNamespace.get/put/delete round trip rather than
+// a hand-rolled fake, so a real serialization or TTL-argument mistake in
+// kv.ts would actually be caught here.
+describe("OtpRecord KV round-trip", () => {
+ const HASH = "deadbeef".repeat(8); // stand-in for a real sha256 hex hash
+
+ beforeEach(async () => {
+ await deleteOtpRecord(env.OTP_KV, HASH);
+ });
+
+ it("returns null for a key that was never written", async () => {
+ expect(await getOtpRecord(env.OTP_KV, HASH)).toBeNull();
+ });
+
+ it("round-trips a written record byte-for-byte", async () => {
+ const now = Date.now();
+ const record = { codeHash: "somehash", attempts: 2, expiresAt: now + 600_000 };
+ await putOtpRecord(env.OTP_KV, HASH, record, now);
+ expect(await getOtpRecord(env.OTP_KV, HASH)).toEqual(record);
+ });
+
+ it("delete removes the record", async () => {
+ const now = Date.now();
+ await putOtpRecord(env.OTP_KV, HASH, { codeHash: "x", attempts: 0, expiresAt: now + 600_000 }, now);
+ await deleteOtpRecord(env.OTP_KV, HASH);
+ expect(await getOtpRecord(env.OTP_KV, HASH)).toBeNull();
+ });
+});
+
+describe("CounterRecord KV round-trip", () => {
+ const KEY = "rl:email:test-key";
+
+ it("round-trips a counter record", async () => {
+ const now = Date.now();
+ const record = { count: 2, expiresAt: now + 3_600_000 };
+ await putCounter(env.OTP_KV, KEY, record, now);
+ expect(await getCounter(env.OTP_KV, KEY)).toEqual(record);
+ });
+
+ it("honours a ttlOverrideS without changing the stored record shape", async () => {
+ const now = Date.now();
+ const record = { count: 1, expiresAt: now + 86_400_000 };
+ await putCounter(env.OTP_KV, "global:sends:test-date", record, now, 90_000);
+ expect(await getCounter(env.OTP_KV, "global:sends:test-date")).toEqual(record);
+ });
+});
+
+describe("rate-limit alert flag", () => {
+ const DATE = "2026-09-05-alert-flag-test";
+
+ it("reports not-sent for a date that was never marked", async () => {
+ expect(await hasAlertBeenSent(env.OTP_KV, DATE)).toBe(false);
+ });
+
+ it("reports sent once marked, and is scoped to that specific date", async () => {
+ await markAlertSent(env.OTP_KV, DATE, 90_000);
+ expect(await hasAlertBeenSent(env.OTP_KV, DATE)).toBe(true);
+ expect(await hasAlertBeenSent(env.OTP_KV, `${DATE}-different`)).toBe(false);
+ });
+});
diff --git a/infra/otp-worker/src/__tests__/otp.test.ts b/infra/otp-worker/src/__tests__/otp.test.ts
new file mode 100644
index 000000000..84beb632b
--- /dev/null
+++ b/infra/otp-worker/src/__tests__/otp.test.ts
@@ -0,0 +1,104 @@
+//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity
+import { describe, expect, it } from "vitest";
+import { createOtpRecord, generateCode, hashCode, verifyOtpAttempt, type OtpRecord } from "../otp";
+
+describe("generateCode", () => {
+ it("produces a 6-digit numeric string by default", () => {
+ const code = generateCode();
+ expect(code).toMatch(/^\d{6}$/);
+ });
+
+ it("produces different codes across calls (not a fixed value)", () => {
+ const codes = new Set(Array.from({ length: 20 }, () => generateCode()));
+ expect(codes.size).toBeGreaterThan(1);
+ });
+
+ it("honours a custom length", () => {
+ expect(generateCode(4)).toMatch(/^\d{4}$/);
+ });
+});
+
+describe("hashCode / createOtpRecord", () => {
+ it("hashCode is deterministic for the same pepper and code", async () => {
+ const a = await hashCode("123456", "pepper");
+ const b = await hashCode("123456", "pepper");
+ expect(a).toBe(b);
+ });
+
+ it("createOtpRecord starts at zero attempts with the given expiry", () => {
+ const record = createOtpRecord("somehash", 1000, 600_000);
+ expect(record).toEqual({ codeHash: "somehash", attempts: 0, expiresAt: 601_000 });
+ });
+});
+
+describe("verifyOtpAttempt", () => {
+ const NOW = 1_000_000;
+ const MAX_ATTEMPTS = 5;
+
+ async function makeRecord(code: string, expiresAt: number, attempts = 0): Promise {
+ return { codeHash: await hashCode(code, "pepper"), attempts, expiresAt };
+ }
+
+ it("verifies a correct code", async () => {
+ const record = await makeRecord("123456", NOW + 60_000);
+ const providedHash = await hashCode("123456", "pepper");
+ const outcome = verifyOtpAttempt(record, providedHash, NOW, MAX_ATTEMPTS);
+ expect(outcome.kind).toBe("verified");
+ });
+
+ it("returns not_found_or_expired for a null record", () => {
+ const outcome = verifyOtpAttempt(null, "any-hash", NOW, MAX_ATTEMPTS);
+ expect(outcome.kind).toBe("not_found_or_expired");
+ });
+
+ it("returns not_found_or_expired once now has reached expiresAt", async () => {
+ const record = await makeRecord("123456", NOW); // expires exactly at NOW
+ const providedHash = await hashCode("123456", "pepper");
+ const outcome = verifyOtpAttempt(record, providedHash, NOW, MAX_ATTEMPTS);
+ expect(outcome.kind).toBe("not_found_or_expired");
+ });
+
+ it("a wrong guess increments attempts but returns the SAME expiresAt — never extends the TTL", async () => {
+ const record = await makeRecord("123456", NOW + 60_000, 0);
+ const wrongHash = await hashCode("000000", "pepper");
+ const outcome = verifyOtpAttempt(record, wrongHash, NOW, MAX_ATTEMPTS);
+ expect(outcome.kind).toBe("wrong");
+ if (outcome.kind !== "wrong") throw new Error("unreachable");
+ expect(outcome.nextRecord.expiresAt).toBe(record.expiresAt);
+ expect(outcome.nextRecord.attempts).toBe(1);
+ expect(outcome.nextRecord.codeHash).toBe(record.codeHash);
+ expect(outcome.attemptsRemaining).toBe(MAX_ATTEMPTS - 1);
+ });
+
+ it("the exact 5th wrong guess (maxAttempts reached) exhausts the record instead of returning wrong", async () => {
+ const record = await makeRecord("123456", NOW + 60_000, MAX_ATTEMPTS - 1); // one guess left
+ const wrongHash = await hashCode("000000", "pepper");
+ const outcome = verifyOtpAttempt(record, wrongHash, NOW, MAX_ATTEMPTS);
+ expect(outcome.kind).toBe("exhausted");
+ });
+
+ it("a record that already reached maxAttempts is exhausted even before this call's comparison", async () => {
+ const record = await makeRecord("123456", NOW + 60_000, MAX_ATTEMPTS);
+ const providedHash = await hashCode("123456", "pepper"); // even the RIGHT code
+ const outcome = verifyOtpAttempt(record, providedHash, NOW, MAX_ATTEMPTS);
+ expect(outcome.kind).toBe("exhausted");
+ });
+
+ it("four consecutive wrong guesses each preserve expiresAt, the fifth exhausts", async () => {
+ let record = await makeRecord("123456", NOW + 60_000, 0);
+ const wrongHash = await hashCode("000000", "pepper");
+ const originalExpiry = record.expiresAt;
+
+ for (let i = 0; i < MAX_ATTEMPTS - 1; i++) {
+ const outcome = verifyOtpAttempt(record, wrongHash, NOW, MAX_ATTEMPTS);
+ expect(outcome.kind).toBe("wrong");
+ if (outcome.kind !== "wrong") throw new Error("unreachable");
+ expect(outcome.nextRecord.expiresAt).toBe(originalExpiry);
+ record = outcome.nextRecord;
+ }
+ expect(record.attempts).toBe(MAX_ATTEMPTS - 1);
+
+ const finalOutcome = verifyOtpAttempt(record, wrongHash, NOW, MAX_ATTEMPTS);
+ expect(finalOutcome.kind).toBe("exhausted");
+ });
+});
diff --git a/infra/otp-worker/src/__tests__/ratelimit.test.ts b/infra/otp-worker/src/__tests__/ratelimit.test.ts
new file mode 100644
index 000000000..cccdf1eaa
--- /dev/null
+++ b/infra/otp-worker/src/__tests__/ratelimit.test.ts
@@ -0,0 +1,115 @@
+//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity
+import { describe, expect, it } from "vitest";
+import { evaluateRateLimits, incrementCounter, isOverCap, shouldSendRateLimitAlert, utcDateString } from "../ratelimit";
+
+describe("incrementCounter", () => {
+ it("opens a fresh window (count 1) when there is no existing record", () => {
+ const now = 1_000_000;
+ const windowMs = 3600_000;
+ expect(incrementCounter(null, now, windowMs)).toEqual({ count: 1, expiresAt: now + windowMs });
+ });
+
+ it("opens a fresh window when the existing one has expired", () => {
+ const now = 1_000_000;
+ const windowMs = 3600_000;
+ const expired = { count: 9, expiresAt: now - 1 };
+ expect(incrementCounter(expired, now, windowMs)).toEqual({ count: 1, expiresAt: now + windowMs });
+ });
+
+ it("increments in place and preserves expiresAt within an open window", () => {
+ const now = 1_000_000;
+ const existing = { count: 2, expiresAt: now + 500_000 };
+ const next = incrementCounter(existing, now, 3600_000);
+ expect(next).toEqual({ count: 3, expiresAt: existing.expiresAt });
+ });
+});
+
+describe("isOverCap", () => {
+ it("is false for a null record", () => {
+ expect(isOverCap(null, 1000, 3)).toBe(false);
+ });
+
+ it("is false for an expired record regardless of count", () => {
+ expect(isOverCap({ count: 999, expiresAt: 999 }, 1000, 3)).toBe(false);
+ });
+
+ it("is false below the cap", () => {
+ expect(isOverCap({ count: 2, expiresAt: 2000 }, 1000, 3)).toBe(false);
+ });
+
+ it("is true at exactly the cap", () => {
+ expect(isOverCap({ count: 3, expiresAt: 2000 }, 1000, 3)).toBe(true);
+ });
+
+ it("is true above the cap", () => {
+ expect(isOverCap({ count: 10, expiresAt: 2000 }, 1000, 3)).toBe(true);
+ });
+});
+
+describe("evaluateRateLimits", () => {
+ const now = 1000;
+ const caps = { email: 3, ip: 10, global: 2000 };
+ const underCap = { count: 1, expiresAt: 2000 };
+ // Comfortably exceeds every cap in `caps` above (email:3, ip:10, global:2000).
+ const overCap = { count: 100_000, expiresAt: 2000 };
+
+ it("allows when all three counters are under cap", () => {
+ const decision = evaluateRateLimits({ emailRecord: underCap, ipRecord: underCap, globalRecord: underCap, now, caps });
+ expect(decision).toEqual({ allowed: true });
+ });
+
+ it("allows when all three counters are absent", () => {
+ const decision = evaluateRateLimits({ emailRecord: null, ipRecord: null, globalRecord: null, now, caps });
+ expect(decision).toEqual({ allowed: true });
+ });
+
+ it("denies with scope 'email' when only the email cap is tripped, checked first", () => {
+ const decision = evaluateRateLimits({ emailRecord: overCap, ipRecord: overCap, globalRecord: overCap, now, caps });
+ expect(decision).toEqual({ allowed: false, scope: "email" });
+ });
+
+ it("denies with scope 'ip' when only the ip cap is tripped", () => {
+ const decision = evaluateRateLimits({ emailRecord: underCap, ipRecord: overCap, globalRecord: underCap, now, caps });
+ expect(decision).toEqual({ allowed: false, scope: "ip" });
+ });
+
+ it("denies with scope 'global' when only the global cap is tripped", () => {
+ const decision = evaluateRateLimits({ emailRecord: underCap, ipRecord: underCap, globalRecord: overCap, now, caps });
+ expect(decision).toEqual({ allowed: false, scope: "global" });
+ });
+});
+
+describe("utcDateString", () => {
+ it("formats as YYYY-MM-DD in UTC", () => {
+ // 2026-03-05T23:30:00Z
+ const ms = Date.UTC(2026, 2, 5, 23, 30, 0);
+ expect(utcDateString(ms)).toBe("2026-03-05");
+ });
+});
+
+describe("shouldSendRateLimitAlert", () => {
+ it("does not alert below the threshold", () => {
+ expect(shouldSendRateLimitAlert(1599, 2000, 80, false)).toBe(false);
+ });
+
+ it("alerts exactly at the threshold", () => {
+ expect(shouldSendRateLimitAlert(1600, 2000, 80, false)).toBe(true);
+ });
+
+ it("alerts above the threshold too", () => {
+ expect(shouldSendRateLimitAlert(2000, 2000, 80, false)).toBe(true);
+ });
+
+ it("never alerts twice in the same day, regardless of count", () => {
+ expect(shouldSendRateLimitAlert(2000, 2000, 80, true)).toBe(false);
+ });
+
+ it("is disabled when thresholdPct is 0 or negative — not 'alert on every send'", () => {
+ expect(shouldSendRateLimitAlert(2000, 2000, 0, false)).toBe(false);
+ expect(shouldSendRateLimitAlert(2000, 2000, -5, false)).toBe(false);
+ });
+
+ it("is disabled when cap is non-positive (misconfiguration, not a division trap)", () => {
+ expect(shouldSendRateLimitAlert(10, 0, 80, false)).toBe(false);
+ });
+});
diff --git a/infra/otp-worker/src/__tests__/resend.test.ts b/infra/otp-worker/src/__tests__/resend.test.ts
new file mode 100644
index 000000000..8b60b28b5
--- /dev/null
+++ b/infra/otp-worker/src/__tests__/resend.test.ts
@@ -0,0 +1,162 @@
+//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity
+import { describe, expect, it, vi } from "vitest";
+import {
+ buildAccountEventBody,
+ buildAccountEventSubject,
+ resolveAccountEvent,
+ sendAccountEventEmail,
+ type Fetcher,
+} from "../resend";
+
+const ENV = {
+ RESEND_API_KEY: "re_fake_key",
+ NOTIFY_FROM: "Meridian Sign-ins ",
+ NOTIFY_EMAIL: "adithya@meridiona.com",
+};
+
+/** Decode the JSON body a `sendAccountEventEmail` call posted. */
+function postedBody(fetcher: ReturnType) {
+ const [, init] = fetcher.mock.calls[0] as unknown as [string, RequestInit];
+ return JSON.parse(String(init.body)) as {
+ from: string;
+ to: string[];
+ subject: string;
+ text: string;
+ };
+}
+
+describe("resolveAccountEvent", () => {
+ it("is a sign-up when there is no previous email", () => {
+ expect(resolveAccountEvent("new@example.com", null)).toEqual({
+ kind: "signed_up",
+ email: "new@example.com",
+ });
+ });
+
+ it("is an email change when the previous email differs", () => {
+ expect(resolveAccountEvent("new@example.com", "old@example.com")).toEqual({
+ kind: "email_changed",
+ from: "old@example.com",
+ to: "new@example.com",
+ });
+ });
+
+ it("is null (nothing to notify) when the previous email is the SAME as the new one", () => {
+ // "Change email" re-entering the address already on file must not send a
+ // notification claiming something changed.
+ expect(resolveAccountEvent("same@example.com", "same@example.com")).toBeNull();
+ });
+});
+
+describe("buildAccountEventSubject", () => {
+ /** The marketing site has sent `New sign-up: ` since June; both
+ * sources must thread together in the same inbox. */
+ it("matches the website's existing sign-up convention exactly", () => {
+ expect(buildAccountEventSubject({ kind: "signed_up", email: "a@b.com" })).toBe("New sign-up: a@b.com");
+ });
+
+ it("gives an email change its own prefix carrying both addresses", () => {
+ expect(
+ buildAccountEventSubject({ kind: "email_changed", from: "old@b.com", to: "new@b.com" }),
+ ).toBe("Email changed: old@b.com -> new@b.com");
+ });
+});
+
+describe("buildAccountEventBody", () => {
+ it("puts the address on line 1 and names the source, mirroring the web format", () => {
+ const lines = buildAccountEventBody({ kind: "signed_up", email: "a@b.com" }).trim().split("\n");
+ expect(lines[0]).toBe("a@b.com");
+ expect(lines[1]).toContain("desktop app");
+ expect(lines[2]).toBe("First time signing in.");
+ });
+
+ it("leads an email change with the NEW address and names the old one", () => {
+ const lines = buildAccountEventBody({
+ kind: "email_changed",
+ from: "old@b.com",
+ to: "new@b.com",
+ })
+ .trim()
+ .split("\n");
+ expect(lines[0]).toBe("new@b.com");
+ expect(lines[2]).toBe("Changed from old@b.com.");
+ });
+
+ it("carries no links", () => {
+ expect(buildAccountEventBody({ kind: "signed_up", email: "a@b.com" })).not.toMatch(/https?:\/\//);
+ });
+});
+
+describe("sendAccountEventEmail", () => {
+ it("posts to Resend with bearer auth and returns true on 2xx", async () => {
+ const fetcher = vi.fn(async () => new Response(JSON.stringify({ id: "x" }), { status: 200 }));
+ const result = await sendAccountEventEmail(
+ { kind: "signed_up", email: "a@b.com" },
+ ENV,
+ fetcher as unknown as Fetcher,
+ );
+ expect(result).toBe(true);
+ const [url, init] = fetcher.mock.calls[0] as unknown as [string, RequestInit];
+ expect(url).toBe("https://api.resend.com/emails");
+ expect((init.headers as Record).Authorization).toBe("Bearer re_fake_key");
+ });
+
+ it("sends to NOTIFY_EMAIL from NOTIFY_FROM, never to the account's own address", async () => {
+ const fetcher = vi.fn(async () => new Response("{}", { status: 200 }));
+ await sendAccountEventEmail(
+ { kind: "signed_up", email: "auser@example.com" },
+ ENV,
+ fetcher as unknown as Fetcher,
+ );
+ const body = postedBody(fetcher);
+ expect(body.to).toEqual(["adithya@meridiona.com"]);
+ expect(body.from).toBe("Meridian Sign-ins ");
+ // The signing-up user is the SUBJECT of the mail, never a recipient of it.
+ expect(body.to).not.toContain("auser@example.com");
+ });
+
+ it("sends text only - no HTML part, matching the website's notification", async () => {
+ const fetcher = vi.fn(async () => new Response("{}", { status: 200 }));
+ await sendAccountEventEmail(
+ { kind: "signed_up", email: "a@b.com" },
+ ENV,
+ fetcher as unknown as Fetcher,
+ );
+ expect(postedBody(fetcher)).not.toHaveProperty("html");
+ });
+
+ it("returns false (never throws) on a non-2xx response", async () => {
+ const fetcher = vi.fn(async () => new Response(JSON.stringify({ message: "nope" }), { status: 422 }));
+ const result = await sendAccountEventEmail(
+ { kind: "signed_up", email: "a@b.com" },
+ ENV,
+ fetcher as unknown as Fetcher,
+ );
+ expect(result).toBe(false);
+ });
+
+ it("returns false (never throws) when the fetch itself rejects", async () => {
+ const fetcher = vi.fn(async () => {
+ throw new Error("network down");
+ });
+ const result = await sendAccountEventEmail(
+ { kind: "signed_up", email: "a@b.com" },
+ ENV,
+ fetcher as unknown as Fetcher,
+ );
+ expect(result).toBe(false);
+ });
+
+ /** A Worker deployed without the secret must degrade to "no notification",
+ * not throw inside the `ctx.waitUntil` where nothing would surface it. */
+ it("returns false without attempting a request when the API key is unset", async () => {
+ const fetcher = vi.fn(async () => new Response("{}", { status: 200 }));
+ const result = await sendAccountEventEmail(
+ { kind: "signed_up", email: "a@b.com" },
+ { ...ENV, RESEND_API_KEY: "" },
+ fetcher as unknown as Fetcher,
+ );
+ expect(result).toBe(false);
+ expect(fetcher).not.toHaveBeenCalled();
+ });
+});
diff --git a/infra/otp-worker/src/__tests__/ses.test.ts b/infra/otp-worker/src/__tests__/ses.test.ts
new file mode 100644
index 000000000..95ea1dfd9
--- /dev/null
+++ b/infra/otp-worker/src/__tests__/ses.test.ts
@@ -0,0 +1,151 @@
+//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity
+import { describe, expect, it, vi } from "vitest";
+import {
+ buildOtpEmailBody,
+ buildOtpEmailHtml,
+ buildRateLimitAlertBody,
+ buildRateLimitAlertHtml,
+ extractSesErrorCode,
+ sendOtpEmail,
+ sendRateLimitAlertEmail,
+ type AwsFetcher,
+} from "../ses";
+
+const ENV = {
+ AWS_ACCESS_KEY_ID: "AKIA_FAKE",
+ AWS_SECRET_ACCESS_KEY: "fake-secret",
+ AWS_REGION: "us-east-1",
+ FROM_ADDRESS: "otp@auth.meridiona.com",
+ FROM_NAME: "Meridian",
+};
+
+const ALERT_ENV = { ...ENV, ALERT_EMAIL: "ops@example.com" };
+
+describe("buildOtpEmailBody", () => {
+ it("includes the code and the TTL, and no links", () => {
+ const body = buildOtpEmailBody("123456", 10);
+ expect(body).toContain("123456");
+ expect(body).toContain("10 minutes");
+ expect(body).not.toMatch(/https?:\/\//);
+ });
+});
+
+describe("buildOtpEmailHtml", () => {
+ it("includes the code and the TTL, and no links or script tags", () => {
+ const html = buildOtpEmailHtml("123456", 10);
+ expect(html).toContain("123456");
+ expect(html).toContain("10 minutes");
+ expect(html).not.toMatch(/https?:\/\//);
+ expect(html.toLowerCase()).not.toContain("