diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0837e78..e199751 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,6 +155,31 @@ jobs: - name: Run frontend unit tests run: npm test + telltale: + name: vitest (telltale) + runs-on: ubuntu-latest + defaults: + run: + working-directory: telltale + steps: + - uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: telltale/package-lock.json + + - run: npm ci + + # The live grader (test/live-grader.test.ts) self-skips without + # TELLTALE_* credentials, so it never runs here — same posture as the + # real-Docker ITs in crates/fleetd/tests/. + - run: npm test + + - run: npm run check + # --------------------------------------------------------------------------- # Rust workspace tests. Fast, OS-independent gate — runs once on Linux. # Does NOT run the `#[ignore]`d real-Docker ITs (see header note above). diff --git a/docs/STATUS.md b/docs/STATUS.md index 1cb14b2..c04641a 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -36,6 +36,22 @@ the fixes that followed, closed **five** defects: **D-7** (view-plugins received Across both runs, **`db74a47` is CONFIRMED twice** — 1,127 samples in dev and 632 in packaged, zero unresponsive in either. +**⚠ Telltale is pivoting OUT of this repo (2026-08-30).** A feedback pipeline — authenticated bug +reports deduplicated into GitHub issues — was built as a `telltale/` subdirectory here (PR #64, 82 +tests, fully reviewed). **That was the wrong repository.** Telltale becomes its own app and repo; +this repo keeps only the **integration**: a `feedback` source adapter for the Project Dashboard that +reads Telltale's `GET /v1/issues` (spec §6, **not started**). Extraction steps, PR dispositions and +the six defects found in the plan's reference code are in +[`docs/handoffs/31f0a85d-8bcc-4d27-a849-e9e950749558.md`](handoffs/31f0a85d-8bcc-4d27-a849-e9e950749558.md). +**PR #64 is to be closed, not merged.** + +**⚠ Intermittent race in the fleetd spend cap, unrelated to the above.** +`server::tests::concurrent_missions_cannot_both_breach_the_cap` failed on PR #64 with **both** +concurrent missions admitted past the $20 global cap (`left: 2, right: 1`) — the condition its own +comment calls "an open race" — then passed on a re-run of the identical tree. `create_mission` holds +the store lock across check and insert, so the obvious explanation does not apply; the `.ok()` that +swallows `upsert_unit`'s error is the first thing to look at. Not investigated further. + **Vision (unchanged):** the Command Center is the operator's **one-stop shop for agentic engineering** — dispatch work, see every project's stage, act without alt-tabbing, host the other tools inside it, and (future) **remote-control** it from away-from-desk. **Feature-complete before @@ -175,6 +191,36 @@ across from #47; close it._ ## Session log +### 2026-08-30 — Built the Telltale Worker here, then pivoted it out; removed the embargo guard + +**Three PRs opened.** [#62](https://github.com/adbarc92/command-center/pull/62) removes the **embargo +guard** in full — hooks, script, CI job, denylist, README section — the embargo having been lifted +2026-08-29. It was five interlocking parts, and `embargo guard` was a *required status check* on +`main`, so deleting the CI job alone would have hung every PR forever on a check that no longer +reports; branch protection was updated in the same breath. Conflicts with `main` (PR #49 landed +mid-session) resolved in `af11995`. + +[#63](https://github.com/adbarc92/command-center/pull/63) carries the **Telltale spec and plan**. The +spec lost roughly half its mass across three rounds of adversarial critique: the entire crash-side +pipeline was deleted once it became clear it was **reimplementing Sentry** (whose native GitHub +integration already opens one issue per group), and fleet dispatch was cut because it would have +widened a credentialed agent's push target from one sandbox to every repo in the registry, for a +pipeline whose input is internet-authored text. + +[#64](https://github.com/adbarc92/command-center/pull/64) built the **ingest Worker** — eleven TDD +tasks, each independently reviewed, plus a whole-branch review and a final fix wave. 82 tests, zero +runtime dependencies. That process found **six defects in the plan's own reference code**, including +a registry entry pointing at `adbarc92/tenzy`, which does not exist — `gh api` silently follows a +transfer redirect to `OpenBarclay/tenzy`, and the primary PAT cannot write to an org repo. + +**Then the pivot.** Telltale was never meant to live in this repo — the operator's earlier "put it in +Command Center" was about the *spec*, and it was extended to the implementation without being put to +them. Telltale becomes its own app and repo; this repo keeps only the `feedback` source adapter. +**#64 is to be closed, not merged.** Full extraction plan: +[`docs/handoffs/31f0a85d-8bcc-4d27-a849-e9e950749558.md`](handoffs/31f0a85d-8bcc-4d27-a849-e9e950749558.md). + +Also surfaced, unrelated: an **intermittent race in fleetd's global spend cap** (see State summary). + ### 2026-08-16 — Built the smoke skill, then it found the defect that would have shipped Two halves. First, built and merged the **`driving-interactive-smoke-tests`** skill into `claude-kit` diff --git a/docs/handoffs/31f0a85d-8bcc-4d27-a849-e9e950749558.md b/docs/handoffs/31f0a85d-8bcc-4d27-a849-e9e950749558.md new file mode 100644 index 0000000..1c30bc2 --- /dev/null +++ b/docs/handoffs/31f0a85d-8bcc-4d27-a849-e9e950749558.md @@ -0,0 +1,187 @@ +# Handoff — pivot: extract Telltale into its own repo, leave only the Command Center integration behind + +**Written:** 2026-08-30 · **Branch:** `feat/telltale-worker` · **Session:** `31f0a85d-8bcc-4d27-a849-e9e950749558` + +## ⏳ Background operation in flight + +**None.** No build, test run, or agent is running. This is a deliberate pivot handoff, not an +idle handoff. Everything below is committed and pushed; nothing is mid-flight. + +CI on PR #64 is green except one **pre-existing, intermittent** `fleetd` failure — see +[Known issues](#known-issues-carried-forward). It is not caused by this work. + +## Goal + +**Telltale is its own app in its own repo.** Command Center keeps only the integration: a +`feedback` source adapter for the Project Dashboard that reads Telltale's `GET /v1/issues`. + +The previous session built the Telltale ingest Worker as a `telltale/` **subdirectory inside the +command-center repo**. That was wrong. The operator's "put it in Command Center" answered a question +about where the *spec document* should live; it was extended to the implementation without ever being +put to them. The work itself is sound and fully reviewed — it is in the wrong repository. + +## State + +- **Active spec:** [`docs/superpowers/specs/2026-08-30-telltale-feedback-pipeline-design.md`](../superpowers/specs/2026-08-30-telltale-feedback-pipeline-design.md) — on branch `docs/telltale-feedback-pipeline-spec` (PR #63), **not on `main` or this branch**. +- **Active plan:** [`docs/superpowers/plans/2026-08-30-telltale-worker.md`](../superpowers/plans/2026-08-30-telltale-worker.md) — same branch. +- **SDD ledger** (28 rulings, every review verdict, all deferred findings): `.claude/worktrees/telltale-worker/.superpowers/sdd/2026-08-30-telltale-worker/progress.md` — **gitignored, lives only in that worktree. Read it before discarding the worktree.** + +### Three open PRs on `adbarc92/command-center` + +| PR | Branch | Disposition under the pivot | +|---|---|---| +| **#62** chore: remove the embargo guard | `chore/remove-embargo-guard` | **Unaffected — merge as-is, first.** Conflicts with `main` already resolved (`af11995`). | +| **#63** docs(spec): Telltale pipeline | `docs/telltale-feedback-pipeline-spec` | **Needs splitting** — see step 4. | +| **#64** feat(telltale): the ingest Worker (P1) | `feat/telltale-worker` | **Close after extraction.** Its content moves to the new repo. Do not merge. | + +### What is in `feat/telltale-worker` + +- **17 commits touching `telltale/`** — the whole TDD + review history, worth preserving. +- `telltale/` — 29 tracked files: `src/` (9 modules), `test/` (11 files), `package.json`, `tsconfig.json`, `vitest.config.ts`, `wrangler.toml`, `README.md`, `.gitignore`. +- **The only non-Telltale change this branch makes** is the `vitest (telltale)` job added to `.github/workflows/ci.yml`. Everything else in a `cb11214..HEAD` diff came from merging `origin/main` (PR #49, plugin-runtime). +- 82 tests + 1 gated integration test, `tsc --noEmit` clean, zero runtime dependencies. + +## Successor's next action + +Work in this order. Steps 1–3 are mechanical; step 4 has a decision in it. + +### 1. Create the repo + +```bash +gh repo create adbarc92/telltale --private \ + --description "Authenticated bug-report intake that deduplicates into GitHub issues" +``` + +Default taken: **`adbarc92`, private**. Rationale in [Decisions](#live-decisions--defaults-taken). + +### 2. Extract `telltale/` with its history + +`git subtree split` rewrites the 17 commits with `telltale/` as the repo root, preserving each +task's TDD and review history. Run from the **main checkout**, not the worktree: + +```bash +cd D:/MajorProjects/CURRENT/command-center +git subtree split --prefix=telltale feat/telltale-worker -b telltale-extracted +git clone . /tmp/telltale-new --branch telltale-extracted --single-branch +cd /tmp/telltale-new +git remote set-url origin https://github.com/adbarc92/telltale.git +git branch -m main +git push -u origin main +``` + +**Verify before continuing:** `npm ci && npx vitest run` in the new clone must give **82 passed, +1 skipped**, and `npm run check` must exit 0. The paths inside the code are all relative to +`telltale/`, so nothing should need editing — but confirm rather than assume. The one thing that +*will* be wrong is `README.md`'s spec link, which points at a command-center path (see step 4). + +### 3. Strip Telltale out of command-center + +On a fresh branch off `chore/remove-embargo-guard`: + +```bash +git rm -r telltale/ +``` + +…and revert the `vitest (telltale)` job from `.github/workflows/ci.yml`. That job is the only +command-center file this work added; with `telltale/` gone it would fail on a missing lock file. + +Then **close PR #64** with a comment pointing at the new repo. Do not merge it. + +### 4. Decide where the spec lives, then rework PR #63 + +The spec covers both halves: §1–§5 and §8–§9 are the Worker; **§6 is the Command Center adapter**; +§7 is the Halyard boundary. + +**Recommended split:** +- The **spec moves to the new repo** (`docs/design.md` or equivalent) — it is Telltale's design doc. +- **command-center keeps a short spec for the `feedback` source adapter only**, reproducing §6 and + linking out to the Telltale repo for the contract it consumes. +- The **plan** ([`2026-08-30-telltale-worker.md`](../superpowers/plans/2026-08-30-telltale-worker.md)) moves with the spec — it describes P1, which is now entirely the other repo's work. + +Whatever you choose, **the plan file still contains six defects execution found and fixed in code.** +Anyone following it would rebuild them. Add a "Defects found during execution" section rather than +rewriting each code block: + +| # | Defect | Consequence | +|---|---|---| +| 1 | Registry placeholders, and `adbarc92/tenzy` in the corrected table | That repo does not exist — `gh api` silently follows a transfer redirect to `OpenBarclay/tenzy`. The primary PAT cannot write to an org repo, and a followed 301 turns a POST into a GET. | +| 2 | `decide()` did not aggregate `not_planned` across closed duplicates | An operator's explicit "won't fix" was silently discarded and the pipeline commented anyway. | +| 3 | GitHub POSTs had no `Content-Type: application/json` | Every test stayed green because the fake never does HTTP; it would have failed only in production. | +| 4 | `restClient` had no tests at all | The entire production HTTP path was verified only through the fake's parallel logic. | +| 5 | Unguarded `JSON.parse(env.TELLTALE_SENDER_SECRETS)` | A malformed secret crashed every request *before* any stat was recorded — the exact silent failure `/v1/stats` exists to eliminate. | +| 6 | `if (!secret) return fail(401)` ran before the registry lookup | Made the spec's required `404` on a typo'd slug unreachable. | + +### 5. Then, and only then, build the integration (spec §6 / P3) + +This is the part that stays in command-center, and it is **not started**. Spec §6.1 has the verified +change surface: + +| File | Change | +|---|---| +| `cockpit/ui/src/lib/dashboard/model.ts` | `Source` union `+ 'feedback'` | +| `cockpit/ui/src/lib/dashboard/adapters/feedback.ts` | New adapter + `FeedbackReader` seam | +| `cockpit/ui/src/lib/dashboard/api.ts` | `tauriFeedbackReader` | +| `cockpit/ui/src/lib/dashboard/store.ts` | `pollFeedback` | +| `cockpit/ui/src/views/Dashboard.svelte` | `SOURCE_LABEL` entry + a `pollFeedback` call | +| `cockpit/ui/src/App.svelte` | Wire the reader | +| `cockpit/ui/src-tauri/src/dashboard.rs` | `feedback_issues` command + `TELLTALE_BASE_URL`/`TELLTALE_TOKEN` env | +| `cockpit/ui/src-tauri/src/lib.rs` | Register the command in `generate_handler!` | + +Three things the spec settles that are easy to get wrong: +- **Cards are `Idle`, never `Build`.** `sortedCards` ranks `Build` above `Live`, so "one old bug exists" would sort above "this project is live in production." +- **`Blocked` only for an open `telltale:crash` issue with no assignee.** `blockedCount` is the board's headline "NEEDS YOU" number; a condition that never clears poisons it. +- **`family` is inert.** It is written by three adapters and read by nothing. Do not claim it clusters cards. + +## Live decisions / defaults taken + +Taken during this sweep, without asking — override freely: + +- **Repo `adbarc92/telltale`, private.** `adbarc92` because Telltale is infrastructure, not a product, + and that account already owns the infrastructure repos. Private because every covered project is + private today; publishing is a separate call that belongs with the launch decisions in + `D:\MajorProjects\LAUNCH-SCHEDULE.md`. +- **`git subtree split`, not a fresh-start copy.** The 17 commits carry an unusually good record — + each task's RED/GREEN evidence and its review round. Worth preserving. +- **PR #64 closed, not merged.** Merging then reverting would put the Worker in command-center's + history permanently. +- **Spec moves; command-center keeps an adapter-only spec.** Recommended, not settled — step 4. + +Settled earlier in the session and already reflected in the code: + +- Issues land in **each project's own repo**, including public ones; the PII exposure was raised and + knowingly accepted, and the scrub covers both `title` and `body`. +- The **ingest-abuse risk** was put to the operator and accepted: HMAC bounds and attributes abuse + rather than preventing it, since a secret in a shipped binary is extractable. +- **Crashes never traverse the Worker.** Sentry's native GitHub integration owns that path. +- **Fleet dispatch is out of scope** — it would widen a credentialed agent's push target from one + sandbox to every repo in the registry, for a pipeline whose input is internet-authored text. + +## Known issues carried forward + +1. **`fleetd` has an intermittent race in its spend cap — unrelated to Telltale, and worth a look.** + `server::tests::concurrent_missions_cannot_both_breach_the_cap` failed on PR #64 with + `left: 2, right: 1` — **both concurrent missions admitted past the $20 global cap**, the exact + condition the test's own comment calls "an open race." It passed on a re-run of the identical + tree, so it is intermittent. `create_mission` *looks* correct (it holds the store lock across + check and insert), which makes it more interesting, not less. First thing to look at: + `s.upsert_unit(&row, now_ms()).ok()` swallows the insert error, so a failed insert would leave + committed spend stale and admit the next caller. This branch adds no Rust; `main` passed the same + test at `e2fc3ce`. +2. **Two registry repos do not exist yet:** `adbarc92/telltale-intake` (pawsport's redirect target) + and `adbarc92/telltale-probe` (the live grader's target). Until they are created, + `GET /v1/issues` carries a permanent `errors` entry for `pawsport` and the grader cannot pass. + The README's Deploy section leads with creating them. +3. **`src/registry.ts`'s `pawsport` comment says "archived on GitHub"** while its target repo is one + the README now tells the operator to create. Parked as a one-line fix; contradictory as written. +4. **Cloudflare free-tier limits for Workers + KV were never verified** (no web access in that + session). Blocks deploy, not implementation. +5. **24 further Minor findings** are triaged in the SDD ledger. All were judged ship-as-is by the + whole-branch review. + +## Do not redo + +- The Worker is **complete and reviewed**: eleven scoped task reviews, a whole-branch review on the + most capable model, and a final fix wave. Do not re-litigate its design; read the spec's §11 + ("Cut from earlier drafts") before proposing anything that looks missing. +- The plan's reference code is **known-defective** (table in step 4). Trust the committed code over + the plan wherever they disagree. diff --git a/telltale/.gitignore b/telltale/.gitignore new file mode 100644 index 0000000..3bdd52e --- /dev/null +++ b/telltale/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.DS_Store diff --git a/telltale/README.md b/telltale/README.md new file mode 100644 index 0000000..13869bf --- /dev/null +++ b/telltale/README.md @@ -0,0 +1,73 @@ +# Telltale ingest Worker + +Accepts authenticated bug reports from the portfolio's apps and games, scrubs +them, deduplicates by fingerprint label, and opens or comments on a GitHub issue +in that project's own repo. + +Spec: [`../docs/superpowers/specs/2026-08-30-telltale-feedback-pipeline-design.md`](../docs/superpowers/specs/2026-08-30-telltale-feedback-pipeline-design.md) + +Crashes do NOT go through this Worker. They go Sentry → Sentry's native GitHub +integration, with no Telltale code — see spec §3. + +## Routes + +| Route | Auth | +|---|---| +| `POST /v1/events` | Per-project HMAC over raw bytes | +| `GET /v1/issues` | `Authorization: Bearer $OPERATOR_READ_TOKEN` | +| `GET /v1/stats` | `Authorization: Bearer $OPERATOR_READ_TOKEN` | + +## Develop + +```bash +npm ci +npm test # unit suite; the live grader self-skips +npm run check # tsc --noEmit +npm run dev # wrangler dev +``` + +## Deploy + +Two registry targets do not exist yet and must be created first, or +`GET /v1/issues` carries a permanent `errors` entry from its very first request +and the live grader can never pass: + +```bash +gh repo create adbarc92/telltale-intake --private # pawsport's target +gh repo create adbarc92/telltale-probe --private # the live grader's target +``` + +```bash +npx wrangler kv namespace create TELLTALE_KV # paste the id into wrangler.toml +npx wrangler secret put TELLTALE_SENDER_SECRETS # {"tenzy":"...","hexy":"..."} +npx wrangler secret put GITHUB_TOKEN_PRIMARY # fine-grained PAT, Issues: read+write +npx wrangler secret put GITHUB_TOKEN_SECONDARY +npx wrangler secret put OPERATOR_READ_TOKEN +npx wrangler secret put IP_HASH_SALT +npm run deploy +``` + +## Adding a project + +Add an explicit entry to `src/registry.ts` — there is no slug-to-repo inference +anywhere, because a wrong guess writes a user's bug report into a stranger's +repository. Then generate an HMAC secret and add it to +`TELLTALE_SENDER_SECRETS`. + +`account` must name the PAT whose **resource owner actually owns the repo**: a +fine-grained PAT owned by the `adbarc92` user cannot write to an `OpenBarclay` +org repo at all, so `OpenBarclay/*` is always `secondary`. Verify the owner +against the repo's real location, not against `gh api repos//` — +that call silently follows a transfer redirect and reports the repo's NEW owner +under the OLD path, which is how a wrong entry got in once already. + +## Running the live grader + +Needs a throwaway repo, never a product repo: + +```bash +TELLTALE_BASE_URL=https://telltale..workers.dev \ +TELLTALE_PROBE_SECRET=... TELLTALE_PROBE_GH_TOKEN=... \ +TELLTALE_PROBE_REPO=/telltale-probe \ +npx vitest run test/live-grader.test.ts +``` diff --git a/telltale/package-lock.json b/telltale/package-lock.json new file mode 100644 index 0000000..f06b83c --- /dev/null +++ b/telltale/package-lock.json @@ -0,0 +1,3014 @@ +{ + "name": "telltale", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "telltale", + "devDependencies": { + "@cloudflare/workers-types": "^4.20240909.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0", + "wrangler": "^3.78.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.3.4.tgz", + "integrity": "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "mime": "^3.0.0" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.0.2.tgz", + "integrity": "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.14", + "workerd": "^1.20250124.0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250718.0.tgz", + "integrity": "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250718.0.tgz", + "integrity": "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250718.0.tgz", + "integrity": "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250718.0.tgz", + "integrity": "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250718.0.tgz", + "integrity": "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260702.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz", + "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "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-plugins/node-globals-polyfill": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz", + "integrity": "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild-plugins/node-modules-polyfill": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.2.2.tgz", + "integrity": "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==", + "dev": true, + "license": "ISC", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "rollup-plugin-node-polyfills": "^0.2.1" + }, + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "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.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "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.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "dev": true, + "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.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "dev": true, + "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.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "dev": true, + "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.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "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.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.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/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/as-table": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "printable-characters": "^1.0.42" + } + }, + "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/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/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, + "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", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/exit-hook": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/exsolve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "dev": true, + "license": "MIT" + }, + "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/get-source": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", + "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "data-uri-to-buffer": "^2.0.0", + "source-map": "^0.6.1" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "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/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/miniflare": { + "version": "3.20250718.3", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20250718.3.tgz", + "integrity": "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "acorn": "8.14.0", + "acorn-walk": "8.3.2", + "exit-hook": "2.2.1", + "glob-to-regexp": "0.4.1", + "stoppable": "1.1.0", + "undici": "^5.28.5", + "workerd": "1.20250718.0", + "ws": "8.18.0", + "youch": "3.3.4", + "zod": "3.22.3" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "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/ohash": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", + "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", + "dev": true, + "license": "MIT" + }, + "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": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "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/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/printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup-plugin-inject": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz", + "integrity": "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1", + "magic-string": "^0.25.3", + "rollup-pluginutils": "^2.8.1" + } + }, + "node_modules/rollup-plugin-inject/node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup-plugin-inject/node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/rollup-plugin-node-polyfills": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-node-polyfills/-/rollup-plugin-node-polyfills-0.2.1.tgz", + "integrity": "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rollup-plugin-inject": "^3.0.0" + } + }, + "node_modules/rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1" + } + }, + "node_modules/rollup-pluginutils/node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true, + "license": "MIT" + }, + "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", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "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/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "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/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "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/stacktracey": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz", + "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "as-table": "^1.0.36", + "get-source": "^2.0.12" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stoppable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, + "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": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "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/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.14", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.14.tgz", + "integrity": "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "exsolve": "^1.0.1", + "ohash": "^2.0.10", + "pathe": "^2.0.3", + "ufo": "^1.5.4" + } + }, + "node_modules/unenv/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/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "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.20250718.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250718.0.tgz", + "integrity": "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20250718.0", + "@cloudflare/workerd-darwin-arm64": "1.20250718.0", + "@cloudflare/workerd-linux-64": "1.20250718.0", + "@cloudflare/workerd-linux-arm64": "1.20250718.0", + "@cloudflare/workerd-windows-64": "1.20250718.0" + } + }, + "node_modules/wrangler": { + "version": "3.114.17", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.114.17.tgz", + "integrity": "sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.3.4", + "@cloudflare/unenv-preset": "2.0.2", + "@esbuild-plugins/node-globals-polyfill": "0.2.3", + "@esbuild-plugins/node-modules-polyfill": "0.2.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.17.19", + "miniflare": "3.20250718.3", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.14", + "workerd": "1.20250718.0" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=16.17.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2", + "sharp": "^0.33.5" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20250408.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", + "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", + "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz", + "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", + "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", + "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", + "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", + "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", + "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", + "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", + "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", + "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", + "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", + "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", + "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", + "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", + "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", + "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", + "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", + "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", + "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", + "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", + "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/wrangler/node_modules/esbuild": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", + "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.19", + "@esbuild/android-arm64": "0.17.19", + "@esbuild/android-x64": "0.17.19", + "@esbuild/darwin-arm64": "0.17.19", + "@esbuild/darwin-x64": "0.17.19", + "@esbuild/freebsd-arm64": "0.17.19", + "@esbuild/freebsd-x64": "0.17.19", + "@esbuild/linux-arm": "0.17.19", + "@esbuild/linux-arm64": "0.17.19", + "@esbuild/linux-ia32": "0.17.19", + "@esbuild/linux-loong64": "0.17.19", + "@esbuild/linux-mips64el": "0.17.19", + "@esbuild/linux-ppc64": "0.17.19", + "@esbuild/linux-riscv64": "0.17.19", + "@esbuild/linux-s390x": "0.17.19", + "@esbuild/linux-x64": "0.17.19", + "@esbuild/netbsd-x64": "0.17.19", + "@esbuild/openbsd-x64": "0.17.19", + "@esbuild/sunos-x64": "0.17.19", + "@esbuild/win32-arm64": "0.17.19", + "@esbuild/win32-ia32": "0.17.19", + "@esbuild/win32-x64": "0.17.19" + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "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": "3.3.4", + "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", + "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "^0.7.1", + "mustache": "^4.2.0", + "stacktracey": "^2.1.8" + } + }, + "node_modules/zod": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/telltale/package.json b/telltale/package.json new file mode 100644 index 0000000..15aec8b --- /dev/null +++ b/telltale/package.json @@ -0,0 +1,17 @@ +{ + "name": "telltale", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "check": "tsc --noEmit", + "dev": "wrangler dev", + "deploy": "wrangler deploy" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20240909.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0", + "wrangler": "^3.78.0" + } +} diff --git a/telltale/src/auth.ts b/telltale/src/auth.ts new file mode 100644 index 0000000..2bb1377 --- /dev/null +++ b/telltale/src/auth.ts @@ -0,0 +1,65 @@ +/** + * Request authentication (spec §4.1). + * + * The threat: this endpoint turns an HTTP request into a public GitHub issue in + * the operator's repo, authored by the operator's token. An unauthenticated + * endpoint is a remote "publish arbitrary text under Alex's identity" primitive. + * + * The signature is over RAW REQUEST BYTES, never a canonicalized JSON + * re-serialization: five independent canonicalizers (GDScript, Dart, RN, browser + * JS, Worker) agreeing byte-for-byte on key order and number formatting is a + * silent-401 generator. + * + * There is NO NONCE. A captured request is replayable inside the +/-10 minute + * window. Accepted: the payoff is a duplicate report, which dedup collapses. + */ + +export type AuthResult = { ok: true } | { ok: false; reason: 'missing' | 'clock_skew' | 'bad_signature' } + +const WINDOW_MS = 10 * 60_000 + +async function key(secret: string): Promise { + return crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ) +} + +export async function sign(secret: string, timestamp: string, rawBody: string): Promise { + const mac = await crypto.subtle.sign( + 'HMAC', + await key(secret), + new TextEncoder().encode(`${timestamp}.${rawBody}`), + ) + return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('') +} + +/** Constant-time compare, so a secret cannot be recovered byte by byte. + * Shared with the operator read-token check in src/read.ts. */ +export function equals(a: string, b: string): boolean { + if (a.length !== b.length) return false + let diff = 0 + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i) + return diff === 0 +} + +export async function verifySignature(args: { + secret: string + timestamp: string | null + signature: string | null + rawBody: string + nowMs: number +}): Promise { + const { secret, timestamp, signature, rawBody, nowMs } = args + if (!timestamp || !signature) return { ok: false, reason: 'missing' } + + const tsSec = Number(timestamp) + if (!Number.isFinite(tsSec)) return { ok: false, reason: 'missing' } + if (Math.abs(nowMs - tsSec * 1000) > WINDOW_MS) return { ok: false, reason: 'clock_skew' } + + const expected = await sign(secret, timestamp, rawBody) + return equals(expected, signature) ? { ok: true } : { ok: false, reason: 'bad_signature' } +} diff --git a/telltale/src/decide.ts b/telltale/src/decide.ts new file mode 100644 index 0000000..c8ff8d8 --- /dev/null +++ b/telltale/src/decide.ts @@ -0,0 +1,45 @@ +/** + * The dedup decision table (spec §4.4, §5.3). Pure — no I/O, fully testable. + * + * Automated report-to-issue WITHOUT dedup is issue spam, and issue spam destroys + * the tracker the pipeline exists to feed. + */ + +export interface CandidateIssue { + number: number + state: 'open' | 'closed' + stateReason: 'completed' | 'not_planned' | null + labels: string[] + isPullRequest: boolean +} + +export type Decision = + | { action: 'create' } + | { action: 'comment'; issue: number } + | { action: 'ignore'; reason: 'muted' | 'not_planned' } + +export function decide(candidates: CandidateIssue[]): Decision { + // GET /issues returns pull requests as issues. A labelled fix PR is not a report. + const issues = candidates.filter((c) => !c.isPullRequest) + if (issues.length === 0) return { action: 'create' } + + // Operator intent to stay silent wins over everything else. + if (issues.some((i) => i.labels.includes('telltale:muted'))) { + return { action: 'ignore', reason: 'muted' } + } + + const open = issues.filter((i) => i.state === 'open').sort((a, b) => a.number - b.number) + if (open.length > 0) return { action: 'comment', issue: open[0]!.number } + + const closed = [...issues].sort((a, b) => a.number - b.number) + + // Operator intent to stay silent wins over everything else — same rule as mute, + // so it is checked across all closed candidates, not just the lowest-numbered one. + if (closed.some((i) => i.stateReason === 'not_planned')) { + return { action: 'ignore', reason: 'not_planned' } + } + + // Closed as completed, or a legacy closure with no state_reason: comment so the + // recurrence is recorded, but NEVER auto-reopen. + return { action: 'comment', issue: closed[0]!.number } +} diff --git a/telltale/src/fingerprint.ts b/telltale/src/fingerprint.ts new file mode 100644 index 0000000..f1d7d7b --- /dev/null +++ b/telltale/src/fingerprint.ts @@ -0,0 +1,34 @@ +/** + * Dedup identity (spec §4.4). + * + * Deliberately WEAK grouping over the title alone. It catches verbatim repeats — + * the common case when a visible bug is reported by many people — and misses + * paraphrases. Semantic grouping is a model call, and a model call in the dedup + * path makes issue identity non-deterministic and untestable. Not doing it is + * the decision. + * + * ALWAYS called with the SCRUBBED title, so identity is stable regardless of + * what redaction removed. + */ + +export function normalize(title: string): string { + return title + .toLowerCase() + .replace(/[^\p{L}\p{N}\s]/gu, '') + .replace(/\s+/g, ' ') + .trim() +} + +export async function fingerprint(scrubbedTitle: string): Promise { + const data = new TextEncoder().encode(normalize(scrubbedTitle)) + const digest = await crypto.subtle.digest('SHA-256', data) + return [...new Uint8Array(digest)] + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + .slice(0, 16) +} + +/** The label IS the idempotency key, the dedup key, and the read key. */ +export function labelFor(fp: string): string { + return `tt:${fp}` +} diff --git a/telltale/src/github.ts b/telltale/src/github.ts new file mode 100644 index 0000000..7dd2867 --- /dev/null +++ b/telltale/src/github.ts @@ -0,0 +1,102 @@ +import type { CandidateIssue } from './decide' +import type { Env, RegistryEntry } from './types' + +export interface RawIssue { + number: number + title: string + body: string | null + state: 'open' | 'closed' + state_reason?: 'completed' | 'not_planned' | null + labels: Array<{ name: string }> + assignee: unknown | null + created_at: string + updated_at: string + html_url: string + pull_request?: unknown +} + +export interface GitHubClient { + findByLabel(repo: string, label: string): Promise + createIssue(repo: string, i: { title: string; body: string; labels: string[] }): + Promise<{ number: number; url: string; labelsDropped: boolean }> + commentIssue(repo: string, number: number, body: string): Promise + listTelltaleIssues(repo: string): Promise +} + +/** A fine-grained PAT is per-account, so the registry names which one to use. */ +export function tokenFor(env: Env, entry: RegistryEntry): string { + return entry.account === 'primary' ? env.GITHUB_TOKEN_PRIMARY : env.GITHUB_TOKEN_SECONDARY +} + +function toCandidate(i: RawIssue): CandidateIssue { + return { + number: i.number, + state: i.state, + // The REST list endpoint omits state_reason on older closures; decide() + // treats null as `completed`. + stateReason: i.state_reason ?? null, + labels: i.labels.map((l) => l.name), + isPullRequest: i.pull_request !== undefined, + } +} + +export function restClient(token: string, fetchImpl: typeof fetch = fetch): GitHubClient { + const headers = { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'telltale', + // Without this, a plain string body defaults to text/plain per the Fetch + // spec and GitHub reads the POST body as text, not JSON. + 'Content-Type': 'application/json', + } + + async function api(path: string, init?: RequestInit): Promise { + // `redirect: 'manual'`, not the default 'follow'. A repo transfer is a + // normal, silent event, and following its 301 rewrites a POST into a GET: + // createIssue would then parse an issue ARRAY as an object, `labels` would + // be undefined, and a stale registry entry would surface as a generic + // github_error far from its cause. Fail loudly, naming the new location. + const res = await fetchImpl(`https://api.github.com${path}`, { ...init, headers, redirect: 'manual' }) + if (res.status >= 300 && res.status < 400) { + const location = res.headers.get('location') + throw new Error( + `github ${res.status} redirect on ${path}${location ? ` -> ${location}` : ''}` + + ' (repo moved or renamed - update src/registry.ts)', + ) + } + if (!res.ok) throw new Error(`github ${res.status} on ${path}`) + return res + } + + return { + // The REST LIST endpoint with `labels=` — not the search API, which is + // eventually consistent and capped at 30 req/min. `labels=` is exact and + // AND-semantic, which is what dedup needs. + async findByLabel(repo, label) { + const res = await api(`/repos/${repo}/issues?labels=${encodeURIComponent(label)}&state=all&per_page=100`) + return ((await res.json()) as RawIssue[]).map(toCandidate) + }, + + async createIssue(repo, i) { + const res = await api(`/repos/${repo}/issues`, { method: 'POST', body: JSON.stringify(i) }) + const created = (await res.json()) as RawIssue + const got = created.labels.map((l) => l.name) + return { + number: created.number, + url: created.html_url, + // Verified, never assumed — see the label-drop test. + labelsDropped: i.labels.some((l) => !got.includes(l)), + } + }, + + async commentIssue(repo, number, body) { + await api(`/repos/${repo}/issues/${number}/comments`, { method: 'POST', body: JSON.stringify({ body }) }) + }, + + async listTelltaleIssues(repo) { + const res = await api(`/repos/${repo}/issues?labels=telltale&state=open&per_page=100`) + return (await res.json()) as RawIssue[] + }, + } +} diff --git a/telltale/src/index.ts b/telltale/src/index.ts new file mode 100644 index 0000000..630f0f3 --- /dev/null +++ b/telltale/src/index.ts @@ -0,0 +1,165 @@ +import type { Env, RegistryEntry } from './types' +import { lookup } from './registry' +import { parseEvent } from './schema' +import { scrubTitle, scrubBody } from './scrub' +import { fingerprint, labelFor } from './fingerprint' +import { verifySignature } from './auth' +import { decide } from './decide' +import { restClient, tokenFor, type GitHubClient } from './github' +import { hashIp, checkRateLimits, shouldComment, recordStat, type StatReason } from './kv' +import { handleIssues, handleStats } from './read' + +export interface Deps { + gh: (entry: RegistryEntry) => GitHubClient + nowMs: number +} + +function json(status: number, body: unknown, headers: Record = {}): Response { + return new Response(JSON.stringify(body), { + status, headers: { 'content-type': 'application/json', ...headers }, + }) +} + +export async function handleEvent(req: Request, env: Env, deps: Deps): Promise { + const kv = env.TELLTALE_KV + const fail = async (status: number, reason: StatReason, extra?: Record) => { + await recordStat(kv, reason) + return json(status, { error: reason }, extra) + } + + const project = req.headers.get('X-Telltale-Project') + if (!project) return fail(401, 'bad_signature') + + // A malformed operator-configured secrets blob is a deploy problem, not a + // rejected request: it must still land in stats (the whole point of + // /v1/stats is to make silent failure visible) and still return the same + // { error: reason } envelope every other path returns, not a bare runtime + // throw. + let secrets: Record + try { + secrets = JSON.parse(env.TELLTALE_SENDER_SECRETS) as Record + } catch { + return fail(500, 'config_error') + } + const secret = secrets[project] + const rawBody = await req.text() + + // 1. Auth. The header is the sole project authority. + if (!secret) { + // No sender secret provisioned for this slug — in practice that almost + // always means the slug itself is unregistered (a typo), not a real + // project missing its secret. Peek at the registry so a typo still gets + // the loud 404 rather than being folded into every other auth failure. + return lookup(project) ? fail(401, 'bad_signature') : fail(404, 'unregistered_project') + } + const auth = await verifySignature({ + secret, + timestamp: req.headers.get('X-Telltale-Timestamp'), + signature: req.headers.get('X-Telltale-Signature'), + rawBody, + nowMs: deps.nowMs, + }) + if (!auth.ok) { + // Hand back server time so the sender's single mandated retry can re-sign. + // Device clock skew is common on Android and would otherwise fail silently. + const extra = { 'X-Telltale-Server-Time': String(Math.floor(deps.nowMs / 1000)) } + return fail(401, auth.reason === 'clock_skew' ? 'clock_skew' : 'bad_signature', extra) + } + + // 2. Registry. An unregistered slug fails loudly rather than dropping silently. + const entry = lookup(project) + if (!entry) return fail(404, 'unregistered_project') + + // 3. Schema. + let parsed + try { parsed = parseEvent(JSON.parse(rawBody)) } catch { return fail(400, 'invalid_schema') } + if (!parsed.ok) return fail(400, 'invalid_schema') + const event = parsed.event + + // 4. Rate limits, on server-observed identity as well as the client's anon_id. + const ipHash = await hashIp(req.headers.get('CF-Connecting-IP') ?? '0.0.0.0', env.IP_HASH_SALT) + const rate = await checkRateLimits(kv, { ipHash, anonId: event.reporter?.anon_id ?? 'anon', project }) + if (!rate.ok) return fail(429, 'rate_limited', { 'Retry-After': '3600' }) + + // 5. Scrub, THEN fingerprint — so identity is stable regardless of redaction. + const title = scrubTitle(event.title) + const body = scrubBody(event.body) + const fp = await fingerprint(title) + const label = labelFor(fp) + + const gh = deps.gh(entry) + let candidates + try { candidates = await gh.findByLabel(entry.repo, label) } catch { return fail(503, 'github_error') } + + const decision = decide(candidates) + if (decision.action === 'ignore') return fail(200, 'ignored') + + // The spec knowingly accepts a concurrent-create race that can open two + // issues for one fingerprint (§4.4). That acceptance is only defensible while + // the operator can SEE it happening, so a multi-match lookup is counted + // alongside the normal 'accepted'. + if (candidates.filter((c) => !c.isPullRequest && c.state === 'open').length > 1) { + await recordStat(kv, 'duplicate_fingerprint') + } + + const footer = + `\n\n---\n` + + (event.release ? `Release: ${project}-${event.release.surface}@${event.release.version}\n` : '') + + (event.context ? `Context: ${JSON.stringify(event.context)}\n` : '') + + `` + + try { + if (decision.action === 'create') { + const created = await gh.createIssue(entry.repo, { + title: `[bug] ${title}`, + body: body + footer, + labels: [...entry.labels, 'telltale:bug', label], + }) + if (created.labelsDropped) { + // Silent label loss breaks the idempotency key, the dedup key and the + // read key at once, while otherwise reporting success. Fail loudly. + await recordStat(kv, 'labels_dropped') + return json(500, { error: 'labels_dropped', issue: created.number }) + } + await recordStat(kv, 'accepted') + return json(202, { issue: created.number, url: created.url }) + } + + if (await shouldComment(kv, project, fp)) { + await gh.commentIssue(entry.repo, decision.issue, `Reported again.${footer}`) + } + await recordStat(kv, 'accepted') + return json(202, { issue: decision.issue }) + } catch { + return fail(503, 'github_error') + } +} + +export default { + async fetch(req: Request, env: Env): Promise { + // Top-level boundary. Anything that escapes here becomes Cloudflare's bare + // 5xx HTML — no { error } envelope and no stat — which is exactly the + // silent failure /v1/stats exists to eliminate. KV is the realistic source: + // its counters are hot keys by construction and a blip must not take the + // pipeline down with it. + try { + const url = new URL(req.url) + const deps: Deps = { + gh: (entry) => restClient(tokenFor(env, entry)), + nowMs: Date.now(), + } + if (req.method === 'POST' && url.pathname === '/v1/events') { + return await handleEvent(req, env, deps) + } + if (req.method === 'GET' && url.pathname === '/v1/issues') { + return await handleIssues(req, env, deps) + } + if (req.method === 'GET' && url.pathname === '/v1/stats') { + return await handleStats(req, env) + } + return json(404, { error: 'not_found' }) + } catch { + return json(500, { error: 'internal' }) + } + }, +} diff --git a/telltale/src/kv.ts b/telltale/src/kv.ts new file mode 100644 index 0000000..3f6949a --- /dev/null +++ b/telltale/src/kv.ts @@ -0,0 +1,86 @@ +/** + * Approximate counters (spec §4.2, §4.6). + * + * KV is CORRECT HERE and was not correct for the dedup gate an earlier design + * draft used it for: these are abuse counters and throttles where a lost + * increment under concurrency is harmless. Correctness-critical dedup lives in + * the GitHub label lookup (src/decide.ts), not here. + */ + +const HOUR = 3600 + +export type RateResult = { ok: true } | { ok: false; scope: 'pair' | 'ip' | 'project' } + +export type StatReason = + | 'accepted' | 'bad_signature' | 'clock_skew' | 'rate_limited' + | 'unregistered_project' | 'invalid_schema' | 'labels_dropped' + | 'duplicate_fingerprint' | 'github_error' | 'ignored' | 'config_error' + +/** The client IP is stored ONLY as a salted hash, and never written to an issue. */ +export async function hashIp(ip: string, salt: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`${salt}:${ip}`)) + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, 32) +} + +function bucket(): string { + return String(Math.floor(Date.now() / 1000 / HOUR)) +} + +async function bump(kv: KVNamespace, key: string, limit: number): Promise { + const k = `rl:${key}:${bucket()}` + const n = Number((await kv.get(k)) ?? '0') + if (n >= limit) return false + await kv.put(k, String(n + 1), { expirationTtl: HOUR * 2 }) + return true +} + +export async function checkRateLimits( + kv: KVNamespace, + a: { ipHash: string; anonId: string; project: string }, +): Promise { + if (!(await bump(kv, `pair:${a.ipHash}:${a.anonId}`, 10))) return { ok: false, scope: 'pair' } + if (!(await bump(kv, `ip:${a.ipHash}`, 200))) return { ok: false, scope: 'ip' } + if (!(await bump(kv, `proj:${a.project}`, 1000))) return { ok: false, scope: 'project' } + return { ok: true } +} + +/** At most one comment per project per fingerprint per hour: a crash hitting a + * thousand users must produce one issue and a handful of comments, not a + * thousand notifications. + * + * The key is PROJECT-SCOPED, like `rl:proj:`. The fingerprint is a hash of the + * scrubbed title alone, and KV is a single namespace across all eleven + * projects: two apps whose users type the same generic title ("Save button + * does nothing") share a fingerprint, and an unscoped key would silently + * suppress the second project's genuine recurrence comment for an hour. */ +export async function shouldComment(kv: KVNamespace, project: string, fp: string): Promise { + const k = `ct:${project}:${fp}:${bucket()}` + if (await kv.get(k)) return false + await kv.put(k, '1', { expirationTtl: HOUR * 2 }) + return true +} + +/** Never throws. Every counter here is a hot key by construction + * (`st:bad_signature:{hour}`) and KV documents roughly one write per second + * per key, so a rejection is expected under real traffic. Swallowing it here + * rather than at each call site means a KV hiccup degrades observability + * instead of taking down the pipeline /v1/stats exists to describe. */ +export async function recordStat(kv: KVNamespace, reason: StatReason): Promise { + try { + const k = `st:${reason}:${bucket()}` + const n = Number((await kv.get(k)) ?? '0') + await kv.put(k, String(n + 1), { expirationTtl: HOUR * 26 }) + } catch { + // Best-effort by design; see above. + } +} + +export async function readStats(kv: KVNamespace): Promise> { + const out: Record = {} + const list = await kv.list({ prefix: 'st:' }) + for (const { name } of list.keys) { + const reason = name.split(':')[1]! + out[reason] = (out[reason] ?? 0) + Number((await kv.get(name)) ?? '0') + } + return out +} diff --git a/telltale/src/read.ts b/telltale/src/read.ts new file mode 100644 index 0000000..3b7a255 --- /dev/null +++ b/telltale/src/read.ts @@ -0,0 +1,90 @@ +import type { Env, RegistryEntry } from './types' +import { REGISTRY } from './registry' +import { readStats } from './kv' +import { equals } from './auth' +import type { GitHubClient, RawIssue } from './github' + +export interface TelltaleIssueDTO { + repo: string; number: number; title: string; body: string + kind: 'bug' | 'crash' | 'unknown' + project: string; isOpen: boolean; hasAssignee: boolean + createdIso: string; updatedIso: string; labels: string[]; url: string +} + +export interface IssuesResponse { + issues: TelltaleIssueDTO[] + errors: Array<{ project: string; message: string }> +} + +/** Explicit whitelist. A `telltale:*` prefix parse would yield 'muted'. */ +function kindOf(labels: string[]): 'bug' | 'crash' | 'unknown' { + if (labels.includes('telltale:crash')) return 'crash' + if (labels.includes('telltale:bug')) return 'bug' + return 'unknown' +} + +export function toDto(repo: string, project: string, i: RawIssue): TelltaleIssueDTO { + const labels = i.labels.map((l) => l.name) + return { + repo, number: i.number, title: i.title, body: i.body ?? '', + kind: kindOf(labels), project, + isOpen: i.state === 'open', + hasAssignee: i.assignee !== null && i.assignee !== undefined, + createdIso: i.created_at, updatedIso: i.updated_at, + labels, url: i.html_url, + } +} + +/** + * A missing secret denies everything. Interpolating an unset OPERATOR_READ_TOKEN + * makes the expected header the literal string "Bearer undefined" — a guessable + * constant that would serve every bug-report body in every private registry repo + * to anyone who sends it. Never "accept a guessable constant"; always "deny". + */ +function unauthorized(req: Request, env: Env): boolean { + if (!env.OPERATOR_READ_TOKEN) return true + return !equals(req.headers.get('Authorization') ?? '', `Bearer ${env.OPERATOR_READ_TOKEN}`) +} + +export async function handleIssues( + req: Request, env: Env, deps: { gh: (entry: RegistryEntry) => GitHubClient }, +): Promise { + if (unauthorized(req, env)) { + return new Response(JSON.stringify({ error: 'unauthorized' }), { + status: 401, headers: { 'content-type': 'application/json' }, + }) + } + + const issues: TelltaleIssueDTO[] = [] + const errors: IssuesResponse['errors'] = [] + + for (const [project, entry] of Object.entries(REGISTRY)) { + if (project === '__probe__') continue + try { + const raw = await deps.gh(entry).listTelltaleIssues(entry.repo) + // GET /issues returns pull requests as issues. + for (const i of raw) { + if (i.pull_request === undefined) issues.push(toDto(entry.repo, project, i)) + } + } catch (e) { + // Degrade only the affected project, never the whole lane. + errors.push({ project, message: e instanceof Error ? e.message : 'unknown' }) + } + } + + return new Response(JSON.stringify({ issues, errors } satisfies IssuesResponse), { + status: 200, + headers: { 'content-type': 'application/json', 'cache-control': 'max-age=60' }, + }) +} + +export async function handleStats(req: Request, env: Env): Promise { + if (unauthorized(req, env)) { + return new Response(JSON.stringify({ error: 'unauthorized' }), { + status: 401, headers: { 'content-type': 'application/json' }, + }) + } + return new Response(JSON.stringify(await readStats(env.TELLTALE_KV)), { + status: 200, headers: { 'content-type': 'application/json' }, + }) +} diff --git a/telltale/src/registry.ts b/telltale/src/registry.ts new file mode 100644 index 0000000..3c3c9aa --- /dev/null +++ b/telltale/src/registry.ts @@ -0,0 +1,40 @@ +import type { RegistryEntry } from './types' + +/** + * The project registry — spec §5.1, with one deliberate deviation: a typed TS + * module rather than registry.yml (see the plan's "deliberate deviations"). + * + * Entries are EXPLICIT. There is no slug-to-repo inference anywhere in this + * Worker: a wrong guess writes a user's bug report into a stranger's repository. + * + * An entry with no shipping sender (e.g. pawsport) is crash-only — it receives + * Sentry-created issues and appears on the board, but no app POSTs to it. + */ +export const REGISTRY: Record = { + // Transferred to the OpenBarclay org. adbarc92/tenzy is ONLY a redirect — + // `gh api` follows it silently — and the primary PAT's resource owner is the + // adbarc92 user, so it cannot write to an org repo at all. + tenzy: { repo: 'OpenBarclay/tenzy', account: 'secondary', labels: ['telltale'] }, + giftkeeper: { repo: 'adbarc92/parcle', account: 'primary', labels: ['telltale'] }, + purposefull: { repo: 'OpenBarclay/purposefull', account: 'secondary', labels: ['telltale'] }, + ironsoul: { repo: 'adbarc92/ironsoul', account: 'primary', labels: ['telltale'] }, + audience: { repo: 'adbarc92/audience', account: 'primary', labels: ['telltale'] }, + lineage: { repo: 'adbarc92/lineage', account: 'primary', labels: ['telltale'] }, + 'robo.learn': { repo: 'OpenBarclay/robo.learn', account: 'secondary', labels: ['telltale'] }, + 'prima-tactica': { repo: 'adbarc92/prima-tactica', account: 'primary', labels: ['telltale', 'game'] }, + hexy: { repo: 'adbarc92/hexy', account: 'primary', labels: ['telltale', 'game'] }, + + // Crash-only: archived on GitHub, so it cannot receive issue writes (spec §5.3). + pawsport: { repo: 'adbarc92/telltale-intake', account: 'primary', labels: ['telltale'] }, + + // The live grader's target (spec §9.1). NEVER a product repo: the grader + // creates real issues, and pointing it at a shipped product would publish + // synthetic reports into a public tracker. + __probe__: { repo: 'adbarc92/telltale-probe', account: 'primary', labels: ['telltale'] }, +} + +export function lookup(project: string): RegistryEntry | null { + return Object.prototype.hasOwnProperty.call(REGISTRY, project) + ? REGISTRY[project]! + : null +} diff --git a/telltale/src/schema.ts b/telltale/src/schema.ts new file mode 100644 index 0000000..fcb5222 --- /dev/null +++ b/telltale/src/schema.ts @@ -0,0 +1,77 @@ +import type { FeedbackEvent, Surface } from './types' + +export type ParseResult = + | { ok: true; event: FeedbackEvent } + | { ok: false; reason: string } + +const SURFACES: readonly Surface[] = ['ios', 'android', 'web', 'desktop'] +const CONTEXT_KEYS = ['platform', 'os_version', 'locale'] as const +const BODY_MAX = 8000 + +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v) +} + +export function parseEvent(raw: unknown): ParseResult { + if (!isRecord(raw)) return { ok: false, reason: 'not_an_object' } + + if (raw.schema_version !== 1) return { ok: false, reason: 'schema_version' } + + // The header is the sole project authority (spec §4.1). A body copy is + // rejected outright rather than ignored, so a stale sender fails loudly. + if ('project' in raw) return { ok: false, reason: 'project_in_body' } + + if (typeof raw.title !== 'string') return { ok: false, reason: 'title' } + const title = raw.title.trim() + if (title.length < 1 || title.length > 120) return { ok: false, reason: 'title' } + + if (raw.body !== undefined && typeof raw.body !== 'string') { + return { ok: false, reason: 'body' } + } + const rawBody = typeof raw.body === 'string' ? raw.body : '' + // Truncate, never reject: a user's report is not worth losing to a length rule. + const body = rawBody.length > BODY_MAX + ? rawBody.slice(0, BODY_MAX) + '\n\n…[truncated]' + : rawBody + + const event: FeedbackEvent = { schema_version: 1, title, body } + + if (raw.release !== undefined) { + if (!isRecord(raw.release)) return { ok: false, reason: 'release' } + const { version, surface } = raw.release + if (typeof version !== 'string' || version.length > 64) { + return { ok: false, reason: 'release.version' } + } + if (typeof surface !== 'string' || !SURFACES.includes(surface as Surface)) { + return { ok: false, reason: 'release.surface' } + } + event.release = { version, surface: surface as Surface } + } + + if (raw.context !== undefined) { + if (!isRecord(raw.context)) return { ok: false, reason: 'context' } + const ctx: Record = {} + // Bounded key set. Unknown keys are DROPPED, not stored — this is what stops + // `context` becoming an unaudited PII channel. + for (const k of CONTEXT_KEYS) { + const v = raw.context[k] + if (typeof v === 'string' && v.length <= 64) ctx[k] = v + } + event.context = ctx + } + + if (raw.reporter !== undefined) { + if (!isRecord(raw.reporter)) return { ok: false, reason: 'reporter' } + const id = raw.reporter.anon_id + if (id !== undefined) { + if (typeof id !== 'string' || id.length > 64) { + return { ok: false, reason: 'reporter.anon_id' } + } + event.reporter = { anon_id: id } + } + } + + if (typeof raw.occurred_at === 'string') event.occurred_at = raw.occurred_at + + return { ok: true, event } +} diff --git a/telltale/src/scrub.ts b/telltale/src/scrub.ts new file mode 100644 index 0000000..7188838 --- /dev/null +++ b/telltale/src/scrub.ts @@ -0,0 +1,28 @@ +/** + * Ingest-side PII redaction (spec §4.3). + * + * Recorded operator decision: issues land in each project's own repo, including + * public ones. That exposure was raised and knowingly accepted; this module is + * the mitigation, not a guarantee — it misses obfuscated forms like + * "alex at example dot com". + * + * Redaction is LOSSY AND ONE-WAY BY DESIGN. The original is never stored + * anywhere: a store of unredacted originals would recreate the hazard. + */ + +const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g + +// E.164 and NANP shapes. Requires a separator or a leading +, so a bare run of +// digits is left to the DIGIT_RUN rule (body only) and version strings survive. +const PHONE = /(?:\+\d{1,3}[\s.-]?)?(?:\(\d{3}\)|\d{3})[\s.-]\d{3}[\s.-]\d{4}\b/g + +// 12+ consecutive digits: card and account numbers. BODY ONLY. +const DIGIT_RUN = /\b\d{12,}\b/g + +export function scrubTitle(s: string): string { + return s.replace(EMAIL, '[redacted:email]').replace(PHONE, '[redacted:phone]') +} + +export function scrubBody(s: string): string { + return scrubTitle(s).replace(DIGIT_RUN, '[redacted:number]') +} diff --git a/telltale/src/types.ts b/telltale/src/types.ts new file mode 100644 index 0000000..828d52c --- /dev/null +++ b/telltale/src/types.ts @@ -0,0 +1,33 @@ +export type Surface = 'ios' | 'android' | 'web' | 'desktop' + +export interface FeedbackEvent { + schema_version: 1 + title: string + body: string + release?: { version: string; surface: Surface } + context?: { platform?: string; os_version?: string; locale?: string } + reporter?: { anon_id?: string } + occurred_at?: string +} + +export interface RegistryEntry { + /** "owner/name" */ + repo: string + /** Which PAT to use, keyed by account. */ + account: 'primary' | 'secondary' + labels: string[] +} + +export interface Env { + TELLTALE_KV: KVNamespace + /** JSON: { "": "" } */ + TELLTALE_SENDER_SECRETS: string + /** Fine-grained PAT, Issues: read+write, primary account. */ + GITHUB_TOKEN_PRIMARY: string + /** Fine-grained PAT, Issues: read+write, secondary account. */ + GITHUB_TOKEN_SECONDARY: string + /** Bearer token the cockpit presents to GET /v1/issues and /v1/stats. */ + OPERATOR_READ_TOKEN: string + /** Salt for hashing client IPs. */ + IP_HASH_SALT: string +} diff --git a/telltale/test/auth.test.ts b/telltale/test/auth.test.ts new file mode 100644 index 0000000..2e77366 --- /dev/null +++ b/telltale/test/auth.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest' +import { sign, verifySignature } from '../src/auth' + +const SECRET = 'test-secret' +const NOW = Date.UTC(2026, 7, 30, 18, 0, 0) +const ts = String(Math.floor(NOW / 1000)) +const BODY = '{"schema_version":1,"title":"x","body":"y"}' + +async function good() { + return { secret: SECRET, timestamp: ts, signature: await sign(SECRET, ts, BODY), rawBody: BODY, nowMs: NOW } +} + +describe('verifySignature', () => { + it('accepts a correctly signed request', async () => { + await expect(verifySignature(await good())).resolves.toEqual({ ok: true }) + }) + + it('rejects a missing signature or timestamp', async () => { + const g = await good() + await expect(verifySignature({ ...g, signature: null })).resolves.toEqual({ ok: false, reason: 'missing' }) + await expect(verifySignature({ ...g, timestamp: null })).resolves.toEqual({ ok: false, reason: 'missing' }) + }) + + it('rejects a wrong secret', async () => { + const g = await good() + await expect(verifySignature({ ...g, secret: 'other-secret' })) + .resolves.toEqual({ ok: false, reason: 'bad_signature' }) + }) + + it('rejects a tampered body even with a valid-looking signature', async () => { + const g = await good() + await expect(verifySignature({ ...g, rawBody: BODY.replace('"x"', '"z"') })) + .resolves.toEqual({ ok: false, reason: 'bad_signature' }) + }) + + it('rejects a replay outside the +/-10 minute window', async () => { + const g = await good() + await expect(verifySignature({ ...g, nowMs: NOW + 11 * 60_000 })) + .resolves.toEqual({ ok: false, reason: 'clock_skew' }) + await expect(verifySignature({ ...g, nowMs: NOW - 11 * 60_000 })) + .resolves.toEqual({ ok: false, reason: 'clock_skew' }) + }) + + it('accepts inside the window in both directions', async () => { + const g = await good() + await expect(verifySignature({ ...g, nowMs: NOW + 9 * 60_000 })).resolves.toEqual({ ok: true }) + await expect(verifySignature({ ...g, nowMs: NOW - 9 * 60_000 })).resolves.toEqual({ ok: true }) + }) + + it('signs over raw bytes, so key order changes the signature', async () => { + const a = await sign(SECRET, ts, '{"a":1,"b":2}') + const b = await sign(SECRET, ts, '{"b":2,"a":1}') + expect(a).not.toBe(b) + }) +}) diff --git a/telltale/test/decide.test.ts b/telltale/test/decide.test.ts new file mode 100644 index 0000000..46298c9 --- /dev/null +++ b/telltale/test/decide.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest' +import { decide, type CandidateIssue } from '../src/decide' + +const base: CandidateIssue = { + number: 7, state: 'open', stateReason: null, labels: ['telltale'], isPullRequest: false, +} + +describe('decide', () => { + it('creates when there is no match', () => { + expect(decide([])).toEqual({ action: 'create' }) + }) + + it('comments on an existing open issue, never opening a second', () => { + expect(decide([base])).toEqual({ action: 'comment', issue: 7 }) + }) + + it('comments but does NOT reopen a completed-closed issue', () => { + // Mobile users run old builds for months: a bug fixed in 1.4.3 keeps + // arriving from 1.4.1 clients and must not perpetually reopen its issue. + const closed = { ...base, state: 'closed' as const, stateReason: 'completed' as const } + expect(decide([closed])).toEqual({ action: 'comment', issue: 7 }) + }) + + it('treats a legacy closure with a null state_reason as completed', () => { + const legacy = { ...base, state: 'closed' as const, stateReason: null } + expect(decide([legacy])).toEqual({ action: 'comment', issue: 7 }) + }) + + it('is silent for a not_planned closure', () => { + const wontfix = { ...base, state: 'closed' as const, stateReason: 'not_planned' as const } + expect(decide([wontfix])).toEqual({ action: 'ignore', reason: 'not_planned' }) + }) + + it('is silent for a muted issue even when open', () => { + const muted = { ...base, labels: ['telltale', 'telltale:muted'] } + expect(decide([muted])).toEqual({ action: 'ignore', reason: 'muted' }) + }) + + it('skips pull requests, which the issues endpoint also returns', () => { + // A fix PR carrying the telltale label would otherwise read as an open bug. + const pr = { ...base, number: 99, isPullRequest: true } + expect(decide([pr])).toEqual({ action: 'create' }) + expect(decide([pr, base])).toEqual({ action: 'comment', issue: 7 }) + }) + + it('prefers the lowest-numbered open issue when duplicates exist', () => { + // Duplicates are the EXPECTED outcome of a concurrent create race, not an + // anomaly — see the plan's note on retry-vs-concurrency idempotency. + const later = { ...base, number: 12 } + expect(decide([later, base])).toEqual({ action: 'comment', issue: 7 }) + }) + + it('prefers an open issue over a closed one', () => { + const closed = { ...base, number: 3, state: 'closed' as const, stateReason: 'completed' as const } + expect(decide([closed, base])).toEqual({ action: 'comment', issue: 7 }) + }) + + it('a not_planned closure silences the fingerprint even when a completed duplicate is lower-numbered', () => { + const completed = { ...base, number: 3, state: 'closed' as const, stateReason: 'completed' as const } + const wontfix = { ...base, number: 5, state: 'closed' as const, stateReason: 'not_planned' as const } + expect(decide([completed, wontfix])).toEqual({ action: 'ignore', reason: 'not_planned' }) + }) +}) diff --git a/telltale/test/events.test.ts b/telltale/test/events.test.ts new file mode 100644 index 0000000..0d491c2 --- /dev/null +++ b/telltale/test/events.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect } from 'vitest' +import { FakeKV, FakeGitHub } from './fakes' +import worker, { handleEvent } from '../src/index' +import { sign } from '../src/auth' +import { readStats } from '../src/kv' +import type { Env } from '../src/types' + +const SECRET = 'sender-secret' +const NOW = Date.UTC(2026, 7, 30, 18, 0, 0) + +function makeEnv(kv: FakeKV): Env { + return { + TELLTALE_KV: kv as unknown as KVNamespace, + TELLTALE_SENDER_SECRETS: JSON.stringify({ tenzy: SECRET }), + GITHUB_TOKEN_PRIMARY: 'a', GITHUB_TOKEN_SECONDARY: 'b', + OPERATOR_READ_TOKEN: 'op', IP_HASH_SALT: 'salt', + } +} + +async function post(body: object, opts: { secret?: string; ts?: string } = {}) { + const raw = JSON.stringify(body) + const ts = opts.ts ?? String(Math.floor(NOW / 1000)) + return new Request('https://t.test/v1/events', { + method: 'POST', + headers: { + 'X-Telltale-Project': 'tenzy', + 'X-Telltale-Timestamp': ts, + 'X-Telltale-Signature': await sign(opts.secret ?? SECRET, ts, raw), + 'CF-Connecting-IP': '203.0.113.7', + }, + body: raw, + }) +} + +/** Every KV operation rejects — the shape of a namespace-wide blip. */ +class ThrowingKV { + async get(): Promise { throw new Error('KV unavailable') } + async put(): Promise { throw new Error('KV unavailable') } + async list(): Promise { throw new Error('KV unavailable') } +} + +/** Only the stats counters reject — the realistic case, since `st:` keys are + * hot keys by construction and KV allows ~1 write per second per key. */ +class StatsHostileKV extends FakeKV { + async put(k: string, v: string, o?: { expirationTtl?: number }): Promise { + if (k.startsWith('st:')) throw new Error('KV write rate limit on a hot key') + return super.put(k, v, o) + } +} + +const EVENT = { schema_version: 1, title: 'Save fails', body: 'nothing happens', reporter: { anon_id: 'a1' } } + +function deps(gh: FakeGitHub) { + return { gh: () => gh, nowMs: NOW } +} + +describe('POST /v1/events', () => { + it('creates an issue carrying the tt: fingerprint label', async () => { + const gh = new FakeGitHub() + const res = await handleEvent(await post(EVENT), makeEnv(new FakeKV()), deps(gh)) + expect(res.status).toBe(202) + expect(gh.issues).toHaveLength(1) + expect(gh.issues[0]!.labels.some((l) => l.startsWith('tt:'))).toBe(true) + expect(gh.issues[0]!.title).toBe('[bug] Save fails') + }) + + it('comments instead of opening a second issue for the same title', async () => { + const gh = new FakeGitHub() + const env = makeEnv(new FakeKV()) + await handleEvent(await post(EVENT), env, deps(gh)) + await handleEvent(await post({ ...EVENT, reporter: { anon_id: 'a2' } }), env, deps(gh)) + expect(gh.issues).toHaveLength(1) + expect(gh.comments).toHaveLength(1) + }) + + it('is idempotent across a sender retry', async () => { + // A create that succeeded with a lost response, retried by the sender, + // must not double-open. The tt: label is what makes the retry safe. + const gh = new FakeGitHub() + const env = makeEnv(new FakeKV()) + const req = await post(EVENT) + await handleEvent(req.clone(), env, deps(gh)) + await handleEvent(await post(EVENT), env, deps(gh)) + expect(gh.issues).toHaveLength(1) + }) + + it('scrubs an email out of the title before the issue is created', async () => { + const gh = new FakeGitHub() + await handleEvent( + await post({ ...EVENT, title: 'crash for alex@example.com' }), + makeEnv(new FakeKV()), deps(gh), + ) + expect(gh.issues[0]!.title).toContain('[redacted:email]') + expect(gh.issues[0]!.title).not.toContain('alex@example.com') + }) + + it('rejects a wrong signature with 401 and reaches no sink', async () => { + const gh = new FakeGitHub() + const res = await handleEvent(await post(EVENT, { secret: 'wrong' }), makeEnv(new FakeKV()), deps(gh)) + expect(res.status).toBe(401) + expect(gh.issues).toHaveLength(0) + }) + + it('returns the server time on a clock-skew rejection so the retry can re-sign', async () => { + const stale = String(Math.floor(NOW / 1000) - 20 * 60) + const res = await handleEvent(await post(EVENT, { ts: stale }), makeEnv(new FakeKV()), deps(new FakeGitHub())) + expect(res.status).toBe(401) + expect(res.headers.get('X-Telltale-Server-Time')).toBeTruthy() + }) + + it('404s an unregistered project so a typo fails loudly', async () => { + const raw = JSON.stringify(EVENT) + const ts = String(Math.floor(NOW / 1000)) + const req = new Request('https://t.test/v1/events', { + method: 'POST', + headers: { + 'X-Telltale-Project': 'nope', 'X-Telltale-Timestamp': ts, + 'X-Telltale-Signature': await sign(SECRET, ts, raw), 'CF-Connecting-IP': '203.0.113.7', + }, + body: raw, + }) + expect((await handleEvent(req, makeEnv(new FakeKV()), deps(new FakeGitHub()))).status).toBe(404) + }) + + it('400s a body that carries a project field', async () => { + const res = await handleEvent( + await post({ ...EVENT, project: 'tenzy' }), makeEnv(new FakeKV()), deps(new FakeGitHub()), + ) + expect(res.status).toBe(400) + }) + + it('does not report success when GitHub silently drops the labels', async () => { + const gh = new FakeGitHub() + gh.dropLabels = true + const res = await handleEvent(await post(EVENT), makeEnv(new FakeKV()), deps(gh)) + expect(res.status).toBe(500) + }) + + it('429s past the per-install ceiling, telling the sender when to come back', async () => { + const gh = new FakeGitHub() + const env = makeEnv(new FakeKV()) + let last: Response | undefined + for (let i = 0; i < 12; i++) last = await handleEvent(await post(EVENT), env, deps(gh)) + expect(last!.status).toBe(429) + // Binding constraint: without Retry-After a throttled sender has no idea + // when to retry and hammers the endpoint through the whole hour bucket. + expect(last!.headers.get('Retry-After')).toBe('3600') + }) + + it('records duplicate_fingerprint when the lookup returns two open matches', async () => { + // The spec knowingly accepts a concurrent-create race that can open two + // issues for one fingerprint. That acceptance is only defensible while the + // operator can see it in /v1/stats. + const gh = new FakeGitHub() + const kv = new FakeKV() + const env = makeEnv(kv) + await handleEvent(await post(EVENT), env, deps(gh)) + gh.issues.push({ ...gh.issues[0]!, number: 2 }) + + const res = await handleEvent(await post(EVENT), env, deps(gh)) + expect(res.status).toBe(202) + const stats = await readStats(kv as unknown as KVNamespace) + expect(stats.duplicate_fingerprint).toBe(1) + expect(stats.accepted).toBe(2) + }) + + it('answers with the JSON envelope, not an unhandled throw, when KV is down', async () => { + // recordStat/bump write hot keys by construction. A rejection that escapes + // handleEvent hands the client Cloudflare's bare 5xx: no { error } body and + // no stat — the exact silent failure /v1/stats exists to eliminate. + const env: Env = { ...makeEnv(new FakeKV()), TELLTALE_KV: new ThrowingKV() as unknown as KVNamespace } + // The default export builds its own deps with the REAL Date.now(), so this + // one request must be signed against wall-clock time, not the frozen NOW. + const req = await post(EVENT, { ts: String(Math.floor(Date.now() / 1000)) }) + const res = await worker.fetch(req, env) + expect(res.status).toBe(500) + expect(await res.json()).toEqual({ error: 'internal' }) + }) + + it('degrades observability, not the pipeline, when a stats write fails', async () => { + const gh = new FakeGitHub() + const res = await handleEvent(await post(EVENT), makeEnv(new StatsHostileKV()), deps(gh)) + expect(res.status).toBe(202) + expect(gh.issues).toHaveLength(1) + }) + + it('500s on a malformed TELLTALE_SENDER_SECRETS instead of throwing', async () => { + const gh = new FakeGitHub() + const env: Env = { ...makeEnv(new FakeKV()), TELLTALE_SENDER_SECRETS: '{not valid json' } + const res = await handleEvent(await post(EVENT), env, deps(gh)) + expect(res.status).toBe(500) + expect(gh.issues).toHaveLength(0) + }) +}) diff --git a/telltale/test/fakes.ts b/telltale/test/fakes.ts new file mode 100644 index 0000000..dcb87f5 --- /dev/null +++ b/telltale/test/fakes.ts @@ -0,0 +1,45 @@ +import type { GitHubClient } from '../src/github' +import type { CandidateIssue } from '../src/decide' + +/** Minimal in-memory KVNamespace stand-in. Enough for counters and TTL-less reads. */ +export class FakeKV { + store = new Map() + async get(k: string): Promise { return this.store.get(k) ?? null } + async put(k: string, v: string, _o?: { expirationTtl?: number }): Promise { this.store.set(k, v) } + async delete(k: string): Promise { this.store.delete(k) } + async list({ prefix }: { prefix: string }) { + return { keys: [...this.store.keys()].filter((k) => k.startsWith(prefix)).map((name) => ({ name })) } + } +} + +export class FakeGitHub implements GitHubClient { + issues: Array = [] + comments: Array<{ number: number; body: string }> = [] + /** Simulates GitHub silently dropping ALL labels when the token lacks push access. */ + dropLabels = false + /** Simulates a PARTIAL drop: this one label is omitted from the response, others survive. */ + dropLabel?: string + private next = 1 + + async findByLabel(_repo: string, label: string): Promise { + return this.issues.filter((i) => i.labels.includes(label)) + } + + async createIssue(_repo: string, i: { title: string; body: string; labels: string[] }) { + const labels = this.dropLabels ? [] : i.labels.filter((l) => l !== this.dropLabel) + const number = this.next++ + this.issues.push({ + number, state: 'open', stateReason: null, labels, + isPullRequest: false, title: i.title, body: i.body, + }) + // Content-based, matching the real client: a count comparison would miss + // a partial drop where another label backfills the missing slot. + return { number, url: `https://example.test/i/${number}`, labelsDropped: i.labels.some((l) => !labels.includes(l)) } + } + + async commentIssue(_repo: string, number: number, body: string) { + this.comments.push({ number, body }) + } + + async listTelltaleIssues() { return [] } +} diff --git a/telltale/test/fingerprint.test.ts b/telltale/test/fingerprint.test.ts new file mode 100644 index 0000000..6ae1bba --- /dev/null +++ b/telltale/test/fingerprint.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest' +import { normalize, fingerprint, labelFor } from '../src/fingerprint' + +describe('fingerprint', () => { + it('normalizes case, punctuation and whitespace', () => { + expect(normalize('Crash on save!')).toBe(normalize('crash on save')) + }) + + it('gives verbatim repeats the same fingerprint', async () => { + expect(await fingerprint('Crash on save!')).toBe(await fingerprint('crash on save')) + }) + + it('gives different titles different fingerprints', async () => { + expect(await fingerprint('crash on save')).not.toBe(await fingerprint('crash on load')) + }) + + it('produces exactly 16 lowercase hex chars', async () => { + expect(await fingerprint('anything at all')).toMatch(/^[0-9a-f]{16}$/) + }) + + it('builds a label well inside GitHub is 50-char limit', () => { + const label = labelFor('0123456789abcdef') + expect(label).toBe('tt:0123456789abcdef') + expect(label.length).toBeLessThanOrEqual(50) + }) +}) diff --git a/telltale/test/github.test.ts b/telltale/test/github.test.ts new file mode 100644 index 0000000..aa8e819 --- /dev/null +++ b/telltale/test/github.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest' +import { FakeGitHub } from './fakes' +import { tokenFor, restClient } from '../src/github' +import type { Env, RegistryEntry } from '../src/types' + +const env = { GITHUB_TOKEN_PRIMARY: 'tok-a', GITHUB_TOKEN_SECONDARY: 'tok-b' } as Env + +describe('tokenFor', () => { + it('selects the token by account, since a PAT cannot span two accounts', () => { + expect(tokenFor(env, { repo: 'x/y', account: 'primary', labels: [] } as RegistryEntry)).toBe('tok-a') + expect(tokenFor(env, { repo: 'x/y', account: 'secondary', labels: [] } as RegistryEntry)).toBe('tok-b') + }) +}) + +describe('createIssue label-drop detection', () => { + it('reports labelsDropped when GitHub silently discards them', async () => { + // GitHub drops `labels` on POST /issues without push access, WITHOUT an + // error. Since tt: is simultaneously the idempotency key, the dedup key and + // the read key, an undetected drop means every later report opens a fresh + // duplicate forever while the Worker reports success. + const gh = new FakeGitHub() + gh.dropLabels = true + const r = await gh.createIssue('x/y', { title: 't', body: 'b', labels: ['tt:abc'] }) + expect(r.labelsDropped).toBe(true) + }) + + it('reports no drop on the happy path', async () => { + const gh = new FakeGitHub() + const r = await gh.createIssue('x/y', { title: 't', body: 'b', labels: ['tt:abc'] }) + expect(r.labelsDropped).toBe(false) + }) + + it('detects a partial drop where other labels survive but tt: specifically is missing', async () => { + // The scenario the spec singles out as most dangerous: a total-count check + // would miss this because the returned label count never changes. + const gh = new FakeGitHub() + gh.dropLabel = 'tt:abc' + const r = await gh.createIssue('x/y', { title: 't', body: 'b', labels: ['bug', 'tt:abc'] }) + expect(r.labelsDropped).toBe(true) + }) +}) + +describe('restClient', () => { + function jsonResponse(body: unknown, status = 201): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) + } + + function captureFetch(respond: (url: string, init?: RequestInit) => Response) { + const calls: Array<{ url: string; init?: RequestInit }> = [] + const fn = (async (url: string | URL, init?: RequestInit) => { + const u = String(url) + calls.push({ url: u, init }) + return respond(u, init) + }) as typeof fetch + return { fn, calls } + } + + it('createIssue reports labelsDropped true when the response omits a requested label, and POSTs JSON with a Content-Type header', async () => { + const { fn, calls } = captureFetch(() => + jsonResponse({ number: 1, html_url: 'https://example.test/i/1', labels: [{ name: 'bug' }] }) + ) + const gh = restClient('tok', fn) + const r = await gh.createIssue('x/y', { title: 't', body: 'b', labels: ['bug', 'tt:abc'] }) + + expect(r).toEqual({ number: 1, url: 'https://example.test/i/1', labelsDropped: true }) + + // A plain string body defaults to text/plain per the Fetch spec unless the + // client sets Content-Type explicitly — GitHub would then read the POST + // body as text, not JSON. + const headers = calls[0]!.init!.headers as Record + expect(headers['Content-Type']).toBe('application/json') + }) + + it('createIssue reports labelsDropped false when the response includes every requested label', async () => { + const { fn } = captureFetch(() => + jsonResponse({ number: 2, html_url: 'https://example.test/i/2', labels: [{ name: 'bug' }, { name: 'tt:abc' }] }) + ) + const gh = restClient('tok', fn) + const r = await gh.createIssue('x/y', { title: 't', body: 'b', labels: ['bug', 'tt:abc'] }) + expect(r.labelsDropped).toBe(false) + }) + + it('findByLabel discriminates pull requests from issues and passes state_reason through untouched', async () => { + const raw = [ + { number: 10, state: 'closed', state_reason: 'not_planned', labels: [{ name: 'telltale' }] }, + { number: 11, state: 'open', labels: [{ name: 'telltale' }], pull_request: {} }, + ] + const { fn } = captureFetch(() => jsonResponse(raw)) + const gh = restClient('tok', fn) + const candidates = await gh.findByLabel('x/y', 'telltale') + + expect(candidates).toEqual([ + { number: 10, state: 'closed', stateReason: 'not_planned', labels: ['telltale'], isPullRequest: false }, + { number: 11, state: 'open', stateReason: null, labels: ['telltale'], isPullRequest: true }, + ]) + }) + + it('refuses to follow a repo-transfer redirect, naming the new location', async () => { + // Workers' default redirect:'follow' rewrites a 301'd POST into a GET, so a + // transferred repo would return an issue ARRAY where createIssue expects + // the created issue — created.labels undefined, and a stale registry entry + // surfacing as a generic github_error far from its cause. + const { fn, calls } = captureFetch(() => + new Response(null, { + status: 301, + headers: { location: 'https://api.github.com/repositories/12345/issues' }, + }) + ) + const gh = restClient('tok', fn) + await expect(gh.createIssue('old/name', { title: 't', body: 'b', labels: ['tt:abc'] })) + .rejects.toThrow(/301.*repositories\/12345\/issues/) + expect((calls[0]!.init as RequestInit).redirect).toBe('manual') + }) + + it('throws on a non-2xx response instead of returning an empty array', async () => { + // An empty array from a failed lookup reads as "no match" and would open + // a duplicate issue — this must fail loudly instead. + const { fn } = captureFetch(() => new Response('server error', { status: 500 })) + const gh = restClient('tok', fn) + await expect(gh.findByLabel('x/y', 'telltale')).rejects.toThrow() + }) +}) diff --git a/telltale/test/kv.test.ts b/telltale/test/kv.test.ts new file mode 100644 index 0000000..0b5dc3f --- /dev/null +++ b/telltale/test/kv.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest' +import { FakeKV } from './fakes' +import { hashIp, checkRateLimits, shouldComment, recordStat, readStats } from '../src/kv' + +const kv = () => new FakeKV() as unknown as KVNamespace + +describe('hashIp', () => { + it('is stable, salted, and never returns the raw IP', async () => { + const a = await hashIp('203.0.113.7', 'salt') + expect(a).toBe(await hashIp('203.0.113.7', 'salt')) + expect(a).not.toContain('203.0.113.7') + expect(a).not.toBe(await hashIp('203.0.113.7', 'other-salt')) + }) +}) + +describe('checkRateLimits', () => { + it('allows traffic under every ceiling', async () => { + const k = kv() + const r = await checkRateLimits(k, { ipHash: 'h', anonId: 'a', project: 'tenzy' }) + expect(r).toEqual({ ok: true }) + }) + + it('blocks the 11th event from one install on one IP', async () => { + const k = kv() + const args = { ipHash: 'h', anonId: 'a', project: 'tenzy' } + for (let i = 0; i < 10; i++) expect(await checkRateLimits(k, args)).toEqual({ ok: true }) + expect(await checkRateLimits(k, args)).toEqual({ ok: false, scope: 'pair' }) + }) + + it('allows many installs behind one CGNAT IP, up to the higher IP ceiling', async () => { + // Carrier-grade NAT puts thousands of mobile users behind one address, and + // four of five sender platforms are mobile. A tight per-IP cap would + // silently destroy the 21st genuine reporter on a carrier. + const k = kv() + for (let i = 0; i < 20; i++) { + expect(await checkRateLimits(k, { ipHash: 'h', anonId: `install-${i}`, project: 'tenzy' })) + .toEqual({ ok: true }) + } + }) +}) + +describe('shouldComment', () => { + it('allows the first comment then throttles within the hour', async () => { + const k = kv() + expect(await shouldComment(k, 'tenzy', 'fp1')).toBe(true) + expect(await shouldComment(k, 'tenzy', 'fp1')).toBe(false) + expect(await shouldComment(k, 'tenzy', 'fp2')).toBe(true) + }) + + it('does not let one project throttle another that shares a fingerprint', async () => { + // The fingerprint is a hash of the scrubbed title alone and KV is ONE + // namespace across all eleven projects, so "Save button does nothing" + // fingerprints identically everywhere. An unscoped key would silently + // swallow the second project's genuine recurrence comment for an hour. + const k = kv() + expect(await shouldComment(k, 'tenzy', 'shared-fp')).toBe(true) + expect(await shouldComment(k, 'hexy', 'shared-fp')).toBe(true) + // ...and each project still throttles itself. + expect(await shouldComment(k, 'hexy', 'shared-fp')).toBe(false) + }) +}) + +describe('stats', () => { + it('counts by reason so silence is diagnosable', async () => { + const k = kv() + await recordStat(k, 'accepted') + await recordStat(k, 'accepted') + await recordStat(k, 'bad_signature') + expect(await readStats(k)).toMatchObject({ accepted: 2, bad_signature: 1 }) + }) +}) diff --git a/telltale/test/live-grader.test.ts b/telltale/test/live-grader.test.ts new file mode 100644 index 0000000..a6f4748 --- /dev/null +++ b/telltale/test/live-grader.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest' +import { sign } from '../src/auth' + +/** + * The independent grader (spec §9.1), in Halyard's verify-launch spirit: it + * asserts by READING THE SINK BACK, never by trusting the Worker's own success + * report — the only way a dedup regression is caught. + * + * Gated like this repo's real-Docker integration tests: it needs live + * credentials, so it is skipped unless they are present. It targets the + * __probe__ registry entry and NEVER a product repo — an earlier design would + * have published synthetic issues into a shipped product's public tracker. + */ + +// No @types/node in this project (no runtime deps, and the rest of the +// Worker only ever sees `env` bindings, never `process`) — this is the one +// file that reads real process env vars, so it declares just enough of the +// shape to satisfy `tsc --noEmit` without pulling in a new dependency. +declare const process: { env: Record } + +const BASE = process.env.TELLTALE_BASE_URL +const SECRET = process.env.TELLTALE_PROBE_SECRET +const GH = process.env.TELLTALE_PROBE_GH_TOKEN +const REPO = process.env.TELLTALE_PROBE_REPO + +const live = BASE && SECRET && GH && REPO ? describe : describe.skip + +live('live grader', () => { + it('collapses N identical reports into exactly one issue', async () => { + const title = `grader ${crypto.randomUUID()}` + const raw = JSON.stringify({ schema_version: 1, title, body: 'synthetic', reporter: { anon_id: 'grader' } }) + const ts = String(Math.floor(Date.now() / 1000)) + const sig = await sign(SECRET!, ts, raw) + + for (let i = 0; i < 3; i++) { + const res = await fetch(`${BASE}/v1/events`, { + method: 'POST', + headers: { + 'X-Telltale-Project': '__probe__', + 'X-Telltale-Timestamp': ts, + 'X-Telltale-Signature': sig, + }, + body: raw, + }) + expect(res.status).toBe(202) + } + + // Read the sink back, not the Worker's own report. + const listed = await fetch( + `https://api.github.com/repos/${REPO}/issues?state=all&per_page=100&labels=telltale`, + { headers: { Authorization: `Bearer ${GH}`, Accept: 'application/vnd.github+json' } }, + ) + const issues = (await listed.json()) as Array<{ number: number; title: string }> + const mine = issues.filter((i) => i.title.includes(title)) + expect(mine).toHaveLength(1) + + // Clean up so a re-run tests the create path again, not the dedup path. + await fetch(`https://api.github.com/repos/${REPO}/issues/${mine[0]!.number}`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${GH}`, Accept: 'application/vnd.github+json' }, + body: JSON.stringify({ state: 'closed', state_reason: 'not_planned' }), + }) + }, 30_000) +}) diff --git a/telltale/test/read.test.ts b/telltale/test/read.test.ts new file mode 100644 index 0000000..c4c68b2 --- /dev/null +++ b/telltale/test/read.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest' +import { FakeKV } from './fakes' +import { handleIssues, handleStats, toDto } from '../src/read' +import type { Env } from '../src/types' +import type { RawIssue } from '../src/github' + +const env = () => ({ + TELLTALE_KV: new FakeKV() as unknown as KVNamespace, + OPERATOR_READ_TOKEN: 'op-token', + GITHUB_TOKEN_PRIMARY: 'a', GITHUB_TOKEN_SECONDARY: 'b', + TELLTALE_SENDER_SECRETS: '{}', IP_HASH_SALT: 's', +}) as Env + +const raw = (o: Partial = {}): RawIssue => ({ + number: 1, title: 't', body: 'b', state: 'open', + labels: [{ name: 'telltale' }, { name: 'telltale:bug' }], + assignee: null, created_at: '2026-08-01T00:00:00Z', updated_at: '2026-08-02T00:00:00Z', + html_url: 'https://example.test/1', ...o, +}) + +const req = (token?: string) => + new Request('https://t.test/v1/issues', token ? { headers: { Authorization: `Bearer ${token}` } } : undefined) + +const statsReq = (token?: string) => + new Request('https://t.test/v1/stats', token ? { headers: { Authorization: `Bearer ${token}` } } : undefined) + +describe('GET /v1/issues auth', () => { + it('401s without the operator read token', async () => { + // The Worker can read PRIVATE registry repos. An open read endpoint would + // serve every private bug-report body to anyone who guesses the hostname. + expect((await handleIssues(req(), env(), { gh: () => ({} as never) })).status).toBe(401) + expect((await handleIssues(req('wrong'), env(), { gh: () => ({} as never) })).status).toBe(401) + }) +}) + +describe('GET /v1/stats auth', () => { + it('401s without the operator read token and 200s with it', async () => { + // "Read endpoints must not be anonymous" is a binding constraint, and + // /v1/stats is the second of the two read routes. + expect((await handleStats(statsReq(), env())).status).toBe(401) + expect((await handleStats(statsReq('wrong'), env())).status).toBe(401) + expect((await handleStats(statsReq('op-token'), env())).status).toBe(200) + }) +}) + +describe('an unset OPERATOR_READ_TOKEN', () => { + it('denies both read routes rather than accepting the literal "Bearer undefined"', async () => { + // First-deploy misconfiguration: if the secret is never set, comparing + // against `Bearer ${env.OPERATOR_READ_TOKEN}` makes "Bearer undefined" a + // working password for every private bug-report body in the registry. + const unset = { ...env(), OPERATOR_READ_TOKEN: undefined as unknown as string } + expect((await handleIssues(req(), unset, { gh: () => ({} as never) })).status).toBe(401) + expect((await handleIssues(req('undefined'), unset, { gh: () => ({} as never) })).status).toBe(401) + expect((await handleStats(statsReq(), unset)).status).toBe(401) + expect((await handleStats(statsReq('undefined'), unset)).status).toBe(401) + }) +}) + +describe('toDto', () => { + it('derives kind from an explicit whitelist, not a telltale:* prefix parse', () => { + expect(toDto('x/y', 'tenzy', raw()).kind).toBe('bug') + expect(toDto('x/y', 'tenzy', raw({ labels: [{ name: 'telltale:crash' }] })).kind).toBe('crash') + // telltale:muted also matches the prefix; it is not a kind. + expect(toDto('x/y', 'tenzy', raw({ labels: [{ name: 'telltale:muted' }] })).kind).toBe('unknown') + expect(toDto('x/y', 'tenzy', raw({ labels: [{ name: 'telltale' }] })).kind).toBe('unknown') + }) + + it('exposes assignee presence, the triage signal the board gates Blocked on', () => { + expect(toDto('x/y', 'tenzy', raw()).hasAssignee).toBe(false) + expect(toDto('x/y', 'tenzy', raw({ assignee: { login: 'a' } })).hasAssignee).toBe(true) + }) + + it('carries createdIso separately from updatedIso', () => { + const d = toDto('x/y', 'tenzy', raw()) + expect(d.createdIso).toBe('2026-08-01T00:00:00Z') + expect(d.updatedIso).toBe('2026-08-02T00:00:00Z') + }) +}) + +describe('GET /issues excludes pull requests', () => { + it('drops issues carrying a pull_request key, keeping only real issues', async () => { + // GET /repos/{o}/{r}/issues returns PRs as issues. Without this filter, a + // labelled fix PR would read as an open bug report that never closes. + const gh = (entry: { repo: string }) => ({ + listTelltaleIssues: async () => { + if (!entry.repo.includes('tenzy')) return [] + return [ + raw({ number: 1 }), + raw({ number: 2, pull_request: {} }), + ] + }, + }) as never + const res = await handleIssues(req('op-token'), env(), { gh }) + const out = await res.json() as { issues: Array<{ number: number }> } + expect(out.issues.length).toBe(1) + expect(out.issues[0]?.number).toBe(1) + }) +}) + +describe('per-repo error isolation', () => { + it('returns the repos that answered plus a per-repo error list', async () => { + // Some registry repos are archived or private with broken billing. One 403 + // must not blank the whole feedback lane. + const gh = (entry: { repo: string }) => ({ + listTelltaleIssues: async () => { + if (entry.repo.includes('lineage')) throw new Error('403') + return [raw()] + }, + }) as never + const res = await handleIssues(req('op-token'), env(), { gh }) + expect(res.status).toBe(200) + const out = await res.json() as { issues: unknown[]; errors: Array<{ project: string }> } + expect(out.issues.length).toBeGreaterThan(0) + expect(out.errors.some((e) => e.project === 'lineage')).toBe(true) + }) +}) diff --git a/telltale/test/registry.test.ts b/telltale/test/registry.test.ts new file mode 100644 index 0000000..db49fd0 --- /dev/null +++ b/telltale/test/registry.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest' +import { REGISTRY, lookup } from '../src/registry' + +describe('registry', () => { + it('resolves a registered project', () => { + const e = lookup('tenzy') + expect(e).not.toBeNull() + expect(e!.repo).toMatch(/^[\w.-]+\/[\w.-]+$/) + }) + + it('returns null for an unregistered project', () => { + expect(lookup('not-a-real-project')).toBeNull() + }) + + it('never infers a repo from the slug', () => { + // A slug that is not an explicit entry must not resolve, even though it + // looks exactly like a plausible repo name. + expect(lookup('command-center')).toBeNull() + }) + + it('includes a __probe__ entry that is not a product repo', () => { + const probe = lookup('__probe__') + expect(probe).not.toBeNull() + const products = Object.entries(REGISTRY) + .filter(([k]) => k !== '__probe__') + .map(([, v]) => v.repo) + expect(products).not.toContain(probe!.repo) + }) + + it('gives every entry at least the telltale label', () => { + for (const entry of Object.values(REGISTRY)) { + expect(entry.labels).toContain('telltale') + } + }) +}) diff --git a/telltale/test/schema.test.ts b/telltale/test/schema.test.ts new file mode 100644 index 0000000..0daa6dd --- /dev/null +++ b/telltale/test/schema.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest' +import { parseEvent } from '../src/schema' + +const valid = { + schema_version: 1, + title: 'Save button does nothing', + body: 'Tapped save, nothing happened.', + release: { version: '1.4.2', surface: 'android' }, + context: { platform: 'android', os_version: '14', locale: 'en-US' }, + reporter: { anon_id: 'abc123' }, + occurred_at: '2026-08-30T18:04:11Z', +} + +describe('parseEvent', () => { + it('accepts a well-formed event', () => { + const r = parseEvent(valid) + expect(r.ok).toBe(true) + }) + + it('rejects an unknown schema_version rather than best-effort parsing', () => { + const r = parseEvent({ ...valid, schema_version: 2 }) + expect(r).toEqual({ ok: false, reason: 'schema_version' }) + }) + + it('rejects a body-level project field, which is not part of the schema', () => { + // The X-Telltale-Project header is the sole authority. A body copy would be + // a second, unvalidated identity — the exact defect round 3 found. + const r = parseEvent({ ...valid, project: 'tenzy' }) + expect(r).toEqual({ ok: false, reason: 'project_in_body' }) + }) + + it('rejects an empty or overlong title', () => { + expect(parseEvent({ ...valid, title: ' ' })).toEqual({ ok: false, reason: 'title' }) + expect(parseEvent({ ...valid, title: 'x'.repeat(121) })).toEqual({ ok: false, reason: 'title' }) + }) + + it('trims the title', () => { + const r = parseEvent({ ...valid, title: ' spaced ' }) + expect(r.ok && r.event.title).toBe('spaced') + }) + + it('truncates an overlong body with a visible marker instead of rejecting it', () => { + const r = parseEvent({ ...valid, body: 'x'.repeat(9000) }) + expect(r.ok).toBe(true) + if (!r.ok) return + expect(r.event.body.length).toBeLessThanOrEqual(8000 + 32) + expect(r.event.body).toContain('[truncated]') + }) + + it('drops unknown context keys rather than storing them', () => { + const r = parseEvent({ ...valid, context: { platform: 'ios', email: 'a@b.c' } }) + expect(r.ok).toBe(true) + if (!r.ok) return + expect(r.event.context).toEqual({ platform: 'ios' }) + expect(JSON.stringify(r.event)).not.toContain('a@b.c') + }) + + it('rejects an unknown release surface', () => { + const r = parseEvent({ ...valid, release: { version: '1.0.0', surface: 'watch' } }) + expect(r).toEqual({ ok: false, reason: 'release.surface' }) + }) + + it('rejects an overlong anon_id', () => { + const r = parseEvent({ ...valid, reporter: { anon_id: 'x'.repeat(65) } }) + expect(r).toEqual({ ok: false, reason: 'reporter.anon_id' }) + }) +}) diff --git a/telltale/test/scrub.test.ts b/telltale/test/scrub.test.ts new file mode 100644 index 0000000..9fede10 --- /dev/null +++ b/telltale/test/scrub.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest' +import { scrubTitle, scrubBody } from '../src/scrub' + +describe('scrub', () => { + it('redacts an email from the body', () => { + expect(scrubBody('reach me at alex@example.com ok')) + .toBe('reach me at [redacted:email] ok') + }) + + it('redacts an email from the TITLE too', () => { + // The title is the most visible, most indexed, most notification-carrying + // field in the system. An earlier draft scrubbed only the body. + expect(scrubTitle('crash when alex@example.com logs in')) + .toBe('crash when [redacted:email] logs in') + }) + + it('redacts phone numbers in both fields', () => { + expect(scrubBody('call +1 415 555 0132')).toContain('[redacted:phone]') + expect(scrubTitle('call 415-555-0132 please')).toContain('[redacted:phone]') + }) + + it('redacts a long digit run in the body', () => { + expect(scrubBody('card 4111111111111111 declined')) + .toBe('card [redacted:number] declined') + }) + + it('does NOT apply the long-digit rule to titles', () => { + // On a 120-char title the rule is all false positives. + expect(scrubTitle('build 4111111111111111 fails')).toBe('build 4111111111111111 fails') + }) + + it('leaves a version string that looks phone-shaped alone', () => { + expect(scrubBody('broke in 1.4.2.0 build 20260830')).toBe('broke in 1.4.2.0 build 20260830') + }) + + it('leaves a crash digest stack address alone', () => { + // Digit-run redaction must not destroy the data triage needs. Hex addresses + // and short frame offsets carry no digit run of 12+. + const digest = 'at 0x00007ff8 in frame 42 (offset 1024)' + expect(scrubBody(digest)).toBe(digest) + }) + + it('is idempotent, so a re-scrub does not mangle a marker', () => { + const once = scrubBody('mail alex@example.com') + expect(scrubBody(once)).toBe(once) + }) +}) diff --git a/telltale/tsconfig.json b/telltale/tsconfig.json new file mode 100644 index 0000000..2ab56a6 --- /dev/null +++ b/telltale/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ES2022", + "moduleResolution": "bundler", + "types": ["@cloudflare/workers-types", "vitest/globals"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/telltale/vitest.config.ts b/telltale/vitest.config.ts new file mode 100644 index 0000000..f93b908 --- /dev/null +++ b/telltale/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config' + +// Node environment, not jsdom: this is a Worker, there is no DOM. Node 20 provides +// the same WebCrypto globals (crypto.subtle) the Workers runtime does, so the pure +// modules and the crypto ones both run unmodified here. +export default defineConfig({ + test: { + environment: 'node', + globals: true, + include: ['test/**/*.test.ts'], + }, +}) diff --git a/telltale/wrangler.toml b/telltale/wrangler.toml new file mode 100644 index 0000000..b42d10f --- /dev/null +++ b/telltale/wrangler.toml @@ -0,0 +1,9 @@ +name = "telltale" +main = "src/index.ts" +compatibility_date = "2026-08-30" + +# Rate-limit counters, comment throttles, and stats. Approximate by design — +# see spec §4.2 for why KV is correct here and was not for the deleted dedup gate. +[[kv_namespaces]] +binding = "TELLTALE_KV" +id = "REPLACE_ME_AFTER_wrangler_kv_namespace_create"