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(" { + const html = buildOtpEmailHtml("123456", 10); + expect(html).toMatch(/^/i); + expect(html).toContain(""); + }); + + it("escapes HTML-significant characters instead of interpolating them raw", () => { + // The code is always 6 digits in practice (see otp.ts's generateCode), + // but this proves the interpolation point isn't a raw injection hole + // regardless of what future caller passes through it. + const html = buildOtpEmailHtml("123456", 10); + expect(html).not.toContain("' as unknown as number); + expect(injected).not.toContain(""); + expect(injected).toContain("<img"); + }); +}); + +describe("buildRateLimitAlertBody / buildRateLimitAlertHtml", () => { + it("includes the current count, cap, and threshold, with no links", () => { + const body = buildRateLimitAlertBody(1600, 2000, 80); + expect(body).toContain("1600/2000"); + expect(body).toContain("80%"); + expect(body).not.toMatch(/https?:\/\//); + }); + + it("the HTML version carries the same numbers, no script tags", () => { + const html = buildRateLimitAlertHtml(1600, 2000, 80); + expect(html).toContain("1600/2000"); + expect(html.toLowerCase()).not.toContain(" { + it("posts to ALERT_EMAIL (not the OTP recipient) and returns true on 2xx", async () => { + const fakeClient: AwsFetcher = { fetch: vi.fn(async () => new Response("{}", { status: 200 })) }; + const result = await sendRateLimitAlertEmail(1600, 2000, 80, ALERT_ENV, fakeClient); + expect(result).toBe(true); + const [, init] = (fakeClient.fetch as ReturnType).mock.calls[0] as [string, RequestInit]; + expect(String(init.body)).toContain(encodeURIComponent(ALERT_ENV.ALERT_EMAIL)); + }); + + it("returns false (never throws) on a non-2xx response", async () => { + const fakeClient: AwsFetcher = { + fetch: vi.fn(async () => new Response(JSON.stringify({ Error: { Code: "Throttling" } }), { status: 429 })), + }; + const result = await sendRateLimitAlertEmail(1600, 2000, 80, ALERT_ENV, fakeClient); + expect(result).toBe(false); + }); +}); + +describe("extractSesErrorCode", () => { + it("extracts an AWS-shaped Error.Code", () => { + expect(extractSesErrorCode(JSON.stringify({ Error: { Code: "MessageRejected" } }))).toBe("MessageRejected"); + }); + + it("falls back to a top-level message field", () => { + expect(extractSesErrorCode(JSON.stringify({ message: "Some SES error" }))).toBe("Some SES error"); + }); + + it("never echoes an arbitrary raw body back — unparseable text yields a fixed placeholder", () => { + // This is the security-relevant case: SES sandbox errors echo the + // destination email address in the raw text. This must not leak through. + const result = extractSesErrorCode("plain text mentioning victim@example.com is not verified"); + expect(result).toBe("unparseable_ses_error_response"); + expect(result).not.toContain("victim@example.com"); + }); + + it("returns a fixed placeholder for a JSON body with neither known shape", () => { + expect(extractSesErrorCode(JSON.stringify({ somethingElse: true }))).toBe("unknown_ses_error_shape"); + }); +}); + +describe("sendOtpEmail", () => { + it("returns true on a 2xx response and posts to the region-specific SES endpoint", async () => { + const fakeClient: AwsFetcher = { fetch: vi.fn(async () => new Response("{}", { status: 200 })) }; + const result = await sendOtpEmail("user@example.com", "123456", 10, ENV, fakeClient); + expect(result).toBe(true); + expect(fakeClient.fetch).toHaveBeenCalledWith( + "https://email.us-east-1.amazonaws.com/", + expect.objectContaining({ method: "POST" }), + ); + }); + + it("returns false on a non-2xx response without throwing", async () => { + const fakeClient: AwsFetcher = { + fetch: vi.fn(async () => new Response(JSON.stringify({ Error: { Code: "Throttling" } }), { status: 429 })), + }; + const result = await sendOtpEmail("user@example.com", "123456", 10, ENV, fakeClient); + expect(result).toBe(false); + }); + + it("returns false (never throws) when the underlying fetch rejects", async () => { + const fakeClient: AwsFetcher = { + fetch: vi.fn(async () => { + throw new Error("network down"); + }), + }; + const result = await sendOtpEmail("user@example.com", "123456", 10, ENV, fakeClient); + expect(result).toBe(false); + }); + + it("never includes the code in the visible request URL (it belongs in the signed body only)", async () => { + const fakeClient: AwsFetcher = { fetch: vi.fn(async () => new Response("{}", { status: 200 })) }; + await sendOtpEmail("user@example.com", "999999", 10, ENV, fakeClient); + const [url] = (fakeClient.fetch as ReturnType).mock.calls[0] as [string, RequestInit]; + expect(url).not.toContain("999999"); + }); +}); diff --git a/infra/otp-worker/src/__tests__/turnstile.test.ts b/infra/otp-worker/src/__tests__/turnstile.test.ts new file mode 100644 index 000000000..34217d0c5 --- /dev/null +++ b/infra/otp-worker/src/__tests__/turnstile.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, vi } from "vitest"; +import { verifyTurnstileToken } from "../turnstile"; + +function fakeFetch(response: Partial<{ ok: boolean; status: number; json: () => unknown }>): typeof fetch { + return vi.fn(async () => ({ + ok: response.ok ?? true, + status: response.status ?? 200, + json: response.json ?? (async () => ({ success: true })), + })) as unknown as typeof fetch; +} + +describe("verifyTurnstileToken", () => { + it("skips verification (returns true) when no secret is configured — gate 2", async () => { + const fetchImpl = fakeFetch({}); + const result = await verifyTurnstileToken("some-token", undefined, "1.2.3.4", fetchImpl); + expect(result).toBe(true); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("skips verification when the secret is an empty string", async () => { + const fetchImpl = fakeFetch({}); + const result = await verifyTurnstileToken("some-token", "", "1.2.3.4", fetchImpl); + expect(result).toBe(true); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("returns true when siteverify reports success", async () => { + const fetchImpl = fakeFetch({ json: async () => ({ success: true }) }); + const result = await verifyTurnstileToken("good-token", "secret", "1.2.3.4", fetchImpl); + expect(result).toBe(true); + }); + + it("fails closed when siteverify reports failure — a present-but-invalid token is rejected", async () => { + const fetchImpl = fakeFetch({ json: async () => ({ success: false, "error-codes": ["invalid-input-response"] }) }); + const result = await verifyTurnstileToken("bad-token", "secret", "1.2.3.4", fetchImpl); + expect(result).toBe(false); + }); + + it("fails closed on a non-2xx siteverify response", async () => { + const fetchImpl = fakeFetch({ ok: false, status: 500 }); + const result = await verifyTurnstileToken("token", "secret", "1.2.3.4", fetchImpl); + expect(result).toBe(false); + }); + + it("fails closed when the fetch itself throws (network error)", async () => { + const throwingFetch = vi.fn(async () => { + throw new Error("network down"); + }) as unknown as typeof fetch; + const result = await verifyTurnstileToken("token", "secret", "1.2.3.4", throwingFetch); + expect(result).toBe(false); + }); +}); diff --git a/infra/otp-worker/src/auth.ts b/infra/otp-worker/src/auth.ts new file mode 100644 index 000000000..8b31db2ff --- /dev/null +++ b/infra/otp-worker/src/auth.ts @@ -0,0 +1,74 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +/** + * Bearer-token origin auth — proves "a genuine Meridian binary sent this", + * not "this is a human". Mirrors the pattern already in production at + * `tray/src-tauri/src/counter_ping.rs` (compiled-in default, bearer auth), + * ported to the Worker side of that same handshake. Documented honestly per + * the plan: this is attestation, not a strong secret — the token is + * extractable from the shipped tray binary. Rate limiting (`ratelimit.ts`) + * and, optionally, Turnstile (`turnstile.ts`) are the actual abuse + * containment; this only keeps out casual/opportunistic callers who never + * had a Meridian binary at all. + * + * # Who calls this + * - `index.ts`, on every request, before any body parsing or KV access. + * + * # Related + * - `crypto-utils.ts` — the timing-safe comparison this relies on. + */ + +import { timingSafeEqualStrings } from "./crypto-utils"; + +export interface AuthEnv { + OTP_CLIENT_TOKEN?: string; + /** Staging-only, see below — absent in production. */ + CI_TEST_TOKEN?: string; + ENVIRONMENT?: string; +} + +export interface AuthResult { + ok: boolean; + /** + * True only when the request authenticated with the staging-only + * `CI_TEST_TOKEN` rather than the normal `OTP_CLIENT_TOKEN`. This is what + * gates the staging code-echo in `/otp/send` — never true outside + * `env.ENVIRONMENT === "staging"`, checked again explicitly below rather + * than relying solely on the secret being unset in production (the plan's + * "never reachable with the production bearer token, enforced with an + * explicit `env.ENVIRONMENT !== "staging"` guard, not just an unset var"). + */ + isCiTestToken: boolean; +} + +const DENY: AuthResult = { ok: false, isCiTestToken: false }; + +function extractBearerToken(header: string | null): string | null { + if (!header) return null; + const match = /^Bearer\s+(.+)$/.exec(header); + return match?.[1] ?? null; +} + +/** + * Check the `Authorization` header against the configured client token (and, + * on staging only, the CI test token). An unconfigured/empty secret never + * matches an empty or missing token — guards against the classic "both + * sides blank" bypass if a secret was never set. + */ +export function checkBearerAuth(authorizationHeader: string | null, env: AuthEnv): AuthResult { + const token = extractBearerToken(authorizationHeader); + if (!token) return DENY; + + const clientToken = env.OTP_CLIENT_TOKEN ?? ""; + if (clientToken.length > 0 && timingSafeEqualStrings(token, clientToken)) { + return { ok: true, isCiTestToken: false }; + } + + if (env.ENVIRONMENT === "staging") { + const ciToken = env.CI_TEST_TOKEN ?? ""; + if (ciToken.length > 0 && timingSafeEqualStrings(token, ciToken)) { + return { ok: true, isCiTestToken: true }; + } + } + + return DENY; +} diff --git a/infra/otp-worker/src/crypto-utils.ts b/infra/otp-worker/src/crypto-utils.ts new file mode 100644 index 000000000..53013e772 --- /dev/null +++ b/infra/otp-worker/src/crypto-utils.ts @@ -0,0 +1,60 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +/** + * Small crypto primitives shared by {@link "./auth"}, {@link "./otp"} and + * {@link "./email"}. Split out on its own so none of those three modules + * import from each other just to reach a hex-encode helper. + * + * # Related + * - `auth.ts` — bearer-token comparison + * - `otp.ts` — OTP code hashing/verification + * - `email.ts` — email-address hashing for KV keys + */ + +/** Hex-encode a digest/signature buffer. */ +export function bufferToHex(buf: ArrayBuffer): string { + return Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** + * Constant-time string comparison, guarding both a bearer token check and an + * OTP-code-hash check against timing side-channels (see + * `workers-best-practices`: "Direct string comparison for secret values"). + * + * Cloudflare's `crypto.subtle.timingSafeEqual` requires equal-length inputs + * and throws otherwise, so an unequal-length pair returns `false` up front + * without calling it. That is a length-based timing signal in principle, but + * length alone is not the secret here (a bearer token's length isn't + * sensitive, and OTP-code HMACs are always the same fixed digest length) — + * only the content comparison needs to be constant-time, which is what this + * still guarantees for any two equal-length inputs. + */ +export function timingSafeEqualStrings(a: string, b: string): boolean { + const enc = new TextEncoder(); + const aBytes = enc.encode(a); + const bBytes = enc.encode(b); + if (aBytes.byteLength !== bBytes.byteLength) { + return false; + } + return crypto.subtle.timingSafeEqual(aBytes, bBytes); +} + +/** SHA-256 hex digest of a UTF-8 string — used for the KV email-hash key. */ +export async function sha256Hex(input: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)); + return bufferToHex(digest); +} + +/** HMAC-SHA256 hex digest of `message` under `key` — used for OTP code hashing. */ +export async function hmacSha256Hex(key: string, message: string): Promise { + const cryptoKey = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(key), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(message)); + return bufferToHex(sig); +} diff --git a/infra/otp-worker/src/email.ts b/infra/otp-worker/src/email.ts new file mode 100644 index 000000000..85b02710d --- /dev/null +++ b/infra/otp-worker/src/email.ts @@ -0,0 +1,38 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +/** + * Email normalization and the KV-key hash. Raw email addresses are NEVER + * used as a KV key directly — always {@link emailHash} of the + * {@link normalizeEmail}-d form, per the plan's KV schema. + * + * # Related + * - `crypto-utils.ts` — the underlying `sha256Hex` + * - `index.ts` — the only caller + */ + +import { sha256Hex } from "./crypto-utils"; + +/** RFC 5321 hard upper bound on a full email address. */ +const MAX_EMAIL_LENGTH = 320; + +/** + * Normalize a raw email for hashing/delivery: trim, lowercase, and a + * deliberately permissive syntactic check. + * + * Full RFC 5322 validation is not this Worker's job — SES will bounce + * anything it can't deliver. This only needs to reject obviously-malformed + * input before it becomes a KV key or an SES recipient. + * + * Returns `null` for anything that doesn't look like an email at all. + */ +export function normalizeEmail(raw: unknown): string | null { + if (typeof raw !== "string") return null; + const trimmed = raw.trim().toLowerCase(); + if (trimmed.length === 0 || trimmed.length > MAX_EMAIL_LENGTH) return null; + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return null; + return trimmed; +} + +/** sha256 hex digest of a normalized email — the KV key material. */ +export async function emailHash(normalizedEmail: string): Promise { + return sha256Hex(normalizedEmail); +} diff --git a/infra/otp-worker/src/env.d.ts b/infra/otp-worker/src/env.d.ts new file mode 100644 index 000000000..19c0188da --- /dev/null +++ b/infra/otp-worker/src/env.d.ts @@ -0,0 +1,50 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +/** + * Augments the generated `Env` interface (`worker-configuration.d.ts`, from + * `npm run types` / `wrangler types`) with the secrets that only ever exist + * via `wrangler secret put` — `wrangler types` has no way to see these since + * they're never written to `wrangler.jsonc`. + * + * Deliberately NOT using a hand-written `Env` interface for everything + * (`workers-best-practices`' anti-pattern list flags exactly that): the + * bindings/vars half of `Env` still comes from the generated file, this only + * adds the secret-shaped half on top via declaration merging. + * + * # Related + * - `worker-configuration.d.ts` — generated, not committed by hand; run + * `npm run types` after any `wrangler.jsonc` binding/vars change. + */ + +export {}; + +declare global { + interface Env { + /** Bearer token the tray binary sends — see `auth.ts`. */ + OTP_CLIENT_TOKEN: string; + /** HMAC pepper for OTP code hashing — see `otp.ts`. Never the bare code. */ + OTP_CODE_PEPPER: string; + AWS_ACCESS_KEY_ID: string; + AWS_SECRET_ACCESS_KEY: string; + AWS_REGION: string; + /** + * Resend sending key for the team sign-up notification only — see + * `resend.ts` for why that one email is not on SES. Scoped in the Resend + * dashboard to sending-access on the `meridiona.com` domain, so it cannot + * manage the account or send as `mail.meridiona.com`. + */ + RESEND_API_KEY: string; + /** + * Staging-only bearer token that also unlocks the `/otp/send` code-echo + * (see `auth.ts`). Never set outside `env.staging` — its mere presence on + * production would still be inert there (see `auth.ts`'s `ENVIRONMENT` + * check), but it should never be set there in the first place. + */ + CI_TEST_TOKEN?: string; + /** + * Cloudflare Turnstile secret key. Unset until/unless the frontend + * feasibility spike (see plan) lands and a Turnstile site is + * provisioned — see `turnstile.ts` for the unconfigured-secret behaviour. + */ + TURNSTILE_SECRET_KEY?: string; + } +} diff --git a/infra/otp-worker/src/index.ts b/infra/otp-worker/src/index.ts new file mode 100644 index 000000000..c0c13f900 --- /dev/null +++ b/infra/otp-worker/src/index.ts @@ -0,0 +1,273 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +/** + * Meridian's OTP send/verify Worker — the backend for the setup wizard's + * one-time email capture step (see the parent plan, + * `giggly-jumping-hopcroft.md`, Part 1). + * + * Exactly two routes exist; everything else 404s: + * `POST /otp/send` `{ email, turnstileToken? }` + * `POST /otp/verify` `{ email, code }` + * Both require `Authorization: Bearer ` — see `auth.ts`. + * + * This file is intentionally thin: routing, request-body validation, and + * gate ordering only. Every gate below (`auth.ts`, `turnstile.ts`, + * `ratelimit.ts`, `otp.ts`) is independently unit-tested as a pure function; + * this is the only place they're wired to a real `KVNamespace` and real + * `fetch` calls (`kv.ts`, `ses.ts`, `turnstile.ts`). + * + * # Who calls this + * - `tray/src-tauri/src/commands/otp.rs` (Part 2 of the plan, out of this + * Worker's scope) — `request_account_otp` / `confirm_account_otp`. + * + * # Related + * - README.md — design rationale, KV schema, status-code mapping, manual + * deploy prerequisites. + * - `scripts/deploy-otp-worker.sh` — post-deploy smoke test against these + * exact routes/status codes. + */ + +import { checkBearerAuth } from "./auth"; +import { emailHash, normalizeEmail } from "./email"; +import { + getCounter, + getOtpRecord, + deleteOtpRecord, + hasAlertBeenSent, + markAlertSent, + putCounter, + putOtpRecord, +} from "./kv"; +import { createOtpRecord, generateCode, hashCode, verifyOtpAttempt } from "./otp"; +import { + evaluateRateLimits, + incrementCounter, + shouldSendRateLimitAlert, + utcDateString, + type RateLimitScope, +} from "./ratelimit"; +import { + badRequest, + forbidden, + gone, + notFound, + ok, + rateLimited, + serviceUnavailable, + unauthorized, +} from "./responses"; +import { resolveAccountEvent, sendAccountEventEmail } from "./resend"; +import { sendOtpEmail, sendRateLimitAlertEmail } from "./ses"; +import { verifyTurnstileToken } from "./turnstile"; + +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; +/** Fixed KV cleanup TTL for `global:sends:` — see `kv.ts`'s `putCounter`. */ +const GLOBAL_COUNTER_KV_TTL_S = 90_000; + +function clientIp(request: Request): string { + return request.headers.get("CF-Connecting-IP") ?? "unknown"; +} + +/** Parse and loosely-type the JSON body; `null` on anything unparseable or non-object. */ +async function readJsonBody(request: Request): Promise | null> { + let parsed: unknown; + try { + parsed = await request.json(); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + return parsed as Record; +} + +/** + * Fire the once-per-UTC-day "approaching the global cap" alert if + * `newGlobalCount` just crossed `ALERT_THRESHOLD_PCT` of `RL_GLOBAL_PER_DAY`. + * Checked AFTER the global counter is durably written, using the + * already-incremented count — never blocks or affects the OTP send's own + * response (the caller wires this into `ctx.waitUntil`, fire-and-forget). + * The `alert:sent:` KV flag is what makes this once-per-day rather + * than once-per-request-past-threshold. + */ +async function maybeSendRateLimitAlert(env: Env, newGlobalCount: number, dateKey: string): Promise { + if (!env.ALERT_EMAIL) return; + const cap = Number(env.RL_GLOBAL_PER_DAY); + const thresholdPct = Number(env.ALERT_THRESHOLD_PCT); + const date = dateKey.replace("global:sends:", ""); + + const alreadySentToday = await hasAlertBeenSent(env.OTP_KV, date); + if (!shouldSendRateLimitAlert(newGlobalCount, cap, thresholdPct, alreadySentToday)) return; + + const sent = await sendRateLimitAlertEmail(newGlobalCount, cap, thresholdPct, env); + if (sent) { + await markAlertSent(env.OTP_KV, date, GLOBAL_COUNTER_KV_TTL_S); + } else { + console.error("otp-worker: rate-limit alert email failed to send", { newGlobalCount, cap }); + } +} + +async function handleSend(request: Request, env: Env, ctx: ExecutionContext): Promise { + const auth = checkBearerAuth(request.headers.get("Authorization"), env); + if (!auth.ok) return unauthorized(); + + const body = await readJsonBody(request); + if (!body) return badRequest("invalid_json"); + + const email = normalizeEmail(body.email); + if (!email) return badRequest("invalid_email"); + + const turnstileToken = body.turnstileToken; + if (turnstileToken !== undefined && typeof turnstileToken !== "string") { + return badRequest("invalid_turnstile_token"); + } + + const ip = clientIp(request); + + // Gate: Turnstile, only when the client actually sent a token (see + // turnstile.ts's module header for the full conditional-support story). + if (typeof turnstileToken === "string" && turnstileToken.length > 0) { + const passed = await verifyTurnstileToken( + turnstileToken, + env.TURNSTILE_SECRET_KEY, + ip !== "unknown" ? ip : undefined, + ); + if (!passed) return forbidden("turnstile_failed"); + } + + const now = Date.now(); + const hash = await emailHash(email); + const dateKey = `global:sends:${utcDateString(now)}`; + + const [emailCounter, ipCounter, globalCounter] = await Promise.all([ + getCounter(env.OTP_KV, `rl:email:${hash}`), + getCounter(env.OTP_KV, `rl:ip:${ip}`), + getCounter(env.OTP_KV, dateKey), + ]); + + const decision = evaluateRateLimits({ + emailRecord: emailCounter, + ipRecord: ipCounter, + globalRecord: globalCounter, + now, + caps: { + email: Number(env.RL_EMAIL_PER_DAY), + ip: Number(env.RL_IP_PER_HOUR), + global: Number(env.RL_GLOBAL_PER_DAY), + }, + }); + if (!decision.allowed) { + const scope: RateLimitScope = decision.scope ?? "global"; + console.warn("otp-worker: send rate limited", { scope, emailHashPrefix: hash.slice(0, 8) }); + return rateLimited(scope); + } + + // Counters are persisted BEFORE the SES call, deliberately: the caps exist + // for cost/abuse containment against ATTEMPTED sends, so a run of SES + // failures (an outage, a bad credential) must still count against budget — + // otherwise an attacker (or a genuine outage) could drive unlimited + // send-attempt traffic for free by ensuring every attempt "fails" cheaply. + const ttlMs = Number(env.OTP_TTL_S) * 1000; + const code = generateCode(); + const codeHash = await hashCode(code, env.OTP_CODE_PEPPER); + const record = createOtpRecord(codeHash, now, ttlMs); + + const newGlobalCounter = incrementCounter(globalCounter, now, DAY_MS); + + await Promise.all([ + putOtpRecord(env.OTP_KV, hash, record, now), + putCounter(env.OTP_KV, `rl:email:${hash}`, incrementCounter(emailCounter, now, DAY_MS), now), + putCounter(env.OTP_KV, `rl:ip:${ip}`, incrementCounter(ipCounter, now, HOUR_MS), now), + putCounter(env.OTP_KV, dateKey, newGlobalCounter, now, GLOBAL_COUNTER_KV_TTL_S), + ]); + + // Fire-and-forget: never let the alert path slow down or fail the actual + // OTP send. `waitUntil` keeps the Worker alive long enough to finish it + // after the response has already been returned to the caller. + ctx.waitUntil(maybeSendRateLimitAlert(env, newGlobalCounter.count, dateKey)); + + const ttlMinutes = Math.max(1, Math.round(Number(env.OTP_TTL_S) / 60)); + const sent = await sendOtpEmail(email, code, ttlMinutes, env); + if (!sent) { + return serviceUnavailable("email_delivery_failed"); + } + + // Staging-only code echo for scripts/deploy-otp-worker.sh's happy-path + // send->verify probe. Both conditions are required and checked here, not + // just at auth time, so this can never fire on production even if a + // CI_TEST_TOKEN secret were mistakenly present there. + if (env.ENVIRONMENT === "staging" && auth.isCiTestToken) { + return ok({ code }); + } + return ok(); +} + +async function handleVerify(request: Request, env: Env, ctx: ExecutionContext): Promise { + const auth = checkBearerAuth(request.headers.get("Authorization"), env); + if (!auth.ok) return unauthorized(); + + const body = await readJsonBody(request); + if (!body) return badRequest("invalid_json"); + + const email = normalizeEmail(body.email); + if (!email) return badRequest("invalid_email"); + + const rawCode = body.code; + if (typeof rawCode !== "string" || !/^\d{6}$/.test(rawCode)) { + return badRequest("invalid_code"); + } + + // Optional, client-supplied, purely informational — see resolveAccountEvent's + // doc. An absent or unparseable value just reads as "no prior email". + const previousEmail = normalizeEmail(body.previousEmail); + + const now = Date.now(); + const hash = await emailHash(email); + const [record, providedHash] = await Promise.all([ + getOtpRecord(env.OTP_KV, hash), + hashCode(rawCode, env.OTP_CODE_PEPPER), + ]); + + const maxAttempts = Number(env.MAX_VERIFY_ATTEMPTS); + const outcome = verifyOtpAttempt(record, providedHash, now, maxAttempts); + + switch (outcome.kind) { + case "verified": { + await deleteOtpRecord(env.OTP_KV, hash); + const event = resolveAccountEvent(email, previousEmail); + if (event && env.NOTIFY_EMAIL) { + ctx.waitUntil( + sendAccountEventEmail(event, env).then((sent) => { + if (!sent) console.error("otp-worker: account-event notification failed to send", { kind: event.kind }); + }), + ); + } + return ok({ verified: true }); + } + case "wrong": + await putOtpRecord(env.OTP_KV, hash, outcome.nextRecord, now); + return ok({ verified: false, attemptsRemaining: outcome.attemptsRemaining }); + case "exhausted": + await deleteOtpRecord(env.OTP_KV, hash); + return gone("code_expired_or_not_found"); + case "not_found_or_expired": + return gone("code_expired_or_not_found"); + } +} + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + const url = new URL(request.url); + try { + if (request.method === "POST" && url.pathname === "/otp/send") { + return await handleSend(request, env, ctx); + } + if (request.method === "POST" && url.pathname === "/otp/verify") { + return await handleVerify(request, env, ctx); + } + return notFound(); + } catch (err) { + console.error("otp-worker: unhandled error", { error: String(err), path: url.pathname }); + return serviceUnavailable("internal_error"); + } + }, +} satisfies ExportedHandler; diff --git a/infra/otp-worker/src/kv.ts b/infra/otp-worker/src/kv.ts new file mode 100644 index 000000000..96a02a3ed --- /dev/null +++ b/infra/otp-worker/src/kv.ts @@ -0,0 +1,103 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +/** + * Thin `KVNamespace` read/write glue for `OTP_KV`. Deliberately the only + * module that touches the binding directly — `otp.ts` and `ratelimit.ts` + * stay pure and unit-testable, this just serializes their record shapes to + * and from JSON with the right TTL. + * + * Every record embeds its own authoritative `expiresAt` (epoch ms) rather + * than relying solely on KV's own TTL, for two reasons documented on + * `otp.ts`/`ratelimit.ts`: (1) a rolling window's expiry must survive a + * read-modify-write without being reset on every increment, which KV's + * `expirationTtl` alone can't do (it must be re-specified on every `put`); + * and (2) **Cloudflare Workers KV requires `expirationTtl >= 60`** — a + * record with, say, 20 real seconds left cannot be written back with a + * 20-second KV TTL. {@link ttlSecondsFromExpiry} clamps to that floor, which + * only affects the KV-level storage cleanup timer; `verifyOtpAttempt` and + * `isOverCap`'s own `now >= expiresAt` checks are what actually enforce + * expiry, so a clamped KV TTL can never let an expired record be honoured — + * it can only make the dead key sit in storage a little longer before KV + * itself reaps it. + * + * # Who calls this + * - `index.ts`'s `/otp/send` and `/otp/verify` handlers. + * + * # Related + * - `otp.ts` — `OtpRecord`, the pure verify state machine + * - `ratelimit.ts` — `CounterRecord`, the pure cap-decision logic + */ + +import type { OtpRecord } from "./otp"; +import type { CounterRecord } from "./ratelimit"; + +/** Cloudflare Workers KV's hard minimum for `expirationTtl`, in seconds. */ +const KV_MIN_TTL_S = 60; + +/** Seconds until `expiresAt`, clamped to KV's minimum TTL. See module header. */ +export function ttlSecondsFromExpiry(expiresAt: number, now: number): number { + return Math.max(KV_MIN_TTL_S, Math.ceil((expiresAt - now) / 1000)); +} + +function otpKey(emailHash: string): string { + return `code:${emailHash}`; +} + +export async function getOtpRecord(kv: KVNamespace, emailHashValue: string): Promise { + return kv.get(otpKey(emailHashValue), "json"); +} + +export async function putOtpRecord( + kv: KVNamespace, + emailHashValue: string, + record: OtpRecord, + now: number, +): Promise { + await kv.put(otpKey(emailHashValue), JSON.stringify(record), { + expirationTtl: ttlSecondsFromExpiry(record.expiresAt, now), + }); +} + +export async function deleteOtpRecord(kv: KVNamespace, emailHashValue: string): Promise { + await kv.delete(otpKey(emailHashValue)); +} + +export async function getCounter(kv: KVNamespace, key: string): Promise { + return kv.get(key, "json"); +} + +/** + * Write a counter record. `ttlOverrideS`, when given, is used verbatim + * instead of the derived clamp — used for `global:sends:`, whose KV + * TTL is a fixed 90000s cleanup buffer per the plan (deliberately longer + * than one calendar day) rather than something derived from the embedded + * window, since that key's real "window" is just "this UTC date". + */ +export async function putCounter( + kv: KVNamespace, + key: string, + record: CounterRecord, + now: number, + ttlOverrideS?: number, +): Promise { + await kv.put(key, JSON.stringify(record), { + expirationTtl: ttlOverrideS ?? ttlSecondsFromExpiry(record.expiresAt, now), + }); +} + +function alertSentKey(date: string): string { + return `alert:sent:${date}`; +} + +/** Whether the daily rate-limit-approaching alert has already fired for this + * UTC date — makes the alert a once-per-day event rather than firing again + * on every request once the threshold is crossed. */ +export async function hasAlertBeenSent(kv: KVNamespace, date: string): Promise { + return (await kv.get(alertSentKey(date))) !== null; +} + +/** Record that the alert fired for this UTC date. `ttlS` mirrors the global + * counter's own cleanup buffer (see `index.ts`'s `GLOBAL_COUNTER_KV_TTL_S`) + * — deliberately longer than one day, not a precise window. */ +export async function markAlertSent(kv: KVNamespace, date: string, ttlS: number): Promise { + await kv.put(alertSentKey(date), "1", { expirationTtl: ttlS }); +} diff --git a/infra/otp-worker/src/otp.ts b/infra/otp-worker/src/otp.ts new file mode 100644 index 000000000..14ab7a7d7 --- /dev/null +++ b/infra/otp-worker/src/otp.ts @@ -0,0 +1,109 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +/** + * OTP code generation, hashing, and the verify-attempt state machine. This + * is the single most security-sensitive module in the Worker — a 6-digit + * code is trivially brute-forced offline from a raw KV dump, which is why + * `code:` never stores the plain code (see {@link hashCode}) and why + * {@link verifyOtpAttempt} is written as a pure function: the one invariant + * it exists to protect — a wrong guess must NEVER extend the record's + * remaining TTL — is exactly the kind of thing that's easy to regress inside + * a KV read/write handler and easy to pin with a unit test outside one. + * + * # Who calls this + * - `index.ts`'s `/otp/send` handler (`generateCode`, `hashCode`, + * `createOtpRecord`) + * - `index.ts`'s `/otp/verify` handler (`hashCode`, `verifyOtpAttempt`) + * + * # Related + * - `crypto-utils.ts` — `hmacSha256Hex` (the actual HMAC), `timingSafeEqualStrings` + * - `ratelimit.ts` — the sibling KV-record state machine for send caps, + * built on the same "pure decision, thin KV glue" split + */ + +import { hmacSha256Hex, timingSafeEqualStrings } from "./crypto-utils"; + +/** The `code:` KV value shape. */ +export interface OtpRecord { + /** HMAC-SHA256(pepper, code) — never the bare code. */ + codeHash: string; + attempts: number; + /** Epoch ms. Authoritative expiry check — KV's own TTL is best-effort cleanup only. */ + expiresAt: number; +} + +/** + * Cryptographically random 6-digit code via rejection sampling — avoids the + * modulo-bias `byte % 10` would introduce (256 is not a multiple of 10, so a + * plain modulo maps bytes 0-5 to digit 0-5 with a fractionally higher chance + * than 6-9; discarding bytes 250-255 removes that bias entirely). Per + * `workers-best-practices`: never `Math.random()` for anything + * security-sensitive. + */ +export function generateCode(length = 6): string { + const digits: string[] = []; + const buf = new Uint8Array(1); + while (digits.length < length) { + crypto.getRandomValues(buf); + const byte = buf[0] as number; + if (byte >= 250) continue; // 250-255 discarded: 256 % 10 !== 0 + digits.push(String(byte % 10)); + } + return digits.join(""); +} + +/** HMAC-SHA256(pepper, code), hex-encoded — the only form of the code that touches KV. */ +export async function hashCode(code: string, pepper: string): Promise { + return hmacSha256Hex(pepper, code); +} + +/** Build a fresh record for a newly-sent code. `ttlMs` comes from `vars.OTP_TTL_S * 1000`. */ +export function createOtpRecord(codeHash: string, now: number, ttlMs: number): OtpRecord { + return { codeHash, attempts: 0, expiresAt: now + ttlMs }; +} + +export type VerifyOutcome = + | { kind: "verified" } + | { kind: "wrong"; nextRecord: OtpRecord; attemptsRemaining: number } + | { kind: "exhausted" } + | { kind: "not_found_or_expired" }; + +/** + * Apply one verify attempt against the current record. Pure: takes the + * record read from KV and returns what to write back (or `null` via the + * `verified`/`exhausted`/`not_found_or_expired` kinds, all of which mean + * "the caller deletes the KV key"). + * + * `exhausted` and `not_found_or_expired` are deliberately the SAME outward + * HTTP response (410, see `responses.ts`) — a caller must not be able to + * distinguish "you used up your 5 attempts" from "there was never a live + * code for this email" from the HTTP layer alone; both just mean "request a + * new code". + */ +export function verifyOtpAttempt( + record: OtpRecord | null, + providedCodeHash: string, + now: number, + maxAttempts: number, +): VerifyOutcome { + if (!record) return { kind: "not_found_or_expired" }; + if (now >= record.expiresAt) return { kind: "not_found_or_expired" }; + if (record.attempts >= maxAttempts) return { kind: "exhausted" }; + + if (timingSafeEqualStrings(providedCodeHash, record.codeHash)) { + return { kind: "verified" }; + } + + const nextAttempts = record.attempts + 1; + if (nextAttempts >= maxAttempts) { + return { kind: "exhausted" }; + } + + // Preserve `expiresAt` EXACTLY — a wrong guess must never extend the + // record's remaining TTL. This line is the one this module exists to get + // right; see `otp.test.ts`'s `wrong guess never extends expiresAt` case. + return { + kind: "wrong", + nextRecord: { codeHash: record.codeHash, attempts: nextAttempts, expiresAt: record.expiresAt }, + attemptsRemaining: maxAttempts - nextAttempts, + }; +} diff --git a/infra/otp-worker/src/ratelimit.ts b/infra/otp-worker/src/ratelimit.ts new file mode 100644 index 000000000..5645bcbd5 --- /dev/null +++ b/infra/otp-worker/src/ratelimit.ts @@ -0,0 +1,108 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +/** + * Pure rate-limit decision logic for `/otp/send`, kept separate from the KV + * read/write glue in `index.ts` so the caps themselves are unit-testable + * without mocking a `KVNamespace`. + * + * Three independent caps, checked in this order (cheapest/most-specific + * first, matching the plan's KV schema): per-email, per-IP, then global. + * `rl:email:` and `rl:ip:` are rolling fixed windows that open on + * the first send and reset only once that window's `expiresAt` has passed — + * NOT a calendar-boundary reset — which is why, like `otp.ts`'s record, the + * expiry is carried in the value itself rather than relied on purely via + * KV's own TTL (KV requires re-specifying `expirationTtl` on every write, so + * a naive re-put on each increment would either reset the window every + * request or silently drop expiry entirely). + * + * `global:sends:` is different: its KV key is itself namespaced by UTC + * date (see {@link utcDateString}), so the "window" is just "this key exists + * for one calendar day" — no embedded expiry needed, a plain KV + * `expirationTtl` for cleanup is enough (see the plan's 90000s, deliberately + * longer than a day as a timezone-drift safety margin, not a precise window). + * + * # Who calls this + * - `index.ts`'s `/otp/send` handler, after auth and body validation, before + * generating a code or spending an SES send. + * + * # Related + * - `otp.ts` — the sibling pure state machine for the OTP record itself. + */ + +/** A rolling fixed-window counter (`rl:email:*`, `rl:ip:*`). */ +export interface CounterRecord { + count: number; + /** Epoch ms — when this window resets, opening a fresh one on next write. */ + expiresAt: number; +} + +/** UTC calendar-day string (`YYYY-MM-DD`) — the `global:sends:` key suffix. */ +export function utcDateString(now: number): string { + return new Date(now).toISOString().slice(0, 10); +} + +/** + * Fold one more send into a rolling counter. Starts a fresh window (count 1) + * if there is no existing record or the existing window has already expired; + * otherwise increments in place, leaving `expiresAt` untouched. + */ +export function incrementCounter(existing: CounterRecord | null, now: number, windowMs: number): CounterRecord { + if (!existing || now >= existing.expiresAt) { + return { count: 1, expiresAt: now + windowMs }; + } + return { count: existing.count + 1, expiresAt: existing.expiresAt }; +} + +/** Whether a counter is currently at or past its cap. An expired/absent window is never over cap. */ +export function isOverCap(record: CounterRecord | null, now: number, cap: number): boolean { + if (!record || now >= record.expiresAt) return false; + return record.count >= cap; +} + +export type RateLimitScope = "email" | "ip" | "global"; + +export interface RateLimitDecision { + allowed: boolean; + scope?: RateLimitScope; +} + +/** + * Evaluate all three caps against counters already read from KV. Checked + * before any counter is incremented — a request that trips a cap must not + * also consume budget from the caps it didn't trip. + */ +export function evaluateRateLimits(params: { + emailRecord: CounterRecord | null; + ipRecord: CounterRecord | null; + globalRecord: CounterRecord | null; + now: number; + caps: { email: number; ip: number; global: number }; +}): RateLimitDecision { + const { emailRecord, ipRecord, globalRecord, now, caps } = params; + if (isOverCap(emailRecord, now, caps.email)) return { allowed: false, scope: "email" }; + if (isOverCap(ipRecord, now, caps.ip)) return { allowed: false, scope: "ip" }; + if (isOverCap(globalRecord, now, caps.global)) return { allowed: false, scope: "global" }; + return { allowed: true }; +} + +/** + * Whether crossing `newGlobalCount` should fire the once-per-day + * "approaching the cap" alert (`index.ts`'s `maybeSendRateLimitAlert`, which + * wraps this with the actual KV read for `alreadySentToday` and the SES + * call). Pure so this can be unit-tested without secrets or a real send — + * `index.test.ts` deliberately configures neither. + * + * `thresholdPct <= 0` disables alerting entirely (treated as "not + * configured" rather than "alert on every send") — matches `ALERT_EMAIL` + * being unset having the same effect at the call site. + */ +export function shouldSendRateLimitAlert( + newGlobalCount: number, + cap: number, + thresholdPct: number, + alreadySentToday: boolean, +): boolean { + if (alreadySentToday) return false; + if (!Number.isFinite(thresholdPct) || thresholdPct <= 0) return false; + if (!Number.isFinite(cap) || cap <= 0) return false; + return newGlobalCount >= (cap * thresholdPct) / 100; +} diff --git a/infra/otp-worker/src/resend.ts b/infra/otp-worker/src/resend.ts new file mode 100644 index 000000000..8a450a373 --- /dev/null +++ b/infra/otp-worker/src/resend.ts @@ -0,0 +1,155 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +/** + * The "someone signed up / changed their email" notification to the team, + * delivered via **Resend** — deliberately a different provider to the OTP + * codes themselves, which stay on SES (`ses.ts`). + * + * # Why this one is not on SES + * The marketing site has sent this exact notification since June via Resend + * (`Meridian Sign-ins ` → the team inbox, subject + * `New sign-up: `). Routing the desktop app's copy through the same + * provider keeps web and desktop sign-ups in one inbox, one dashboard and one + * searchable history, with one sender identity, rather than splitting them + * across two providers by accident of which codebase emitted them. + * + * This does NOT reverse the SES-over-Resend decision recorded in + * `README.md` — that decision was specifically about OTP *code* delivery, + * where Resend's free tier (100/day) cannot cover the expected hundreds of + * user-facing sends a day. An internal notification to a single address is a + * couple of dozen a day at most, so the volume objection simply does not + * apply to it. + * + * # Who calls this + * - `index.ts`'s `/otp/verify` handler, on the `verified` outcome only, via + * `ctx.waitUntil` (fire-and-forget — see {@link sendAccountEventEmail}). + * + * # Related + * - `ses.ts` — OTP code delivery and the rate-limit alert, both still SES. + * - README.md — "Account-event notification". + */ + +/** Resend's transactional send endpoint. */ +const RESEND_ENDPOINT = "https://api.resend.com/emails"; + +export interface ResendEnv { + RESEND_API_KEY: string; + /** Full RFC 5322 from-header, e.g. `Meridian Sign-ins `. */ + NOTIFY_FROM: string; + /** Single internal recipient. Absent/empty disables the notification entirely. */ + NOTIFY_EMAIL: string; +} + +/** + * The `fetch` slice this module uses, so tests can intercept the network call + * without a live API key. Mirrors `ses.ts`'s `AwsFetcher` rationale. + */ +export type Fetcher = (input: string, init: RequestInit) => Promise; + +/** + * `previousEmail` is client-supplied and purely informational (see + * `index.ts`'s `handleVerify`) — never used for a security decision, only to + * word this notification. + */ +export type AccountEvent = + | { kind: "signed_up"; email: string } + | { kind: "email_changed"; from: string; to: string }; + +/** + * Decide what (if anything) happened, from `handleVerify`'s point of view. + * Pure, so it is unit-tested directly rather than only through a full + * send→verify round trip. + * + * `previousEmail` arrives already normalized by `email.ts`'s + * `normalizeEmail`, or `null` when absent/unparseable. Returns `null` for a + * no-op re-verify of the SAME address — which happens legitimately when + * "Change email" is used to re-enter the address already on file, and must + * not generate a notification saying nothing changed. + */ +export function resolveAccountEvent( + newEmail: string, + previousEmail: string | null, +): AccountEvent | null { + if (previousEmail === newEmail) return null; + if (previousEmail) return { kind: "email_changed", from: previousEmail, to: newEmail }; + return { kind: "signed_up", email: newEmail }; +} + +/** + * Subject line, matching the marketing site's existing convention exactly + * (`New sign-up: `) so both sources thread together in the inbox. + * `email_changed` has no web equivalent and gets its own prefix. + */ +export function buildAccountEventSubject(event: AccountEvent): string { + return event.kind === "signed_up" + ? `New sign-up: ${event.email}` + : `Email changed: ${event.from} -> ${event.to}`; +} + +/** + * Plain-text body, deliberately mirroring the shape the website already + * sends: the address on line 1, an identifying line, then one sentence of + * status. **Text only, no HTML part** — the web notification has no HTML + * part either, and an internal one-line alert gains nothing from markup. + * + * Where the web version carries `Clerk user id: …`, the desktop app has no + * equivalent (Clerk was removed), so the second line names the source + * instead — which is also what makes a desktop notification distinguishable + * from a web one at a glance. + */ +export function buildAccountEventBody(event: AccountEvent): string { + const source = "Source: desktop app (email OTP)"; + return event.kind === "signed_up" + ? `${event.email}\n${source}\nFirst time signing in.\n` + : `${event.to}\n${source}\nChanged from ${event.from}.\n`; +} + +/** + * Send the notification. Returns `false` (never throws) on any failure — + * network error, non-2xx, or an unset API key — because this rides on + * `ctx.waitUntil` behind an OTP verify that has already succeeded from the + * user's point of view. A failed notification must never turn into a failed + * sign-in. + * + * The API key is checked here rather than at the call site so a Worker + * deployed without the secret degrades to "no notification" instead of + * throwing inside a `waitUntil` where nothing would surface it. + */ +export async function sendAccountEventEmail( + event: AccountEvent, + env: ResendEnv, + fetcher: Fetcher = fetch, +): Promise { + if (!env.RESEND_API_KEY) { + console.error("otp-worker: RESEND_API_KEY is not configured — account-event notification skipped"); + return false; + } + try { + const res = await fetcher(RESEND_ENDPOINT, { + method: "POST", + headers: { + Authorization: `Bearer ${env.RESEND_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from: env.NOTIFY_FROM, + to: [env.NOTIFY_EMAIL], + subject: buildAccountEventSubject(event), + text: buildAccountEventBody(event), + }), + }); + if (!res.ok) { + // Deliberately logs the STATUS only, never the response body: a Resend + // error echoes the `to`/`from` addresses back, and the recipient here + // is an internal address we have no reason to put in Workers Logs. + console.error("otp-worker: account-event notification failed", { + status: res.status, + kind: event.kind, + }); + return false; + } + return true; + } catch (err) { + console.error("otp-worker: account-event notification threw", { error: String(err) }); + return false; + } +} diff --git a/infra/otp-worker/src/responses.ts b/infra/otp-worker/src/responses.ts new file mode 100644 index 000000000..d592efb09 --- /dev/null +++ b/infra/otp-worker/src/responses.ts @@ -0,0 +1,29 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +/** + * Small, consistent JSON response builders for the exact status codes the + * plan specifies (400/401/403/404/410/429/503) — kept out of `index.ts` so + * the handler reads as "gate, gate, gate, do the thing" rather than inline + * `Response.json(...)` literals repeated across `/otp/send` and + * `/otp/verify`. + * + * # Related + * - `index.ts` — the only caller + * - README.md — the status-code-to-meaning mapping, documented there since + * the plan states the set of codes but not the full route-by-route mapping + */ + +function jsonResponse(body: unknown, status: number): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +export const notFound = (): Response => jsonResponse({ error: "not_found" }, 404); +export const unauthorized = (): Response => jsonResponse({ error: "unauthorized" }, 401); +export const badRequest = (error: string): Response => jsonResponse({ error }, 400); +export const forbidden = (error: string): Response => jsonResponse({ error }, 403); +export const gone = (error: string): Response => jsonResponse({ error }, 410); +export const rateLimited = (scope: string): Response => jsonResponse({ error: "rate_limited", scope }, 429); +export const serviceUnavailable = (error: string): Response => jsonResponse({ error }, 503); +export const ok = (body: Record = {}): Response => jsonResponse({ ok: true, ...body }, 200); diff --git a/infra/otp-worker/src/ses.ts b/infra/otp-worker/src/ses.ts new file mode 100644 index 000000000..481e9c930 --- /dev/null +++ b/infra/otp-worker/src/ses.ts @@ -0,0 +1,290 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +/** + * Email delivery via AWS SES's `SendEmail` (Query API, v1, + * `2010-12-01`), SigV4-signed with `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 v1's Query API over + * SESv2's JSON API, because it's the one with a documented `aws4fetch` + * example (`service: "email"`, form-urlencoded body) — this is a low-stakes + * choice per a single `SendEmail` call either way. + * + * `from` is always `${FROM_NAME} <${FROM_ADDRESS}>` where `FROM_ADDRESS` is + * a verified subdomain of meridiona.com (`vars.FROM_ADDRESS` in + * `wrangler.jsonc`, currently the placeholder `otp@auth.meridiona.com` — + * DNS verification is a manual step outside this Worker's code, see + * README.md). Plain code, no links, per the plan. + * + * # Who calls this + * - `index.ts`'s `/otp/send` handler, after the OTP record is written and + * all rate-limit/Turnstile gates have passed. + * + * # Related + * - README.md — the SES-vs-Resend rationale and the sandbox/production-access + * prerequisite (external, not something this code can detect at runtime + * beyond a failed send surfacing as a 503). + */ + +import { AwsClient } from "aws4fetch"; + +export interface SesEnv { + AWS_ACCESS_KEY_ID: string; + AWS_SECRET_ACCESS_KEY: string; + AWS_REGION: string; + FROM_ADDRESS: string; + FROM_NAME: string; +} + +/** + * The slice of `AwsClient` this module actually uses. Tests inject a fake + * implementing just this shape instead of a real `AwsClient` — `AwsClient` + * has no `fetch` override in its `RequestInit` (only an `aws` signing-options + * override), so the only way to intercept the network call for a test is at + * this level, not by passing a custom `fetch` down into `client.fetch()`. + */ +export interface AwsFetcher { + fetch(input: string, init?: RequestInit): Promise; +} + +/** The exact plain-text body sent to the user — no links, matching the plan. + * Kept as the `Text` part alongside {@link buildOtpEmailHtml}'s `Html` part + * so clients that don't render HTML (or have it disabled) still get a + * readable code. */ +export function buildOtpEmailBody(code: string, ttlMinutes: number): string { + return ( + `Your Meridian verification code is: ${code}\n\n` + + `This code expires in ${ttlMinutes} minutes. If you didn't request this, you can safely ignore this email.` + ); +} + +/** Escape the handful of characters that matter inside an HTML text node. + * `code` is always exactly 6 digits (see `otp.ts`'s `generateCode`) and + * never needs this, but `ttlMinutes` is attacker-uncontrolled server config, + * not user input — this exists as defense-in-depth if either shape ever + * changes, not because either is untrusted today. */ +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** + * The HTML body sent alongside the plain-text part. No `