From dfd839c521f56e4bc818457a6f67eb8a9eddafcf Mon Sep 17 00:00:00 2001 From: Kevin Dethlefs Date: Tue, 10 Nov 2026 21:31:25 -0600 Subject: [PATCH 1/2] Add the Twitch EBS as a Cloudflare Worker (ebs-worker) Port the ASP.NET Extension Backend Service to a Hono + TypeScript Worker backed by D1, keeping the HTTP contract (routes, status codes, headers, problem+json, limits, CORS) so the desktop app and extension are unchanged. - D1 schema replaces ebs.db; Twitch tokens are AES-GCM encrypted with TOKEN_ENCRYPTION_KEY - OAuth sessions and one-time codes move from memory to D1 - Cron triggers replace the prune, token refresh and credential check background services - Rate Limiting bindings for per-IP limits, a D1 counter for per-channel limits - Compensating re-checks replace per-channel locks so no snapshot outlives its credential - 128 vitest tests run the real SQL against node:sqlite - CI workflow (deploy gated on repo secrets), Dependabot entry, README, AGENTS.md row The .NET service is left in place until cutover is done. Co-Authored-By: Claude Sonnet 5.5 --- .github/dependabot.yml | 9 + .github/workflows/ebs-worker.yml | 115 ++ AGENTS.md | 3 +- ebs-worker/.dev.vars.example | 13 + ebs-worker/.gitignore | 6 + ebs-worker/README.md | 132 ++ ebs-worker/migrations/0001_init.sql | 49 + ebs-worker/package-lock.json | 2766 +++++++++++++++++++++++++++ ebs-worker/package.json | 25 + ebs-worker/src/app.ts | 270 +++ ebs-worker/src/clearing.ts | 24 + ebs-worker/src/config.ts | 162 ++ ebs-worker/src/context.ts | 15 + ebs-worker/src/crypto.ts | 99 + ebs-worker/src/db/pending.ts | 82 + ebs-worker/src/db/ratelimit.ts | 38 + ebs-worker/src/db/state.ts | 47 + ebs-worker/src/db/status.ts | 48 + ebs-worker/src/db/tokens.ts | 143 ++ ebs-worker/src/env.ts | 37 + ebs-worker/src/http.ts | 60 + ebs-worker/src/index.ts | 12 + ebs-worker/src/routes/oauth.ts | 234 +++ ebs-worker/src/scheduled.ts | 84 + ebs-worker/src/twitch/jwt.ts | 33 + ebs-worker/src/twitch/oauth.ts | 107 ++ ebs-worker/src/twitch/probe.ts | 76 + ebs-worker/src/twitch/pubsub.ts | 53 + ebs-worker/test/helpers.ts | 190 ++ ebs-worker/test/oauth.test.ts | 268 +++ ebs-worker/test/scheduled.test.ts | 188 ++ ebs-worker/test/state.test.ts | 447 +++++ ebs-worker/test/units.test.ts | 122 ++ ebs-worker/tsconfig.json | 16 + ebs-worker/vitest.config.ts | 5 + ebs-worker/wrangler.example.jsonc | 44 + 36 files changed, 6021 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ebs-worker.yml create mode 100644 ebs-worker/.dev.vars.example create mode 100644 ebs-worker/.gitignore create mode 100644 ebs-worker/README.md create mode 100644 ebs-worker/migrations/0001_init.sql create mode 100644 ebs-worker/package-lock.json create mode 100644 ebs-worker/package.json create mode 100644 ebs-worker/src/app.ts create mode 100644 ebs-worker/src/clearing.ts create mode 100644 ebs-worker/src/config.ts create mode 100644 ebs-worker/src/context.ts create mode 100644 ebs-worker/src/crypto.ts create mode 100644 ebs-worker/src/db/pending.ts create mode 100644 ebs-worker/src/db/ratelimit.ts create mode 100644 ebs-worker/src/db/state.ts create mode 100644 ebs-worker/src/db/status.ts create mode 100644 ebs-worker/src/db/tokens.ts create mode 100644 ebs-worker/src/env.ts create mode 100644 ebs-worker/src/http.ts create mode 100644 ebs-worker/src/index.ts create mode 100644 ebs-worker/src/routes/oauth.ts create mode 100644 ebs-worker/src/scheduled.ts create mode 100644 ebs-worker/src/twitch/jwt.ts create mode 100644 ebs-worker/src/twitch/oauth.ts create mode 100644 ebs-worker/src/twitch/probe.ts create mode 100644 ebs-worker/src/twitch/pubsub.ts create mode 100644 ebs-worker/test/helpers.ts create mode 100644 ebs-worker/test/oauth.test.ts create mode 100644 ebs-worker/test/scheduled.test.ts create mode 100644 ebs-worker/test/state.test.ts create mode 100644 ebs-worker/test/units.test.ts create mode 100644 ebs-worker/tsconfig.json create mode 100644 ebs-worker/vitest.config.ts create mode 100644 ebs-worker/wrangler.example.jsonc diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5a81e61..5e843f5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -20,6 +20,15 @@ updates: nuget: patterns: ['*'] + # The EBS Cloudflare Worker (ebs-worker/). + - package-ecosystem: npm + directory: /ebs-worker + schedule: + interval: weekly + groups: + npm: + patterns: ['*'] + # The EBS container's base images (mcr.microsoft.com/dotnet/sdk + aspnet). - package-ecosystem: docker directory: /src/EDNexus.Ebs diff --git a/.github/workflows/ebs-worker.yml b/.github/workflows/ebs-worker.yml new file mode 100644 index 0000000..e7a5c80 --- /dev/null +++ b/.github/workflows/ebs-worker.yml @@ -0,0 +1,115 @@ +name: EBS Worker + +# Tests the Twitch Extension Backend Service Cloudflare Worker (ebs-worker/) on every change and deploys +# it from main. +# +# Deploying needs three repository secrets (Settings > Secrets and variables > Actions): +# CLOUDFLARE_API_TOKEN API token allowed to edit Workers scripts and D1 databases +# CLOUDFLARE_ACCOUNT_ID your Cloudflare account ID +# CLOUDFLARE_D1_DATABASE_ID the database_id printed by `wrangler d1 create ednexus-ebs` +# +# The IDs are kept as secrets on purpose: this repository is public and ebs-worker/wrangler.jsonc is +# gitignored, so this workflow builds it from ebs-worker/wrangler.example.jsonc at deploy time. A fork +# without these secrets still runs the tests and simply skips the deploy. The Worker's runtime secrets +# (TWITCH_CLIENT_SECRET, ...) are set once with `wrangler secret put` and persist across deploys, so they +# are not handled here. See ebs-worker/README.md. + +on: + push: + branches: [main] + paths: + - 'ebs-worker/**' + - '.github/workflows/ebs-worker.yml' + pull_request: + paths: + - 'ebs-worker/**' + - '.github/workflows/ebs-worker.yml' + workflow_dispatch: + +permissions: + contents: read + +defaults: + run: + working-directory: ebs-worker + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + cache: npm + cache-dependency-path: ebs-worker/package-lock.json + + - run: npm ci + - run: npm run typecheck + - run: npm test + + # Proves the Worker bundles and the config is valid, without touching Cloudflare. The placeholder + # database id is fine because a dry run never contacts the account. + - name: Bundle (dry run) + run: | + cp wrangler.example.jsonc wrangler.jsonc + npx wrangler deploy --dry-run --outdir "$RUNNER_TEMP/ebs-worker-dry-run" + + deploy: + needs: test + # Pull requests only test. Deploys come from main, or when someone runs the workflow by hand on main. + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + runs-on: ubuntu-latest + # Never run two deploys (or migrations) at once, and let a running one finish instead of cancelling it. + concurrency: + group: ebs-worker-deploy + cancel-in-progress: false + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + D1_DATABASE_ID: ${{ secrets.CLOUDFLARE_D1_DATABASE_ID }} + steps: + - name: Check Cloudflare secrets are configured + id: cf + shell: bash + # Runs before checkout, so the default working directory (ebs-worker/) does not exist yet. + working-directory: . + run: | + if [ -n "$CLOUDFLARE_API_TOKEN" ] && [ -n "$CLOUDFLARE_ACCOUNT_ID" ] && [ -n "$D1_DATABASE_ID" ]; then + echo "configured=true" >> "$GITHUB_OUTPUT" + else + echo "configured=false" >> "$GITHUB_OUTPUT" + echo "::notice::CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID or CLOUDFLARE_D1_DATABASE_ID is not set, so the deploy was skipped." + fi + + - if: steps.cf.outputs.configured == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - if: steps.cf.outputs.configured == 'true' + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + cache: npm + cache-dependency-path: ebs-worker/package-lock.json + + - name: Install dependencies + if: steps.cf.outputs.configured == 'true' + run: npm ci + + - name: Create wrangler.jsonc from the template + if: steps.cf.outputs.configured == 'true' + run: sed "s/REPLACE_WITH_YOUR_D1_DATABASE_ID/${D1_DATABASE_ID}/" wrangler.example.jsonc > wrangler.jsonc + + # Migrations go first so the new code never runs against an older schema. + - name: Apply D1 migrations + if: steps.cf.outputs.configured == 'true' + run: npx wrangler d1 migrations apply ednexus-ebs --remote + + - name: Deploy Worker + if: steps.cf.outputs.configured == 'true' + run: npx wrangler deploy diff --git a/AGENTS.md b/AGENTS.md index 1f60972..9bb4ab4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,8 @@ Journal.*.log + *.json ──► JournalWatcher ──► JournalEventBus | `src/EDNexus.Core` | Engine + feature services. No UI dependencies. | | `src/EDNexus.App` | Avalonia 12 desktop UI (MVVM via CommunityToolkit.Mvvm). | | `src/EDNexus.Cli` | Headless harness for validation. | -| `src/EDNexus.Ebs` | Twitch Extension Backend Service: relays commander state to viewers. | +| `src/EDNexus.Ebs` | Twitch Extension Backend Service (ASP.NET): relays commander state to viewers. Being replaced by `ebs-worker/`; keep both in step until the cutover is done and this project is removed. | +| `ebs-worker/` | The same EBS as a Cloudflare Worker (Hono + TypeScript + D1). Same HTTP contract. `cd ebs-worker && npm test`; see its README. Uses Wrangler (it has a Wrangler config). | | `extension/` | The Twitch extension frontend (vanilla HTML/CSS/JS, no build step). | | `src/EDNexus.Plugins.Abstractions`, `src/EDNexus.Plugins.Hosting` | Plugin SDK contract (plugins reference only this) and the plugin loader/installer/bridge. | | `samples/HelloCommander` | Sample plugin + template. Its build assembles `out//` (git-ignored); `tests/EDNexus.Samples.Tests` loads it through the real host. | diff --git a/ebs-worker/.dev.vars.example b/ebs-worker/.dev.vars.example new file mode 100644 index 0000000..4cf0336 --- /dev/null +++ b/ebs-worker/.dev.vars.example @@ -0,0 +1,13 @@ +# Copy to .dev.vars (git-ignored) for `wrangler dev`. Never put real secrets in this example file. +# Restart `wrangler dev` after editing .dev.vars. +TWITCH_CLIENT_ID= +TWITCH_CLIENT_SECRET= +TWITCH_EXTENSION_ID= +# base64, the Extension Secret from the Twitch Developer Console (at least 16 bytes once decoded) +TWITCH_EXTENSION_SECRET= +# base64 of 32 random bytes. Encrypts the Twitch tokens stored in D1: +# node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" +TOKEN_ENCRYPTION_KEY= +TWITCH_OAUTH_REDIRECT_URI=http://localhost:8787/oauth/callback +# TWITCH_OWNER_USER_ID= +# ADDITIONAL_ALLOWED_FRONTEND_ORIGINS=https://localhost:8080 diff --git a/ebs-worker/.gitignore b/ebs-worker/.gitignore new file mode 100644 index 0000000..4daf40e --- /dev/null +++ b/ebs-worker/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.wrangler/ +# The real config holds the id of YOUR D1 database; commit only wrangler.example.jsonc. +wrangler.jsonc +# Local secrets for `wrangler dev`. +.dev.vars diff --git a/ebs-worker/README.md b/ebs-worker/README.md new file mode 100644 index 0000000..0f0fcd2 --- /dev/null +++ b/ebs-worker/README.md @@ -0,0 +1,132 @@ +# ebs-worker — the Twitch Extension Backend Service on Cloudflare Workers + +A Cloudflare Worker (Hono + TypeScript + D1) that does what [`src/EDNexus.Ebs`](../src/EDNexus.Ebs/README.md) +does: it bridges the EDNexus desktop app and the Twitch extension frontend in [`extension/`](../extension/README.md). +The desktop app POSTs state here; the Worker verifies the caller and relays the update to viewers through +Twitch's Extensions PubSub API. It is also the desktop app's only path to Twitch login (the real OAuth 2.0 +Authorization Code handshake with PKCE, issuing its own opaque token back to the desktop). + +**The HTTP contract is unchanged**, so the desktop app and the extension need no change: same routes, status codes, +headers (`Retry-After`, `X-EDNexus-Pubsub-Rejected`, `X-EDNexus-Snapshot-Removed`), problem+json bodies, limits and +CORS policy. The API is documented in the [.NET README](../src/EDNexus.Ebs/README.md#api); the sections there on +*OAuth login flow*, *API*, *Rate limits* and *Privacy* apply as written. This README covers what is different. + +## What changed from the ASP.NET service + +| ASP.NET service | Worker | +|---|---| +| SQLite file `ebs.db` on a volume | D1 database `ednexus-ebs` (`migrations/0001_init.sql`) | +| Data Protection key ring on a separate volume | AES-GCM with the `TOKEN_ENCRYPTION_KEY` secret (also kept apart from the database) | +| Pending OAuth sessions and one-time codes in process memory | The `oauth_pending` table (short-lived; code payloads are encrypted, since they carry Twitch tokens) | +| Three `BackgroundService`s | Cron Triggers: prune (15 min), token refresh (30 min), credential check (6 h) | +| Per-channel `ChannelLocks` | None (see [Concurrency](#concurrency)) | +| ASP.NET rate limiter, `X-Forwarded-For` from a trusted proxy | Rate Limiting bindings for per-IP limits, a D1 counter for per-channel limits; client address is `CF-Connecting-IP` | +| Refuses to start on bad configuration | Answers `503 {"status":"misconfigured"}` and logs every problem (`wrangler tail`) | +| Docker image + Caddy | `wrangler deploy` | + +Sessions or broadcaster tokens in the old database are **not** migrated (they are encrypted with a Data Protection +key ring that does not exist here). Every broadcaster signs in again once; the desktop app prompts for it on the +first `401`. Published cards reappear within the desktop's 10-minute heartbeat. + +## Setup + +```sh +cd ebs-worker +npm install +cp wrangler.example.jsonc wrangler.jsonc # gitignored: it holds YOUR D1 database id +npx wrangler d1 create ednexus-ebs # paste the database_id it prints into wrangler.jsonc +npm run db:migrate:remote +``` + +The repository is public, so nothing that identifies a Cloudflare account is committed: the D1 id lives only in your +local `wrangler.jsonc` and in the `CLOUDFLARE_D1_DATABASE_ID` GitHub secret. + +### Secrets + +Set each with `npx wrangler secret put NAME` (a masked prompt; never pass one as an argument): + +| Secret | What it is | +|---|---| +| `TWITCH_CLIENT_ID` | The Twitch application that performs the OAuth login. | +| `TWITCH_CLIENT_SECRET` | Its client secret. | +| `TWITCH_EXTENSION_ID` | The extension's Client ID, sent as the `Client-Id` of the PubSub call. Often the same value as `TWITCH_CLIENT_ID`. | +| `TWITCH_EXTENSION_SECRET` | The extension's base64 secret from the Developer Console (≥ 16 bytes decoded; Twitch issues 32). Signs the PubSub JWT. | +| `TOKEN_ENCRYPTION_KEY` | **New.** base64 of 32 random bytes: `node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"`. Encrypts the Twitch tokens in D1. Losing or changing it logs every broadcaster out (they sign in again); the Worker keeps running. | + +`npx wrangler secret list` prints names only, which makes it safe to audit against `src/env.ts`. + +### Variables + +In `wrangler.jsonc` under `vars` (all optional apart from the redirect URI, which has a default): + +| Variable | Default | | +|---|---|---| +| `TWITCH_OAUTH_REDIRECT_URI` | `https://ednexus.signal-and-thread.com/oauth/callback` | Exactly as registered on the Twitch application. `http` is accepted for `localhost` only. | +| `TWITCH_OAUTH_SCOPES` | `user:read:email` | Space- or comma-separated; duplicates are dropped. | +| `TWITCH_OWNER_USER_ID` | unset | Numeric user id of the extension's owner, the `user_id` claim of the PubSub JWT. Set it if publishing fails with 401/403. | +| `MAX_STATE_PAYLOAD_BYTES` | `5000` | Compact state size forwarded to PubSub. Above `5120` (Twitch's hard limit) is refused. | +| `MAX_REQUEST_BODY_BYTES` | `16384` | Largest body `/api/update-state` and `/oauth/*` read; larger gets `413`. | +| `UPDATE_STATE_RATE_LIMIT` / `UPDATE_STATE_RATE_LIMIT_WINDOW_SECONDS` | `1` / `2` | Per authenticated channel. | +| `INITIAL_STATE_CACHE_SECONDS` | `5` | `max-age` of the public `Cache-Control` on a served snapshot; `0` disables it. | +| `OAUTH_SESSION_TTL_MINUTES` / `OAUTH_CODE_TTL_SECONDS` | `10` / `60` | | +| `TWITCH_TOKEN_REFRESH_BUFFER_MINUTES` | `60` | How far ahead of expiry a grant is refreshed. | +| `CHANNEL_STATE_MAX_AGE_HOURS` | `24` | Oldest snapshot served; `0` disables it, `1`–`11` is refused. See the [privacy note](../src/EDNexus.Ebs/README.md#privacy-the-24-hour-snapshot-window). | +| `ADDITIONAL_ALLOWED_FRONTEND_ORIGINS` | unset | Extra origins that may read `initial-state` from a browser (Developer Rig). **Leave unset in production.** | + +The per-IP limits (`GET /api/initial-state`: 60 per 10 s; `/oauth/*`: 30 per 60 s, shared) are set on the +`ratelimits` bindings in `wrangler.jsonc`, not by variables: the Rate Limiting binding supports a 10 s or 60 s period +only, which is what both limits use. + +### Local development + +```sh +cp .dev.vars.example .dev.vars # fill in test values; git-ignored; restart `wrangler dev` after editing it +npm run db:migrate:local +npm run dev # http://localhost:8787 +npm test # vitest, no Cloudflare account needed +npm run typecheck +``` + +The tests run the Worker's real SQL against SQLite (`node:sqlite`) loaded with the real migration, with Twitch +stubbed at `fetch`. They cover the OAuth flow, authentication, validation, limits, the credential check, token +refresh and the cron jobs. + +## Deploying + +CI (`.github/workflows/ebs-worker.yml`) tests every change to `ebs-worker/`, and on `main` applies migrations then +deploys, **only when** the repository has the secrets `CLOUDFLARE_API_TOKEN` (Account → Workers Scripts: Edit and +D1: Edit), `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_D1_DATABASE_ID`. Without them it tests and skips the deploy. Or by +hand: `npm run db:migrate:remote && npm run deploy`. + +### Cutover from the container + +1. Create the D1 database, apply the migration, set the five secrets, deploy. +2. `curl https:///healthz` → `{"status":"ok","pubsub":"unknown",...}`; an unsigned + `POST /api/update-state` → `401`; `GET /api/initial-state/1` → `404`. +3. Attach the custom domain in the dashboard (Worker → Settings → Domains & Routes → Add → Custom Domain) once the + container no longer owns the hostname. The registered OAuth redirect URI already points at it, so nothing changes + on the Twitch side; if you test on `*.workers.dev` first, that redirect URI must be registered too. +4. Run the [publish smoke test](../src/EDNexus.Ebs/README.md#publish-smoke-test) from the desktop app, and watch + `npx wrangler tail`. Within 6 hours (or run the cron by hand) `/healthz` reports `pubsub: accepted`. +5. Only then stop the container and remove `src/EDNexus.Ebs`, its tests, `ebs-docker.yml` and the Docker files. + +## Behaviour notes + +### Concurrency + +A Worker has no process to hold a per-channel lock, so a publish and a sign-out can interleave. The invariant +(*no snapshot outlives its credential*) is kept by ordering instead: a publish re-checks its token before storing and +again afterwards, and takes the card back down if the token has gone; a sign-out clears, deletes the token, then +removes the snapshot once more. Every interleaving of the two ends with no snapshot and no live credential. The +residual cost is that viewers watching during that sub-second window can see the card flash on and then be sent +`offline` again. If that ever matters, a per-channel Durable Object is the way to get real serialisation. + +### Limits worth knowing + +- **Token refresh** handles at most 40 grants per 30-minute run (one Twitch subrequest each; the free plan caps a + run at 50). The soonest-expiring go first and the buffer is an hour, so a backlog drains before anything expires. +- **`pubsubCheckedAt`** in `/healthz` is when the PubSub verdict last *changed*, not when it was last checked (an + unchanged verdict is not rewritten, to save a D1 write on every publish). +- **The credential check** runs every 6 hours; there is no 10-second-after-boot check because a Worker has no boot. + `pubsub` is `unknown` until the first cron or the first real publish. +- Chunked uploads that declare no `Content-Length` are cut off when read, not as they stream. diff --git a/ebs-worker/migrations/0001_init.sql b/ebs-worker/migrations/0001_init.sql new file mode 100644 index 0000000..e83f8f8 --- /dev/null +++ b/ebs-worker/migrations/0001_init.sql @@ -0,0 +1,49 @@ +-- EDNexus EBS schema (ported from the SQLite file the ASP.NET service kept in ebs.db). +-- Timestamps are fixed-width ISO-8601 UTC text (Date.toISOString), so they compare correctly as text. + +-- Long-lived broadcaster tokens and the Twitch grants they wrap. The EBS bearer token is stored only +-- as a SHA-256 hash; the Twitch access/refresh tokens are AES-GCM ciphertext (see src/crypto.ts). +CREATE TABLE broadcaster_tokens ( + channel_id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + username TEXT NOT NULL, + twitch_access_token TEXT NOT NULL, + twitch_refresh_token TEXT NOT NULL, + twitch_expires_at TEXT NOT NULL, + is_twitch_grant_valid INTEGER NOT NULL, + created_at TEXT NOT NULL, + last_refreshed_at TEXT NULL +); + +-- The last state each channel published, served by GET /api/initial-state/{channelId}. +CREATE TABLE channel_state ( + channel_id TEXT PRIMARY KEY, + state_json TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX ix_channel_state_updated_at ON channel_state (updated_at); + +-- Pending /oauth/authorize sessions (kind 'session') and one-time authorization codes (kind 'code'). +-- The .NET service held these in process memory; Worker isolates share none, so they live here, short +-- lived. A 'code' payload carries Twitch tokens and is AES-GCM ciphertext, never plaintext. +CREATE TABLE oauth_pending ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + payload TEXT NOT NULL, + expires_at TEXT NOT NULL +); +CREATE INDEX ix_oauth_pending_expires_at ON oauth_pending (expires_at); + +-- Fixed-window counters for the per-channel limits (update-state, clear-state). +CREATE TABLE rate_limit ( + key TEXT PRIMARY KEY, + window_start INTEGER NOT NULL, + count INTEGER NOT NULL +); + +-- Small key/value status rows (whether Twitch accepts the PubSub credentials). +CREATE TABLE service_status ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL +); diff --git a/ebs-worker/package-lock.json b/ebs-worker/package-lock.json new file mode 100644 index 0000000..0d2bdba --- /dev/null +++ b/ebs-worker/package-lock.json @@ -0,0 +1,2766 @@ +{ + "name": "ednexus-ebs-worker", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ednexus-ebs-worker", + "version": "0.1.0", + "dependencies": { + "hono": "^4.7.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20261110.1", + "@types/node": "^24.0.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0", + "wrangler": "^4.0.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz", + "integrity": "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20261110.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20261110.1.tgz", + "integrity": "sha512-RazOdADqTgwYhE60+i6c/R5BA1J5aC//39oousnWBxmoVAiqUh1qsXNCGMbaEV6UC5RnZyWsIBRIcAYNDe1xTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20261110.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20261110.1.tgz", + "integrity": "sha512-TFsn/t+wczWnWdm+ytlWe+xHEO9sDgfIBmtMo8OxujuS2DXCmGimiyvJHmuv+6+N9OhUb0dM+fveXJI9yjuzew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20261110.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20261110.1.tgz", + "integrity": "sha512-i4080Pp6Q0nwS8OMqt3cbpJoWvbCrIlFKa+4Inbn+y2pWKTVsq6F9bgdi0l8CBZPMszd03gDlQR++dqMbErh2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20261110.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20261110.1.tgz", + "integrity": "sha512-0l+JyYWOhEzTs7yOkIdf1ZzcXd9WalPZ1kD0bBAobFvDhO/1jBnQkS4rzzyUkGV7g8pb3dkfrBP9c2ZiRLOfdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20261110.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20261110.1.tgz", + "integrity": "sha512-zP8IGyWvohyJhbNjHOnTA9anRQtGN7X01T/dG+R9IA4vu2egRODsD02aoYzv0a0qImjt7tTpOOA+uVotDJPcSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "5.20261110.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20261110.1.tgz", + "integrity": "sha512-XOr3TDQ2sNDiP9zoZtdwYVhuLZ3aFY+G+G4iSeNO9p/HDSveZJpyCbUtmpEElaClLBe9hIzfN26qd0i/S7ksPg==", + "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/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.68.2.tgz", + "integrity": "sha512-ItKrNMkEL3hy85kLBXwB0+M6CWWVI8vr7RV+Eb7oDlQIMXLaiEhGfdaj5jAUadYn2v3nwzAhIRl/nLyjAqV9vQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.68.2.tgz", + "integrity": "sha512-dCkT8BMPLo6X6GCFVtuzrnr981PpJSeK4ZO/d/mZdJ/ao61Qa1Usd/OHIQ69MQgw4qQclR/X6uRRUONiWwD8cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-x64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-x64/-/rollup-android-x64-4.68.2.tgz", + "integrity": "sha512-p2jkKiscqQOGmyL6qu+KvJctvBc3SQTd+Xi2lQYn5QvFLEWz+QI8Z9iI+xcIjez6vKRsSnWP56Y3Ep7g3Stiug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.68.2.tgz", + "integrity": "sha512-aVIfOteo5zoX8aL742H/S3QsyN6aokNYjdzrLnQBKxgRVoh4cibTErpi61RzSmrv2hBzDB1i751hwwdc8QK0vQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.68.2.tgz", + "integrity": "sha512-+Wuf4TFTHiE/WnjKgH1SkNf/9D5ddoed1UR0p3jxjNAGemdeeQRL+r1bJ1LlZyha0VKDBYLB2U/a9a9KlSw4Kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.68.2.tgz", + "integrity": "sha512-feKK2dc13j/6D9mAp7XKhBM/VUqn0N/xRgP6rT7/mdRVVJUaeZ4lsJ9hN90aTi0k3OD+mkjlcN01vQ96Ac9VzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.68.2.tgz", + "integrity": "sha512-s1oGXPGX1PNqdi9TLWD+RwtPEYf/BOTbCcq4anJBqNbzyjucti86ldYH37eKvsyTP7Bl5/Hf8XmbIxCz/iafTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.68.2.tgz", + "integrity": "sha512-ksR3ZmzHrm6HOUP8UlDBy5hNTcG9cEIa3EyGnxn5BjorLRjBh4ucxhwjwwVEm0dqcIN4Egc/XjDUiPQwN1ic9A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.68.2.tgz", + "integrity": "sha512-STZVVBM35wULVsQah0xKfCBQMPpHliOfjnRaUNT35jrbpfapUrD8ix6Yhl7tLHW0xQSWotXSSghjUi/DclwRSw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.68.2.tgz", + "integrity": "sha512-Fm/GPC2R0zlJYQIp9aY9L1lO2z+P+UrcvXG6fhubfo/BbMOLZlLXshnbECOTqr3pxfZFb6nNn0QgDETDtrX7TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.68.2.tgz", + "integrity": "sha512-VoqlckWcwNF3WqsUkLZg91zYFgBvrC37Lc5e8AU+BKdCUFDMPoN0Rsin/LvF+rBVF527gWo5KT0oHkaAZaL1GA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.68.2.tgz", + "integrity": "sha512-Zr8X/SUf7Jjwxt99BqPDF/Wq+sz4oUBIP6HBaFQWwb8wO1nV6LBIpD6jB9cMybLho93Z+B8rrN0uXEL6oNOUJQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.68.2.tgz", + "integrity": "sha512-2EhrYEJNXIyhSZESwm/kCke1tiH7QAPOU3v3MsLM9J+Nfn2QCDe/pGROUAvYFK0BL2MEbzNgfrqrbnE5CFXkLw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.68.2.tgz", + "integrity": "sha512-IC0dYuEQknw0m2FYBNBKlbrgBJBH5OMRiaRfCc4CFSpYnmDnqTpNSv+TgX+loezGTdRjBNZuYZi3t+Oc5NxNYg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.68.2.tgz", + "integrity": "sha512-Zlzm6fGuDvtAqMEl071/g17vxq6u2Y2v/rON/gDY4NvzWLJ41BGAlwhF3QuAzwq05zIPo4LYzdqwhx3oisYCUw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.68.2.tgz", + "integrity": "sha512-KhEpYRtHZ3Kj4shavjXeH+K1bQlA6UbP6MJ45rMPF/11JlZb1SRNLeuWCdigUEoMMaFO4+LwwcS+ew1SoLJu/g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.68.2.tgz", + "integrity": "sha512-LPn9qvPsM/Ofd6Ekq3im6nB8Tk1ruJhTd3x/2bJkTR8m/XTsfxA+EU0RJ23WJqgJg123kitHAqbEK7B4cL1uPg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.68.2.tgz", + "integrity": "sha512-Q1+E+pzSP+FiXuIZtxKR+pK7l2KQ9gsM+VaFPv1LR277kp7l61crG46WGLKLLay3Js5cvyU9Keqdzxa1EcFO+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.68.2.tgz", + "integrity": "sha512-3Btp4Lca/WzsmbaKu9Lh9d/XwyRDIegd/5khRBusHGw/Ll6SoF5Shn63OrjPdkLcpNi2PGDApXzNmxriRxGYjA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.68.2.tgz", + "integrity": "sha512-5SKVaUiVs18+DGBnpg0mORGxgq/0udczngGxV/vf2nvK1djqpOKIxY/+8rBWxAbWGRE4RnnwxWabDwJN3pCwIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.68.2.tgz", + "integrity": "sha512-KBpm48/lESsHgWd+dRlcy18APBYhXU4Jb6VlJoqbpWNwfpztFXwP22qVv9aHd3k1quSrmLeJwYLc7rpQ+BFZrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.68.2.tgz", + "integrity": "sha512-o0D0bfRW8QGA1Ph6OzqVHMq5LRu1xkr1iM7qQIXUSFuR60ddXeDhN9UR4bUBPLYsrT46sZwwgQa51TWtBV3r5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.68.2.tgz", + "integrity": "sha512-r17kcu5kmd6WQwokYmfBnfwM/icureCWCPjpJVALcrSv5Lfy6Ggk9azxXor6hILhCHbc/4IfEiv+L5RXaOHinA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.68.2.tgz", + "integrity": "sha512-yUtOqq7ZrQoZ3WiHSR6QGTdOl4YgMHqXIYSBYU6siIcC0YF0pEwx7aGsPTm2mRwrmW6SMJkxzEdFm6wqXpmbNw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.68.2.tgz", + "integrity": "sha512-5IYoWxQGo5WECbJbImIf2w33uCHJNLv4o/FQtSyeYDed7AC6zx84NsEcGYccoI2geDEYkrxUXcsYK/6EhwVSIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.68.2.tgz", + "integrity": "sha512-ee+c14ggUiM7lg4PtCjSBYU9O9dHp4FFespRnd5yj7SilUBJCL3x3aHlB+sIqbGjcPo3pEyODASnfKJuZ26YBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@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.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/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/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/es-module-lexer": { + "version": "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.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hono": { + "version": "4.13.17", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.17.tgz", + "integrity": "sha512-vZIcbkvjOR5hHRWERbGkr8fGfF0GGM1ooYkuH448iO3b2AdVEr3AXZSw4Z3Kn+1L5z0CVoVeyV29VGYmFh05kQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/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/miniflare": { + "version": "5.20261110.0-beta", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20261110.0-beta.tgz", + "integrity": "sha512-UiJRL6D4pNkHxgZ3QXDCaz/N27FFuuHRRgOmD/SucEZIwDOfWwX7s/n8ZrFemFlURRfnQpBfAdWy8ixsW0yJWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.5", + "undici": "7.30.1", + "workerd": "1.20261110.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, + "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/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "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/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "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.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.68.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.68.2.tgz", + "integrity": "sha512-xzt45rKOxzEuqjTdkGTIEaePcq9xRELlFI1uoazlKap8T6bENJkONSCfhK51P6XFTixa4rosPcDHihSdduXndQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.10" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.68.2", + "@rollup/rollup-android-arm64": "4.68.2", + "@rollup/rollup-android-x64": "4.68.2", + "@rollup/rollup-darwin-arm64": "4.68.2", + "@rollup/rollup-darwin-x64": "4.68.2", + "@rollup/rollup-freebsd-arm64": "4.68.2", + "@rollup/rollup-freebsd-x64": "4.68.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.68.2", + "@rollup/rollup-linux-arm-musleabihf": "4.68.2", + "@rollup/rollup-linux-arm64-gnu": "4.68.2", + "@rollup/rollup-linux-arm64-musl": "4.68.2", + "@rollup/rollup-linux-loong64-gnu": "4.68.2", + "@rollup/rollup-linux-loong64-musl": "4.68.2", + "@rollup/rollup-linux-ppc64-gnu": "4.68.2", + "@rollup/rollup-linux-ppc64-musl": "4.68.2", + "@rollup/rollup-linux-riscv64-gnu": "4.68.2", + "@rollup/rollup-linux-riscv64-musl": "4.68.2", + "@rollup/rollup-linux-s390x-gnu": "4.68.2", + "@rollup/rollup-linux-x64-gnu": "4.68.2", + "@rollup/rollup-linux-x64-musl": "4.68.2", + "@rollup/rollup-openbsd-x64": "4.68.2", + "@rollup/rollup-openharmony-arm64": "4.68.2", + "@rollup/rollup-win32-arm64-msvc": "4.68.2", + "@rollup/rollup-win32-ia32-msvc": "4.68.2", + "@rollup/rollup-win32-x64-gnu": "4.68.2", + "@rollup/rollup-win32-x64-msvc": "4.68.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz", + "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "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": "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/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "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/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/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": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz", + "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/vite": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.8.tgz", + "integrity": "sha512-3tTi78MAKhVk9h+YZVXMQ8POmneJuE97QUPAWtJghHzhH7BAyiqQOC2KZh1DWOsu/USB2cRXhznPByaabvYXfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "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", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "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.20261110.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20261110.1.tgz", + "integrity": "sha512-GKdtki7t19f16vegol8Iw5Dh0mnjHLuwLx8rlhTPvyRBEXM0Osl9eYexWAW8uZD7OMw4WKNq9yvENg5kPDUJ9w==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20261110.1", + "@cloudflare/workerd-darwin-arm64": "1.20261110.1", + "@cloudflare/workerd-linux-64": "1.20261110.1", + "@cloudflare/workerd-linux-arm64": "1.20261110.1", + "@cloudflare/workerd-windows-64": "1.20261110.1" + } + }, + "node_modules/wrangler": { + "version": "4.160.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.160.0.tgz", + "integrity": "sha512-nwzmpu7Hd5Csax02twpCNRAloC+kduXKw9rjfq+oEuo8RnjWRaahSV+qk+p0w88EgafMcDE0XnZRSfOM3lKEKg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.2", + "@cspotcode/source-map-support": "0.8.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.2", + "miniflare": "5.20261110.0-beta", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20261110.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20261110.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/ebs-worker/package.json b/ebs-worker/package.json new file mode 100644 index 0000000..4660fb7 --- /dev/null +++ b/ebs-worker/package.json @@ -0,0 +1,25 @@ +{ + "name": "ednexus-ebs-worker", + "version": "0.1.0", + "private": true, + "description": "EDNexus Twitch Extension Backend Service as a Cloudflare Worker", + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "db:migrate:local": "wrangler d1 migrations apply ednexus-ebs --local", + "db:migrate:remote": "wrangler d1 migrations apply ednexus-ebs --remote" + }, + "dependencies": { + "hono": "^4.7.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20261110.1", + "@types/node": "^24.0.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0", + "wrangler": "^4.0.0" + } +} diff --git a/ebs-worker/src/app.ts b/ebs-worker/src/app.ts new file mode 100644 index 0000000..9d008fb --- /dev/null +++ b/ebs-worker/src/app.ts @@ -0,0 +1,270 @@ +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { clearChannel, OFFLINE_MESSAGE, SNAPSHOT_REMOVED_HEADER } from './clearing'; +import { loadConfig, PROJECT_SITE_URL, type ConfigResult } from './config'; +import type { Deps } from './context'; +import { checkFixedWindow } from './db/ratelimit'; +import { getState, removeState, setState } from './db/state'; +import { getCredentialStatus, REJECTED_HEADER } from './db/status'; +import { getByToken } from './db/tokens'; +import type { Env, RateLimit } from './env'; +import { byteLength, isPlainObject, json, problem, readJsonBody, unauthorized } from './http'; +import { registerOAuthRoutes } from './routes/oauth'; +import { broadcast } from './twitch/pubsub'; + +export type AuthResult = { ok: true; channelId: string; token: string } | { ok: false; response: Response }; + +export type AppEnv = { + Bindings: Env; + Variables: { deps: Deps; auth: AuthResult }; +}; + +export interface AppOptions { + /** Injectable clock, for tests. */ + now?: () => Date; +} + +const configCache = new WeakMap(); + +/** Validated once per isolate (bindings do not change within one). */ +export function configFor(env: Env): ConfigResult { + let result = configCache.get(env); + if (!result) { + result = loadConfig(env); + configCache.set(env, result); + if (!result.ok) console.error(`EDNexus EBS cannot serve requests: the configuration is invalid.\n - ${result.failures.join('\n - ')}`); + } + return result; +} + +/** The client address Cloudflare saw. The edge sets this header itself, so a client cannot choose its own bucket. */ +export function clientIp(request: Request): string { + return request.headers.get('cf-connecting-ip') ?? 'unknown'; +} + +/** A 429 that tells the client when to retry and is never replayed from a shared cache. */ +export function tooManyRequests(retryAfterSeconds: number): Response { + return new Response(null, { status: 429, headers: { 'retry-after': String(retryAfterSeconds), 'cache-control': 'no-store' } }); +} + +/** Per-client-IP limit through a Rate Limiting binding. Skipped when the binding is not configured (tests, local dev). */ +export async function limitByIp(binding: RateLimit | undefined, request: Request, periodSeconds: number): Promise { + if (!binding) return null; + const { success } = await binding.limit({ key: clientIp(request) }); + return success ? null : tooManyRequests(periodSeconds); +} + +function isAllowedFrontendOrigin(origin: string, additional: Set): boolean { + if (additional.has(origin.toLowerCase())) return true; + try { + const url = new URL(origin); + return url.protocol === 'https:' && (url.hostname === 'ext-twitch.tv' || url.hostname.endsWith('.ext-twitch.tv')); + } catch { + return false; + } +} + +const bearerToken = (header: string | null): string | null => { + if (!header || !/^bearer /i.test(header.trim())) return null; + return header.trim().slice('Bearer '.length).trim(); +}; + +/** + * Authenticates a broadcaster by the long-lived EBS token minted at POST /oauth/token. The channel id is + * resolved server-side from the token, never taken from the request body, so a compromised client cannot + * spoof another broadcaster's channel. + */ +export async function authenticateBroadcaster(deps: Deps, header: string | null, requireValidTwitchGrant: boolean): Promise { + const token = bearerToken(header); + if (!token) return { ok: false, response: unauthorized() }; + + const record = await getByToken(deps, token); + if (!record) { + // Say which kind of 401 this is: an empty 401 sends people hunting through their Twitch console + // configuration. Tokens survive restarts, so an unknown one was revoked, replaced by a newer + // sign-in, or issued by a different EBS. + return { + ok: false, + response: problem(401, 'This token is not known to the service. It was revoked, replaced by a newer sign-in, or issued by a different service — log in again.'), + }; + } + if (requireValidTwitchGrant && !record.isTwitchGrantValid) + return { ok: false, response: problem(401, 'The underlying Twitch grant is no longer valid — please log in again.') }; + + return { ok: true, channelId: record.channelId, token }; +} + +function describeKind(value: unknown): string { + if (Array.isArray(value)) return 'array'; + if (typeof value === 'boolean') return String(value); + return typeof value; +} + +export function createApp(options: AppOptions = {}): Hono { + const now = options.now ?? (() => new Date()); + const app = new Hono(); + + // Anyone who lands on the bare EBS host (e.g. following the OAuth redirect URI's origin) gets the + // project site rather than a 404. Needs no configuration, so it answers even when that is invalid. + app.get('/', (c) => c.redirect(PROJECT_SITE_URL, 302)); + + // Validate the configuration (the ASP.NET build refused to start; a Worker can only refuse to serve). + app.use('*', async (c, next) => { + const result = configFor(c.env); + if (!result.ok) return c.json({ status: 'misconfigured' }, 503); + c.set('deps', { env: c.env, db: c.env.DB, config: result.config, now }); + await next(); + }); + + // Refuse an honestly-declared oversized body before anything reads it. A chunked upload that declares + // nothing is caught where the body is read (readJsonBody). + app.use('*', async (c, next) => { + const path = new URL(c.req.url).pathname.toLowerCase(); + if ((c.req.method === 'POST' || c.req.method === 'DELETE') && (path.startsWith('/api/update-state') || path.startsWith('/oauth'))) { + const limit = c.var.deps.config.maxRequestBodyBytes; + if (Number(c.req.header('content-length')) > limit) return problem(413, `The request body exceeds the ${limit} byte limit.`); + } + await next(); + }); + + // Only the extension's own Twitch-hosted iframe (https://*.ext-twitch.tv), plus origins listed in + // ADDITIONAL_ALLOWED_FRONTEND_ORIGINS (Developer Rig, local asset server), may read from a browser. + app.use('*', (c, next) => + cors({ + origin: (origin) => (isAllowedFrontendOrigin(origin, c.var.deps.config.additionalAllowedFrontendOrigins) ? origin : ''), + allowMethods: ['GET'], + // A browser hides every response header outside the CORS-safelisted set from the extension's + // script unless it is exposed; the frontend honours Retry-After on a 429 from initial-state. + exposeHeaders: ['Retry-After'], + })(c, next), + ); + + // "ok" means the Worker is up and answering, and a credential problem is reported alongside it, never + // instead of it. `pubsub` is "unknown", "accepted" or "rejected". + app.get('/healthz', async (c) => { + const status = await getCredentialStatus(c.var.deps); + return c.json({ status: 'ok', pubsub: status.state, pubsubStatus: status.httpStatus, pubsubCheckedAt: status.at }); + }); + + registerOAuthRoutes(app); + + // Authenticates (via the EBS-issued token) and rate-limits per authenticated channel; an unauthenticated + // caller is limited per client address instead, so it cannot grind through guesses for free. + app.use('/api/update-state', async (c, next) => { + const method = c.req.method; + if (method !== 'POST' && method !== 'DELETE') return next(); + + const deps = c.var.deps; + // Taking a card down needs no Twitch grant (PubSub is signed with the extension secret), and a + // broadcaster whose grant lapsed must still be able to stop being shown. + const auth = await authenticateBroadcaster(deps, c.req.header('authorization') ?? null, method === 'POST'); + c.set('auth', auth); + + const partition = auth.ok ? `ch:${auth.channelId}` : `ip:${clientIp(c.req.raw)}`; + // Separate budgets so switching the card off straight after a publish is never rejected by that + // publish's window: a clear is the privacy-critical request of the two. + const limited = + method === 'POST' + ? await checkFixedWindow(deps, 'update-state', partition, deps.config.updateStateRateLimit, deps.config.updateStateRateLimitWindowSeconds) + : await checkFixedWindow(deps, 'clear-state', partition, 10, 60); + if (!limited.allowed) return tooManyRequests(limited.retryAfterSeconds); + + await next(); + }); + + app.post('/api/update-state', async (c) => { + const deps = c.var.deps; + const auth = c.var.auth; + if (!auth.ok) return auth.response; + + const body = await readJsonBody(c.req.raw, deps.config.maxRequestBodyBytes); + if (!body.ok) return body.response; + + const state = isPlainObject(body.value) ? body.value.state : undefined; + if (!isPlainObject(state)) { + return problem(400, state === undefined || state === null ? 'The request must contain a "state" JSON object.' : `"state" must be a JSON object, not ${describeKind(state)}.`); + } + + // What is size-checked is the compact serialisation, so that is what is stored and broadcast. + const message = JSON.stringify(state); + const bytes = byteLength(message); + if (bytes > deps.config.maxStatePayloadBytes) + return problem(413, `Serialized state payload is ${bytes} bytes, which exceeds the ${deps.config.maxStatePayloadBytes} byte limit.`); + + const channelId = auth.channelId; + + // Still the live credential? A sign-out that finished while this request was being read has revoked + // the token, and storing a snapshot after that clear would leave a public card with no credential + // able to take it down. + const stillAuthorized = await authenticateBroadcaster(deps, c.req.header('authorization') ?? null, true); + if (!stillAuthorized.ok || stillAuthorized.channelId !== channelId) return stillAuthorized.ok ? unauthorized() : stillAuthorized.response; + + await setState(deps, channelId, message); + const result = await broadcast(deps, channelId, message); + + // Without a per-channel lock (a Worker has none), close the remaining race after the fact: a sign-out + // that landed between the check above and the store has revoked the token, and its own clear may have + // run before this snapshot was written. If the credential is gone now, take the card back down. + const afterwards = await authenticateBroadcaster(deps, c.req.header('authorization') ?? null, false); + if (!afterwards.ok || afterwards.channelId !== channelId) { + await removeState(deps, channelId); + if (result === 'published') await broadcast(deps, channelId, OFFLINE_MESSAGE); + return afterwards.ok ? unauthorized() : afterwards.response; + } + + if (result !== 'published') { + console.warn(`Failed to publish state update for channel ${channelId} to Twitch PubSub.`); + + // Twitch refusing the EBS's own credentials is a server misconfiguration, not an outage, and no + // amount of retrying fixes it. Say so with a header (a proxy in front can replace a 502's body) + // and a 503 (which one does not rewrite), so the desktop app can tell the commander it is not + // their sign-in. Decided from THIS call's outcome, not the shared status. + if (result === 'credentials_rejected') { + return problem( + 503, + "Twitch is rejecting this service's PubSub credentials, so live updates cannot be delivered. The card is stored and new viewers still see it. This is a server configuration problem.", + { [REJECTED_HEADER]: 'credentials' }, + ); + } + return problem(502, 'Failed to publish the update to Twitch PubSub.'); + } + + return json({ published: true, channelId, bytes }); + }); + + // The broadcaster switched the card off or signed out: forget the stored snapshot so /api/initial-state + // stops serving it to anyone who asks, and tell viewers already watching to hide it. + app.delete('/api/update-state', async (c) => { + const auth = c.var.auth; + if (!auth.ok) return auth.response; + + const { viewersTold } = await clearChannel(c.var.deps, auth.channelId); + + // A 502, like the POST's, when viewers already watching were not told: the snapshot is gone either + // way, and a retry is harmless, so the client tries again rather than leave them the card. + if (!viewersTold) + return problem(502, 'The card was removed, but Twitch PubSub did not deliver the offline message to viewers already watching.', { + [SNAPSHOT_REMOVED_HEADER]: 'true', + }); + + return new Response(null, { status: 204 }); + }); + + app.get('/api/initial-state/:channelId', async (c) => { + const deps = c.var.deps; + const limited = await limitByIp(c.env.RL_INITIAL_STATE, c.req.raw, 10); + if (limited) return limited; + + const state = await getState(deps, c.req.param('channelId')); + if (state === null) return json({ message: 'No state has been published for this channel yet.' }, 404); + + // Lets a CDN/proxy/browser absorb a burst of viewers; live changes arrive by PubSub, so a few seconds + // of staleness is invisible. The CORS middleware adds Vary: Origin, so a shared cache keeps one copy + // per allowed origin instead of replaying one origin's Access-Control-Allow-Origin. + const headers: Record = { 'content-type': 'application/json' }; + if (deps.config.initialStateCacheSeconds > 0) headers['cache-control'] = `public, max-age=${deps.config.initialStateCacheSeconds}`; + return new Response(state, { headers }); + }); + + return app; +} diff --git a/ebs-worker/src/clearing.ts b/ebs-worker/src/clearing.ts new file mode 100644 index 0000000..5c46a43 --- /dev/null +++ b/ebs-worker/src/clearing.ts @@ -0,0 +1,24 @@ +import type { Deps } from './context'; +import { removeState } from './db/state'; +import { broadcast } from './twitch/pubsub'; + +/** + * Broadcast to viewers already watching so their open card hides immediately. The extension recognises + * `offline`; `v` matches the snapshot schema so older frontends don't reject it as a newer version. + */ +export const OFFLINE_MESSAGE = JSON.stringify({ v: 1, offline: true }); + +/** Set on a failed clear's 502 when the snapshot was removed and only the offline broadcast failed. */ +export const SNAPSHOT_REMOVED_HEADER = 'X-EDNexus-Snapshot-Removed'; + +/** + * Takes a channel's card off the air: shared by DELETE /api/update-state (the broadcaster switched the + * card off) and POST /oauth/revoke (they signed out). The snapshot is removed whatever PubSub does, + * since it is what leaks to future viewers; `viewersTold` says whether viewers already watching were + * told, so the caller can decide whether that failure is worth a retry. + */ +export async function clearChannel(deps: Deps, channelId: string): Promise<{ removed: boolean; viewersTold: boolean }> { + const removed = await removeState(deps, channelId); + const viewersTold = (await broadcast(deps, channelId, OFFLINE_MESSAGE)) === 'published'; + return { removed, viewersTold }; +} diff --git a/ebs-worker/src/config.ts b/ebs-worker/src/config.ts new file mode 100644 index 0000000..b4fb18e --- /dev/null +++ b/ebs-worker/src/config.ts @@ -0,0 +1,162 @@ +import type { Env } from './env'; + +/** Twitch's hard ceiling on a PubSub message. */ +export const TWITCH_PUBSUB_MAX_MESSAGE_BYTES = 5120; +/** Smallest non-zero snapshot lifetime: older desktop builds refresh an unchanged card only every 6 hours. */ +export const MIN_CHANNEL_STATE_MAX_AGE_HOURS = 12; +export const MIN_EXTENSION_SECRET_BYTES = 16; + +export const TWITCH_AUTHORIZATION_ENDPOINT = 'https://id.twitch.tv/oauth2/authorize'; +export const TWITCH_TOKEN_ENDPOINT = 'https://id.twitch.tv/oauth2/token'; +export const TWITCH_REVOKE_ENDPOINT = 'https://id.twitch.tv/oauth2/revoke'; +export const TWITCH_USERS_ENDPOINT = 'https://api.twitch.tv/helix/users'; +export const TWITCH_PUBSUB_URL = 'https://api.twitch.tv/helix/extensions/pubsub'; +export const PROJECT_SITE_URL = 'https://signal-thread.github.io/EDNexus/'; + +export interface Config { + clientId: string; + clientSecret: string; + extensionId: string; + extensionSecret: string; + ownerUserId: string | null; + tokenEncryptionKey: string; + oauthRedirectUri: string; + oauthScopes: string[]; + maxStatePayloadBytes: number; + maxRequestBodyBytes: number; + updateStateRateLimit: number; + updateStateRateLimitWindowSeconds: number; + initialStateCacheSeconds: number; + oauthSessionTtlMinutes: number; + oauthCodeTtlSeconds: number; + tokenRefreshBufferMinutes: number; + /** 0 disables the limit. */ + channelStateMaxAgeHours: number; + additionalAllowedFrontendOrigins: Set; +} + +export type ConfigResult = { ok: true; config: Config } | { ok: false; failures: string[] }; + +/** + * Reads and validates the Worker's configuration. A Worker has no startup, so this runs on the first + * request (and every cron) and reports every problem at once, as the ASP.NET build's options validators did. + */ +export function loadConfig(env: Env): ConfigResult { + const failures: string[] = []; + const text = (name: keyof Env): string => { + const value = env[name]; + return typeof value === 'string' ? value.trim() : ''; + }; + const integer = (name: keyof Env, fallback: number): number => { + const raw = text(name); + if (raw === '') return fallback; + if (!/^-?\d+$/.test(raw)) { + failures.push(`${name} must be a whole number (got "${raw}").`); + return fallback; + } + return Number.parseInt(raw, 10); + }; + + const clientId = text('TWITCH_CLIENT_ID'); + const clientSecret = text('TWITCH_CLIENT_SECRET'); + const extensionId = text('TWITCH_EXTENSION_ID'); + const extensionSecret = text('TWITCH_EXTENSION_SECRET'); + const tokenEncryptionKey = text('TOKEN_ENCRYPTION_KEY'); + + if (!clientId) failures.push('TWITCH_CLIENT_ID must be set.'); + if (!clientSecret) failures.push('TWITCH_CLIENT_SECRET must be set (the OAuth login cannot work without it).'); + if (!extensionId) failures.push('TWITCH_EXTENSION_ID must be set (it is the Client-Id of the PubSub call; often the same value as TWITCH_CLIENT_ID).'); + + if (!extensionSecret) { + failures.push('TWITCH_EXTENSION_SECRET must be set to a non-empty, valid base64 string.'); + } else { + const bytes = tryBase64(extensionSecret); + if (!bytes) failures.push('TWITCH_EXTENSION_SECRET must be set to a non-empty, valid base64 string.'); + else if (bytes.length < MIN_EXTENSION_SECRET_BYTES) + failures.push(`TWITCH_EXTENSION_SECRET decodes to only ${bytes.length} bytes; expected at least ${MIN_EXTENSION_SECRET_BYTES} (Twitch issues 32).`); + } + + if (!tokenEncryptionKey) { + failures.push('TOKEN_ENCRYPTION_KEY must be set to the base64 of 32 random bytes.'); + } else if (tryBase64(tokenEncryptionKey)?.length !== 32) { + failures.push('TOKEN_ENCRYPTION_KEY must be the base64 of exactly 32 bytes.'); + } + + const oauthRedirectUri = text('TWITCH_OAUTH_REDIRECT_URI') || 'https://ednexus.signal-and-thread.com/oauth/callback'; + if (!isAcceptableRedirectUri(oauthRedirectUri)) + failures.push('TWITCH_OAUTH_REDIRECT_URI must be an absolute https URL (http is allowed only for localhost), exactly as registered with Twitch.'); + + const ownerUserId = text('TWITCH_OWNER_USER_ID'); + if (ownerUserId && !/^\d+$/.test(ownerUserId)) failures.push('TWITCH_OWNER_USER_ID, when set, must be the numeric Twitch user id of the extension owner.'); + + const scopes = [ + ...new Set( + (text('TWITCH_OAUTH_SCOPES') || 'user:read:email') + .split(/[\s,]+/) + .filter(Boolean) + .map((s) => s.toLowerCase()), + ), + ]; + + const maxStatePayloadBytes = integer('MAX_STATE_PAYLOAD_BYTES', 5000); + const maxRequestBodyBytes = integer('MAX_REQUEST_BODY_BYTES', 16 * 1024); + const channelStateMaxAgeHours = integer('CHANNEL_STATE_MAX_AGE_HOURS', 24); + + if (maxStatePayloadBytes < 1 || maxStatePayloadBytes > TWITCH_PUBSUB_MAX_MESSAGE_BYTES) + failures.push(`MAX_STATE_PAYLOAD_BYTES must be between 1 and ${TWITCH_PUBSUB_MAX_MESSAGE_BYTES} (Twitch drops a PubSub message over 5 KiB).`); + if (maxRequestBodyBytes < 1024) failures.push('MAX_REQUEST_BODY_BYTES must be at least 1024.'); + else if (maxRequestBodyBytes < maxStatePayloadBytes + 64) + failures.push('MAX_REQUEST_BODY_BYTES must leave room for a full MAX_STATE_PAYLOAD_BYTES state plus the request envelope.'); + if (channelStateMaxAgeHours > 0 && channelStateMaxAgeHours < MIN_CHANNEL_STATE_MAX_AGE_HOURS) + failures.push( + `CHANNEL_STATE_MAX_AGE_HOURS must be 0 (no limit) or at least ${MIN_CHANNEL_STATE_MAX_AGE_HOURS}: a shorter limit could take down the live card of a desktop build that refreshes an unchanged card only every 6 hours.`, + ); + + const origins = new Set( + text('ADDITIONAL_ALLOWED_FRONTEND_ORIGINS') + .split(/[\s,]+/) + .filter(Boolean) + .map((o) => o.toLowerCase()), + ); + + const config: Config = { + clientId, + clientSecret, + extensionId, + extensionSecret, + ownerUserId: ownerUserId || null, + tokenEncryptionKey, + oauthRedirectUri, + oauthScopes: scopes, + maxStatePayloadBytes, + maxRequestBodyBytes, + updateStateRateLimit: Math.max(1, integer('UPDATE_STATE_RATE_LIMIT', 1)), + updateStateRateLimitWindowSeconds: Math.max(1, integer('UPDATE_STATE_RATE_LIMIT_WINDOW_SECONDS', 2)), + initialStateCacheSeconds: integer('INITIAL_STATE_CACHE_SECONDS', 5), + oauthSessionTtlMinutes: Math.max(1, integer('OAUTH_SESSION_TTL_MINUTES', 10)), + oauthCodeTtlSeconds: Math.max(10, integer('OAUTH_CODE_TTL_SECONDS', 60)), + tokenRefreshBufferMinutes: Math.max(0, integer('TWITCH_TOKEN_REFRESH_BUFFER_MINUTES', 60)), + channelStateMaxAgeHours, + additionalAllowedFrontendOrigins: origins, + }; + + return failures.length === 0 ? { ok: true, config } : { ok: false, failures }; +} + +function isAcceptableRedirectUri(value: string): boolean { + try { + const url = new URL(value); + if (url.protocol === 'https:') return true; + return url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]'); + } catch { + return false; + } +} + +function tryBase64(value: string): Uint8Array | null { + try { + return Uint8Array.from(atob(value), (c) => c.charCodeAt(0)); + } catch { + return null; + } +} diff --git a/ebs-worker/src/context.ts b/ebs-worker/src/context.ts new file mode 100644 index 0000000..8368f1e --- /dev/null +++ b/ebs-worker/src/context.ts @@ -0,0 +1,15 @@ +import type { Config } from './config'; +import type { Env } from './env'; + +/** Everything a store or route needs: bindings, validated config, and an injectable clock. */ +export interface Deps { + env: Env; + db: D1Database; + config: Config; + now: () => Date; +} + +/** Fixed-width ISO-8601 UTC text; compares correctly as text. */ +export function iso(date: Date): string { + return date.toISOString(); +} diff --git a/ebs-worker/src/crypto.ts b/ebs-worker/src/crypto.ts new file mode 100644 index 0000000..2fb1975 --- /dev/null +++ b/ebs-worker/src/crypto.ts @@ -0,0 +1,99 @@ +/** WebCrypto helpers: base64url, SHA-256, HMAC, PKCE, and AES-GCM for tokens at rest. */ + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +export function base64UrlEncode(bytes: Uint8Array): string { + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +export function base64UrlDecode(value: string): Uint8Array { + const padded = value.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4)); + return Uint8Array.from(binary, (c) => c.charCodeAt(0)); +} + +export function base64Decode(value: string): Uint8Array { + return Uint8Array.from(atob(value), (c) => c.charCodeAt(0)); +} + +/** A random, URL-safe opaque token of `byteLength` random bytes. */ +export function randomToken(byteLength = 32): string { + return base64UrlEncode(crypto.getRandomValues(new Uint8Array(byteLength))); +} + +/** Byte length of long-lived broadcaster tokens, and of short-lived session ids and codes. */ +export const TOKEN_BYTES = 32; +export const SHORT_LIVED_BYTES = 24; + +export async function sha256Hex(value: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', encoder.encode(value)); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +export async function hmacSha256(key: Uint8Array, data: Uint8Array): Promise { + const cryptoKey = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + return new Uint8Array(await crypto.subtle.sign('HMAC', cryptoKey, data)); +} + +/** Constant-time comparison of two strings (length differences are not hidden; they are not secret here). */ +export function timingSafeEqual(a: string, b: string): boolean { + const x = encoder.encode(a); + const y = encoder.encode(b); + if (x.length !== y.length) return false; + let diff = 0; + for (let i = 0; i < x.length; i++) diff |= x[i]! ^ y[i]!; + return diff === 0; +} + +/** RFC 7636 S256 challenge for a verifier. */ +export async function pkceChallenge(verifier: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', encoder.encode(verifier)); + return base64UrlEncode(new Uint8Array(digest)); +} + +/** Verifies a PKCE verifier against the recorded challenge in constant time. */ +export async function verifyPkce(verifier: string, challenge: string): Promise { + if (!verifier || !challenge) return false; + return timingSafeEqual(await pkceChallenge(verifier), challenge); +} + +// --------------------------------------------------------------------------------------------- +// Encryption of Twitch tokens at rest. The ASP.NET build used Data Protection with a key ring kept +// on a separate volume; here the key is a Worker secret, so a copy of the D1 database alone cannot be +// decrypted. Format: "v1..", both base64url. `context` (the row it belongs to) is +// bound in as additional authenticated data, so a ciphertext cannot be replayed into another row. +// --------------------------------------------------------------------------------------------- + +const FORMAT = 'v1'; + +export class DecryptionError extends Error {} + +async function importKey(base64Key: string, usage: 'encrypt' | 'decrypt'): Promise { + return crypto.subtle.importKey('raw', base64Decode(base64Key), 'AES-GCM', false, [usage]); +} + +export async function encryptText(base64Key: string, plaintext: string, context: string): Promise { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await importKey(base64Key, 'encrypt'); + const sealed = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: encoder.encode(context) }, key, encoder.encode(plaintext)); + return `${FORMAT}.${base64UrlEncode(iv)}.${base64UrlEncode(new Uint8Array(sealed))}`; +} + +export async function decryptText(base64Key: string, sealed: string, context: string): Promise { + const [format, iv, data] = sealed.split('.'); + if (format !== FORMAT || !iv || !data) throw new DecryptionError('Unrecognised ciphertext format.'); + try { + const key = await importKey(base64Key, 'decrypt'); + const plain = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: base64UrlDecode(iv), additionalData: encoder.encode(context) }, + key, + base64UrlDecode(data), + ); + return decoder.decode(plain); + } catch (error) { + throw new DecryptionError(error instanceof Error ? error.message : 'Decryption failed.'); + } +} diff --git a/ebs-worker/src/db/pending.ts b/ebs-worker/src/db/pending.ts new file mode 100644 index 0000000..ecee2f0 --- /dev/null +++ b/ebs-worker/src/db/pending.ts @@ -0,0 +1,82 @@ +import { decryptText, encryptText, randomToken, SHORT_LIVED_BYTES, DecryptionError } from '../crypto'; +import { iso, type Deps } from '../context'; + +/** Most sessions / codes held at once. Far above any real login rate; the per-IP limit on /oauth/* keeps one caller from filling it. */ +export const MAX_PENDING_ENTRIES = 5000; + +export interface PendingSession { + desktopRedirectUri: string; + desktopState: string; + codeChallenge: string; +} + +export interface PendingBroadcasterAuth { + channelId: string; + username: string; + twitchAccessToken: string; + twitchRefreshToken: string; + twitchExpiresAt: string; + codeChallenge: string; + redirectUri: string; +} + +type Kind = 'session' | 'code'; + +async function insert(deps: Deps, kind: Kind, payload: (id: string) => Promise, ttlMs: number): Promise { + const id = randomToken(SHORT_LIVED_BYTES); + const now = deps.now(); + await deps.db.batch([ + deps.db.prepare('DELETE FROM oauth_pending WHERE expires_at < ?').bind(iso(now)), + deps.db + .prepare('INSERT INTO oauth_pending (id, kind, payload, expires_at) VALUES (?, ?, ?, ?)') + .bind(id, kind, await payload(id), iso(new Date(now.getTime() + ttlMs))), + // The cap: when a burst fills a kind, drop whatever is closest to expiring rather than grow without bound. + deps.db + .prepare( + `DELETE FROM oauth_pending WHERE id IN ( + SELECT id FROM oauth_pending WHERE kind = ? ORDER BY expires_at DESC LIMIT -1 OFFSET ?)`, + ) + .bind(kind, MAX_PENDING_ENTRIES), + ]); + return id; +} + +/** Deletes and returns an unexpired entry: single use, atomically, so a replayed id finds nothing. */ +async function consume(deps: Deps, kind: Kind, id: string): Promise { + const row = await deps.db + .prepare('DELETE FROM oauth_pending WHERE id = ? AND kind = ? RETURNING payload, expires_at') + .bind(id, kind) + .first<{ payload: string; expires_at: string }>(); + if (!row || Date.parse(row.expires_at) < deps.now().getTime()) return null; + return row.payload; +} + +export async function createSession(deps: Deps, session: PendingSession, ttlMs: number): Promise { + return insert(deps, 'session', async () => JSON.stringify(session), ttlMs); +} + +export async function consumeSession(deps: Deps, sessionId: string): Promise { + const payload = await consume(deps, 'session', sessionId); + return payload ? (JSON.parse(payload) as PendingSession) : null; +} + +/** The code's payload carries Twitch tokens, so it is stored encrypted, bound to the code's own id. */ +export async function createAuthorizationCode(deps: Deps, auth: PendingBroadcasterAuth, ttlMs: number): Promise { + return insert(deps, 'code', (id) => encryptText(deps.config.tokenEncryptionKey, JSON.stringify(auth), id), ttlMs); +} + +export async function consumeAuthorizationCode(deps: Deps, code: string): Promise { + const payload = await consume(deps, 'code', code); + if (!payload) return null; + try { + return JSON.parse(await decryptText(deps.config.tokenEncryptionKey, payload, code)) as PendingBroadcasterAuth; + } catch (error) { + if (error instanceof DecryptionError) return null; + throw error; + } +} + +export async function pruneExpiredPending(deps: Deps): Promise { + const result = await deps.db.prepare('DELETE FROM oauth_pending WHERE expires_at < ?').bind(iso(deps.now())).run(); + return result.meta.changes ?? 0; +} diff --git a/ebs-worker/src/db/ratelimit.ts b/ebs-worker/src/db/ratelimit.ts new file mode 100644 index 0000000..ecfa696 --- /dev/null +++ b/ebs-worker/src/db/ratelimit.ts @@ -0,0 +1,38 @@ +import type { Deps } from '../context'; + +export type LimitResult = { allowed: true } | { allowed: false; retryAfterSeconds: number }; + +/** + * Fixed-window counter in D1, keyed per policy and partition (a channel id). One atomic upsert both + * counts and rolls the window, so concurrent requests cannot both slip under the limit. Used where the + * window is shorter than the Rate Limiting binding supports (update-state: 1 per 2 seconds) and where + * the partition is an authenticated channel, so the write volume is bounded by real broadcasters. + */ +export async function checkFixedWindow(deps: Deps, policy: string, partition: string, limit: number, windowSeconds: number): Promise { + const windowMs = windowSeconds * 1000; + const nowMs = deps.now().getTime(); + const windowStart = Math.floor(nowMs / windowMs) * windowMs; + + const row = await deps.db + .prepare( + `INSERT INTO rate_limit (key, window_start, count) VALUES (?, ?, 1) + ON CONFLICT(key) DO UPDATE SET + count = CASE WHEN rate_limit.window_start = excluded.window_start THEN rate_limit.count + 1 ELSE 1 END, + window_start = excluded.window_start + RETURNING count`, + ) + .bind(`${policy}:${partition}`, windowStart) + .first<{ count: number }>(); + + if ((row?.count ?? 1) <= limit) return { allowed: true }; + return { allowed: false, retryAfterSeconds: Math.max(1, Math.ceil((windowStart + windowMs - nowMs) / 1000)) }; +} + +/** Deletes counters whose window ended more than an hour ago. */ +export async function pruneRateLimits(deps: Deps): Promise { + const result = await deps.db + .prepare('DELETE FROM rate_limit WHERE window_start < ?') + .bind(deps.now().getTime() - 3_600_000) + .run(); + return result.meta.changes ?? 0; +} diff --git a/ebs-worker/src/db/state.ts b/ebs-worker/src/db/state.ts new file mode 100644 index 0000000..1b8e734 --- /dev/null +++ b/ebs-worker/src/db/state.ts @@ -0,0 +1,47 @@ +import { iso, type Deps } from '../context'; + +/** Stores the compact JSON of a channel's last published state. */ +export async function setState(deps: Deps, channelId: string, stateJson: string): Promise { + await deps.db + .prepare( + `INSERT INTO channel_state (channel_id, state_json, updated_at) VALUES (?, ?, ?) + ON CONFLICT(channel_id) DO UPDATE SET state_json = excluded.state_json, updated_at = excluded.updated_at`, + ) + .bind(channelId, stateJson, iso(deps.now())) + .run(); +} + +/** + * The stored state JSON, or null when there is none or it is older than CHANNEL_STATE_MAX_AGE_HOURS. + * Past the limit counts as gone even before the cron has deleted the row; an unparseable timestamp is + * treated as expired (failing closed keeps an unreadable row from being served forever). + */ +export async function getState(deps: Deps, channelId: string): Promise { + const row = await deps.db + .prepare('SELECT state_json, updated_at FROM channel_state WHERE channel_id = ?') + .bind(channelId) + .first<{ state_json: string; updated_at: string }>(); + if (!row) return null; + + const maxAgeHours = deps.config.channelStateMaxAgeHours; + if (maxAgeHours > 0) { + const updated = Date.parse(row.updated_at); + if (Number.isNaN(updated) || deps.now().getTime() - updated > maxAgeHours * 3_600_000) return null; + } + return row.state_json; +} + +/** Forgets the snapshot. True when a row was removed. */ +export async function removeState(deps: Deps, channelId: string): Promise { + const result = await deps.db.prepare('DELETE FROM channel_state WHERE channel_id = ?').bind(channelId).run(); + return (result.meta.changes ?? 0) > 0; +} + +/** Deletes snapshots older than the limit. Returns how many. */ +export async function pruneExpiredState(deps: Deps): Promise { + const maxAgeHours = deps.config.channelStateMaxAgeHours; + if (maxAgeHours <= 0) return 0; + const cutoff = new Date(deps.now().getTime() - maxAgeHours * 3_600_000); + const result = await deps.db.prepare('DELETE FROM channel_state WHERE updated_at < ?').bind(iso(cutoff)).run(); + return result.meta.changes ?? 0; +} diff --git a/ebs-worker/src/db/status.ts b/ebs-worker/src/db/status.ts new file mode 100644 index 0000000..4bdb970 --- /dev/null +++ b/ebs-worker/src/db/status.ts @@ -0,0 +1,48 @@ +import { iso, type Deps } from '../context'; + +/** What the EBS last learned about whether Twitch accepts the credentials it signs PubSub calls with. */ +export type CredentialState = 'unknown' | 'accepted' | 'rejected'; + +export interface CredentialStatus { + state: CredentialState; + /** The HTTP status behind a rejection (401 or 403). */ + httpStatus: number | null; + /** When the state last changed. */ + at: string | null; +} + +/** Response header on a failure caused by Twitch refusing the EBS's credentials. Mirrored by the desktop's StreamStateApiClient. */ +export const REJECTED_HEADER = 'X-EDNexus-Pubsub-Rejected'; + +const KEY = 'pubsub_credentials'; + +/** True for the statuses Twitch uses to refuse a signed token: the credentials, not the payload or Twitch itself. */ +export function isCredentialRefusal(httpStatus: number): boolean { + return httpStatus === 401 || httpStatus === 403; +} + +export async function getCredentialStatus(deps: Deps): Promise { + const row = await deps.db + .prepare('SELECT value, updated_at FROM service_status WHERE key = ?') + .bind(KEY) + .first<{ value: string; updated_at: string }>(); + if (!row) return { state: 'unknown', httpStatus: null, at: null }; + const parsed = JSON.parse(row.value) as { state: CredentialState; httpStatus: number | null }; + return { state: parsed.state, httpStatus: parsed.httpStatus, at: row.updated_at }; +} + +// Written only when the verdict changes. Every real broadcast reports success, and an unconditional +// write per publish would cost a D1 row write for no new information. +async function write(deps: Deps, state: CredentialState, httpStatus: number | null): Promise { + await deps.db + .prepare( + `INSERT INTO service_status (key, value, updated_at) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + WHERE service_status.value != excluded.value`, + ) + .bind(KEY, JSON.stringify({ state, httpStatus }), iso(deps.now())) + .run(); +} + +export const markCredentialsAccepted = (deps: Deps) => write(deps, 'accepted', null); +export const markCredentialsRejected = (deps: Deps, httpStatus: number) => write(deps, 'rejected', httpStatus); diff --git a/ebs-worker/src/db/tokens.ts b/ebs-worker/src/db/tokens.ts new file mode 100644 index 0000000..2fe1207 --- /dev/null +++ b/ebs-worker/src/db/tokens.ts @@ -0,0 +1,143 @@ +import { DecryptionError, decryptText, encryptText, randomToken, sha256Hex, TOKEN_BYTES } from '../crypto'; +import { iso, type Deps } from '../context'; + +export interface BroadcasterToken { + channelId: string; + username: string; + twitchAccessToken: string; + twitchRefreshToken: string; + twitchExpiresAt: Date; + /** False when Twitch revoked the grant, a refresh was rejected, or the stored ciphertext cannot be decrypted. */ + isTwitchGrantValid: boolean; + createdAt: Date; + lastRefreshedAt: Date | null; +} + +interface Row { + channel_id: string; + username: string; + twitch_access_token: string; + twitch_refresh_token: string; + twitch_expires_at: string; + is_twitch_grant_valid: number; + created_at: string; + last_refreshed_at: string | null; +} + +const COLUMNS = + 'channel_id, username, twitch_access_token, twitch_refresh_token, twitch_expires_at, is_twitch_grant_valid, created_at, last_refreshed_at'; + +/** + * Issues a new long-lived bearer token for a broadcaster and stores it, hashed, with their Twitch grant + * encrypted. channel_id is the primary key, so REPLACE drops any prior row (and with it the prior token's + * hash) in one statement: signing in again invalidates the old token atomically. + */ +export async function issueToken( + deps: Deps, + channelId: string, + username: string, + twitchAccessToken: string, + twitchRefreshToken: string, + twitchExpiresAt: Date, +): Promise<{ token: string; channelId: string; username: string }> { + const token = randomToken(TOKEN_BYTES); + const key = deps.config.tokenEncryptionKey; + await deps.db + .prepare( + `INSERT OR REPLACE INTO broadcaster_tokens + (channel_id, token_hash, username, twitch_access_token, twitch_refresh_token, twitch_expires_at, is_twitch_grant_valid, created_at, last_refreshed_at) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, NULL)`, + ) + .bind( + channelId, + await sha256Hex(token), + username, + await encryptText(key, twitchAccessToken, channelId), + await encryptText(key, twitchRefreshToken, channelId), + iso(twitchExpiresAt), + iso(deps.now()), + ) + .run(); + return { token, channelId, username }; +} + +export async function getByToken(deps: Deps, token: string): Promise { + const row = await deps.db + .prepare(`SELECT ${COLUMNS} FROM broadcaster_tokens WHERE token_hash = ?`) + .bind(await sha256Hex(token)) + .first(); + return row ? readRow(deps, row) : null; +} + +/** Grants whose access token expires at or before `cutoff`, soonest first, at most `limit` of them. */ +export async function dueForRefresh(deps: Deps, cutoff: Date, limit: number): Promise { + const { results } = await deps.db + .prepare( + `SELECT ${COLUMNS} FROM broadcaster_tokens + WHERE is_twitch_grant_valid = 1 AND twitch_expires_at <= ? + ORDER BY twitch_expires_at LIMIT ?`, + ) + .bind(iso(cutoff), limit) + .all(); + return Promise.all(results.map((row) => readRow(deps, row))); +} + +export async function updateTwitchTokens( + deps: Deps, + channelId: string, + twitchAccessToken: string, + twitchRefreshToken: string, + twitchExpiresAt: Date, +): Promise { + const key = deps.config.tokenEncryptionKey; + await deps.db + .prepare( + `UPDATE broadcaster_tokens + SET twitch_access_token = ?, twitch_refresh_token = ?, twitch_expires_at = ?, is_twitch_grant_valid = 1, last_refreshed_at = ? + WHERE channel_id = ?`, + ) + .bind( + await encryptText(key, twitchAccessToken, channelId), + await encryptText(key, twitchRefreshToken, channelId), + iso(twitchExpiresAt), + iso(deps.now()), + channelId, + ) + .run(); +} + +export async function markTwitchGrantInvalid(deps: Deps, channelId: string): Promise { + await deps.db.prepare('UPDATE broadcaster_tokens SET is_twitch_grant_valid = 0 WHERE channel_id = ?').bind(channelId).run(); +} + +/** Deletes the token with this value. True when one existed. */ +export async function revoke(deps: Deps, token: string): Promise { + const result = await deps.db.prepare('DELETE FROM broadcaster_tokens WHERE token_hash = ?').bind(await sha256Hex(token)).run(); + return (result.meta.changes ?? 0) > 0; +} + +async function readRow(deps: Deps, row: Row): Promise { + let isGrantValid = row.is_twitch_grant_valid !== 0; + let access = ''; + let refresh = ''; + try { + access = await decryptText(deps.config.tokenEncryptionKey, row.twitch_access_token, row.channel_id); + refresh = await decryptText(deps.config.tokenEncryptionKey, row.twitch_refresh_token, row.channel_id); + } catch (error) { + if (!(error instanceof DecryptionError)) throw error; + // TOKEN_ENCRYPTION_KEY was changed or lost: the grant is unrecoverable. Surface it as invalid so + // /api/update-state answers 401 and the desktop client prompts the commander to log in again. + console.warn(`Could not decrypt the stored Twitch grant for channel ${row.channel_id}; treating it as invalid until the broadcaster re-authenticates.`); + isGrantValid = false; + } + return { + channelId: row.channel_id, + username: row.username, + twitchAccessToken: access, + twitchRefreshToken: refresh, + twitchExpiresAt: new Date(row.twitch_expires_at), + isTwitchGrantValid: isGrantValid, + createdAt: new Date(row.created_at), + lastRefreshedAt: row.last_refreshed_at ? new Date(row.last_refreshed_at) : null, + }; +} diff --git a/ebs-worker/src/env.ts b/ebs-worker/src/env.ts new file mode 100644 index 0000000..90e4674 --- /dev/null +++ b/ebs-worker/src/env.ts @@ -0,0 +1,37 @@ +/** Bindings of the EBS Worker. Keep this, wrangler.example.jsonc, .dev.vars.example and README.md in step. */ +export interface Env { + DB: D1Database; + + // Optional Rate Limiting bindings (per-client-IP limits). Absent in tests and when not configured. + RL_INITIAL_STATE?: RateLimit; + RL_OAUTH?: RateLimit; + + // Secrets. + TWITCH_CLIENT_ID: string; + TWITCH_CLIENT_SECRET: string; + TWITCH_EXTENSION_ID: string; + /** base64 Extension Secret from the Twitch Developer Console; signs the PubSub JWT. */ + TWITCH_EXTENSION_SECRET: string; + /** base64 of 32 random bytes; encrypts the Twitch tokens stored in D1. Lose it and every broadcaster logs in again. */ + TOKEN_ENCRYPTION_KEY: string; + + // Vars (all optional except the redirect URI, which has a default). + TWITCH_OAUTH_REDIRECT_URI?: string; + TWITCH_OAUTH_SCOPES?: string; + TWITCH_OWNER_USER_ID?: string; + MAX_STATE_PAYLOAD_BYTES?: string; + MAX_REQUEST_BODY_BYTES?: string; + UPDATE_STATE_RATE_LIMIT?: string; + UPDATE_STATE_RATE_LIMIT_WINDOW_SECONDS?: string; + INITIAL_STATE_CACHE_SECONDS?: string; + OAUTH_SESSION_TTL_MINUTES?: string; + OAUTH_CODE_TTL_SECONDS?: string; + TWITCH_TOKEN_REFRESH_BUFFER_MINUTES?: string; + CHANNEL_STATE_MAX_AGE_HOURS?: string; + ADDITIONAL_ALLOWED_FRONTEND_ORIGINS?: string; +} + +/** The Workers Rate Limiting binding. */ +export interface RateLimit { + limit(options: { key: string }): Promise<{ success: boolean }>; +} diff --git a/ebs-worker/src/http.ts b/ebs-worker/src/http.ts new file mode 100644 index 0000000..666d8e1 --- /dev/null +++ b/ebs-worker/src/http.ts @@ -0,0 +1,60 @@ +/** Response helpers that reproduce ASP.NET's `Results.Problem` / `Results.Unauthorized` shapes. */ + +const PROBLEM_TYPES: Record = { + 400: { type: 'https://tools.ietf.org/html/rfc9110#section-15.5.1', title: 'Bad Request' }, + 401: { type: 'https://tools.ietf.org/html/rfc9110#section-15.5.2', title: 'Unauthorized' }, + 413: { type: 'https://tools.ietf.org/html/rfc9110#section-15.5.14', title: 'Request Entity Too Large' }, + 502: { type: 'https://tools.ietf.org/html/rfc9110#section-15.6.3', title: 'Bad Gateway' }, + 503: { type: 'https://tools.ietf.org/html/rfc9110#section-15.6.4', title: 'Service Unavailable' }, +}; + +/** An RFC 9457 problem+json response with `detail`, like `Results.Problem(detail, statusCode: status)`. */ +export function problem(status: number, detail: string, headers?: Record): Response { + const known = PROBLEM_TYPES[status] ?? { type: 'about:blank', title: 'Error' }; + return new Response(JSON.stringify({ type: known.type, title: known.title, status, detail }), { + status, + headers: { 'content-type': 'application/problem+json', ...headers }, + }); +} + +/** A bare 401, like `Results.Unauthorized()`. */ +export function unauthorized(): Response { + return new Response(null, { status: 401 }); +} + +export function json(body: unknown, status = 200, headers?: Record): Response { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers } }); +} + +/** UTF-8 byte length of a string. */ +export function byteLength(value: string): number { + return new TextEncoder().encode(value).length; +} + +export type BodyResult = { ok: true; value: unknown } | { ok: false; response: Response }; + +/** + * Reads a JSON request body, refusing one over `limit` bytes with a 413 before parsing it. The + * Content-Length check refuses an honest oversized request without reading a byte; the length of + * what was actually read covers a chunked upload that declared nothing. + */ +export async function readJsonBody(request: Request, limit: number): Promise { + const tooLarge = (): BodyResult => ({ ok: false, response: problem(413, `The request body exceeds the ${limit} byte limit.`) }); + + const declared = Number(request.headers.get('content-length')); + if (Number.isFinite(declared) && declared > limit) return tooLarge(); + + const buffer = await request.arrayBuffer(); + if (buffer.byteLength > limit) return tooLarge(); + if (buffer.byteLength === 0) return { ok: true, value: undefined }; + + try { + return { ok: true, value: JSON.parse(new TextDecoder().decode(buffer)) }; + } catch { + return { ok: false, response: problem(400, 'The request body is not valid JSON.') }; + } +} + +export function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/ebs-worker/src/index.ts b/ebs-worker/src/index.ts new file mode 100644 index 0000000..bae0e44 --- /dev/null +++ b/ebs-worker/src/index.ts @@ -0,0 +1,12 @@ +import { createApp } from './app'; +import type { Env } from './env'; +import { runScheduled } from './scheduled'; + +const app = createApp(); + +export default { + fetch: app.fetch, + scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): void { + ctx.waitUntil(runScheduled(env, controller.cron)); + }, +} satisfies ExportedHandler; diff --git a/ebs-worker/src/routes/oauth.ts b/ebs-worker/src/routes/oauth.ts new file mode 100644 index 0000000..c1c4985 --- /dev/null +++ b/ebs-worker/src/routes/oauth.ts @@ -0,0 +1,234 @@ +import type { Hono } from 'hono'; +import { clientIp, limitByIp, type AppEnv } from '../app'; +import { clearChannel, OFFLINE_MESSAGE } from '../clearing'; +import { TWITCH_AUTHORIZATION_ENDPOINT } from '../config'; +import { verifyPkce } from '../crypto'; +import { + consumeAuthorizationCode, + consumeSession, + createAuthorizationCode, + createSession, +} from '../db/pending'; +import { removeState } from '../db/state'; +import { getByToken, issueToken, revoke } from '../db/tokens'; +import { isPlainObject, json, problem, readJsonBody } from '../http'; +import { broadcast } from '../twitch/pubsub'; +import { exchangeAuthorizationCode, getUser, revokeToken, TwitchOAuthError } from '../twitch/oauth'; + +// A real redirect_uri is a loopback address with a port and a short path; state is a random token; a +// PKCE S256 challenge is always exactly 43 base64url characters. Anything bigger is not a client of ours +// and would only be bytes held for the session's lifetime. +const MAX_REDIRECT_URI_LENGTH = 512; +const MAX_STATE_LENGTH = 512; +const CODE_CHALLENGE_LENGTH = 43; +const MAX_TOKEN_FIELD_LENGTH = 512; + +const isBase64UrlChallenge = (value: string) => value.length === CODE_CHALLENGE_LENGTH && /^[A-Za-z0-9_-]+$/.test(value); + +/** Exact host matching on purpose. The IPv6 loopback keeps its brackets in URL.hostname. */ +function parseLoopbackRedirect(value: string): URL | null { + try { + const url = new URL(value); + if (url.protocol !== 'http:') return null; + return url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]' ? url : null; + } catch { + return null; + } +} + +function buildDesktopRedirect(base: URL, state: string, extra: { code?: string; error?: string; errorDescription?: string }): string { + // Serialised from the parsed URL, so the Location header is exactly the address that passed + // parseLoopbackRedirect; any query string the desktop registered is kept. + const url = new URL(base.href); + url.searchParams.append('state', state); + if (extra.code) url.searchParams.append('code', extra.code); + if (extra.error) url.searchParams.append('error', extra.error); + if (extra.errorDescription) url.searchParams.append('error_description', extra.errorDescription); + return url.href; +} + +const escapeHtml = (value: string) => + value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + +function errorPage(message: string): Response { + return new Response( + ` +Login failed + +

Login failed

${escapeHtml(message)}

+`, + { status: 400, headers: { 'content-type': 'text/html; charset=utf-8' } }, + ); +} + +const bearer = (header: string | undefined): string | null => + header && /^bearer /i.test(header.trim()) ? header.trim().slice('Bearer '.length).trim() : null; + +/** + * The EBS-mediated Twitch OAuth flow: the desktop client only ever talks to these endpoints, never to + * Twitch directly. See README.md for the full request/response contract. + */ +export function registerOAuthRoutes(app: Hono): void { + // One per-IP budget across all four routes: they are unauthenticated, and /authorize and /token each + // allocate server-side state. + app.use('/oauth/*', async (c, next) => { + const limited = await limitByIp(c.env.RL_OAUTH, c.req.raw, 60); + return limited ?? next(); + }); + + // Step 1: the desktop app opens the commander's browser here. Records the desktop's own loopback + // redirect/state/PKCE challenge, then redirects to Twitch's real consent page with the EBS's own + // registered redirect URI and a session id (as Twitch's `state`) standing in for it. + app.get('/oauth/authorize', async (c) => { + const deps = c.var.deps; + const query = c.req.query(); + const redirectUri = query.redirect_uri ?? ''; + const state = query.state ?? ''; + const codeChallenge = query.code_challenge ?? ''; + const method = query.code_challenge_method ?? ''; + + if (!redirectUri.trim() || !state.trim() || !codeChallenge.trim()) + return problem(400, 'redirect_uri, state, and code_challenge are all required.'); + if (redirectUri.length > MAX_REDIRECT_URI_LENGTH || state.length > MAX_STATE_LENGTH) return problem(400, 'redirect_uri or state is too long.'); + if (method && method !== 'S256') return problem(400, 'Only the S256 code_challenge_method is supported.'); + if (!isBase64UrlChallenge(codeChallenge)) return problem(400, 'code_challenge must be a 43-character base64url S256 challenge.'); + if (!parseLoopbackRedirect(redirectUri)) return problem(400, 'redirect_uri must be an http loopback (localhost/127.0.0.1/[::1]) address.'); + + const sessionId = await createSession( + deps, + { desktopRedirectUri: redirectUri, desktopState: state, codeChallenge }, + deps.config.oauthSessionTtlMinutes * 60_000, + ); + + const target = new URL(TWITCH_AUTHORIZATION_ENDPOINT); + // URLSearchParams would encode the scope separator as "+"; Twitch is documented with "%20". + const scope = encodeURIComponent(deps.config.oauthScopes.join(' ')); + const search = + `client_id=${encodeURIComponent(deps.config.clientId)}` + + `&redirect_uri=${encodeURIComponent(deps.config.oauthRedirectUri)}` + + `&response_type=code&scope=${scope}&state=${encodeURIComponent(sessionId)}`; + return c.redirect(`${target.origin}${target.pathname}?${search}`, 302); + }); + + // Step 2: Twitch redirects the commander's browser back here after they approve/deny the request. The + // EBS exchanges the code for Twitch tokens using its client secret (never exposed to the desktop + // client), identifies the broadcaster, and redirects the browser back to the desktop's own loopback + // listener with a one-time authorization code (not a Twitch token). + app.get('/oauth/callback', async (c) => { + const deps = c.var.deps; + const query = c.req.query(); + + const sessionId = query.state ?? ''; + const session = sessionId.trim() ? await consumeSession(deps, sessionId) : null; + if (!session) return errorPage('This login link has expired or was already used. Please restart the login from EDNexus.'); + + // /oauth/authorize only stores a redirect_uri that passed this same check. Re-parsing it here means + // every redirect below is built from the validated URL, never from the raw stored string. + const desktop = parseLoopbackRedirect(session.desktopRedirectUri); + if (!desktop) return errorPage('This login session is invalid. Please restart the login from EDNexus.'); + + if (query.error) return c.redirect(buildDesktopRedirect(desktop, session.desktopState, { error: query.error, errorDescription: query.error_description }), 302); + + const code = query.code ?? ''; + if (!code.trim()) + return c.redirect(buildDesktopRedirect(desktop, session.desktopState, { error: 'server_error', errorDescription: 'Twitch did not return an authorization code.' }), 302); + + try { + const twitchToken = await exchangeAuthorizationCode(deps.config, code, deps.config.oauthRedirectUri); + const user = await getUser(deps.config, twitchToken.accessToken); + if (!user?.id.trim()) + return c.redirect(buildDesktopRedirect(desktop, session.desktopState, { error: 'server_error', errorDescription: 'Could not retrieve the Twitch user profile.' }), 302); + + const authCode = await createAuthorizationCode( + deps, + { + channelId: user.id, + username: user.displayName.trim() ? user.displayName : user.login, + twitchAccessToken: twitchToken.accessToken, + twitchRefreshToken: twitchToken.refreshToken, + twitchExpiresAt: new Date(deps.now().getTime() + twitchToken.expiresIn * 1000).toISOString(), + codeChallenge: session.codeChallenge, + redirectUri: session.desktopRedirectUri, + }, + deps.config.oauthCodeTtlSeconds * 1000, + ); + return c.redirect(buildDesktopRedirect(desktop, session.desktopState, { code: authCode }), 302); + } catch (error) { + if (!(error instanceof TwitchOAuthError)) throw error; + console.warn(`Twitch rejected the OAuth callback exchange: ${error.message}`); + return c.redirect(buildDesktopRedirect(desktop, session.desktopState, { error: 'server_error', errorDescription: 'Twitch rejected the login.' }), 302); + } + }); + + // Step 3: the desktop's loopback listener has the authorization code; it exchanges it here (proving + // possession of the PKCE verifier) for the long-lived, per-broadcaster token. + app.post('/oauth/token', async (c) => { + const deps = c.var.deps; + const body = await readJsonBody(c.req.raw, deps.config.maxRequestBodyBytes); + if (!body.ok) return body.response; + + const fields = isPlainObject(body.value) ? body.value : {}; + const code = typeof fields.code === 'string' ? fields.code : ''; + const verifier = typeof fields.code_verifier === 'string' ? fields.code_verifier : ''; + const redirectUri = typeof fields.redirect_uri === 'string' ? fields.redirect_uri : ''; + + if (!code.trim() || !verifier.trim() || !redirectUri.trim()) return problem(400, 'code, code_verifier, and redirect_uri are all required.'); + if (code.length > MAX_TOKEN_FIELD_LENGTH || verifier.length > MAX_TOKEN_FIELD_LENGTH || redirectUri.length > MAX_REDIRECT_URI_LENGTH) + return problem(400, 'code, code_verifier, or redirect_uri is too long.'); + + const pending = await consumeAuthorizationCode(deps, code); + if (!pending) return problem(400, 'invalid_grant: the authorization code is unknown, expired, or already used.'); + if (!(await verifyPkce(verifier, pending.codeChallenge))) return problem(400, 'invalid_grant: the PKCE code_verifier does not match.'); + + // RFC 6749 §4.1.3 / RFC 7636: the redirect_uri presented here must match the one bound to the + // authorization at /oauth/authorize. + if (redirectUri !== pending.redirectUri) return problem(400, 'invalid_grant: redirect_uri does not match the one used to start this authorization.'); + + const issued = await issueToken( + deps, + pending.channelId, + pending.username, + pending.twitchAccessToken, + pending.twitchRefreshToken, + new Date(pending.twitchExpiresAt), + ); + return json({ token: issued.token, channelId: issued.channelId, username: issued.username }); + }); + + // Best-effort logout: revokes the broadcaster's long-lived token and their underlying Twitch grant, + // and takes their card off the air, since signing out is a request to stop being shown. Idempotent: an + // unknown or missing token gets 200, as there is nothing to do. + app.post('/oauth/revoke', async (c) => { + const deps = c.var.deps; + const token = bearer(c.req.header('authorization')); + const record = token ? await getByToken(deps, token) : null; + if (!token || !record) return new Response(null, { status: 200 }); + + // Clear before revoking: if the clear throws, the token still works and the client can retry. A + // failed offline broadcast does not hold the revoke back: the snapshot is already gone, and leaving + // a signed-out credential valid through a PubSub outage is the worse outcome. + await clearChannel(deps, record.channelId); + await revoke(deps, token); + + // There is no per-channel lock on a Worker, so a publish that authenticated before the revoke can + // still store its snapshot after the clear above. A publish re-checks its token after storing and + // takes the card back down if it is gone; this second removal covers the other interleaving (the + // store landed before the token was deleted, but after the clear). Together no snapshot outlives + // its credential. + if (await removeState(deps, record.channelId)) await broadcast(deps, record.channelId, OFFLINE_MESSAGE); + + // Best-effort: the EBS credential is already dead, and this is a network call to Twitch. Twitch + // documents the endpoint as taking the access token; the refresh token is deleted with the record + // and expires on Twitch's own schedule. + try { + await revokeToken(deps.config, record.twitchAccessToken); + } catch { + /* best-effort */ + } + + return new Response(null, { status: 200 }); + }); +} + +// Re-exported for tests that want the same view of a request as the rate limiter has. +export { clientIp }; diff --git a/ebs-worker/src/scheduled.ts b/ebs-worker/src/scheduled.ts new file mode 100644 index 0000000..30fe19d --- /dev/null +++ b/ebs-worker/src/scheduled.ts @@ -0,0 +1,84 @@ +import { configFor } from './app'; +import type { Deps } from './context'; +import { pruneExpiredPending } from './db/pending'; +import { pruneRateLimits } from './db/ratelimit'; +import { pruneExpiredState } from './db/state'; +import { dueForRefresh, markTwitchGrantInvalid, updateTwitchTokens } from './db/tokens'; +import type { Env } from './env'; +import { refreshToken, TwitchOAuthError } from './twitch/oauth'; +import { checkCredentials } from './twitch/probe'; + +/** Cron expressions registered in wrangler.example.jsonc. Keep the two in step. */ +export const CRON_PRUNE = '*/15 * * * *'; +export const CRON_REFRESH = '*/30 * * * *'; +export const CRON_CREDENTIAL_CHECK = '0 */6 * * *'; + +/** + * Each refresh is one subrequest to Twitch, and a Worker invocation has a subrequest cap (50 on the free + * plan). Do the soonest-to-expire grants first and leave the rest for the next run: the buffer is an + * hour and the runs are 30 minutes apart, so a backlog drains before anything actually expires. + */ +const MAX_REFRESHES_PER_RUN = 40; + +/** Replaces the three background services of the ASP.NET build. */ +export async function runScheduled(env: Env, cron: string, now: () => Date = () => new Date()): Promise { + const result = configFor(env); + if (!result.ok) return; // already logged by configFor + const deps: Deps = { env, db: env.DB, config: result.config, now }; + + switch (cron) { + case CRON_PRUNE: { + // A snapshot whose clear never arrived does not stay public forever, and expired logins and counters do not pile up. + const [state, pending, limits] = [await pruneExpiredState(deps), await pruneExpiredPending(deps), await pruneRateLimits(deps)]; + console.log(`Pruned ${state} snapshot(s), ${pending} pending login row(s) and ${limits} rate-limit counter(s).`); + return; + } + case CRON_REFRESH: + await refreshDueGrants(deps); + return; + case CRON_CREDENTIAL_CHECK: + await checkCredentials(deps); + return; + default: + console.warn(`Unrecognised cron "${cron}"; nothing to do.`); + } +} + +/** + * Keeps every broadcaster's underlying Twitch grant alive without them ever having to re-auth: refreshes + * the access token of any grant whose expiry is within the buffer. If Twitch rejects the refresh token + * (a 400/401: the commander revoked access, or it expired) the grant is marked invalid so + * /api/update-state starts answering 401 and the desktop client prompts for a new login. A single + * broadcaster's failure never aborts the run or logs anyone out. + */ +export async function refreshDueGrants(deps: Deps): Promise { + const cutoff = new Date(deps.now().getTime() + deps.config.tokenRefreshBufferMinutes * 60_000); + + for (const record of await dueForRefresh(deps, cutoff, MAX_REFRESHES_PER_RUN)) { + if (!record.twitchRefreshToken) { + // Could not be decrypted (TOKEN_ENCRYPTION_KEY changed): nothing to refresh with, so say so. + await markTwitchGrantInvalid(deps, record.channelId); + continue; + } + + try { + const refreshed = await refreshToken(deps.config, record.twitchRefreshToken); + await updateTwitchTokens( + deps, + record.channelId, + refreshed.accessToken, + refreshed.refreshToken.trim() ? refreshed.refreshToken : record.twitchRefreshToken, + new Date(deps.now().getTime() + refreshed.expiresIn * 1000), + ); + } catch (error) { + if (error instanceof TwitchOAuthError && error.isGrantRejection) { + console.warn(`Failed to refresh the Twitch grant for channel ${record.channelId}; marking it invalid until the broadcaster re-authenticates: ${error.message}`); + await markTwitchGrantInvalid(deps, record.channelId); + } else { + // A network blip, timeout, Twitch 5xx/429, an unparseable body, or "invalid client" from a + // misconfigured secret: the grant is left valid and simply retried on the next run. + console.warn(`Transient error refreshing the Twitch grant for channel ${record.channelId}; will retry next run: ${error instanceof Error ? error.message : error}`); + } + } + } +} diff --git a/ebs-worker/src/twitch/jwt.ts b/ebs-worker/src/twitch/jwt.ts new file mode 100644 index 0000000..e927db0 --- /dev/null +++ b/ebs-worker/src/twitch/jwt.ts @@ -0,0 +1,33 @@ +import { base64Decode, base64UrlEncode, hmacSha256 } from '../crypto'; +import type { Config } from '../config'; + +const encoder = new TextEncoder(); + +/** The `user_id` claim used when TWITCH_OWNER_USER_ID is not configured: the placeholder the service always shipped with. */ +export const FALLBACK_USER_ID = 'ednexus_ebs'; + +/** + * Signs the EBS's own OUTBOUND "external" role JWT (HS256, keyed by the base64 Extension Secret) for the + * Helix PubSub call. Twitch's extension secrets are HMAC keys, so no JWT library is needed for one algorithm. + */ +export async function createExternalServiceToken( + config: Pick, + channelId: string, + now: Date, + lifetimeSeconds = 180, +): Promise { + if (!channelId.trim()) throw new Error('channelId must not be empty.'); + const iat = Math.floor(now.getTime() / 1000); + const header = { alg: 'HS256', typ: 'JWT' }; + const payload = { + exp: iat + Math.max(30, lifetimeSeconds), + iat, + user_id: config.ownerUserId ?? FALLBACK_USER_ID, + role: 'external', + channel_id: channelId, + pubsub_perms: { send: ['broadcast'] }, + }; + const signingInput = `${base64UrlEncode(encoder.encode(JSON.stringify(header)))}.${base64UrlEncode(encoder.encode(JSON.stringify(payload)))}`; + const signature = await hmacSha256(base64Decode(config.extensionSecret), encoder.encode(signingInput)); + return `${signingInput}.${base64UrlEncode(signature)}`; +} diff --git a/ebs-worker/src/twitch/oauth.ts b/ebs-worker/src/twitch/oauth.ts new file mode 100644 index 0000000..bd25c3c --- /dev/null +++ b/ebs-worker/src/twitch/oauth.ts @@ -0,0 +1,107 @@ +import { + TWITCH_REVOKE_ENDPOINT, + TWITCH_TOKEN_ENDPOINT, + TWITCH_USERS_ENDPOINT, + type Config, +} from '../config'; + +const TIMEOUT_MS = 15_000; + +export interface TwitchTokenResponse { + accessToken: string; + refreshToken: string; + expiresIn: number; +} + +export interface TwitchUser { + id: string; + login: string; + displayName: string; +} + +/** Thrown when Twitch rejects a token/user request the EBS makes on the broadcaster's behalf. */ +export class TwitchOAuthError extends Error { + constructor( + message: string, + readonly status: number | null = null, + ) { + super(message); + } + + /** + * True only when Twitch said the grant itself is dead: a 400/401 for the refresh token (Invalid + * refresh token, invalid_grant), meaning the commander revoked access or it expired. Everything else + * is transient and the grant must be left alone: a 5xx, a 429, a timeout or an unparseable body says + * nothing about the grant, and neither does "invalid client", which is THIS service's misconfiguration + * (a rotated secret) and would otherwise log every broadcaster out at once. + */ + get isGrantRejection(): boolean { + return (this.status === 400 || this.status === 401) && !/invalid client/i.test(this.message); + } +} + +async function postToken(config: Config, form: Record): Promise { + const response = await fetch(TWITCH_TOKEN_ENDPOINT, { + method: 'POST', + body: new URLSearchParams(form), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + const text = await response.text(); + if (!response.ok) throw new TwitchOAuthError(`Twitch rejected the token request: HTTP ${response.status} — ${text}`, response.status); + + let parsed: { access_token?: string; refresh_token?: string; expires_in?: number }; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new TwitchOAuthError(`Unparseable Twitch token response: ${error instanceof Error ? error.message : error}`); + } + if (!parsed.access_token?.trim()) throw new TwitchOAuthError('Twitch returned an empty token response.'); + return { accessToken: parsed.access_token, refreshToken: parsed.refresh_token ?? '', expiresIn: parsed.expires_in ?? 0 }; +} + +export function exchangeAuthorizationCode(config: Config, code: string, redirectUri: string): Promise { + return postToken(config, { + client_id: config.clientId, + client_secret: config.clientSecret, + code, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + }); +} + +export function refreshToken(config: Config, refresh: string): Promise { + return postToken(config, { + client_id: config.clientId, + client_secret: config.clientSecret, + grant_type: 'refresh_token', + refresh_token: refresh, + }); +} + +/** The user the access token belongs to, or null when Twitch returned none. */ +export async function getUser(config: Config, accessToken: string): Promise { + const response = await fetch(TWITCH_USERS_ENDPOINT, { + headers: { authorization: `Bearer ${accessToken}`, 'client-id': config.clientId }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + const text = await response.text(); + if (!response.ok) throw new TwitchOAuthError(`Twitch rejected the user lookup: HTTP ${response.status} — ${text}`, response.status); + + try { + const data = (JSON.parse(text) as { data?: { id?: string; login?: string; display_name?: string }[] }).data; + const first = data?.[0]; + return first ? { id: first.id ?? '', login: first.login ?? '', displayName: first.display_name ?? '' } : null; + } catch (error) { + throw new TwitchOAuthError(`Unparseable Twitch user response: ${error instanceof Error ? error.message : error}`); + } +} + +/** Best-effort: a revoke failing (e.g. an already-revoked token) must not block clearing local state. */ +export async function revokeToken(config: Config, token: string): Promise { + const response = await fetch(TWITCH_REVOKE_ENDPOINT, { + method: 'POST', + body: new URLSearchParams({ client_id: config.clientId, token }), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + await response.body?.cancel(); +} diff --git a/ebs-worker/src/twitch/probe.ts b/ebs-worker/src/twitch/probe.ts new file mode 100644 index 0000000..fa33837 --- /dev/null +++ b/ebs-worker/src/twitch/probe.ts @@ -0,0 +1,76 @@ +import { TWITCH_PUBSUB_URL } from '../config'; +import type { Deps } from '../context'; +import { isCredentialRefusal, markCredentialsAccepted, markCredentialsRejected } from '../db/status'; +import { createExternalServiceToken } from './jwt'; +import { pubSubClientId } from './pubsub'; + +export type ProbeResult = 'inconclusive' | 'accepted' | 'rejected'; + +/** Not a target Twitch knows (broadcast, global, whisper-…), so it is refused whatever else is true. */ +export const INVALID_TARGET = '__ednexus_credential_check__'; + +/** What Twitch says (as a 403) to a verified token that asks for a target it has no permission for. */ +export const TARGET_REFUSED_MESSAGE = 'Permission denied to use target'; + +/** + * Checks the PubSub credentials by sending the real endpoint a request signed exactly like a broadcast + * but aimed at a target Twitch cannot deliver to, so nothing ever reaches a viewer. + * + * Twitch validates the body first (400), then verifies the token (403 "JWT could not be verified" for a + * wrong secret, extension id or owner id), then checks the token's permissions against the target. So a + * valid token meets the bogus target with 403 "Permission denied to use target", and a refused one with + * 403 "JWT could not be verified". `accepted` therefore means "not refused"; only a real broadcast + * proves the credentials work, which is why a real broadcast overrides this verdict. A status that says + * nothing about the credentials (network error, 5xx, 429) is `inconclusive` and changes nothing. + */ +export async function checkCredentials(deps: Deps): Promise { + // Any channel id will do for a request refused on its target; the owner's is the most natural, and "0" cannot be a real one. + const channelId = deps.config.ownerUserId ?? '0'; + + let response: Response; + try { + response = await fetch(TWITCH_PUBSUB_URL, { + method: 'POST', + headers: { + authorization: `Bearer ${await createExternalServiceToken(deps.config, channelId, deps.now())}`, + 'client-id': pubSubClientId(deps), + 'content-type': 'application/json', + }, + body: JSON.stringify({ broadcaster_id: channelId, target: [INVALID_TARGET], message: '{}' }), + signal: AbortSignal.timeout(10_000), + }); + } catch (error) { + // Cannot reach Twitch (or cannot even sign: a malformed secret): says nothing about whether Twitch accepts the credentials. + console.warn(`The Twitch credential check could not be completed; it proves nothing either way: ${error instanceof Error ? error.message : error}`); + return 'inconclusive'; + } + + const text = await response.text().catch(() => ''); + + if (isCredentialRefusal(response.status)) { + if (response.status === 403 && text.toLowerCase().includes(TARGET_REFUSED_MESSAGE.toLowerCase())) return accepted(deps); + + await markCredentialsRejected(deps, response.status); + console.error( + `Twitch REJECTED the EBS's PubSub credentials (${response.status}: ${text}). Live updates will NOT reach viewers until this is fixed. ` + + `Check TWITCH_EXTENSION_SECRET (this extension's own base64 secret), TWITCH_EXTENSION_ID (this extension's Client ID) ` + + `and TWITCH_OWNER_USER_ID (the numeric Twitch user id of the extension's owner).`, + ); + return 'rejected'; + } + + // 400: the credentials got past verification and only the deliberately invalid target was refused. + if (response.status === 400) return accepted(deps); + if (response.ok) { + console.warn(`The Twitch credential check was accepted outright (${response.status}); it should have been refused for its invalid target.`); + return accepted(deps); + } + + console.warn(`The Twitch credential check got ${response.status}, which says nothing about the credentials; leaving the status unchanged.`); + return 'inconclusive'; +} + +async function accepted(deps: Deps): Promise { + await markCredentialsAccepted(deps); + return 'accepted'; +} diff --git a/ebs-worker/src/twitch/pubsub.ts b/ebs-worker/src/twitch/pubsub.ts new file mode 100644 index 0000000..0651f16 --- /dev/null +++ b/ebs-worker/src/twitch/pubsub.ts @@ -0,0 +1,53 @@ +import { TWITCH_PUBSUB_URL } from '../config'; +import type { Deps } from '../context'; +import { isCredentialRefusal, markCredentialsAccepted, markCredentialsRejected } from '../db/status'; +import { createExternalServiceToken } from './jwt'; + +export type PubSubResult = 'published' | 'credentials_rejected' | 'failed'; + +/** Bounds how long one slow Helix call can hold a request. */ +const TIMEOUT_MS = 10_000; + +/** The Client-Id sent to Helix PubSub: the extension's id (the JWT is signed with its secret). */ +export function pubSubClientId(deps: Deps): string { + return deps.config.extensionId || deps.config.clientId; +} + +/** + * Relays a message to every viewer of the broadcaster's channel via "Send Extension PubSub Message". + * `message` is the already-serialised JSON string Twitch requires. A network failure or timeout is a + * `failed` result, not an exception: the caller decides what to tell the client. + */ +export async function broadcast(deps: Deps, broadcasterId: string, message: string): Promise { + let response: Response; + try { + response = await fetch(TWITCH_PUBSUB_URL, { + method: 'POST', + headers: { + authorization: `Bearer ${await createExternalServiceToken(deps.config, broadcasterId, deps.now())}`, + 'client-id': pubSubClientId(deps), + 'content-type': 'application/json', + }, + body: JSON.stringify({ target: ['broadcast'], broadcaster_id: broadcasterId, message }), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + } catch (error) { + console.warn(`Twitch PubSub broadcast for channel ${broadcasterId} could not be sent: ${error instanceof Error ? error.message : error}`); + return 'failed'; + } + + if (!response.ok) { + console.warn(`Twitch PubSub broadcast for channel ${broadcasterId} failed with ${response.status}: ${await response.text().catch(() => '')}`); + // Only a refusal of the signed token says anything about the credentials; a 400 is the payload and + // a 5xx is Twitch, and neither should flip the status. + if (isCredentialRefusal(response.status)) { + await markCredentialsRejected(deps, response.status); + return 'credentials_rejected'; + } + return 'failed'; + } + + // A real broadcast is the authority: it clears a rejection the credential check recorded. + await markCredentialsAccepted(deps); + return 'published'; +} diff --git a/ebs-worker/test/helpers.ts b/ebs-worker/test/helpers.ts new file mode 100644 index 0000000..e7c01f8 --- /dev/null +++ b/ebs-worker/test/helpers.ts @@ -0,0 +1,190 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { DatabaseSync, type SQLInputValue } from 'node:sqlite'; +import { vi } from 'vitest'; +import { createApp } from '../src/app'; +import { pkceChallenge } from '../src/crypto'; +import type { Env } from '../src/env'; + +// --------------------------------------------------------------------------------------------- +// A D1 stand-in over real SQLite (node:sqlite), so the tests run the Worker's actual SQL — upserts, +// RETURNING, the real migration — rather than asserting on recorded strings. +// --------------------------------------------------------------------------------------------- + +class FakeStatement { + constructor( + private readonly sqlite: DatabaseSync, + readonly sql: string, + private readonly params: SQLInputValue[] = [], + ) {} + + bind(...params: SQLInputValue[]): FakeStatement { + return new FakeStatement(this.sqlite, this.sql, params); + } + + async first(): Promise { + return (this.sqlite.prepare(this.sql).get(...this.params) as T | undefined) ?? null; + } + + async all(): Promise<{ results: T[] }> { + return { results: this.sqlite.prepare(this.sql).all(...this.params) as T[] }; + } + + async run(): Promise<{ success: true; results: []; meta: { changes: number } }> { + const result = this.sqlite.prepare(this.sql).run(...this.params); + return { success: true, results: [], meta: { changes: Number(result.changes) } }; + } +} + +class FakeD1 { + constructor(readonly sqlite: DatabaseSync) {} + + prepare(sql: string): FakeStatement { + return new FakeStatement(this.sqlite, sql); + } + + async batch(statements: FakeStatement[]) { + this.sqlite.exec('BEGIN'); + try { + const results = []; + for (const statement of statements) results.push(await statement.run()); + this.sqlite.exec('COMMIT'); + return results; + } catch (error) { + this.sqlite.exec('ROLLBACK'); + throw error; + } + } +} + +const migration = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../migrations/0001_init.sql'), 'utf8'); + +export const b64 = (bytes: number, fill: number) => Buffer.alloc(bytes, fill).toString('base64'); + +export interface TestWorld { + env: Env; + sqlite: DatabaseSync; + clock: { now: () => Date; advance: (ms: number) => void; set: (iso: string) => void }; + app: ReturnType; + fetchCalls: FetchCall[]; + /** Replaces the handler for outbound fetches (Twitch). Default answers every Twitch endpoint happily. */ + onFetch: (handler: (call: FetchCall) => Response | Promise) => void; + request: (path: string, init?: RequestInit) => Promise; + /** Runs the whole OAuth login and returns the issued EBS token and channel id. */ + login: (options?: { twitchUserId?: string }) => Promise<{ token: string; channelId: string }>; +} + +export interface FetchCall { + url: string; + method: string; + headers: Headers; + body: string; +} + +export const EXTENSION_SECRET = b64(32, 7); +export const ENCRYPTION_KEY = b64(32, 9); + +export function makeEnv(overrides: Partial = {}): { env: Env; sqlite: DatabaseSync } { + const sqlite = new DatabaseSync(':memory:'); + sqlite.exec(migration); + const env = { + DB: new FakeD1(sqlite) as unknown as D1Database, + TWITCH_CLIENT_ID: 'client-id', + TWITCH_CLIENT_SECRET: 'client-secret', + TWITCH_EXTENSION_ID: 'extension-id', + TWITCH_EXTENSION_SECRET: EXTENSION_SECRET, + TOKEN_ENCRYPTION_KEY: ENCRYPTION_KEY, + TWITCH_OAUTH_REDIRECT_URI: 'https://ebs.example.test/oauth/callback', + ...overrides, + } satisfies Env; + return { env, sqlite }; +} + +const DESKTOP_REDIRECT = 'http://127.0.0.1:53682/callback'; + +function defaultTwitch(call: FetchCall): Response { + if (call.url.startsWith('https://id.twitch.tv/oauth2/token')) + return Response.json({ access_token: 'tw-access', refresh_token: 'tw-refresh', expires_in: 14400 }); + if (call.url.startsWith('https://api.twitch.tv/helix/users')) + return Response.json({ data: [{ id: '4242', login: 'cmdr', display_name: 'Cmdr' }] }); + if (call.url.startsWith('https://api.twitch.tv/helix/extensions/pubsub')) return new Response(null, { status: 204 }); + if (call.url.startsWith('https://id.twitch.tv/oauth2/revoke')) return new Response(null, { status: 200 }); + return new Response('unexpected request in test', { status: 500 }); +} + +export function makeWorld(overrides: Partial = {}): TestWorld { + const { env, sqlite } = makeEnv(overrides); + + let time = Date.parse('2026-11-10T12:00:00.000Z'); + const clock = { + now: () => new Date(time), + advance: (ms: number) => void (time += ms), + set: (isoText: string) => void (time = Date.parse(isoText)), + }; + + const fetchCalls: FetchCall[] = []; + let handler: (call: FetchCall) => Response | Promise = defaultTwitch; + vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { + const call: FetchCall = { + url: typeof input === 'string' ? input : input instanceof URL ? input.href : input.url, + method: init?.method ?? 'GET', + headers: new Headers(init?.headers), + body: init?.body instanceof URLSearchParams ? init.body.toString() : String(init?.body ?? ''), + }; + fetchCalls.push(call); + return handler(call); + }); + + const app = createApp({ now: clock.now }); + const request = (path: string, init?: RequestInit) => Promise.resolve(app.request(path, init, env)); + + async function login(options: { twitchUserId?: string } = {}) { + if (options.twitchUserId) { + const previous = handler; + handler = (call) => + call.url.startsWith('https://api.twitch.tv/helix/users') + ? Response.json({ data: [{ id: options.twitchUserId, login: 'cmdr', display_name: 'Cmdr' }] }) + : previous(call); + } + const verifier = 'v'.repeat(64); + const challenge = await pkceChallenge(verifier); + const authorize = await request( + `/oauth/authorize?${new URLSearchParams({ redirect_uri: DESKTOP_REDIRECT, state: 'desktop-state', code_challenge: challenge, code_challenge_method: 'S256' })}`, + ); + const twitchUrl = new URL(authorize.headers.get('location')!); + const sessionId = twitchUrl.searchParams.get('state')!; + const callback = await request(`/oauth/callback?code=twitch-code&state=${encodeURIComponent(sessionId)}`); + const code = new URL(callback.headers.get('location')!).searchParams.get('code')!; + const tokenResponse = await request('/oauth/token', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ code, code_verifier: verifier, redirect_uri: DESKTOP_REDIRECT }), + }); + const body = (await tokenResponse.json()) as { token: string; channelId: string }; + return { token: body.token, channelId: body.channelId }; + } + + return { + env, + sqlite, + clock, + app, + fetchCalls, + onFetch: (h) => void (handler = h), + request, + login, + }; +} + +export const DESKTOP_REDIRECT_URI = DESKTOP_REDIRECT; + +export const bearer = (token: string) => ({ authorization: `Bearer ${token}` }); + +export const postState = (token: string, state: unknown = { v: 1, headline: 'hello' }, extra: Record = {}): RequestInit => ({ + method: 'POST', + headers: { 'content-type': 'application/json', ...bearer(token), ...extra }, + body: JSON.stringify({ state }), +}); + +export const pubsubCalls = (world: TestWorld) => world.fetchCalls.filter((c) => c.url.startsWith('https://api.twitch.tv/helix/extensions/pubsub')); diff --git a/ebs-worker/test/oauth.test.ts b/ebs-worker/test/oauth.test.ts new file mode 100644 index 0000000..0b9fc92 --- /dev/null +++ b/ebs-worker/test/oauth.test.ts @@ -0,0 +1,268 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { pkceChallenge } from '../src/crypto'; +import { DESKTOP_REDIRECT_URI, bearer, makeWorld, postState, pubsubCalls } from './helpers'; + +afterEach(() => vi.unstubAllGlobals()); + +const authorizeUrl = async (overrides: Record = {}) => + `/oauth/authorize?${new URLSearchParams({ + redirect_uri: DESKTOP_REDIRECT_URI, + state: 's', + code_challenge: await pkceChallenge('v'.repeat(64)), + code_challenge_method: 'S256', + ...overrides, + })}`; + +describe('GET /oauth/authorize', () => { + it('redirects to Twitch with the EBS redirect URI, a session id as state, and %20-separated scopes', async () => { + const world = makeWorld(); + const response = await world.request(await authorizeUrl()); + expect(response.status).toBe(302); + const location = new URL(response.headers.get('location')!); + expect(location.origin + location.pathname).toBe('https://id.twitch.tv/oauth2/authorize'); + expect(location.searchParams.get('client_id')).toBe('client-id'); + expect(location.searchParams.get('redirect_uri')).toBe('https://ebs.example.test/oauth/callback'); + expect(location.searchParams.get('response_type')).toBe('code'); + expect(location.searchParams.get('scope')).toBe('user:read:email'); + expect(location.searchParams.get('state')).not.toBe('s'); // the desktop's own state never goes to Twitch + }); + + it('asks Twitch for each scope once', async () => { + const world = makeWorld({ TWITCH_OAUTH_SCOPES: 'user:read:email, USER:READ:EMAIL channel:bot' }); + const response = await world.request(await authorizeUrl()); + expect(response.headers.get('location')).toContain('scope=user%3Aread%3Aemail%20channel%3Abot'); + }); + + it.each([ + ['a missing redirect_uri', { redirect_uri: '' }], + ['a missing state', { state: '' }], + ['a missing code_challenge', { code_challenge: '' }], + ['a plain challenge method', { code_challenge_method: 'plain' }], + ['a challenge that is not 43 base64url characters', { code_challenge: 'short' }], + ['a non-loopback redirect_uri', { redirect_uri: 'http://evil.example/callback' }], + ['an https loopback redirect_uri', { redirect_uri: 'https://127.0.0.1/callback' }], + ['a loopback lookalike host', { redirect_uri: 'http://127.0.0.1.evil.example/cb' }], + ['an oversized state', { state: 's'.repeat(513) }], + ['an oversized redirect_uri', { redirect_uri: `http://localhost/${'a'.repeat(520)}` }], + ])('refuses %s with a 400', async (_name, overrides) => { + const world = makeWorld(); + const response = await world.request(await authorizeUrl(overrides)); + expect(response.status).toBe(400); + expect(response.headers.get('content-type')).toContain('application/problem+json'); + }); + + it('accepts the IPv6 loopback', async () => { + const world = makeWorld(); + const response = await world.request(await authorizeUrl({ redirect_uri: 'http://[::1]:5000/cb' })); + expect(response.status).toBe(302); + }); +}); + +describe('the full login', () => { + it('ends with a working token, and the desktop only ever sees a one-time code', async () => { + const world = makeWorld(); + const verifier = 'v'.repeat(64); + const authorize = await world.request(await authorizeUrl()); + const sessionId = new URL(authorize.headers.get('location')!).searchParams.get('state')!; + + const callback = await world.request(`/oauth/callback?code=twitch-code&state=${encodeURIComponent(sessionId)}`); + expect(callback.status).toBe(302); + const back = new URL(callback.headers.get('location')!); + expect(back.origin + back.pathname).toBe(DESKTOP_REDIRECT_URI); + expect(back.searchParams.get('state')).toBe('s'); + expect(back.href).not.toContain('tw-access'); + expect(back.href).not.toContain('tw-refresh'); + + const exchanged = world.fetchCalls.find((c) => c.url === 'https://id.twitch.tv/oauth2/token')!; + expect(new URLSearchParams(exchanged.body).get('client_secret')).toBe('client-secret'); + expect(new URLSearchParams(exchanged.body).get('redirect_uri')).toBe('https://ebs.example.test/oauth/callback'); + + const token = await world.request('/oauth/token', { + method: 'POST', + body: JSON.stringify({ code: back.searchParams.get('code'), code_verifier: verifier, redirect_uri: DESKTOP_REDIRECT_URI }), + }); + expect(token.status).toBe(200); + expect(await token.json()).toMatchObject({ channelId: '4242', username: 'Cmdr', token: expect.any(String) }); + }); + + it('is single use: the code, and the session, work once', async () => { + const world = makeWorld(); + const authorize = await world.request(await authorizeUrl()); + const sessionId = new URL(authorize.headers.get('location')!).searchParams.get('state')!; + const url = `/oauth/callback?code=c&state=${encodeURIComponent(sessionId)}`; + const first = await world.request(url); + const code = new URL(first.headers.get('location')!).searchParams.get('code')!; + + const replayedSession = await world.request(url); + expect(replayedSession.status).toBe(400); + expect(await replayedSession.text()).toContain('expired or was already used'); + + const body = JSON.stringify({ code, code_verifier: 'v'.repeat(64), redirect_uri: DESKTOP_REDIRECT_URI }); + expect((await world.request('/oauth/token', { method: 'POST', body })).status).toBe(200); + const replayedCode = await world.request('/oauth/token', { method: 'POST', body }); + expect(replayedCode.status).toBe(400); + expect(await replayedCode.text()).toContain('invalid_grant'); + }); + + it('refuses a code once it has expired', async () => { + const world = makeWorld(); + const authorize = await world.request(await authorizeUrl()); + const sessionId = new URL(authorize.headers.get('location')!).searchParams.get('state')!; + const callback = await world.request(`/oauth/callback?code=c&state=${encodeURIComponent(sessionId)}`); + const code = new URL(callback.headers.get('location')!).searchParams.get('code')!; + + world.clock.advance(61_000); // OAUTH_CODE_TTL_SECONDS defaults to 60 + const response = await world.request('/oauth/token', { + method: 'POST', + body: JSON.stringify({ code, code_verifier: 'v'.repeat(64), redirect_uri: DESKTOP_REDIRECT_URI }), + }); + expect(response.status).toBe(400); + }); + + it('refuses a session once the commander has taken too long on the consent page', async () => { + const world = makeWorld(); + const authorize = await world.request(await authorizeUrl()); + const sessionId = new URL(authorize.headers.get('location')!).searchParams.get('state')!; + world.clock.advance(11 * 60_000); + const response = await world.request(`/oauth/callback?code=c&state=${encodeURIComponent(sessionId)}`); + expect(response.status).toBe(400); + }); + + it('refuses the wrong PKCE verifier, and the code is spent by the attempt', async () => { + const world = makeWorld(); + const authorize = await world.request(await authorizeUrl()); + const sessionId = new URL(authorize.headers.get('location')!).searchParams.get('state')!; + const callback = await world.request(`/oauth/callback?code=c&state=${encodeURIComponent(sessionId)}`); + const code = new URL(callback.headers.get('location')!).searchParams.get('code')!; + + const wrong = await world.request('/oauth/token', { + method: 'POST', + body: JSON.stringify({ code, code_verifier: 'x'.repeat(64), redirect_uri: DESKTOP_REDIRECT_URI }), + }); + expect(wrong.status).toBe(400); + expect(await wrong.text()).toContain('code_verifier'); + + const right = await world.request('/oauth/token', { + method: 'POST', + body: JSON.stringify({ code, code_verifier: 'v'.repeat(64), redirect_uri: DESKTOP_REDIRECT_URI }), + }); + expect(right.status).toBe(400); + }); + + it('refuses a redirect_uri that differs from the one the login started with', async () => { + const world = makeWorld(); + const authorize = await world.request(await authorizeUrl()); + const sessionId = new URL(authorize.headers.get('location')!).searchParams.get('state')!; + const callback = await world.request(`/oauth/callback?code=c&state=${encodeURIComponent(sessionId)}`); + const code = new URL(callback.headers.get('location')!).searchParams.get('code')!; + const response = await world.request('/oauth/token', { + method: 'POST', + body: JSON.stringify({ code, code_verifier: 'v'.repeat(64), redirect_uri: 'http://127.0.0.1:1/other' }), + }); + expect(response.status).toBe(400); + expect(await response.text()).toContain('redirect_uri'); + }); + + it('hands a Twitch denial back to the desktop as an error redirect', async () => { + const world = makeWorld(); + const authorize = await world.request(await authorizeUrl()); + const sessionId = new URL(authorize.headers.get('location')!).searchParams.get('state')!; + const callback = await world.request( + `/oauth/callback?error=access_denied&error_description=nope&state=${encodeURIComponent(sessionId)}`, + ); + const back = new URL(callback.headers.get('location')!); + expect(back.searchParams.get('error')).toBe('access_denied'); + expect(back.searchParams.get('error_description')).toBe('nope'); + expect(back.searchParams.get('state')).toBe('s'); + expect(back.searchParams.get('code')).toBeNull(); + }); + + it('reports a Twitch exchange failure to the desktop without leaking Twitch\'s response', async () => { + const world = makeWorld(); + world.onFetch(() => new Response('secret detail', { status: 400 })); + const authorize = await world.request(await authorizeUrl()); + const sessionId = new URL(authorize.headers.get('location')!).searchParams.get('state')!; + const callback = await world.request(`/oauth/callback?code=c&state=${encodeURIComponent(sessionId)}`); + const back = new URL(callback.headers.get('location')!); + expect(back.searchParams.get('error')).toBe('server_error'); + expect(back.href).not.toContain('secret'); + }); + + it('keeps a query string the desktop registered on its redirect', async () => { + const world = makeWorld(); + const authorize = await world.request(await authorizeUrl({ redirect_uri: 'http://localhost:5000/cb?app=ednexus' })); + const sessionId = new URL(authorize.headers.get('location')!).searchParams.get('state')!; + const callback = await world.request(`/oauth/callback?code=c&state=${encodeURIComponent(sessionId)}`); + const back = new URL(callback.headers.get('location')!); + expect(back.searchParams.get('app')).toBe('ednexus'); + expect(back.searchParams.get('code')).toBeTruthy(); + }); + + it('signing in again invalidates the previous token', async () => { + const world = makeWorld(); + const first = await world.login(); + const second = await world.login(); + expect(second.token).not.toBe(first.token); + expect((await world.request('/api/update-state', postState(first.token))).status).toBe(401); + world.clock.advance(5_000); + expect((await world.request('/api/update-state', postState(second.token))).status).toBe(200); + }); + + it('never stores a Twitch token or a bearer token in the clear', async () => { + const world = makeWorld(); + const { token } = await world.login(); + const dump = JSON.stringify(world.sqlite.prepare('SELECT * FROM broadcaster_tokens').all()); + expect(dump).not.toContain('tw-access'); + expect(dump).not.toContain('tw-refresh'); + expect(dump).not.toContain(token); + // the one-time code's row is encrypted too, while it exists + await world.request(await authorizeUrl()); + const authorize = await world.request(await authorizeUrl()); + const sessionId = new URL(authorize.headers.get('location')!).searchParams.get('state')!; + await world.request(`/oauth/callback?code=c&state=${encodeURIComponent(sessionId)}`); + expect(JSON.stringify(world.sqlite.prepare("SELECT payload FROM oauth_pending WHERE kind = 'code'").all())).not.toContain('tw-access'); + }); + + it.each([ + ['a missing field', { code: 'x' }], + ['an oversized field', { code: 'x'.repeat(513), code_verifier: 'y', redirect_uri: 'z' }], + ])('POST /oauth/token refuses %s', async (_name, body) => { + const response = await makeWorld().request('/oauth/token', { method: 'POST', body: JSON.stringify(body) }); + expect(response.status).toBe(400); + }); +}); + +describe('POST /oauth/revoke', () => { + it('signs out: clears the card, tells viewers, kills the token and revokes the Twitch grant', async () => { + const world = makeWorld(); + const { token, channelId } = await world.login(); + await world.request('/api/update-state', postState(token)); + expect((await world.request(`/api/initial-state/${channelId}`)).status).toBe(200); + + const before = pubsubCalls(world).length; + const response = await world.request('/oauth/revoke', { method: 'POST', headers: bearer(token) }); + expect(response.status).toBe(200); + + expect((await world.request(`/api/initial-state/${channelId}`)).status).toBe(404); + expect(JSON.parse(JSON.parse(pubsubCalls(world)[before]!.body).message)).toEqual({ v: 1, offline: true }); + expect((await world.request('/api/update-state', postState(token))).status).toBe(401); + const revoked = world.fetchCalls.find((c) => c.url === 'https://id.twitch.tv/oauth2/revoke')!; + expect(new URLSearchParams(revoked.body).get('token')).toBe('tw-access'); + }); + + it('is idempotent: an unknown or missing token gets a quiet 200', async () => { + const world = makeWorld(); + expect((await world.request('/oauth/revoke', { method: 'POST' })).status).toBe(200); + expect((await world.request('/oauth/revoke', { method: 'POST', headers: bearer('nope') })).status).toBe(200); + expect(world.fetchCalls).toHaveLength(0); + }); + + it('still revokes the token when Twitch is down', async () => { + const world = makeWorld(); + const { token } = await world.login(); + world.onFetch(() => new Response('down', { status: 503 })); + expect((await world.request('/oauth/revoke', { method: 'POST', headers: bearer(token) })).status).toBe(200); + world.onFetch(() => new Response(null, { status: 204 })); + expect((await world.request('/api/update-state', postState(token))).status).toBe(401); + }); +}); diff --git a/ebs-worker/test/scheduled.test.ts b/ebs-worker/test/scheduled.test.ts new file mode 100644 index 0000000..6f4f0ea --- /dev/null +++ b/ebs-worker/test/scheduled.test.ts @@ -0,0 +1,188 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CRON_CREDENTIAL_CHECK, CRON_PRUNE, CRON_REFRESH, runScheduled } from '../src/scheduled'; +import { INVALID_TARGET } from '../src/twitch/probe'; +import { makeWorld, postState } from './helpers'; + +afterEach(() => vi.unstubAllGlobals()); + +const run = (world: ReturnType, cron: string) => runScheduled(world.env, cron, world.clock.now); +const grant = (world: ReturnType) => + world.sqlite.prepare('SELECT is_twitch_grant_valid AS valid, twitch_expires_at AS expires, last_refreshed_at AS refreshed FROM broadcaster_tokens').get() as { + valid: number; + expires: string; + refreshed: string | null; + }; + +describe('prune cron', () => { + it('deletes snapshots past the limit, expired pending logins and old counters, and nothing else', async () => { + const world = makeWorld(); + const { token, channelId } = await world.login(); + await world.request('/api/update-state', postState(token)); + await world.request('/oauth/authorize?redirect_uri=http%3A%2F%2Flocalhost%2Fcb&state=s&code_challenge=' + 'a'.repeat(43)); + + await run(world, CRON_PRUNE); + expect(world.sqlite.prepare('SELECT COUNT(*) AS n FROM channel_state').get()).toEqual({ n: 1 }); + expect(world.sqlite.prepare('SELECT COUNT(*) AS n FROM oauth_pending').get()).toEqual({ n: 1 }); + + world.clock.advance(25 * 3_600_000); + await run(world, CRON_PRUNE); + expect(world.sqlite.prepare('SELECT COUNT(*) AS n FROM channel_state').get()).toEqual({ n: 0 }); + expect(world.sqlite.prepare('SELECT COUNT(*) AS n FROM oauth_pending').get()).toEqual({ n: 0 }); + expect(world.sqlite.prepare('SELECT COUNT(*) AS n FROM rate_limit').get()).toEqual({ n: 0 }); + // the broadcaster's token is not a cache: it stays + expect(world.sqlite.prepare('SELECT channel_id FROM broadcaster_tokens').get()).toEqual({ channel_id: channelId }); + }); +}); + +describe('refresh cron', () => { + it('refreshes only grants inside the expiry buffer, and stores the new tokens encrypted', async () => { + const world = makeWorld(); + await world.login(); + await run(world, CRON_REFRESH); // 4h left, buffer is 1h + expect(world.fetchCalls.filter((c) => c.url === 'https://id.twitch.tv/oauth2/token')).toHaveLength(1); // only the login's exchange + + world.clock.advance(3.5 * 3_600_000); // 30 minutes left + world.onFetch(() => Response.json({ access_token: 'new-access', refresh_token: 'new-refresh', expires_in: 14400 })); + await run(world, CRON_REFRESH); + + const refresh = world.fetchCalls.at(-1)!; + expect(new URLSearchParams(refresh.body).get('grant_type')).toBe('refresh_token'); + expect(new URLSearchParams(refresh.body).get('refresh_token')).toBe('tw-refresh'); + expect(grant(world).valid).toBe(1); + expect(Date.parse(grant(world).expires)).toBe(world.clock.now().getTime() + 14400 * 1000); + expect(JSON.stringify(world.sqlite.prepare('SELECT * FROM broadcaster_tokens').all())).not.toContain('new-access'); + }); + + it('keeps the old refresh token when Twitch does not send a new one', async () => { + const world = makeWorld(); + await world.login(); + world.clock.advance(4 * 3_600_000); + world.onFetch(() => Response.json({ access_token: 'a2', expires_in: 100 })); + await run(world, CRON_REFRESH); + world.onFetch(() => Response.json({ access_token: 'a3', refresh_token: 'r3', expires_in: 100 })); + world.clock.advance(3_600_000); + await run(world, CRON_REFRESH); + expect(new URLSearchParams(world.fetchCalls.at(-1)!.body).get('refresh_token')).toBe('tw-refresh'); + }); + + it('marks a grant invalid when Twitch rejects the refresh token, so the next publish 401s', async () => { + const world = makeWorld(); + const { token } = await world.login(); + world.clock.advance(4 * 3_600_000); + world.onFetch(() => new Response('{"message":"Invalid refresh token"}', { status: 400 })); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + await run(world, CRON_REFRESH); + expect(grant(world).valid).toBe(0); + + world.onFetch(() => new Response(null, { status: 204 })); + expect((await world.request('/api/update-state', postState(token))).status).toBe(401); + // an invalid grant is not retried every run + const calls = world.fetchCalls.length; + await run(world, CRON_REFRESH); + expect(world.fetchCalls).toHaveLength(calls); + }); + + it.each([ + ['a Twitch 500', () => new Response('', { status: 500 })], + ['a 429', () => new Response('', { status: 429 })], + ['"invalid client" (a rotated secret of OURS)', () => new Response('{"message":"invalid client secret"}', { status: 400 })], + ['a network error', () => { + throw new TypeError('network down'); + }], + ['an unparseable body', () => new Response('', { status: 200 })], + ])('leaves the grant valid to retry after %s, and never logs anyone out', async (_name, respond) => { + const world = makeWorld(); + await world.login(); + world.clock.advance(4 * 3_600_000); + world.onFetch(respond); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + await run(world, CRON_REFRESH); + expect(grant(world).valid).toBe(1); + }); + + it('one broadcaster failing does not stop the others', async () => { + const world = makeWorld(); + await world.login({ twitchUserId: '1' }); + await world.login({ twitchUserId: '2' }); + world.clock.advance(4 * 3_600_000); + let first = true; + world.onFetch(() => { + if (first) { + first = false; + throw new TypeError('blip'); + } + return Response.json({ access_token: 'a', refresh_token: 'r', expires_in: 100 }); + }); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + await run(world, CRON_REFRESH); + const rows = world.sqlite.prepare('SELECT last_refreshed_at AS r FROM broadcaster_tokens ORDER BY channel_id').all() as { r: string | null }[]; + expect(rows.filter((r) => r.r !== null)).toHaveLength(1); + }); +}); + +describe('credential check cron', () => { + const check = async (response: () => Response) => { + const world = makeWorld({ TWITCH_OWNER_USER_ID: '777' }); + world.onFetch(response); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); + await run(world, CRON_CREDENTIAL_CHECK); + return { world, health: await (await world.request('/healthz')).json() }; + }; + + it('sends a request signed like a broadcast but aimed at a target nothing can receive', async () => { + const { world } = await check(() => new Response('', { status: 400 })); + const call = world.fetchCalls[0]!; + expect(JSON.parse(call.body)).toEqual({ broadcaster_id: '777', target: [INVALID_TARGET], message: '{}' }); + expect(call.headers.get('client-id')).toBe('extension-id'); + expect(call.headers.get('authorization')).toMatch(/^Bearer [\w-]+\.[\w-]+\.[\w-]+$/); + }); + + it.each([ + ['a 400 (the bogus target was refused)', 400], + ['a 403 "Permission denied to use target" (valid credentials, bogus target)', 403], + ])('records "accepted" for %s', async (_n, status) => { + const { health } = await check(() => new Response(status === 403 ? 'Permission denied to use target' : '', { status })); + expect(health).toMatchObject({ pubsub: 'accepted' }); + }); + + it.each([401, 403])('records "rejected" for a %i that is not the permission answer, and logs it as an error', async (status) => { + const { health } = await check(() => new Response('JWT could not be verified', { status })); + expect(health).toMatchObject({ pubsub: 'rejected', pubsubStatus: status }); + expect(console.error).toHaveBeenCalled(); + }); + + it.each([ + ['a 500', () => new Response('', { status: 500 })], + ['a 429', () => new Response('', { status: 429 })], + ['a network error', () => { + throw new TypeError('down'); + }], + ])('says nothing after %s', async (_n, respond) => { + const { health } = await check(respond); + expect(health).toMatchObject({ pubsub: 'unknown' }); + }); + + it('does not overwrite a rejection with an inconclusive answer', async () => { + const world = makeWorld(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + world.onFetch(() => new Response('', { status: 403 })); + await run(world, CRON_CREDENTIAL_CHECK); + world.onFetch(() => new Response('', { status: 503 })); + await run(world, CRON_CREDENTIAL_CHECK); + expect(await (await world.request('/healthz')).json()).toMatchObject({ pubsub: 'rejected' }); + }); +}); + +describe('scheduled dispatch', () => { + it('ignores an unknown cron and an invalid configuration without throwing', async () => { + const world = makeWorld(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + await expect(run(world, '1 1 1 1 1')).resolves.toBeUndefined(); + const broken = makeWorld({ TOKEN_ENCRYPTION_KEY: '' }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + await expect(run(broken, CRON_REFRESH)).resolves.toBeUndefined(); + }); +}); diff --git a/ebs-worker/test/state.test.ts b/ebs-worker/test/state.test.ts new file mode 100644 index 0000000..0d4d1ec --- /dev/null +++ b/ebs-worker/test/state.test.ts @@ -0,0 +1,447 @@ +import { createHmac } from 'node:crypto'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { base64UrlDecode } from '../src/crypto'; +import { EXTENSION_SECRET, bearer, makeWorld, postState, pubsubCalls } from './helpers'; + +afterEach(() => vi.unstubAllGlobals()); + +describe('POST /api/update-state', () => { + it('stores the compact state, broadcasts it to Twitch, and says so', async () => { + const world = makeWorld(); + const { token, channelId } = await world.login(); + const response = await world.request('/api/update-state', { + method: 'POST', + headers: { 'content-type': 'application/json', ...bearer(token) }, + body: '{ "state": { "v": 1, "headline": "Docked" } }', + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ published: true, channelId, bytes: 27 }); + + const [call] = pubsubCalls(world); + expect(call!.headers.get('client-id')).toBe('extension-id'); + const sent = JSON.parse(call!.body); + expect(sent).toMatchObject({ target: ['broadcast'], broadcaster_id: channelId }); + // What was size-checked is what is stored and what is broadcast: the compact form, not the request's whitespace. + expect(sent.message).toBe('{"v":1,"headline":"Docked"}'); + expect(await (await world.request(`/api/initial-state/${channelId}`)).text()).toBe('{"v":1,"headline":"Docked"}'); + }); + + it('signs the PubSub call with a verifiable external-role JWT for the broadcaster', async () => { + const world = makeWorld({ TWITCH_OWNER_USER_ID: '999' }); + const { token, channelId } = await world.login(); + await world.request('/api/update-state', postState(token)); + + const jwt = pubsubCalls(world)[0]!.headers.get('authorization')!.slice('Bearer '.length); + const [header, payload, signature] = jwt.split('.') as [string, string, string]; + const expected = createHmac('sha256', Buffer.from(EXTENSION_SECRET, 'base64')).update(`${header}.${payload}`).digest('base64url'); + expect(signature).toBe(expected); + expect(JSON.parse(new TextDecoder().decode(base64UrlDecode(header)))).toEqual({ alg: 'HS256', typ: 'JWT' }); + const claims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payload))); + expect(claims).toMatchObject({ role: 'external', channel_id: channelId, user_id: '999', pubsub_perms: { send: ['broadcast'] } }); + expect(claims.exp - claims.iat).toBe(180); + }); + + it('uses the historical placeholder user_id when no owner is configured', async () => { + const world = makeWorld(); + const { token } = await world.login(); + await world.request('/api/update-state', postState(token)); + const payload = pubsubCalls(world)[0]!.headers.get('authorization')!.split('.')[1]!; + expect(JSON.parse(new TextDecoder().decode(base64UrlDecode(payload))).user_id).toBe('ednexus_ebs'); + }); + + describe('authentication', () => { + it('401s without a token, with a non-Bearer header, and says why for an unknown token', async () => { + const world = makeWorld(); + expect((await world.request('/api/update-state', { method: 'POST', body: '{}' })).status).toBe(401); + world.clock.advance(2_000); // unauthenticated callers share a per-address budget + expect((await world.request('/api/update-state', { method: 'POST', headers: { authorization: 'Basic abc' }, body: '{}' })).status).toBe(401); + + world.clock.advance(2_000); + const unknown = await world.request('/api/update-state', postState('not-a-token')); + expect(unknown.status).toBe(401); + expect(await unknown.text()).toContain('not known to the service'); + }); + + it('401s a broadcaster whose Twitch grant has lapsed, but still lets them take their card down', async () => { + const world = makeWorld(); + const { token, channelId } = await world.login(); + await world.request('/api/update-state', postState(token)); + world.sqlite.exec('UPDATE broadcaster_tokens SET is_twitch_grant_valid = 0'); + + world.clock.advance(5_000); + const publish = await world.request('/api/update-state', postState(token)); + expect(publish.status).toBe(401); + expect(await publish.text()).toContain('Twitch grant is no longer valid'); + + expect((await world.request('/api/update-state', { method: 'DELETE', headers: bearer(token) })).status).toBe(204); + expect((await world.request(`/api/initial-state/${channelId}`)).status).toBe(404); + }); + + it('treats a grant that cannot be decrypted (a changed TOKEN_ENCRYPTION_KEY) as lapsed, not as a 500', async () => { + const world = makeWorld(); + const { token } = await world.login(); + world.sqlite.exec("UPDATE broadcaster_tokens SET twitch_access_token = 'v1.AAAA.AAAA'"); + const response = await world.request('/api/update-state', postState(token)); + expect(response.status).toBe(401); + }); + + it('takes the channel from the token, never from the body', async () => { + const world = makeWorld(); + const { token, channelId } = await world.login(); + await world.request('/api/update-state', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ channelId: 'someone-else', broadcaster_id: 'someone-else', state: { v: 1 } }), + }); + expect(JSON.parse(pubsubCalls(world)[0]!.body).broadcaster_id).toBe(channelId); + }); + }); + + describe('validation', () => { + it.each([ + ['no state', {}, 'must contain a "state" JSON object'], + ['a null state', { state: null }, 'must contain a "state" JSON object'], + ['a string state', { state: 'x' }, 'not string'], + ['an array state', { state: [1] }, 'not array'], + ['a number state', { state: 3 }, 'not number'], + ])('400s %s', async (_name, body, message) => { + const world = makeWorld(); + const { token } = await world.login(); + const response = await world.request('/api/update-state', { method: 'POST', headers: bearer(token), body: JSON.stringify(body) }); + expect(response.status).toBe(400); + expect(((await response.json()) as { detail: string }).detail).toContain(message); + }); + + it('400s a body that is not JSON', async () => { + const world = makeWorld(); + const { token } = await world.login(); + expect((await world.request('/api/update-state', { method: 'POST', headers: bearer(token), body: '{nope' })).status).toBe(400); + }); + + it('413s a state over the payload limit, measured on its compact form, and stores nothing', async () => { + const world = makeWorld({ MAX_STATE_PAYLOAD_BYTES: '100' }); + const { token, channelId } = await world.login(); + const response = await world.request('/api/update-state', postState(token, { v: 1, pad: 'x'.repeat(100) })); + expect(response.status).toBe(413); + expect((await world.request(`/api/initial-state/${channelId}`)).status).toBe(404); + expect(pubsubCalls(world)).toHaveLength(0); + }); + + it('counts UTF-8 bytes, not characters', async () => { + const world = makeWorld({ MAX_STATE_PAYLOAD_BYTES: '1024' }); + const { token } = await world.login(); + // 400 three-byte characters: 1200 bytes, only 400 characters + expect((await world.request('/api/update-state', postState(token, { v: 1, name: '€'.repeat(400) }))).status).toBe(413); + }); + + it('413s a declared body over the request cap before reading it', async () => { + const world = makeWorld(); + const { token } = await world.login(); + const response = await world.request('/api/update-state', { + method: 'POST', + headers: { ...bearer(token), 'content-length': '20000' }, + body: '{}', + }); + expect(response.status).toBe(413); + }); + + it('413s an undeclared (chunked) body over the cap once it has been read', async () => { + const world = makeWorld(); + const { token } = await world.login(); + const response = await world.request('/api/update-state', { + method: 'POST', + headers: bearer(token), + body: JSON.stringify({ state: { v: 1, pad: 'x'.repeat(20000) } }), + }); + expect(response.status).toBe(413); + }); + + it('applies the same body cap to /oauth/*', async () => { + const world = makeWorld(); + const response = await world.request('/oauth/token', { method: 'POST', headers: { 'content-length': '20000' }, body: '{}' }); + expect(response.status).toBe(413); + }); + }); + + describe('when Twitch fails', () => { + it('answers 502 and still stores the card, so new viewers see it', async () => { + const world = makeWorld(); + const { token, channelId } = await world.login(); + world.onFetch(() => new Response('boom', { status: 500 })); + const response = await world.request('/api/update-state', postState(token)); + expect(response.status).toBe(502); + expect(response.headers.get('x-ednexus-pubsub-rejected')).toBeNull(); + expect((await world.request(`/api/initial-state/${channelId}`)).status).toBe(200); + }); + + it('answers 502 when the call cannot be made at all', async () => { + const world = makeWorld(); + const { token } = await world.login(); + world.onFetch(() => { + throw new TypeError('network down'); + }); + expect((await world.request('/api/update-state', postState(token))).status).toBe(502); + }); + + it('answers 503 with the credentials header when Twitch refuses the EBS’s own credentials', async () => { + const world = makeWorld(); + const { token, channelId } = await world.login(); + world.onFetch(() => new Response('JWT could not be verified', { status: 403 })); + const response = await world.request('/api/update-state', postState(token)); + expect(response.status).toBe(503); + expect(response.headers.get('x-ednexus-pubsub-rejected')).toBe('credentials'); + expect((await world.request(`/api/initial-state/${channelId}`)).status).toBe(200); + + const health = await (await world.request('/healthz')).json(); + expect(health).toMatchObject({ status: 'ok', pubsub: 'rejected', pubsubStatus: 403 }); + }); + + it('does not call a 400 or a 5xx a credentials problem, and a later success clears a rejection', async () => { + const world = makeWorld(); + const { token } = await world.login(); + world.onFetch(() => new Response('', { status: 403 })); + await world.request('/api/update-state', postState(token)); + world.clock.advance(5_000); + world.onFetch(() => new Response('', { status: 400 })); + expect((await world.request('/api/update-state', postState(token))).headers.get('x-ednexus-pubsub-rejected')).toBeNull(); + expect(await (await world.request('/healthz')).json()).toMatchObject({ pubsub: 'rejected' }); + + world.clock.advance(5_000); + world.onFetch(() => new Response(null, { status: 204 })); + expect((await world.request('/api/update-state', postState(token))).status).toBe(200); + expect(await (await world.request('/healthz')).json()).toMatchObject({ pubsub: 'accepted', pubsubStatus: null }); + }); + }); + + describe('rate limiting', () => { + it('allows one publish per window per channel, with Retry-After and no caching on the 429', async () => { + const world = makeWorld(); + const { token } = await world.login(); + expect((await world.request('/api/update-state', postState(token))).status).toBe(200); + + const limited = await world.request('/api/update-state', postState(token)); + expect(limited.status).toBe(429); + expect(limited.headers.get('retry-after')).toBe('2'); + expect(limited.headers.get('cache-control')).toBe('no-store'); + + world.clock.advance(2_000); + expect((await world.request('/api/update-state', postState(token))).status).toBe(200); + }); + + it('keeps channels apart', async () => { + const world = makeWorld(); + const a = await world.login({ twitchUserId: '1' }); + const b = await world.login({ twitchUserId: '2' }); + expect((await world.request('/api/update-state', postState(a.token))).status).toBe(200); + expect((await world.request('/api/update-state', postState(b.token))).status).toBe(200); + }); + + it('limits an unauthenticated caller per client address, so guessing tokens is not free', async () => { + const world = makeWorld(); + const guess = { ...postState('guess'), headers: { ...bearer('guess'), 'cf-connecting-ip': '203.0.113.9' } }; + expect((await world.request('/api/update-state', guess)).status).toBe(401); + expect((await world.request('/api/update-state', guess)).status).toBe(429); + const other = { ...guess, headers: { ...guess.headers, 'cf-connecting-ip': '203.0.113.10' } }; + expect((await world.request('/api/update-state', other)).status).toBe(401); + }); + + it('gives a clear its own budget, so switching off straight after a publish is never rejected', async () => { + const world = makeWorld(); + const { token } = await world.login(); + expect((await world.request('/api/update-state', postState(token))).status).toBe(200); + expect((await world.request('/api/update-state', { method: 'DELETE', headers: bearer(token) })).status).toBe(204); + }); + + it('allows 10 clears a minute, then 429s', async () => { + const world = makeWorld(); + const { token } = await world.login(); + for (let i = 0; i < 10; i++) expect((await world.request('/api/update-state', { method: 'DELETE', headers: bearer(token) })).status).toBe(204); + expect((await world.request('/api/update-state', { method: 'DELETE', headers: bearer(token) })).status).toBe(429); + }); + }); + + it('takes the card back down if the token is revoked while the publish is in flight', async () => { + const world = makeWorld(); + const { token, channelId } = await world.login(); + // The sign-out lands while the PubSub call is out: the token is gone by the time the publish returns. + world.onFetch(() => { + world.sqlite.exec('DELETE FROM broadcaster_tokens'); + return new Response(null, { status: 204 }); + }); + + const response = await world.request('/api/update-state', postState(token)); + expect(response.status).toBe(401); + expect((await world.request(`/api/initial-state/${channelId}`)).status).toBe(404); + // the viewers who just got the card are told to hide it again + const messages = pubsubCalls(world).map((c) => JSON.parse(JSON.parse(c.body).message)); + expect(messages.at(-1)).toEqual({ v: 1, offline: true }); + }); +}); + +describe('DELETE /api/update-state', () => { + it('forgets the snapshot, tells viewers, and answers 204', async () => { + const world = makeWorld(); + const { token, channelId } = await world.login(); + await world.request('/api/update-state', postState(token)); + const response = await world.request('/api/update-state', { method: 'DELETE', headers: bearer(token) }); + expect(response.status).toBe(204); + expect((await world.request(`/api/initial-state/${channelId}`)).status).toBe(404); + expect(JSON.parse(JSON.parse(pubsubCalls(world).at(-1)!.body).message)).toEqual({ v: 1, offline: true }); + }); + + it('is idempotent, and fine for a channel that never published', async () => { + const world = makeWorld(); + const { token } = await world.login(); + expect((await world.request('/api/update-state', { method: 'DELETE', headers: bearer(token) })).status).toBe(204); + expect((await world.request('/api/update-state', { method: 'DELETE', headers: bearer(token) })).status).toBe(204); + }); + + it('answers 502 with X-EDNexus-Snapshot-Removed when viewers could not be told, though the snapshot is gone', async () => { + const world = makeWorld(); + const { token, channelId } = await world.login(); + await world.request('/api/update-state', postState(token)); + world.onFetch(() => new Response('', { status: 500 })); + const response = await world.request('/api/update-state', { method: 'DELETE', headers: bearer(token) }); + expect(response.status).toBe(502); + expect(response.headers.get('x-ednexus-snapshot-removed')).toBe('true'); + expect((await world.request(`/api/initial-state/${channelId}`)).status).toBe(404); + }); + + it('401s without a token', async () => { + expect((await makeWorld().request('/api/update-state', { method: 'DELETE' })).status).toBe(401); + }); +}); + +describe('GET /api/initial-state/{channelId}', () => { + it('404s with a message when nothing was published, and is not cacheable', async () => { + const response = await makeWorld().request('/api/initial-state/123'); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ message: 'No state has been published for this channel yet.' }); + expect(response.headers.get('cache-control')).toBeNull(); + }); + + it('serves the snapshot as JSON, cacheable for a few seconds', async () => { + const world = makeWorld(); + const { token, channelId } = await world.login(); + await world.request('/api/update-state', postState(token, { v: 1, headline: 'x' })); + const response = await world.request(`/api/initial-state/${channelId}`); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + expect(response.headers.get('cache-control')).toBe('public, max-age=5'); + expect(await response.json()).toEqual({ v: 1, headline: 'x' }); + }); + + it('can turn caching off', async () => { + const world = makeWorld({ INITIAL_STATE_CACHE_SECONDS: '0' }); + const { token, channelId } = await world.login(); + await world.request('/api/update-state', postState(token)); + expect((await world.request(`/api/initial-state/${channelId}`)).headers.get('cache-control')).toBeNull(); + }); + + it('treats a snapshot older than the maximum age as gone, before the cron has deleted it', async () => { + const world = makeWorld(); + const { token, channelId } = await world.login(); + await world.request('/api/update-state', postState(token)); + world.clock.advance(23 * 3_600_000); + expect((await world.request(`/api/initial-state/${channelId}`)).status).toBe(200); + world.clock.advance(2 * 3_600_000); + expect((await world.request(`/api/initial-state/${channelId}`)).status).toBe(404); + }); + + it('fails closed on a row with an unreadable timestamp', async () => { + const world = makeWorld(); + const { token, channelId } = await world.login(); + await world.request('/api/update-state', postState(token)); + world.sqlite.exec("UPDATE channel_state SET updated_at = 'garbage'"); + expect((await world.request(`/api/initial-state/${channelId}`)).status).toBe(404); + }); + + it('keeps a snapshot for ever when the age limit is 0', async () => { + const world = makeWorld({ CHANNEL_STATE_MAX_AGE_HOURS: '0' }); + const { token, channelId } = await world.login(); + await world.request('/api/update-state', postState(token)); + world.clock.advance(1000 * 3_600_000); + expect((await world.request(`/api/initial-state/${channelId}`)).status).toBe(200); + }); + + it('is limited per client address through the rate-limit binding, with Retry-After', async () => { + const seen: string[] = []; + const world = makeWorld({ + RL_INITIAL_STATE: { + limit: async ({ key }) => { + seen.push(key); + return { success: key !== '198.51.100.1' }; + }, + }, + }); + const limited = await world.request('/api/initial-state/1', { headers: { 'cf-connecting-ip': '198.51.100.1' } }); + expect(limited.status).toBe(429); + expect(limited.headers.get('retry-after')).toBe('10'); + expect(limited.headers.get('cache-control')).toBe('no-store'); + expect((await world.request('/api/initial-state/1', { headers: { 'cf-connecting-ip': '198.51.100.2' } })).status).toBe(404); + expect(seen).toEqual(['198.51.100.1', '198.51.100.2']); + }); + + describe('CORS', () => { + it.each(['https://abc123.ext-twitch.tv', 'https://ext-twitch.tv'])('allows %s and varies by origin', async (origin) => { + const response = await makeWorld().request('/api/initial-state/1', { headers: { origin } }); + expect(response.headers.get('access-control-allow-origin')).toBe(origin); + expect(response.headers.get('vary')).toContain('Origin'); + expect(response.headers.get('access-control-expose-headers')).toContain('Retry-After'); + }); + + it.each(['https://evil.example', 'http://abc.ext-twitch.tv', 'https://ext-twitch.tv.evil.example', 'https://notext-twitch.tv'])( + 'does not allow %s', + async (origin) => { + const response = await makeWorld().request('/api/initial-state/1', { headers: { origin } }); + expect(response.headers.get('access-control-allow-origin')).toBeNull(); + }, + ); + + it('allows an origin listed in ADDITIONAL_ALLOWED_FRONTEND_ORIGINS', async () => { + const world = makeWorld({ ADDITIONAL_ALLOWED_FRONTEND_ORIGINS: 'https://localhost:8080' }); + const response = await world.request('/api/initial-state/1', { headers: { origin: 'https://localhost:8080' } }); + expect(response.headers.get('access-control-allow-origin')).toBe('https://localhost:8080'); + }); + + it('answers a preflight for GET from an allowed origin', async () => { + const response = await makeWorld().request('/api/initial-state/1', { + method: 'OPTIONS', + headers: { + origin: 'https://abc.ext-twitch.tv', + 'access-control-request-method': 'GET', + 'access-control-request-headers': 'x-custom', + }, + }); + expect(response.status).toBe(204); + expect(response.headers.get('access-control-allow-origin')).toBe('https://abc.ext-twitch.tv'); + expect(response.headers.get('access-control-allow-methods')).toBe('GET'); + expect(response.headers.get('access-control-allow-headers')).toBe('x-custom'); + }); + }); +}); + +describe('GET / and /healthz', () => { + it('redirects the bare host to the project site', async () => { + const response = await makeWorld().request('/'); + expect(response.status).toBe(302); + expect(response.headers.get('location')).toBe('https://signal-thread.github.io/EDNexus/'); + }); + + it('reports ok with pubsub "unknown" until something has been learned', async () => { + const response = await makeWorld().request('/healthz'); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ status: 'ok', pubsub: 'unknown', pubsubStatus: null, pubsubCheckedAt: null }); + }); + + it('refuses to serve with an invalid configuration, naming nothing secret, but still redirects the bare host', async () => { + const world = makeWorld({ TWITCH_EXTENSION_SECRET: 'not base64!' }); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const response = await world.request('/healthz'); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ status: 'misconfigured' }); + expect(error.mock.calls[0]![0]).toContain('TWITCH_EXTENSION_SECRET'); + expect((await world.request('/')).status).toBe(302); + error.mockRestore(); + }); +}); diff --git a/ebs-worker/test/units.test.ts b/ebs-worker/test/units.test.ts new file mode 100644 index 0000000..27b65a4 --- /dev/null +++ b/ebs-worker/test/units.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { loadConfig } from '../src/config'; +import { decryptText, DecryptionError, encryptText, pkceChallenge, timingSafeEqual, verifyPkce } from '../src/crypto'; +import { TwitchOAuthError } from '../src/twitch/oauth'; +import { ENCRYPTION_KEY, b64, makeEnv } from './helpers'; + +describe('encryption at rest', () => { + it('round-trips and never repeats a ciphertext', async () => { + const a = await encryptText(ENCRYPTION_KEY, 'secret', 'chan'); + const b = await encryptText(ENCRYPTION_KEY, 'secret', 'chan'); + expect(a).not.toBe(b); + expect(a).not.toContain('secret'); + expect(await decryptText(ENCRYPTION_KEY, a, 'chan')).toBe('secret'); + }); + + it('refuses a ciphertext moved to another row, a different key, or a tampered value', async () => { + const sealed = await encryptText(ENCRYPTION_KEY, 'secret', 'chan-1'); + await expect(decryptText(ENCRYPTION_KEY, sealed, 'chan-2')).rejects.toBeInstanceOf(DecryptionError); + await expect(decryptText(b64(32, 1), sealed, 'chan-1')).rejects.toBeInstanceOf(DecryptionError); + await expect(decryptText(ENCRYPTION_KEY, sealed.slice(0, -2) + 'AA', 'chan-1')).rejects.toBeInstanceOf(DecryptionError); + await expect(decryptText(ENCRYPTION_KEY, 'plaintext', 'chan-1')).rejects.toBeInstanceOf(DecryptionError); + }); +}); + +describe('PKCE', () => { + it('matches the RFC 7636 appendix B vector', async () => { + expect(await pkceChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'); + }); + + it('verifies, and refuses empty and mismatched values', async () => { + const challenge = await pkceChallenge('verifier'); + expect(await verifyPkce('verifier', challenge)).toBe(true); + expect(await verifyPkce('other', challenge)).toBe(false); + expect(await verifyPkce('', challenge)).toBe(false); + expect(await verifyPkce('verifier', '')).toBe(false); + }); + + it('compares in a way that is length-aware', () => { + expect(timingSafeEqual('abc', 'abc')).toBe(true); + expect(timingSafeEqual('abc', 'abd')).toBe(false); + expect(timingSafeEqual('abc', 'abcd')).toBe(false); + }); +}); + +describe('TwitchOAuthError.isGrantRejection', () => { + it.each([ + [400, 'Invalid refresh token', true], + [401, 'invalid_grant', true], + [400, 'invalid client secret', false], + [400, 'Invalid Client', false], + [429, 'slow down', false], + [500, 'oops', false], + [null, 'Unparseable body', false], + ])('%s %s -> %s', (status, message, expected) => { + expect(new TwitchOAuthError(message, status).isGrantRejection).toBe(expected); + }); +}); + +describe('configuration validation', () => { + const load = (overrides = {}) => loadConfig(makeEnv(overrides).env); + const failures = (overrides = {}) => { + const result = load(overrides); + return result.ok ? [] : result.failures; + }; + + it('accepts a complete configuration and applies the defaults', () => { + const result = load(); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.config).toMatchObject({ + maxStatePayloadBytes: 5000, + maxRequestBodyBytes: 16384, + updateStateRateLimit: 1, + updateStateRateLimitWindowSeconds: 2, + initialStateCacheSeconds: 5, + oauthSessionTtlMinutes: 10, + oauthCodeTtlSeconds: 60, + tokenRefreshBufferMinutes: 60, + channelStateMaxAgeHours: 24, + oauthScopes: ['user:read:email'], + ownerUserId: null, + }); + }); + + it('reports every problem at once, naming the setting', () => { + const list = failures({ + TWITCH_CLIENT_ID: '', + TWITCH_CLIENT_SECRET: '', + TWITCH_EXTENSION_ID: '', + TWITCH_EXTENSION_SECRET: '', + TOKEN_ENCRYPTION_KEY: '', + }); + expect(list).toHaveLength(5); + for (const name of ['TWITCH_CLIENT_ID', 'TWITCH_CLIENT_SECRET', 'TWITCH_EXTENSION_ID', 'TWITCH_EXTENSION_SECRET', 'TOKEN_ENCRYPTION_KEY']) + expect(list.some((f) => f.includes(name))).toBe(true); + }); + + it.each([ + ['a non-base64 extension secret', { TWITCH_EXTENSION_SECRET: 'not base64!' }, 'TWITCH_EXTENSION_SECRET'], + ['an extension secret under 16 bytes', { TWITCH_EXTENSION_SECRET: b64(15, 1) }, 'only 15 bytes'], + ['an encryption key that is not 32 bytes', { TOKEN_ENCRYPTION_KEY: b64(16, 1) }, 'exactly 32 bytes'], + ['an http redirect URI that is not localhost', { TWITCH_OAUTH_REDIRECT_URI: 'http://ebs.example.test/cb' }, 'TWITCH_OAUTH_REDIRECT_URI'], + ['a relative redirect URI', { TWITCH_OAUTH_REDIRECT_URI: '/oauth/callback' }, 'TWITCH_OAUTH_REDIRECT_URI'], + ['a non-numeric owner id', { TWITCH_OWNER_USER_ID: 'abc' }, 'TWITCH_OWNER_USER_ID'], + ['a payload limit above 5 KiB', { MAX_STATE_PAYLOAD_BYTES: '5121' }, 'MAX_STATE_PAYLOAD_BYTES'], + ['a request cap under 1024', { MAX_REQUEST_BODY_BYTES: '100' }, 'MAX_REQUEST_BODY_BYTES'], + ['a request cap with no room for the state', { MAX_REQUEST_BODY_BYTES: '4096' }, 'leave room'], + ['a snapshot lifetime of 1-11 hours', { CHANNEL_STATE_MAX_AGE_HOURS: '11' }, 'CHANNEL_STATE_MAX_AGE_HOURS'], + ['a setting that is not a number', { MAX_STATE_PAYLOAD_BYTES: 'abc' }, 'must be a whole number'], + ])('refuses %s', (_name, overrides, fragment) => { + expect(failures(overrides).join('\n')).toContain(fragment); + }); + + it.each([ + ['http for localhost', { TWITCH_OAUTH_REDIRECT_URI: 'http://localhost:8787/oauth/callback' }], + ['a snapshot lifetime of 0 (no limit)', { CHANNEL_STATE_MAX_AGE_HOURS: '0' }], + ['a snapshot lifetime of 12 hours', { CHANNEL_STATE_MAX_AGE_HOURS: '12' }], + ['a numeric owner id', { TWITCH_OWNER_USER_ID: '12345' }], + ])('accepts %s', (_name, overrides) => { + expect(failures(overrides)).toEqual([]); + }); +}); diff --git a/ebs-worker/tsconfig.json b/ebs-worker/tsconfig.json new file mode 100644 index 0000000..6e41060 --- /dev/null +++ b/ebs-worker/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2023"], + "types": ["@cloudflare/workers-types", "node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "noEmit": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/ebs-worker/vitest.config.ts b/ebs-worker/vitest.config.ts new file mode 100644 index 0000000..3a3b21d --- /dev/null +++ b/ebs-worker/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { environment: 'node', include: ['test/**/*.test.ts'] }, +}); diff --git a/ebs-worker/wrangler.example.jsonc b/ebs-worker/wrangler.example.jsonc new file mode 100644 index 0000000..91efe41 --- /dev/null +++ b/ebs-worker/wrangler.example.jsonc @@ -0,0 +1,44 @@ +// Template for ebs-worker/wrangler.jsonc. Copy it and fill in your own values: +// +// cp wrangler.example.jsonc wrangler.jsonc +// +// wrangler.jsonc is gitignored on purpose: it holds the ID of YOUR D1 database, which belongs to your +// Cloudflare account and should not be committed or shared. See README.md for the full setup. +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "ednexus-ebs", + "main": "src/index.ts", + "compatibility_date": "2025-01-01", + "d1_databases": [ + // Run `npx wrangler d1 create ednexus-ebs`, then paste the database_id it prints. + { "binding": "DB", "database_name": "ednexus-ebs", "database_id": "REPLACE_WITH_YOUR_D1_DATABASE_ID", "migrations_dir": "migrations" } + ], + // Per-client-IP limits on the unauthenticated routes. The Rate Limiting binding only supports a 10 or + // 60 second period, which is exactly what these two limits use. (The per-channel limits on + // /api/update-state are counted in D1: they need a 2 second window.) + "ratelimits": [ + { "name": "RL_INITIAL_STATE", "namespace_id": "1001", "simple": { "limit": 60, "period": 10 } }, + { "name": "RL_OAUTH", "namespace_id": "1002", "simple": { "limit": 30, "period": 60 } } + ], + // Replaces the three background services of the ASP.NET build. + // */15 * * * * prune expired snapshots, pending logins and rate-limit rows + // */30 * * * * refresh Twitch grants that are close to expiring + // 0 */6 * * * check that Twitch accepts the PubSub credentials + "triggers": { "crons": ["*/15 * * * *", "*/30 * * * *", "0 */6 * * *"] }, + "vars": { + // The EBS's own redirect URI, exactly as registered on the Twitch application. + "TWITCH_OAUTH_REDIRECT_URI": "https://ednexus.signal-and-thread.com/oauth/callback" + // Optional tunables (defaults shown in README.md): TWITCH_OAUTH_SCOPES, TWITCH_OWNER_USER_ID, + // MAX_STATE_PAYLOAD_BYTES, MAX_REQUEST_BODY_BYTES, UPDATE_STATE_RATE_LIMIT, + // UPDATE_STATE_RATE_LIMIT_WINDOW_SECONDS, INITIAL_STATE_CACHE_SECONDS, OAUTH_SESSION_TTL_MINUTES, + // OAUTH_CODE_TTL_SECONDS, TWITCH_TOKEN_REFRESH_BUFFER_MINUTES, CHANNEL_STATE_MAX_AGE_HOURS, + // ADDITIONAL_ALLOWED_FRONTEND_ORIGINS + }, + "observability": { "enabled": true } + // The custom domain (ednexus.signal-and-thread.com) is attached in the dashboard at cutover, not here: + // Worker -> Settings -> Domains & Routes -> Add -> Custom Domain. + // + // Secrets (set with `wrangler secret put `, or put in .dev.vars for local runs): + // TWITCH_CLIENT_ID, TWITCH_CLIENT_SECRET, TWITCH_EXTENSION_ID, TWITCH_EXTENSION_SECRET, + // TOKEN_ENCRYPTION_KEY +} From 1f631ab75af95b8ced7741b3fafbbe1ed861489d Mon Sep 17 00:00:00 2001 From: Kevin Dethlefs Date: Tue, 10 Nov 2026 21:43:09 -0600 Subject: [PATCH 2/2] Address review feedback on the EBS Worker - Read request bodies as a stream and cancel once the cap is passed, so a chunked upload cannot make the isolate buffer more than it will accept - Make PubSub credential-status writes non-fatal: bookkeeping must not turn a published broadcast or a sign-out into an error - Fix the flaky tamper test (the fixed replacement matched the real tag byte about 1 run in 256) - Correct the checkFixedWindow comment: unauthenticated callers also cost a D1 write - Add tests for the streamed cap and the non-fatal status write Co-Authored-By: Claude Sonnet 5.5 --- ebs-worker/src/db/ratelimit.ts | 6 ++++-- ebs-worker/src/db/status.ts | 22 ++++++++++++++-------- ebs-worker/src/http.ts | 28 +++++++++++++++++++++++++--- ebs-worker/test/state.test.ts | 30 ++++++++++++++++++++++++++++++ ebs-worker/test/units.test.ts | 5 ++++- 5 files changed, 77 insertions(+), 14 deletions(-) diff --git a/ebs-worker/src/db/ratelimit.ts b/ebs-worker/src/db/ratelimit.ts index ecfa696..f34fe76 100644 --- a/ebs-worker/src/db/ratelimit.ts +++ b/ebs-worker/src/db/ratelimit.ts @@ -5,8 +5,10 @@ export type LimitResult = { allowed: true } | { allowed: false; retryAfterSecond /** * Fixed-window counter in D1, keyed per policy and partition (a channel id). One atomic upsert both * counts and rolls the window, so concurrent requests cannot both slip under the limit. Used where the - * window is shorter than the Rate Limiting binding supports (update-state: 1 per 2 seconds) and where - * the partition is an authenticated channel, so the write volume is bounded by real broadcasters. + * window is shorter than the Rate Limiting binding supports (update-state: 1 per 2 seconds). The + * partition is the authenticated channel, or the client address when authentication failed, so an + * unauthenticated caller also costs a D1 row write per request (and a row per address until the prune + * cron removes it); only the row count of authenticated channels is bounded by real broadcasters. */ export async function checkFixedWindow(deps: Deps, policy: string, partition: string, limit: number, windowSeconds: number): Promise { const windowMs = windowSeconds * 1000; diff --git a/ebs-worker/src/db/status.ts b/ebs-worker/src/db/status.ts index 4bdb970..7bd7cfb 100644 --- a/ebs-worker/src/db/status.ts +++ b/ebs-worker/src/db/status.ts @@ -33,15 +33,21 @@ export async function getCredentialStatus(deps: Deps): Promise // Written only when the verdict changes. Every real broadcast reports success, and an unconditional // write per publish would cost a D1 row write for no new information. +// This is health bookkeeping, so a failed write is logged and swallowed: it must never turn a publish or +// a sign-out that Twitch already answered into an error. async function write(deps: Deps, state: CredentialState, httpStatus: number | null): Promise { - await deps.db - .prepare( - `INSERT INTO service_status (key, value, updated_at) VALUES (?, ?, ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at - WHERE service_status.value != excluded.value`, - ) - .bind(KEY, JSON.stringify({ state, httpStatus }), iso(deps.now())) - .run(); + try { + await deps.db + .prepare( + `INSERT INTO service_status (key, value, updated_at) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + WHERE service_status.value != excluded.value`, + ) + .bind(KEY, JSON.stringify({ state, httpStatus }), iso(deps.now())) + .run(); + } catch (error) { + console.warn(`Could not record the PubSub credential status: ${error instanceof Error ? error.message : error}`); + } } export const markCredentialsAccepted = (deps: Deps) => write(deps, 'accepted', null); diff --git a/ebs-worker/src/http.ts b/ebs-worker/src/http.ts index 666d8e1..42fbe59 100644 --- a/ebs-worker/src/http.ts +++ b/ebs-worker/src/http.ts @@ -44,9 +44,31 @@ export async function readJsonBody(request: Request, limit: number): Promise limit) return tooLarge(); - const buffer = await request.arrayBuffer(); - if (buffer.byteLength > limit) return tooLarge(); - if (buffer.byteLength === 0) return { ok: true, value: undefined }; + // Count bytes as they arrive and stop reading the moment the limit is passed, so an undeclared + // (chunked) upload cannot make the isolate buffer far more than it will ever accept. + const chunks: Uint8Array[] = []; + let total = 0; + if (request.body) { + const reader = request.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > limit) { + await reader.cancel(); + return tooLarge(); + } + chunks.push(value); + } + } + if (total === 0) return { ok: true, value: undefined }; + + const buffer = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + buffer.set(chunk, offset); + offset += chunk.byteLength; + } try { return { ok: true, value: JSON.parse(new TextDecoder().decode(buffer)) }; diff --git a/ebs-worker/test/state.test.ts b/ebs-worker/test/state.test.ts index 0d4d1ec..26bd13e 100644 --- a/ebs-worker/test/state.test.ts +++ b/ebs-worker/test/state.test.ts @@ -156,6 +156,36 @@ describe('POST /api/update-state', () => { expect(response.status).toBe(413); }); + it('stops reading an undeclared body as soon as it passes the cap', async () => { + const world = makeWorld(); + const { token } = await world.login(); + let pulls = 0; + const stream = new ReadableStream({ + pull(controller) { + pulls++; + controller.enqueue(new Uint8Array(8 * 1024)); + if (pulls >= 1000) controller.close(); + }, + }); + const response = await world.request('/api/update-state', { + method: 'POST', + headers: bearer(token), + body: stream, + // @ts-expect-error duplex is required by undici for a streamed request body + duplex: 'half', + }); + expect(response.status).toBe(413); + expect(pulls).toBeLessThan(10); // not the whole 8 MB + }); + + it('does not fail a publish when recording the credential status fails', async () => { + const world = makeWorld(); + const { token } = await world.login(); + world.sqlite.exec('DROP TABLE service_status'); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect((await world.request('/api/update-state', postState(token))).status).toBe(200); + }); + it('applies the same body cap to /oauth/*', async () => { const world = makeWorld(); const response = await world.request('/oauth/token', { method: 'POST', headers: { 'content-length': '20000' }, body: '{}' }); diff --git a/ebs-worker/test/units.test.ts b/ebs-worker/test/units.test.ts index 27b65a4..55e95d6 100644 --- a/ebs-worker/test/units.test.ts +++ b/ebs-worker/test/units.test.ts @@ -17,7 +17,10 @@ describe('encryption at rest', () => { const sealed = await encryptText(ENCRYPTION_KEY, 'secret', 'chan-1'); await expect(decryptText(ENCRYPTION_KEY, sealed, 'chan-2')).rejects.toBeInstanceOf(DecryptionError); await expect(decryptText(b64(32, 1), sealed, 'chan-1')).rejects.toBeInstanceOf(DecryptionError); - await expect(decryptText(ENCRYPTION_KEY, sealed.slice(0, -2) + 'AA', 'chan-1')).rejects.toBeInstanceOf(DecryptionError); + // Change the second-to-last character: its 6 bits always change the final tag byte, so this can never + // reproduce the original (replacing the last two with a fixed value would, about 1 run in 256). + const tampered = sealed.slice(0, -2) + (sealed.at(-2) === 'A' ? 'B' : 'A') + sealed.at(-1); + await expect(decryptText(ENCRYPTION_KEY, tampered, 'chan-1')).rejects.toBeInstanceOf(DecryptionError); await expect(decryptText(ENCRYPTION_KEY, 'plaintext', 'chan-1')).rejects.toBeInstanceOf(DecryptionError); }); });