diff --git a/CLAUDE.md b/CLAUDE.md
index 5ca650e..22e9b75 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -13,7 +13,10 @@ Telegram bot that forwards [status.claude.com](https://status.claude.com/) (Atla
- `npx wrangler deploy --dry-run` — Verify build without deploying
- `node scripts/setup-bot.js` — One-time: register bot commands + set Telegram webhook (interactive prompts)
-No test framework configured yet. No linter configured.
+- `npm test` — Run tests (vitest + @cloudflare/vitest-pool-workers, runs in Workers runtime)
+- `npm run test:watch` — Run tests in watch mode
+
+No linter configured.
## Secrets (set via `wrangler secret put`)
diff --git a/docs/feature-decisions.md b/docs/feature-decisions.md
index b4efcbb..4aa5e34 100644
--- a/docs/feature-decisions.md
+++ b/docs/feature-decisions.md
@@ -78,7 +78,23 @@ Ordered by likelihood of future implementation (top = most likely to revisit).
**Why this rank**: Out of scope. The bot is the product — adding a web frontend changes the project's nature.
-### 10. Digest / Quiet Mode
+### 10. Dead Letter Queue for Failed Messages
+
+**Idea**: After CF Queues exhausts 3 retries, persist failed messages to KV or a dedicated DLQ for debugging.
+
+**Decision**: Skip. CF Workers already logs all queue consumer errors (including final retry failures) via the observability config. With 100% log sampling and persisted invocation logs, failed messages are visible in the Cloudflare Dashboard. Adding a KV-based DLQ introduces write overhead on every failure and cleanup logic for stale entries — not worth it when logs already provide the same visibility.
+
+**Why this rank**: Logging is sufficient for current scale. Revisit only if log retention (3-day free tier) is too short for debugging patterns.
+
+### 11. KV List Scalability (Subscriber Sharding)
+
+**Idea**: Shard subscriber keys by event type (e.g., `sub:incident:{chatId}`, `sub:component:{chatId}`) to avoid listing all subscribers on every webhook.
+
+**Decision**: Skip. Current `kv.list({ prefix: "sub:" })` pagination works for hundreds of subscribers. Sharding requires a KV schema migration, dual-write logic during transition, and doubles storage for subscribers who want both types. Not justified until `kv.list()` latency or cost becomes measurable.
+
+**Why this rank**: Clear trigger: slow webhook response times at high subscriber counts. Migration path is straightforward when needed.
+
+### 12. Digest / Quiet Mode
**Idea**: Batch notifications into a daily summary instead of instant alerts.
diff --git a/docs/system-architecture.md b/docs/system-architecture.md
index 71fb3bd..8bb9afa 100644
--- a/docs/system-architecture.md
+++ b/docs/system-architecture.md
@@ -56,7 +56,7 @@ A middleware in `index.js` normalizes double slashes in URL paths (Statuspage oc
| File | Lines | Responsibility |
|------|-------|---------------|
| `index.js` | ~30 | Hono router, path normalization middleware, export handlers |
-| `bot-commands.js` | ~145 | `/start`, `/stop`, `/subscribe` — subscription management |
+| `bot-commands.js` | ~155 | `/start`, `/stop`, `/subscribe` — subscription management (cached Bot instance) |
| `bot-info-commands.js` | ~125 | `/help`, `/status`, `/history`, `/uptime` — read-only info |
| `statuspage-webhook.js` | ~85 | Webhook validation, event parsing, subscriber fan-out |
| `queue-consumer.js` | ~65 | Batch message delivery, retry/removal logic |
@@ -94,6 +94,7 @@ Binding: `claude-status` queue
- **Batch size**: 30 messages per consumer invocation
- **Max retries**: 3 (configured in `wrangler.jsonc`)
- **429 handling**: `msg.retry()` with CF Queues backoff; `Retry-After` header logged
+- **5xx handling**: `msg.retry()` for transient Telegram server errors
- **403/400 handling**: subscriber removed from KV, message acknowledged
- **Network errors**: `msg.retry()` for transient failures
@@ -108,6 +109,7 @@ Enabled via `wrangler.jsonc` `observability` config. Automatic — no code chang
## Security
+- **Statuspage webhook always-200**: Handler always returns HTTP 200 (even on errors) to prevent Statuspage from removing the webhook subscription. Errors are logged, not surfaced as HTTP status codes.
- **Statuspage webhook auth**: URL path secret validated with timing-safe SHA-256 comparison
- **Telegram webhook**: Registered via `setup-bot.js` — Telegram only sends to the registered URL
- **No secrets in code**: `BOT_TOKEN` and `WEBHOOK_SECRET` stored as Cloudflare secrets
diff --git a/package-lock.json b/package-lock.json
index 9aafe82..e8c67e0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,8 @@
"hono": "^4.12.12"
},
"devDependencies": {
+ "@cloudflare/vitest-pool-workers": "^0.14.2",
+ "vitest": "^4.1.3",
"wrangler": "^4.81.0"
}
},
@@ -42,6 +44,25 @@
}
}
},
+ "node_modules/@cloudflare/vitest-pool-workers": {
+ "version": "0.14.2",
+ "resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.14.2.tgz",
+ "integrity": "sha512-LM91FyE/cW8ttUEYTaYZyCzcP/aD5PRAsUUIvfq07xEfpRbooAdF7v+nnDbsJq/gncuWNpPPl0rlNWX4vZleBw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cjs-module-lexer": "^1.2.3",
+ "esbuild": "0.27.3",
+ "miniflare": "4.20260405.0",
+ "wrangler": "4.81.0",
+ "zod": "^3.25.76"
+ },
+ "peerDependencies": {
+ "@vitest/runner": "^4.1.0",
+ "@vitest/snapshot": "^4.1.0",
+ "vitest": "^4.1.0"
+ }
+ },
"node_modules/@cloudflare/workerd-darwin-64": {
"version": "1.20260405.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260405.1.tgz",
@@ -140,6 +161,18 @@
"node": ">=12"
}
},
+ "node_modules/@emnapi/core": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
+ "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.0",
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@emnapi/runtime": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
@@ -151,6 +184,17 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
+ "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
@@ -1117,6 +1161,35 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
+ "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.123.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz",
+ "integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
"node_modules/@poppinss/colors": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
@@ -1146,6 +1219,281 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.0.0-rc.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz",
+ "integrity": "sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.0.0-rc.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.13.tgz",
+ "integrity": "sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.0.0-rc.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.13.tgz",
+ "integrity": "sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.0.0-rc.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.13.tgz",
+ "integrity": "sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.0-rc.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.13.tgz",
+ "integrity": "sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.0-rc.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.13.tgz",
+ "integrity": "sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.0-rc.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.13.tgz",
+ "integrity": "sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.0.0-rc.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.13.tgz",
+ "integrity": "sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.0.0-rc.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.13.tgz",
+ "integrity": "sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.0-rc.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz",
+ "integrity": "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.0-rc.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz",
+ "integrity": "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.0-rc.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.13.tgz",
+ "integrity": "sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.0-rc.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.13.tgz",
+ "integrity": "sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.9.1",
+ "@emnapi/runtime": "1.9.1",
+ "@napi-rs/wasm-runtime": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
+ "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.0-rc.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.13.tgz",
+ "integrity": "sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.0-rc.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.13.tgz",
+ "integrity": "sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
+ "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@sindresorhus/is": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
@@ -1166,6 +1514,162 @@
"dev": true,
"license": "CC0-1.0"
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+ "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.3.tgz",
+ "integrity": "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.3",
+ "@vitest/utils": "4.1.3",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz",
+ "integrity": "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.3",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.3.tgz",
+ "integrity": "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.3.tgz",
+ "integrity": "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.3",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.3.tgz",
+ "integrity": "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.3",
+ "@vitest/utils": "4.1.3",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.3.tgz",
+ "integrity": "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.3.tgz",
+ "integrity": "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.3",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -1178,6 +1682,16 @@
"node": ">=6.5"
}
},
+ "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",
@@ -1185,6 +1699,30 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/cjs-module-lexer": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz",
+ "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
@@ -1236,6 +1774,13 @@
"url": "https://github.com/sponsors/antfu"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
+ "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
@@ -1278,6 +1823,16 @@
"@esbuild/win32-x64": "0.27.3"
}
},
+ "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/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
@@ -1287,6 +1842,34 @@
"node": ">=6"
}
},
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1336,6 +1919,277 @@
"node": ">=6"
}
},
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
"node_modules/miniflare": {
"version": "4.20260405.0",
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260405.0.tgz",
@@ -1363,6 +2217,25 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "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/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -1383,6 +2256,17 @@
}
}
},
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "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",
@@ -1397,6 +2281,89 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.9",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
+ "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
+ "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.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.0.0-rc.13",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.13.tgz",
+ "integrity": "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.123.0",
+ "@rolldown/pluginutils": "1.0.0-rc.13"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.0.0-rc.13",
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.13",
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.13",
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.13",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13",
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13",
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13",
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.13",
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.13",
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.13",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13",
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13"
+ }
+ },
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -1455,6 +2422,37 @@
"@img/sharp-win32-x64": "0.34.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/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
+ "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/supports-color": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
@@ -1468,6 +2466,50 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
+ "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -1502,6 +2544,174 @@
"pathe": "^2.0.3"
}
},
+ "node_modules/vite": {
+ "version": "8.0.7",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz",
+ "integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.8",
+ "rolldown": "1.0.0-rc.13",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.1.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.3.tgz",
+ "integrity": "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.3",
+ "@vitest/mocker": "4.1.3",
+ "@vitest/pretty-format": "4.1.3",
+ "@vitest/runner": "4.1.3",
+ "@vitest/snapshot": "4.1.3",
+ "@vitest/spy": "4.1.3",
+ "@vitest/utils": "4.1.3",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.3",
+ "@vitest/browser-preview": "4.1.3",
+ "@vitest/browser-webdriverio": "4.1.3",
+ "@vitest/coverage-istanbul": "4.1.3",
+ "@vitest/coverage-v8": "4.1.3",
+ "@vitest/ui": "4.1.3",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@@ -1518,6 +2728,23 @@
"webidl-conversions": "^3.0.0"
}
},
+ "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.20260405.1",
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260405.1.tgz",
@@ -1620,6 +2847,16 @@
"@poppinss/exception": "^1.2.2",
"error-stack-parser-es": "^1.0.5"
}
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
}
}
}
diff --git a/package.json b/package.json
index ef65ebd..0d74e13 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,9 @@
"type": "module",
"scripts": {
"dev": "wrangler dev",
- "deploy": "wrangler deploy"
+ "deploy": "wrangler deploy",
+ "test": "vitest run",
+ "test:watch": "vitest"
},
"repository": {
"type": "git",
@@ -24,6 +26,8 @@
"hono": "^4.12.12"
},
"devDependencies": {
+ "@cloudflare/vitest-pool-workers": "^0.14.2",
+ "vitest": "^4.1.3",
"wrangler": "^4.81.0"
}
}
diff --git a/src/bot-commands.js b/src/bot-commands.js
index ba0112c..fad2a79 100644
--- a/src/bot-commands.js
+++ b/src/bot-commands.js
@@ -8,6 +8,13 @@ import {
} from "./kv-store.js";
import { fetchComponentByName, escapeHtml } from "./status-fetcher.js";
import { registerInfoCommands } from "./bot-info-commands.js";
+
+/**
+ * Module-level KV reference, updated each request.
+ * Safe because CF Workers are single-threaded per isolate.
+ */
+let kv = null;
+
/**
* Extract chatId and threadId from grammY context
*/
@@ -19,11 +26,10 @@ function getChatTarget(ctx) {
}
/**
- * Handle incoming Telegram webhook via grammY
+ * Create Bot with all commands registered. Called once per isolate.
*/
-export async function handleTelegramWebhook(c) {
- const bot = new Bot(c.env.BOT_TOKEN);
- const kv = c.env.claude_status;
+function createBot(token) {
+ const bot = new Bot(token);
bot.command("start", async (ctx) => {
const { chatId, threadId } = getChatTarget(ctx);
@@ -140,6 +146,29 @@ export async function handleTelegramWebhook(c) {
);
});
- const handler = webhookCallback(bot, "cloudflare-mod");
- return handler(c.req.raw);
+ return bot;
+}
+
+/**
+ * Cached Bot instance — avoids rebuilding middleware chain on every request.
+ * CF Workers reuse isolates, so module-level state persists across requests.
+ */
+let cachedBot = null;
+let cachedToken = null;
+let cachedHandler = null;
+
+/**
+ * Handle incoming Telegram webhook via grammY
+ */
+export async function handleTelegramWebhook(c) {
+ // Update module-level KV ref (same binding across requests, but kept explicit)
+ kv = c.env.claude_status;
+
+ if (!cachedBot || cachedToken !== c.env.BOT_TOKEN) {
+ cachedBot = createBot(c.env.BOT_TOKEN);
+ cachedToken = c.env.BOT_TOKEN;
+ cachedHandler = webhookCallback(cachedBot, "cloudflare-mod");
+ }
+
+ return cachedHandler(c.req.raw);
}
diff --git a/src/kv-store.js b/src/kv-store.js
index f8eb65e..3cd8415 100644
--- a/src/kv-store.js
+++ b/src/kv-store.js
@@ -17,7 +17,7 @@ function parseKvKey(kvKey) {
const lastColon = raw.lastIndexOf(":");
// No colon or only negative sign prefix — no threadId
if (lastColon <= 0) {
- return { chatId: raw, threadId: null };
+ return { chatId: Number(raw), threadId: null };
}
// Check if the part after last colon is a valid threadId (numeric)
const possibleThread = raw.slice(lastColon + 1);
diff --git a/src/queue-consumer.js b/src/queue-consumer.js
index 1527e88..0f564c5 100644
--- a/src/queue-consumer.js
+++ b/src/queue-consumer.js
@@ -46,6 +46,10 @@ export async function handleQueue(batch, env) {
console.log(`Queue: rate limited for ${chatId}, Retry-After: ${retryAfter ?? "unknown"}`);
retried++;
msg.retry();
+ } else if (res.status >= 500) {
+ console.error(`Queue: Telegram 5xx (${res.status}) for ${chatId}, retrying`);
+ retried++;
+ msg.retry();
} else {
console.error(`Queue: unexpected HTTP ${res.status} for ${chatId}`);
failed++;
diff --git a/src/statuspage-webhook.js b/src/statuspage-webhook.js
index 70e888f..44add6f 100644
--- a/src/statuspage-webhook.js
+++ b/src/statuspage-webhook.js
@@ -34,53 +34,73 @@ function formatComponentMessage(component, update) {
}
/**
- * Handle incoming Statuspage webhook
+ * Handle incoming Statuspage webhook.
+ * CRITICAL: Always return 200 — Statuspage removes subscriber webhooks on non-2xx responses.
*/
export async function handleStatuspageWebhook(c) {
- // Validate URL secret (timing-safe)
- const secret = c.req.param("secret");
- if (!await timingSafeEqual(secret, c.env.WEBHOOK_SECRET)) {
- return c.text("Unauthorized", 401);
- }
-
- // Parse body
- let body;
try {
- body = await c.req.json();
- } catch {
- return c.text("Bad Request", 400);
+ // Validate URL secret (timing-safe)
+ const secret = c.req.param("secret");
+ if (!await timingSafeEqual(secret, c.env.WEBHOOK_SECRET)) {
+ console.error("Statuspage webhook: invalid secret");
+ return c.text("OK", 200);
+ }
+
+ // Parse body
+ let body;
+ try {
+ body = await c.req.json();
+ } catch {
+ console.error("Statuspage webhook: invalid JSON body");
+ return c.text("OK", 200);
+ }
+
+ const eventType = body?.meta?.event_type;
+ if (!eventType) {
+ console.error("Statuspage webhook: missing event_type");
+ return c.text("OK", 200);
+ }
+
+ console.log(`Statuspage webhook: ${eventType}`);
+
+ // Determine category and format message
+ let category, html, componentName;
+ if (eventType.startsWith("incident.")) {
+ if (!body.incident) {
+ console.error("Statuspage webhook: incident event missing incident data");
+ return c.text("OK", 200);
+ }
+ category = "incident";
+ html = formatIncidentMessage(body.incident);
+ } else if (eventType.startsWith("component.")) {
+ if (!body.component) {
+ console.error("Statuspage webhook: component event missing component data");
+ return c.text("OK", 200);
+ }
+ category = "component";
+ componentName = body.component.name || null;
+ html = formatComponentMessage(body.component, body.component_update);
+ } else {
+ console.error(`Statuspage webhook: unknown event type ${eventType}`);
+ return c.text("OK", 200);
+ }
+
+ // Get filtered subscribers (with component name filtering)
+ const subscribers = await getSubscribersByType(c.env.claude_status, category, componentName);
+
+ // Enqueue messages for fan-out via CF Queues (batch for performance)
+ const messages = subscribers.map(({ chatId, threadId }) => ({
+ body: { chatId, threadId, html },
+ }));
+ for (let i = 0; i < messages.length; i += 100) {
+ await c.env["claude-status"].sendBatch(messages.slice(i, i + 100));
+ }
+
+ console.log(`Enqueued ${messages.length} messages for ${category}${componentName ? `:${componentName}` : ""}`);
+ return c.text("OK", 200);
+ } catch (err) {
+ // Catch-all: log error but still return 200 to prevent Statuspage from removing us
+ console.error("Statuspage webhook: unexpected error", err);
+ return c.text("OK", 200);
}
-
- const eventType = body?.meta?.event_type;
- if (!eventType) return c.text("Bad Request", 400);
-
- console.log(`Statuspage webhook: ${eventType}`);
-
- // Determine category and format message
- let category, html, componentName;
- if (eventType.startsWith("incident.")) {
- category = "incident";
- html = formatIncidentMessage(body.incident);
- } else if (eventType.startsWith("component.")) {
- category = "component";
- componentName = body.component?.name || null;
- html = formatComponentMessage(body.component, body.component_update);
- } else {
- return c.text("Unknown event type", 400);
- }
-
- // Get filtered subscribers (with component name filtering)
- const subscribers = await getSubscribersByType(c.env.claude_status, category, componentName);
-
- // Enqueue messages for fan-out via CF Queues (batch for performance)
- const messages = subscribers.map(({ chatId, threadId }) => ({
- body: { chatId, threadId, html },
- }));
- for (let i = 0; i < messages.length; i += 100) {
- await c.env["claude-status"].sendBatch(messages.slice(i, i + 100));
- }
-
- console.log(`Enqueued ${messages.length} messages for ${category}${componentName ? `:${componentName}` : ""}`);
-
- return c.text("OK", 200);
}
diff --git a/test/crypto-utils.test.js b/test/crypto-utils.test.js
new file mode 100644
index 0000000..c36a7f6
--- /dev/null
+++ b/test/crypto-utils.test.js
@@ -0,0 +1,20 @@
+import { describe, it, expect } from "vitest";
+import { timingSafeEqual } from "../src/crypto-utils.js";
+
+describe("timingSafeEqual", () => {
+ it("returns true for identical strings", async () => {
+ expect(await timingSafeEqual("secret123", "secret123")).toBe(true);
+ });
+
+ it("returns false for different strings", async () => {
+ expect(await timingSafeEqual("secret123", "wrong")).toBe(false);
+ });
+
+ it("returns false for empty vs non-empty", async () => {
+ expect(await timingSafeEqual("", "something")).toBe(false);
+ });
+
+ it("returns true for both empty", async () => {
+ expect(await timingSafeEqual("", "")).toBe(true);
+ });
+});
diff --git a/test/kv-store.test.js b/test/kv-store.test.js
new file mode 100644
index 0000000..b468e06
--- /dev/null
+++ b/test/kv-store.test.js
@@ -0,0 +1,124 @@
+import { describe, it, expect } from "vitest";
+import { env } from "cloudflare:test";
+import {
+ addSubscriber,
+ removeSubscriber,
+ getSubscriber,
+ updateSubscriberTypes,
+ updateSubscriberComponents,
+ getSubscribersByType,
+} from "../src/kv-store.js";
+
+// Each test uses unique chatIds to avoid cross-test interference (miniflare KV persists across tests)
+describe("kv-store", () => {
+ const kv = env.claude_status;
+
+ describe("addSubscriber / getSubscriber", () => {
+ it("adds subscriber with default types", async () => {
+ await addSubscriber(kv, 100, null);
+ const sub = await getSubscriber(kv, 100, null);
+ expect(sub).toEqual({ types: ["incident", "component"], components: [] });
+ });
+
+ it("adds subscriber with threadId", async () => {
+ await addSubscriber(kv, 101, 456);
+ const sub = await getSubscriber(kv, 101, 456);
+ expect(sub).toEqual({ types: ["incident", "component"], components: [] });
+ });
+
+ it("handles threadId=0 (General topic)", async () => {
+ await addSubscriber(kv, 102, 0);
+ const sub = await getSubscriber(kv, 102, 0);
+ expect(sub).toEqual({ types: ["incident", "component"], components: [] });
+ });
+
+ it("preserves existing data on re-subscribe", async () => {
+ await addSubscriber(kv, 103, null);
+ await updateSubscriberTypes(kv, 103, null, ["incident"]);
+ await addSubscriber(kv, 103, null);
+ const sub = await getSubscriber(kv, 103, null);
+ expect(sub.types).toEqual(["incident"]);
+ });
+ });
+
+ describe("removeSubscriber", () => {
+ it("removes existing subscriber", async () => {
+ await addSubscriber(kv, 200, null);
+ await removeSubscriber(kv, 200, null);
+ const sub = await getSubscriber(kv, 200, null);
+ expect(sub).toBeNull();
+ });
+ });
+
+ describe("updateSubscriberTypes", () => {
+ it("updates types for existing subscriber", async () => {
+ await addSubscriber(kv, 300, null);
+ const result = await updateSubscriberTypes(kv, 300, null, ["incident"]);
+ expect(result).toBe(true);
+ const sub = await getSubscriber(kv, 300, null);
+ expect(sub.types).toEqual(["incident"]);
+ });
+
+ it("returns false for non-existent subscriber", async () => {
+ const result = await updateSubscriberTypes(kv, 99999, null, ["incident"]);
+ expect(result).toBe(false);
+ });
+ });
+
+ describe("updateSubscriberComponents", () => {
+ it("sets component filter", async () => {
+ await addSubscriber(kv, 400, null);
+ await updateSubscriberComponents(kv, 400, null, ["API"]);
+ const sub = await getSubscriber(kv, 400, null);
+ expect(sub.components).toEqual(["API"]);
+ });
+ });
+
+ describe("getSubscribersByType", () => {
+ it("filters by event type", async () => {
+ // Use unique IDs unlikely to collide with other tests
+ await addSubscriber(kv, 50001, null);
+ await updateSubscriberTypes(kv, 50001, null, ["incident"]);
+ await addSubscriber(kv, 50002, null);
+ await updateSubscriberTypes(kv, 50002, null, ["component"]);
+
+ const incident = await getSubscribersByType(kv, "incident");
+ const incidentIds = incident.map((s) => s.chatId);
+ expect(incidentIds).toContain(50001);
+ expect(incidentIds).not.toContain(50002);
+
+ const component = await getSubscribersByType(kv, "component");
+ const componentIds = component.map((s) => s.chatId);
+ expect(componentIds).toContain(50002);
+ expect(componentIds).not.toContain(50001);
+ });
+
+ it("filters by component name", async () => {
+ await addSubscriber(kv, 60001, null);
+ await updateSubscriberComponents(kv, 60001, null, ["API"]);
+ await addSubscriber(kv, 60002, null); // no component filter = all
+
+ const results = await getSubscribersByType(kv, "component", "API");
+ const ids = results.map((s) => s.chatId);
+ expect(ids).toContain(60001);
+ expect(ids).toContain(60002);
+ });
+
+ it("excludes non-matching component filter", async () => {
+ await addSubscriber(kv, 70001, null);
+ await updateSubscriberComponents(kv, 70001, null, ["Console"]);
+
+ const results = await getSubscribersByType(kv, "component", "API");
+ const ids = results.map((s) => s.chatId);
+ expect(ids).not.toContain(70001);
+ });
+
+ it("returns chatId as number", async () => {
+ await addSubscriber(kv, 80001, null);
+ const results = await getSubscribersByType(kv, "incident");
+ const match = results.find((s) => s.chatId === 80001);
+ expect(match).toBeDefined();
+ expect(typeof match.chatId).toBe("number");
+ });
+ });
+});
diff --git a/test/queue-consumer.test.js b/test/queue-consumer.test.js
new file mode 100644
index 0000000..a82d919
--- /dev/null
+++ b/test/queue-consumer.test.js
@@ -0,0 +1,79 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { handleQueue } from "../src/queue-consumer.js";
+
+/**
+ * Create a mock queue message with ack/retry tracking
+ */
+function mockMessage(body) {
+ return {
+ body,
+ ack: vi.fn(),
+ retry: vi.fn(),
+ };
+}
+
+describe("handleQueue", () => {
+ let env;
+
+ beforeEach(() => {
+ env = {
+ BOT_TOKEN: "test-token",
+ claude_status: {
+ delete: vi.fn(),
+ },
+ };
+ vi.restoreAllMocks();
+ });
+
+ it("acks on successful send", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, status: 200 }));
+ const msg = mockMessage({ chatId: 123, html: "test" });
+ await handleQueue({ messages: [msg] }, env);
+ expect(msg.ack).toHaveBeenCalled();
+ expect(msg.retry).not.toHaveBeenCalled();
+ });
+
+ it("removes subscriber and acks on 403", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 403 }));
+ const msg = mockMessage({ chatId: 123, threadId: null, html: "test" });
+ await handleQueue({ messages: [msg] }, env);
+ expect(msg.ack).toHaveBeenCalled();
+ expect(env.claude_status.delete).toHaveBeenCalled();
+ });
+
+ it("retries on 429 rate limit", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({
+ ok: false,
+ status: 429,
+ headers: new Headers({ "Retry-After": "5" }),
+ })
+ );
+ const msg = mockMessage({ chatId: 123, html: "test" });
+ await handleQueue({ messages: [msg] }, env);
+ expect(msg.retry).toHaveBeenCalled();
+ expect(msg.ack).not.toHaveBeenCalled();
+ });
+
+ it("retries on 5xx server error", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 502 }));
+ const msg = mockMessage({ chatId: 123, html: "test" });
+ await handleQueue({ messages: [msg] }, env);
+ expect(msg.retry).toHaveBeenCalled();
+ expect(msg.ack).not.toHaveBeenCalled();
+ });
+
+ it("retries on network error", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network fail")));
+ const msg = mockMessage({ chatId: 123, html: "test" });
+ await handleQueue({ messages: [msg] }, env);
+ expect(msg.retry).toHaveBeenCalled();
+ });
+
+ it("skips malformed messages", async () => {
+ const msg = mockMessage({ chatId: null, html: null });
+ await handleQueue({ messages: [msg] }, env);
+ expect(msg.ack).toHaveBeenCalled();
+ });
+});
diff --git a/test/status-fetcher.test.js b/test/status-fetcher.test.js
new file mode 100644
index 0000000..f11d2c3
--- /dev/null
+++ b/test/status-fetcher.test.js
@@ -0,0 +1,63 @@
+import { describe, it, expect } from "vitest";
+import {
+ escapeHtml,
+ humanizeStatus,
+ statusIndicator,
+ formatComponentLine,
+ formatOverallStatus,
+} from "../src/status-fetcher.js";
+
+describe("escapeHtml", () => {
+ it("escapes HTML special chars", () => {
+ expect(escapeHtml('')).toBe(
+ "<script>"alert&"</script>"
+ );
+ });
+
+ it("returns empty string for null/undefined", () => {
+ expect(escapeHtml(null)).toBe("");
+ expect(escapeHtml(undefined)).toBe("");
+ });
+});
+
+describe("humanizeStatus", () => {
+ it("maps known statuses", () => {
+ expect(humanizeStatus("operational")).toBe("Operational");
+ expect(humanizeStatus("major_outage")).toBe("Major Outage");
+ expect(humanizeStatus("resolved")).toBe("Resolved");
+ });
+
+ it("returns raw string for unknown status", () => {
+ expect(humanizeStatus("custom_status")).toBe("custom_status");
+ });
+});
+
+describe("statusIndicator", () => {
+ it("returns green check for operational", () => {
+ expect(statusIndicator("operational")).toBe("\u2705");
+ });
+
+ it("returns question mark for unknown", () => {
+ expect(statusIndicator("unknown_status")).toBe("\u2753");
+ });
+});
+
+describe("formatComponentLine", () => {
+ it("formats component with indicator and escaped name", () => {
+ const line = formatComponentLine({ name: "API", status: "operational" });
+ expect(line).toContain("\u2705");
+ expect(line).toContain("API");
+ expect(line).toContain("Operational");
+ });
+});
+
+describe("formatOverallStatus", () => {
+ it("maps known indicators", () => {
+ expect(formatOverallStatus("none")).toContain("All Systems Operational");
+ expect(formatOverallStatus("critical")).toContain("Critical System Outage");
+ });
+
+ it("returns raw value for unknown indicator", () => {
+ expect(formatOverallStatus("custom")).toBe("custom");
+ });
+});
diff --git a/vitest.config.js b/vitest.config.js
new file mode 100644
index 0000000..e9f4a8d
--- /dev/null
+++ b/vitest.config.js
@@ -0,0 +1,22 @@
+import { defineConfig } from "vitest/config";
+import { cloudflarePool, cloudflareTest } from "@cloudflare/vitest-pool-workers";
+
+export default defineConfig({
+ plugins: [
+ cloudflareTest({
+ wrangler: { configPath: "./wrangler.jsonc" },
+ miniflare: {
+ // Override remote KV with local-only for tests
+ kvNamespaces: ["claude_status"],
+ },
+ }),
+ ],
+ test: {
+ pool: cloudflarePool({
+ wrangler: { configPath: "./wrangler.jsonc" },
+ miniflare: {
+ kvNamespaces: ["claude_status"],
+ },
+ }),
+ },
+});
diff --git a/wrangler.jsonc b/wrangler.jsonc
index 1987f49..4c6e38b 100644
--- a/wrangler.jsonc
+++ b/wrangler.jsonc
@@ -25,7 +25,7 @@
]
},
"observability": {
- "enabled": false,
+ "enabled": true,
"head_sampling_rate": 1,
"logs": {
"enabled": true,